/** * 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 * * Développé par Quentin Rouiller (QRO) — DGNSI, Canton de Vaud. Licence MIT. */ // ============================================================================ // viewer.js — vue claire du planning techniciens // ============================================================================ // v2026.5.48 — stats : le pompier du jour ne compte plus dans « tech. dispo » // (il est compté à part, il apparaissait deux fois). // v2026.5.48 — méta réservation (n°/ville/rue/catégorie) : elle vit dans la // réservation du STORE (partagée via le dossier, horodatée // resMetaAt — l'édition la plus récente gagne la fusion). Le // carnet local devient un tampon de pose consommé à l'ingestion : // fin de la ré-injection à chaque parse qui faisait hériter les // infos d'origine à toute réservation du même créneau. // v2026.5.48 — réservation re-planifiée DANS EasyVista : méta transférée sur // la nouvelle occurrence quand le rapprochement est sans ambiguïté. // v2026.5.48 — deux réservations au même créneau : chacune garde sa méta // (édition ciblée par actionId, pose jamais par-dessus l'existant). // v2026.5.48 — emails des techniciens : colonne dans Paramètres → Équipe // (saisie, récupération annuaire EV, ou déduction prenom.nom@vd.ch). // v2026.5.48 — option « Email après un déplacement » : brouillon Outlook // (mailto) pré-adressé aux 2 techs concernés, envoi manuel. // v2026.5.48 — audit réservations (9 correctifs) : tech dans l'identité // (ivKey — actionId recyclé inter-techs) ; champs vidés jamais // ressuscités par la fusion ; carnet : clé avec heure de fin, // TTL sur la date cible, vidage disque après persistance du // jour ; isPompier jamais sur une réservation (et sur le sujet // seul) ; « réservé toute la journée » sur les heures affichées // (multi-jours) ; purge du store épargne aujourd'hui/futur ; // champs pré-remplis protégés du lookup n° ; libellé du bouton // supprimer conservé après un échec. // v2026.5.48 — email de déplacement : case « prévenir par email » VISIBLE // dans la fenêtre de déplacement ET la confirmation drag & drop // (bascule unique _setMoveEmailEnabled, synchronisée avec // Paramètres → Équipe, persistante) ; modèle d'email éditable // (sujet + message, {variables} documentées, modèle de base en // config — rien en dur) rempli par _moveEmailRempli. // v2026.5.48 — légende des variables CLIQUABLE : un clic insère la variable // à la position du curseur, dans le Sujet ou le Message. // v2026.5.48 — check complet email : garde « aucun changement » dans // _applyReschedule (plus de brouillon Avant = Après) ; UN seul // comportement d'enregistrement dans l'onglet Équipe (équipe + // adresses + domaine + modèle ensemble) ; domaine des adresses // déduites configurable (cfg.emailDomain) ; lieu/contact du // brouillon avec les mêmes replis que la carte ; toast si un // destinataire n'a pas d'adresse ; garde de session sur le // bouton annuaire ; échec d'écriture de l'option signalé et // case remise dans l'état réel ; libellés harmonisés. // v2026.5.48 — l'email ne se propose que pour un VRAI déplacement (autre // jour ou autre technicien — consigne Quentin, pas d'email // pour un simple ajustement d'heures ni pour les réservations) ; // zone du modèle refaite au propre : bloc encadré, grille de // variables cliquables, APERÇU en direct sur un exemple fictif. // v2026.5.48 — adresse(s) de tech introuvable(s) : le brouillon s'ouvre // QUAND MÊME (destinataires vides/partiels, le coordinateur // complète dans l'email) ; notification en bas à droite qui // dit qui manque. // v2026.5.48 — recherche : la recherche « qui ne part pas quand on // arrête de taper » est corrigée. Cause : pendant la lecture // LENTE de l'index (dossier partagé, réseau interne), tout // survol/focus de la barre relançait _srDoSearch qui TUAIT la // recherche en vol (jeton _searchSeq) et repartait de zéro — // les recherches s'entretuaient en boucle, et deux // constructions d'index concurrentes se réinitialisaient // mutuellement. Correctifs : index à VOL UNIQUE // (_srCacheBuildInflight), relance au survol bloquée pendant // une recherche en vol ou un debounce (_srSearchEnCours), // roue visible dès la phase d'index, timer de debounce remis // à null au déclenchement, Entrée = recherche immédiate // (câblage existant, désormais fiable). // v2026.5.48 — retouches Quentin : (a) recherche — cliquer la bascule // « Planifiée (passé) / AFFICHER » ne ferme plus la zone de // résultats (l'élément cliqué était réécrit par innerHTML et // remontait DÉTACHÉ au gestionnaire clic-extérieur ; garde // isConnected — couvre aussi le pied « N de plus ») ; (b) la // pastille de statut des résultats ne s'affiche que pour un // état FINAL (Clôturé / Fait / Terminée), plus de « En // cours » (le liseré de couleur reste) ; (c) fenêtre de // modification : plus d'air entre la case email et les // boutons ; (d) aperçu du modèle : n° d'exemple au vrai // format EV (S2602407_00233). // v2026.5.48 — case email des fenêtres de déplacement (fenêtre de // modification ET confirmation drag & drop) : DÉCOCHER ne // vaut plus que pour CE déplacement (change.sansEmail — // l'option globale reste active, la prochaine fenêtre // revient cochée) ; COCHER quand l'option est inactive // l'active pour de bon (inchangé). L'option ne se désactive // durablement que dans Paramètres → Email. // v2026.5.48 — retours Quentin : (a) migration du modèle de base par // EMPREINTE tolérante (_empreinteModeleEmail — fins de ligne, // espaces, tirets typographiques ignorés) : l'égalité au // caractère près pouvait rater la migration et laisser // l'ancien format dans l'onglet Email ; (b) puces de // variables INTERACTIVES (survol = grossit, clic = s'enfonce, // styles dans viewer.css) ; (c) « Domaine des adresses » // déménagé dans l'onglet Équipe (barre d'outils, à côté des // emails qu'il sert) — réglage GLOBAL : toutes les adresses // déduites le suivent, re-render immédiat après Enregistrer. // v2026.5.48 — modèle de base : le CONTACT sort, et une vraie séparation // visuelle AVANT / APRÈS (deux blocs titrés, lisibles d'un // coup d'œil) ; la migration au boot couvre désormais la // LISTE des anciens modèles de base (_ANCIENS_MODELES_BASE : // les deux générations précédentes) — un modèle personnalisé n'est jamais touché. // v2026.5.48 — modèle de base : les HEURES sortent (dates seules), mise // en forme puces « • » + lignes d'air ; migration au boot pour // les postes restés sur l'ancien modèle de base (un modèle // personnalisé n'est jamais touché) ; _moveEmailRempli // supprime aussi les lignes à puce vides et préserve // l'indentation du modèle. La zone Message reste du TEXTE // (contrainte mailto:) : un collage depuis Outlook/Word garde // caractères, puces et sauts de ligne — jamais gras/couleurs. // v2026.5.48 — retours Quentin : séparateur « ; » entre les // destinataires du mailto (Outlook FR/CH ne reconnaît pas la // virgule) ; la colonne Email et le bouton « Récupérer les // emails » REVIENNENT dans l'onglet Équipe (boutons au-dessus // de la table, complétion des noms comprise) ; l'onglet Email // ne garde qu'Options (email après déplacement + domaine) et // Modèle ; textes et libellés raccourcis partout (cases des // fenêtres de déplacement : « Prévenir les techniciens par // email ») ; légende des variables AU-DESSUS des champs // Sujet/Message, en colonnes logiques Intervention / Avant / // Après (MOVE_EMAIL_VARIABLE_GROUPES), descriptions en // infobulles. // v2026.5.48 — onglet « Email » dédié dans les Paramètres (demande // Quentin) : adresses des techniciens (boutons Récupérer / // Enregistrer AU-DESSUS de la liste), option « email après un // déplacement », domaine et modèle avec aperçu — tout au même // endroit, l'onglet Équipe n'a plus que le groupe, la liste et // les absences (son Enregistrer passe au-dessus de la table). // La récupération annuaire complète AUSSI un nom tronqué // (« Martins, Solange » → « De Almeida Martins, Solange », // garde anti-homonyme _nomCompletCompatible) : le nom config // est celui affiché sur les cartes. // v2026.5.48 — aucun cache + session expirée : le bouton « Ouvrir // EasyVista » de l'écran plein pouvait devenir définitivement // inopérant. Deux verrous dans _checkForRestoredSession : // (a) si state.session est déjà posé mais qu'un écran plein // est encore affiché (chargement jamais abouti), un clic de // reconnexion RETENTE le chargement au lieu d'être ignoré // (même PHPSESSID = le polling ne faisait plus jamais rien) ; // (b) le toast « Reconnecté » et le masquage des écrans // pleins (« Session expirée » / « EV inaccessible » — ce // dernier n'étant jamais masqué par renderFromData) sont // conditionnés à un RENDU réel du planning, plus de faux // « Reconnecté » sur une page vide quand le fetch a échoué. // v2026.5.46 — bug « navigation rapide entre dates » : rendus périmés // refusés (verrous storeSyncFolder + pipeline 3bis + filet renderFromData). // v2026.5.46 — bug « tech retiré encore affiché » : la projection filtre // l'annuaire du jour par l'équipe configurée actuelle (TEAM). // v2026.5.46 — vue horizontale « échelle exacte » : lignes d'heures et // ligne « maintenant » bornées avant le bloc des absents journée entière ; // isTechAbsent évalue la plage configurée (DAY_START/DAY_END). // v2026.5.46 — bug « navigation rapide » (suite) : jeton d'annulation pris // dès l'entrée de loadForDate + gardes après les await pré-pipeline. // v2026.5.46 — même famille : garde du chemin de boot sans session // (refreshSessionAndLoad) contre une navigation pendant la lecture du store. // v2026.5.46 — retours de revue : propriété du pipeline (_loadPipelineToken, // « Arrêter » ciblé, 3bis avorté sans effet), re-rendu des réglages permis, // double plage pour « absent toute la journée », _dayRange() unique, // segments calculés une fois, filtre TEAM en hasOwnProperty. // v2026.5.46 — retours de revue (2e passe) : cycle de vie du bouton // « Arrêter » lié à la propriété du pipeline (plus de bouton fantôme, clic // sans pipeline sans effet), zones absentes adjacentes fusionnées (plus de // tronçons dans les interstices), dayLen dans _dayRange(). // v2026.5.46 — bug « réservations multiples le même jour » : le créneau // (heures) entre dans ivKey/_ivAbsenceSig des réservations/absences — deux // créneaux d'un même tech/jour ne fusionnent plus (un seul s'affichait quand // EV partageait le request_id). + modal Modifier réservation : le N° // d'intervention auto-remplit ville/rue/catégorie (helper _wireResNumberLookup // partagé avec la création). // v2026.5.46 — passe /simplify : _fusionnerPlages() remplace 4 copies de // la fusion d'intervalles, _jourAffiche() nomme le prédicat « jour affiché » // (3 sites), tolérance des interstices dérivée du gap CSS réel, dayE // destructuré partout où dayStart+dayLen le re-dérivait. // v2026.5.46 — bug « boucle de conflit du cache dossier » : le CAS de // storeSyncFolder utilisait le savedAt renvoyé par « stat » (que l'hôte sert // depuis _index.json, parfois périmé) comme référence d'écriture, alors que // l'hôte compare le CAS au savedAt RELU dans le fichier. Index en retard = // conflit permanent, écriture jamais aboutie, index jamais rafraîchi (deadlock). // On prend désormais la référence CAS dans le fichier réellement lu et fusionné // (folder.savedAt), pas dans stat : l'écriture aboutit et guérit l'index. // v2026.5.46 — deux correctifs d'affichage (vue horizontale) : // 1. fond des cartes d'absence : en échelle exacte la carte est haute et le // dégradé « couleur → neutre à 40 % » laissait le bas vide → en vue // horizontale la couleur douce remplit toute la carte (CSS). // 2. plus de DOUBLON absence récurrente + vraie absence : si le tech a une // vraie absence EV ce jour (congé/maladie), l'absence récurrente configurée // ne s'affiche plus (fond/badge/note suivent la vraie absence). // v2026.5.47 — passage au numéro de version 2026.5.47 (la 2026.5.46 est publiée ; // le travail non poussé se fait désormais sous 2026.5.47). Pas de pull. // v2026.5.47 — vue horizontale : la barre de progression « Actualisation // X/Y » ne recouvre plus le haut de la sidebar (badge/titre/zone de reconnexion) ; // elle se cale au-dessus de la zone de CONTENU, à droite de la sidebar // (--sidebar-w mesuré en JS, robuste aux breakpoints/zoom). // v2026.5.47b — la barre passe SOUS la banderole de session/reconnexion // quand il y en a une (top = --stack-top, qui intègre la hauteur des banderoles), // au lieu de la recouvrir ; top:0 quand il n'y a pas de banderole. // v2026.5.47 — onglet Synchro : bouton « Tester la communication » qui fait // l'aller-retour extension → hôte (version) → dossier (stat réel) et affiche un // diagnostic lisible. Va de pair avec l'hôte natif v2026.1.8 (verrou dédié sur // _index.json contre les « lost update » multi-postes ; icône tray ré-affichée // au redémarrage d'explorer via TaskbarCreated). // v2026.5.46 — refonte complète de la couche données : store unifié par // jour (planv2_*), fusion unique vie/mort, verdict unique par intervention, // projection rendu centralisée, sync dossier v2. Cf. section « COUCHE // DONNÉES v2 » plus bas pour le modèle complet. // // v2026.5.46 — vue horizontale « échelle de temps exacte » (option // Apparence, exclusive avec « Afficher les pauses ») : cartes posées en // position réelle sur une bande continue, cf. _buildExactRow. // // v2026.5.46 — fiabilisation de la détection de session EasyVista // (faux « déconnecté ») : cookie overwrite ignoré + vérification serveur // avant bannière, expiration sur preuve uniquement, cookie sans onglet EV, // contre-vérification des statuts HTTP côté background. // // v2026.5.46 — mode exact : poignée de déplacement à gauche des cartes // (remplace le bloc d'heures), règle des heures alignée par MESURE runtime // (_alignExactRuler), vraie césure midi (style timeline-noon). // // v2026.5.46 — mode exact : cartes étroites jamais vides (ville + fin // d'adresse compactes), tolérance d'empilement 10 min (les enchaînements // pile ne s'empilent plus), ligne « maintenant » UNIQUE et continue. // // v2026.5.46 — mode exact : sous-lignes en FLUX réel (.iv-exact-lane), // bande à hauteur auto — un empilement n'est plus jamais rogné. // // v2026.5.46 — mode exact : position/ancres/hauteur des cartes // verrouillées (!important) — la règle « position: relative » des cartes // closed/resolved (mode flux, plus spécifique) cassait posé + taille. // // v2026.5.46 — la méta réservation (n° DS/ville/rue/catégorie, locale au // poste, EV ne la stocke pas) remonte enfin au dossier partagé et aux autres // postes : ivMerge la complète depuis l'autre copie (n'était jamais recopiée // → écrasée par l'iv EV re-fetchée) + l'édition « infos seules » déclenche // bien storeSave/push (avant : mutation mémoire sans persistance). // // v2026.5.46 — (1) ouverture de fiche des interventions faites/clôturées // réparée (formLink retiré au dossier → régénération via la réf) ; (2) mode // exact : cartes à fond neutre, seul le bord gauche est coloré et sert de // poignée (clic = modifier, glisser = déplacer). // // v2026.5.46 — mode exact : bord gauche à points colorés sur TOUTES les // cartes (drag bloqué pour faites/clôturées/réservations/absences), bulles // moins hautes (sous-ligne 60→48 px) ; croix native du champ recherche masquée. // // v2026.5.46 — mode exact : lignes d'heures verticales CONTINUES qui // traversent tout le tableau (calque global, _layoutExactGrid) ; règle des // heures réellement fixe en haut au scroll (top:0, la topbar est masquée). // // v2026.5.46 — mode exact : fines lignes aux demi-heures (h30) ; les // traits passent DERRIÈRE les bulles d'intervention (z-index sous les cartes). // // v2026.5.46 — mode exact : vide sous les bulles supprimé (bande centrée, // hauteur ↑), heures pleines vs demi-heures (pointillé) différenciées, aucune // ligne sur les techs absents, adresse abrégée tenue plus longtemps. // // v2026.5.46 — mode exact : bulles un chouïa plus hautes (texte qui // respire) et espace haut/bas resserré ; léger contour conservé. // // v2026.5.46 — mode exact : bulles encore agrandies (remplissent leur // zone) et ensemble resserré (marges de bande et entre sous-lignes réduites). // // v2026.5.46 — mode exact : bulles encore un peu plus grandes + n° // d'intervention détaché du bloc ville+adresse (qui restent groupés). // // v2026.5.46 — mode exact : bulles encore agrandies + plus d'espace sous // le n° ; bandelette pompier refixée en haut (le centrage la décentrait). // // v2026.5.46 — mode exact : bulles encore agrandies ; pompier — bandelette // figée en haut (taille inchangée) + bulles centrées dans l'espace restant. // // v2026.5.46 — contact : deux numéros de téléphone séparés par un espace // n'étaient plus collés à l'affichage (re-découpe du bloc capturé). // // v2026.5.46 — alerte doublons de réservation RETIRÉE à la // demande : le marquage ROUGE des conflits (detectOverlaps, pré-existant) // reste seul en place, comme avant. // // v2026.5.46 — vue par heure : les réservations affichent le lieu (meta) // sinon le créateur ; catégorie « Autre » en fuchsia sur toutes les vues. // // v2026.5.46 — « Réservation » en tête de bulle comme le n° d'intervention // (gabarit + espace dessous) ; « Autre » passe du fuchsia au vert lime. // // v2026.5.46 — « Autre » : lime pas retenu → rose framboise. // // v2026.5.46 — vue par heure : les journées totalement VIDES ont enfin // leurs trous (survol « Libre », clic droit, drop précis) — la bande est // construite même sans aucune intervention. // // v2026.5.46 — « Autre » en violet foncé ; la ligne du temps saute les // techs absents (helper _exactAbsentSegments partagé avec les lignes d'heures). // // v2026.5.46 — la ligne du temps n'a plus ni point ni trait sur la règle // des heures : elle démarre au haut des cartes. // // v2026.5.46 — la ligne du temps est REPOSÉE immédiatement après chaque // rendu/resize/bascule (avant : détruite par le rendu, elle ne revenait qu'au // tick d'horloge de 30 s → souvent absente). // // 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) => { // Bruit bénin de Chrome lors d'un redimensionnement rapide de fenêtre : // un callback de ResizeObserver déclenche un layout qui programme un nouveau // resize au tick suivant. Le navigateur gère la situation tout seul (les // notifications restantes seront livrées au tick suivant) — il n'y a rien // à corriger côté JS, mais Chrome signale quand même l'event en boucle. // On filtre pour éviter de spammer les logs et le toast utilisateur. const msg = (event.message || "") + ""; if (msg.includes("ResizeObserver loop")) return; LOG.exception("global", "uncaught error", event.error || new Error(event.message || "unknown"), { filename: event.filename, lineno: event.lineno, colno: event.colno }); 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() }); LOG.warn("planification", `Planification v${LOG.version()} — Mode ${LOG.isDebug() ? "DEBUG" : "PROD"}.`); // ============================================================================ // 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 = {}; // v2026.5.48 — emails des techniciens ({ id: email }, Paramètres → // Équipe) et option « brouillon email après un déplacement ». let TEAM_EMAILS = {}; let MOVE_EMAIL_ENABLED = false; // v2026.5.48 — domaine des adresses déduites (prenom.nom@), // configurable dans Paramètres → Email (R14) ; valeur de 1er lancement ci-dessous. const DEFAULT_EMAIL_DOMAIN = "vd.ch"; let EMAIL_DOMAIN = ""; // v2026.5.48 — modèle de l'email de déplacement (sujet + message), éditable // dans Paramètres → Email (R14) ; rechargé au boot depuis admin_config. let MOVE_EMAIL_SUBJECT = ""; let MOVE_EMAIL_BODY = ""; // Modèle de PREMIER LANCEMENT (filet, comme DEFAULT_GROUP_ID) : sert à remplir // admin_config la première fois et de repli si le modèle est vidé — le // brouillon lui-même est toujours construit depuis la config. const DEFAULT_MOVE_EMAIL_SUBJECT = "Planification — intervention {numero} déplacée"; // v2026.5.48 — modèle de base SANS les heures (dates seules), sans // contact, mis en forme : blocs AVANT / APRÈS bien séparés, puces « • » et // lignes d'air (demandes Quentin). Les variables retirées (heures, contact) // restent disponibles dans la légende pour qui veut les rajouter. const DEFAULT_MOVE_EMAIL_BODY = [ "Bonjour,", "", "L'intervention {numero} a été déplacée.", "", "---------- AVANT ----------", " • Technicien : {ancien_tech}", " • Date : {ancienne_date}", "", "---------- APRÈS ----------", " • Technicien : {nouveau_tech}", " • Date : {nouvelle_date}", "", " • Lieu : {lieu}", "", "Merci de prendre note du changement." ].join("\n"); // Anciens modèles de base (les deux générations précédentes), gardés pour la migration : un // poste dont le modèle enregistré est resté EXACTEMENT l'un d'eux reçoit le // modèle de base actuel au boot (un modèle personnalisé n'est jamais touché). const _ANCIENS_MODELES_BASE = [ [ "Bonjour,", "", "L'intervention {numero} a été déplacée :", "- Avant : {ancien_tech}, le {ancienne_date} de {ancienne_heure_debut} à {ancienne_heure_fin}", "- Après : {nouveau_tech}, le {nouvelle_date} de {nouvelle_heure_debut} à {nouvelle_heure_fin}", "- Contact : {contact}", "- Lieu : {lieu}", "", "Merci de prendre note du changement." ].join("\n"), [ "Bonjour,", "", "L'intervention {numero} a été déplacée.", "", " • Avant : {ancien_tech} — {ancienne_date}", " • Après : {nouveau_tech} — {nouvelle_date}", "", " • Contact : {contact}", " • Lieu : {lieu}", "", "Merci de prendre note du changement." ].join("\n") ]; // v2026.5.48 — empreinte TOLÉRANTE d'un modèle : fins de ligne, espaces // multiples, tirets typographiques et lignes vides ne comptent pas. Sert à // reconnaître un ancien modèle de BASE même si le stockage a normalisé des // détails (la comparaison au caractère près ratait la migration → l'onglet // Email continuait d'afficher l'ancien format). function _empreinteModeleEmail(s) { return String(s || "") .replace(/\r\n?/g, "\n") .replace(/\u00A0/g, " ") .replace(/[—–]/g, "-") .split("\n") .map((l) => l.replace(/[ \t]+/g, " ").trim()) .filter(Boolean) .join("\n"); } // Les variables du modèle et leur signification — la légende affichée dans // Paramètres → Email est générée depuis CETTE liste (une seule source). const MOVE_EMAIL_VARIABLES = [ ["numero", "n° de l'intervention (ex. I250123)"], ["ancien_tech", "technicien AVANT le déplacement"], ["nouveau_tech", "technicien APRÈS le déplacement"], ["ancienne_date", "date avant (JJ/MM/AAAA)"], ["nouvelle_date", "date après (JJ/MM/AAAA)"], ["ancienne_heure_debut", "heure de début avant"], ["ancienne_heure_fin", "heure de fin avant"], ["nouvelle_heure_debut", "heure de début après"], ["nouvelle_heure_fin", "heure de fin après"], ["contact", "contact de l'intervention"], ["lieu", "lieu de l'intervention"] ]; // v2026.5.48 — regroupement d'AFFICHAGE de la légende (colonnes logiques // au lieu d'une longue liste — demande Quentin). Les descriptions restent // dans MOVE_EMAIL_VARIABLES (source unique) et servent d'infobulles. const MOVE_EMAIL_VARIABLE_GROUPES = [ ["Intervention", ["numero", "contact", "lieu"]], ["Avant", ["ancien_tech", "ancienne_date", "ancienne_heure_debut", "ancienne_heure_fin"]], ["Après", ["nouveau_tech", "nouvelle_date", "nouvelle_heure_debut", "nouvelle_heure_fin"]] ]; // v2026.5.46 — vue horizontale « échelle de temps exacte » (option // Apparence). Miroir de cfg.horizontalExactMode, lu au boot et au toggle. let HORIZONTAL_EXACT_MODE = false; let RECURRING_ABSENCES = {}; // idx commun dérivé des bornes "startMin-endMin", posé sur le // .gap-placeholder (rect carte-body / mini-cards) ET sur le // .timeline-hole correspondant. Pas de matching fragile — si les bornes // sont identiques, les 2 partagent le même attribut. highlightGap // toggle .gap-hover-active sur tous les [data-gap-idx="N"] dans la // card, exactement comme highlightIntervention pour [data-iv-idx="N"]. function highlightGap(cardEl, gapIdx, on) { if (!cardEl || gapIdx == null) return; cardEl.querySelectorAll(`[data-gap-idx="${gapIdx}"]`).forEach(el => { el.classList.toggle("gap-hover-active", on); }); } function _bindGapPlaceholder(phEl, cardEl) { if (!cardEl || !phEl) return; const startMin = Number(phEl.dataset.startMin); const endMin = Number(phEl.dataset.endMin); if (!Number.isFinite(startMin) || !Number.isFinite(endMin)) return; const idx = startMin + "-" + endMin; phEl.dataset.gapIdx = idx; phEl.addEventListener("mouseenter", () => highlightGap(cardEl, idx, true)); phEl.addEventListener("mouseleave", () => highlightGap(cardEl, idx, false)); } // Détection post-rendu d'une mini-card iv tronquée → masque les // gap-placeholder-mini de la MÊME ligne pour libérer la place. Le // flex CSS ne suffit pas car le navigateur ne sait pas que le // contenu de l'iv déborde son flex-basis (overflow:hidden masque le // débordement). On le détecte explicitement via scrollWidth > clientWidth. function _trimMiniGapsForFit() { document.querySelectorAll(".iv-mini-block-cards").forEach(blockCards => { // Reset : remettre tous les gaps non-full visibles d'abord blockCards.querySelectorAll(".gap-placeholder-mini:not(.gap-placeholder-full)").forEach(g => { g.style.removeProperty("display"); }); // Détection : au moins une iv tronque-t-elle son contenu ? let needsTrim = false; blockCards.querySelectorAll(".iv-mini-card").forEach(iv => { const txt = iv.querySelector(".iv-mini-card-text"); if (txt && txt.scrollWidth > txt.clientWidth + 1) needsTrim = true; const tCol = iv.querySelector(".iv-mini-time-vertical"); if (tCol && tCol.scrollWidth > tCol.clientWidth + 1) needsTrim = true; }); if (needsTrim) { blockCards.querySelectorAll(".gap-placeholder-mini:not(.gap-placeholder-full)").forEach(g => { g.style.display = "none"; }); } }); } // Re-check au resize de la fenêtre (la largeur dispo change → certaines // iv passent ou non en troncature). let _trimRO = null; function _attachMiniGapsResizeWatcher() { if (typeof ResizeObserver === "undefined") return; if (_trimRO) return; let scheduled = false; _trimRO = new ResizeObserver(() => { if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; _trimMiniGapsForFit(); // v2026.5.46 — recale la règle des heures du mode exact (resize, // bandeaux qui apparaissent/disparaissent, zoom, sidebar). if (typeof _alignExactRuler === "function") _alignExactRuler(); if (typeof _layoutExactGrid === "function") _layoutExactGrid(); if (typeof updateNowLine === "function") updateNowLine(); }); }); _trimRO.observe(document.body); } async function _initTeamFromConfig() { try { const cfg = await loadAdminConfig(); TEAM = cfg.team || {}; RECURRING_ABSENCES = cfg.recurringAbsences || {}; TEAM_EMAILS = cfg.teamEmails || {}; // v2026.5.48 MOVE_EMAIL_ENABLED = !!cfg.moveEmailEnabled; // v2026.5.48 // v2026.5.48 — le modèle de base a changé (dates seules, // puces, blocs AVANT/APRÈS) : un poste resté sur un ANCIEN modèle de base // reçoit l'actuel. Comparaison par EMPREINTE tolérante (R18) : l'égalité // au caractère près ratait la migration au moindre détail de stockage. // Un modèle réellement personnalisé n'est jamais touché. const _empreinteStockee = _empreinteModeleEmail(cfg.moveEmailBody); if (_empreinteStockee && _ANCIENS_MODELES_BASE.some((m) => _empreinteModeleEmail(m) === _empreinteStockee)) { cfg.moveEmailBody = DEFAULT_MOVE_EMAIL_BODY; await saveAdminConfig(cfg); } // v2026.5.48 — un modèle vidé retombe sur celui de base. MOVE_EMAIL_SUBJECT = String(cfg.moveEmailSubject || "").trim() ? cfg.moveEmailSubject : DEFAULT_MOVE_EMAIL_SUBJECT; MOVE_EMAIL_BODY = String(cfg.moveEmailBody || "").trim() ? cfg.moveEmailBody : DEFAULT_MOVE_EMAIL_BODY; EMAIL_DOMAIN = String(cfg.emailDomain || "").trim() || DEFAULT_EMAIL_DOMAIN; // v2026.5.48 // v2026.5.46 — mode « échelle de temps exacte » (vue horizontale). HORIZONTAL_EXACT_MODE = !!cfg.horizontalExactMode; document.documentElement.classList.toggle("horizontal-exact", HORIZONTAL_EXACT_MODE); // applique l'option "Afficher les pauses" au boot. // v2026.5.46 — exclusive avec le mode exact : si les deux traînent // dans la config, le mode exact gagne à l'affichage. document.documentElement.classList.toggle("show-iv-gaps", !!cfg.showInterventionGaps && !HORIZONTAL_EXACT_MODE); // recharger les statuts personnalisables depuis la config. if (Array.isArray(cfg.closedStatus) && cfg.closedStatus.length > 0) { CLOSED_STATUS = cfg.closedStatus.slice(); } if (Array.isArray(cfg.resolvedStatus) && cfg.resolvedStatus.length > 0) { RESOLVED_STATUS = cfg.resolvedStatus.slice(); } if (Array.isArray(cfg.cancelledStatus) && cfg.cancelledStatus.length > 0) { CANCELLED_STATUS = cfg.cancelledStatus.slice(); } KEEP_DIAG_GHOSTS = !!cfg.keepDisappearedGhosts; COORDINATOR_MODE = !!cfg.coordinatorMode; _impersonate = cfg.impersonate || null; } catch (e) { LOG.warn("boot", "_initTeamFromConfig err", e); } } // statuts EasyVista éditables depuis admin_config (Paramètres → EV). // Les valeurs par défaut servent de filet de sécurité au 1er install et // pour les versions où la config n'aurait pas encore ces clés. // Clôturé et Terminé par défaut (les 2 statuts qui signifient // "finalisé officiellement" dans EV). N'affecte QUE les nouveaux installs // (config vide) — si une config existe déjà avec d'autres valeurs, // loadAdminConfig la garde intacte (cf. fusion `...stored`). const DEFAULT_CLOSED_STATUS = ["Clôturé", "Terminé"]; const DEFAULT_RESOLVED_STATUS = []; const DEFAULT_CANCELLED_STATUS = ["Annulé", "Supprimé"]; let CLOSED_STATUS = [...DEFAULT_CLOSED_STATUS]; let RESOLVED_STATUS = [...DEFAULT_RESOLVED_STATUS]; let CANCELLED_STATUS = [...DEFAULT_CANCELLED_STATUS]; // v2026.5.45 — flag diagnostic, lu depuis admin_config au boot et mis à // jour quand on toggle la case "Garder les disparitions". Quand true : on // ne retire JAMAIS les ghosts (KEEP forcé), peu importe le verdict. let KEEP_DIAG_GHOSTS = false; // v2026.5.46 — mode coordinateur : affiche le badge "Mes actions" dans la // barre de stats (nb d'interventions où l'utilisateur a une action). Off par défaut. let COORDINATOR_MODE = false; // Mode coordinateur — "Mes actions" : fetch + parse de la page EV // General_MyActionsItem PAR L'EXTENSION (aucune fenêtre EV ouverte). // _myActionsCount : nombre (badge "A faire : N"). null = pas encore chargé. // _myActionsRows : lignes parsées [{numero}]. null = pas encore chargé. let _myActionsCount = null; let _myActionsRows = null; let _myActionsLoading = false; let _myActionsDiag = ""; // diagnostic du dernier fetch (affiché dans l'onglet) // v2026.5.46 — impersonation (RÉSERVÉE à Quentin Rouiller) : permet de tester // la vue coordinateur en "se faisant passer pour" une autre personne. Chargé // depuis admin_config (cfg.impersonate = { enabled, name, initials, memberId }). let _impersonate = null; // Vrai uniquement si l'utilisateur connecté EST Quentin Rouiller (garde-fou // pour n'afficher l'onglet Test qu'à lui). function _isQuentin() { const n = (state && state.currentUser && state.currentUser.name) || ""; return /rouiller/i.test(n) && /quentin/i.test(n); } // Identité effective utilisée pour retrouver "l'action liée à la personne" // (Partie A « Mes actions »). Si impersonation active ET QRO → identité simulée, // sinon l'utilisateur réel. function _effectiveIdentity() { const real = { name: (state && state.currentUser && state.currentUser.name) || "", initials: (state && state.currentUser && state.currentUser.initials) || "", memberId: (state && state.currentUser && state.currentUser.memberId) || "", impersonated: false }; if (_isQuentin() && _impersonate && _impersonate.enabled && (_impersonate.name || _impersonate.initials || _impersonate.memberId)) { return { name: _impersonate.name || "", initials: _impersonate.initials || "", memberId: _impersonate.memberId || "", impersonated: true }; } return real; } // Clés de stockage // v2026.5.46 — store unifié v2. L'ancien préfixe "planning_cache_" est // abandonné (format incompatible) : les vieilles clés sont purgées au boot // (cf. storePurgeLegacy) et l'enrichissement se reconstruit au fil de l'eau. const STORE_PREFIX = "planv2_"; // + YYYY-MM-DD → entrée jour schéma 2 const LEGACY_CACHE_PREFIX = "planning_cache_"; // ancien format (purgé au boot) // v2026.5.46 — meta des réservations créées depuis l'extension (n° DS, // ville, rue, catégorie). EasyVista ne stocke PAS ces infos. // v2026.5.48 — le carnet est un TAMPON DE POSE, plus une source permanente : // une entrée n'attend que l'apparition de la réservation saisie dans le store // (le parse qui suit la création), puis elle est CONSOMMÉE. La méta vit ensuite // DANS la réservation du store et voyage par le dossier partagé (ivMerge et // _ivApplyLiveFields la préservent). Avant, le carnet était réappliqué à CHAQUE // parse : toute réservation occupant plus tard le même créneau (tech|date|heure) // — y compris celle d'un autre coordinateur — héritait des infos d'origine. // Structure : { "||-": { numero, ville, rue, categorie, at } } // v2026.5.48 — l'heure de FIN fait partie de la clé : deux réservations du // même tech au même début (fins différentes) posées coup sur coup gardaient // une seule entrée (la 2e écrasait la 1re, attribuable à la mauvaise résa). const RESERVATION_META_KEY = "reservation_meta"; // TTL compté depuis la DATE CIBLE de l'entrée (pas la pose) : une réservation // créée 3 semaines à l'avance garde sa méta jusqu'au jour J (v2026.5.48). const RESERVATION_META_TTL_MS = 7 * 24 * 60 * 60 * 1000; let _resMetaStore = {}; // v2026.5.48 — consommations pas encore écrites sur disque : le carnet // n'est vidé qu'après qu'un jour a été RÉELLEMENT persisté (storeSave), sinon // un crash entre les deux perdait la méta des deux côtés. let _resMetaDirty = false; function _resMetaKey(techId, ddmmyyyyDate, hhmmDebut, hhmmFin) { return `${ddmmyyyyDate}|${techId}|${(hhmmDebut || "").slice(0, 5)}-${(hhmmFin || "").slice(0, 5)}`; } function _resMetaPersist() { chrome.storage.local.set({ [RESERVATION_META_KEY]: _resMetaStore }) .catch(e => LOG.warn("reservation", "écriture carnet méta échouée", { err: e && e.message })); } async function loadResMetaStore() { try { const r = await chrome.storage.local.get(RESERVATION_META_KEY); _resMetaStore = r[RESERVATION_META_KEY] || {}; } catch (e) { _resMetaStore = {}; } // v2026.5.48 — purge au boot : // - entrées d'ANCIEN format (clé sans heure de fin — 5.46/5.47 publiées et // la refonte du carnet) : inertes (le lookup ne les matchera jamais) et potentiellement // mal attribuées (le bug d'origine) → supprimées d'office ; // - entrées dont la DATE CIBLE est passée depuis plus du TTL : posées mais // jamais consommées (réservation supprimée avant le parse du jour). const now = Date.now(); let dirty = false; for (const [k, m] of Object.entries(_resMetaStore)) { const [dateCible, , creneau] = k.split("|"); const dm = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(dateCible || ""); const cibleMs = dm ? new Date(`${dm[3]}-${dm[2]}-${dm[1]}T23:59:59`).getTime() : 0; const ancienFormat = !creneau || creneau.indexOf("-") < 0; if (!m || typeof m !== "object" || ancienFormat || !cibleMs || now - cibleMs > RESERVATION_META_TTL_MS) { delete _resMetaStore[k]; dirty = true; } } if (dirty) _resMetaPersist(); } function _resMetaSet(techIds, isoDate, hhmmDebut, hhmmFin, meta) { // La clé date doit être au format du XML calendar_block (JJ/MM/AAAA = iv.startDate), // pas en ISO, sinon le lookup à l'ingestion ne matche jamais. const dkey = (typeof isoToDDMMYYYY === "function") ? isoToDDMMYYYY(isoDate) : isoDate; for (const techId of (techIds || [])) { _resMetaStore[_resMetaKey(String(techId), dkey, hhmmDebut, hhmmFin)] = { numero: meta.numero || "", ville: meta.ville || "", rue: meta.rue || "", categorie: meta.categorie || "", at: Date.now() }; } _resMetaPersist(); } // v2026.5.48 — les 4 champs de méta réservation se manipulent par CES // helpers (consommation, édition, fusion, transfert) : une seule liste de // champs à maintenir, et l'horodatage de saisie (resMetaAt, arbitre des // fusions dans ivMerge) ne peut pas être oublié en route. function _ivHasResMeta(iv) { return !!(iv.resNumero || iv.resVille || iv.resRue || iv.resCategorie); } function _ivSetResMeta(rec, meta, at) { rec.resNumero = meta.numero || ""; rec.resVille = meta.ville || ""; rec.resRue = meta.rue || ""; rec.resCategorie = meta.categorie || ""; rec.resMetaAt = at || Date.now(); } function _ivResMetaOf(rec) { return { numero: rec.resNumero || "", ville: rec.resVille || "", rue: rec.resRue || "", categorie: rec.resCategorie || "" }; } // v2026.5.48 — consommation du carnet : applique l'entrée correspondante à // une réservation du STORE qui n'a pas encore de méta, puis l'EFFACE (une pose // ne sert qu'une fois). Ne touche jamais une réservation déjà renseignée (R4 : // deux réservations peuvent occuper le même créneau, chacune garde la sienne). // La suppression n'est écrite sur disque qu'au prochain storeSave réussi // (_resMetaDirty) : un crash avant la persistance du jour re-consommera // l'entrée au prochain boot au lieu de perdre la méta (v2026.5.48). function _resMetaConsumeInto(rec) { if (rec.type !== "AL-Reservation") return false; if (_ivHasResMeta(rec)) return false; const key = _resMetaKey(String(rec.techId), rec.startDate, rec.startTime, rec.endTime); const meta = _resMetaStore[key]; if (!meta) return false; _ivSetResMeta(rec, meta, meta.at); delete _resMetaStore[key]; _resMetaDirty = true; return true; } // v2026.5.46 — assignations pompier : liste de périodes // { techId, name, start, end } (dates ISO YYYY-MM-DD). Le « pompier du mois » // affiché = l'assignation couvrant la date affichée. Règle : un seul pompier // par jour → on bloque tout chevauchement avec un AUTRE technicien. const POMPIER_KEY = "pompier_assignments"; let _pompierAssignments = []; async function loadPompierAssignments() { try { const r = await chrome.storage.local.get(POMPIER_KEY); _pompierAssignments = Array.isArray(r[POMPIER_KEY]) ? r[POMPIER_KEY] : []; } catch (e) { _pompierAssignments = []; } } // Assignation couvrant une date ISO (comparaison lexicale OK sur YYYY-MM-DD). function _pompierForDate(isoDate) { if (!isoDate) return null; return _pompierAssignments.find(a => a.start <= isoDate && isoDate <= a.end) || null; } // Périodes en conflit avec [start,end] portées par un AUTRE technicien. function _pompierConflicts(start, end, techId) { return _pompierAssignments.filter(a => String(a.techId) !== String(techId) && a.start <= end && start <= a.end); } async function _pompierAddAssignment(techId, name, start, end) { // Remplacement propre : on retire les périodes du MÊME tech qui chevauchent. _pompierAssignments = _pompierAssignments.filter(a => !(String(a.techId) === String(techId) && a.start <= end && start <= a.end)); _pompierAssignments.push({ techId: String(techId), name: name || "", start, end }); try { await chrome.storage.local.set({ [POMPIER_KEY]: _pompierAssignments }); } catch (e) { LOG.warn("pompier", "écriture assignation pompier échouée", { err: e && e.message }); } } // v2026.5.46 — retire une assignation exacte (après déplacement de dates). async function _pompierRemoveExact(techId, start, end) { const before = _pompierAssignments.length; _pompierAssignments = _pompierAssignments.filter(a => !(String(a.techId) === String(techId) && a.start === start && a.end === end)); if (_pompierAssignments.length !== before) { try { await chrome.storage.local.set({ [POMPIER_KEY]: _pompierAssignments }); } catch (e) {} } } // v2026.5.46 — collecte les pompiers depuis 2 sources et fusionne : // (1) le store local d'assignations (créées via l'extension), // (2) le CACHE du planning : toute absence type Pompier réellement présente dans // EasyVista, sur les jours déjà chargés. Chaque absence porte sa période // début→fin (JJ/MM/AAAA dans le cache → converti en ISO). // Dédupliqué par tech|début|fin. Renvoie une liste {techId, name, start, end} (ISO). async function _collectPompiers() { const _toIso = (s) => { const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(s || ""); return m ? `${m[3]}-${m[2]}-${m[1]}` : (s || ""); }; const map = new Map(); // actionId vient du cache (nécessaire pour supprimer l'absence lors d'une // modification de dates). Le store local n'en a pas → complété par le cache. const add = (techId, name, start, end, actionId) => { if (!techId || !start || !end) return; const k = `${techId}|${start}|${end}`; const ex = map.get(k); if (!ex) map.set(k, { techId: String(techId), name: name || "", start, end, actionId: actionId || null }); else if (!ex.actionId && actionId) ex.actionId = actionId; }; for (const a of _pompierAssignments) add(a.techId, a.name, a.start, a.end, null); try { // v2026.5.46 — scan des jours du store v2 (liste plate d'ivs) const all = await chrome.storage.local.get(null); for (const key of Object.keys(all)) { if (key.indexOf(STORE_PREFIX) !== 0) continue; const entry = all[key]; if (!entry || !Array.isArray(entry.ivs)) continue; for (const iv of entry.ivs) { if (!iv || !iv.isPompier || ivIsDead(iv)) continue; const tid = iv.techId; add(tid, (TEAM && TEAM[tid]) || "Pompier", _toIso(iv.startDate), _toIso(iv.endDate || iv.startDate), iv.actionId); } } } catch (e) { LOG.warn("pompier", "scan store pompiers échoué", { err: e && e.message }); } return [...map.values()]; } // 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, // projection rendue (techs[] — objets iv PARTAGÉS avec le store) currentDay: null, // v2026.5.46 — store du jour affiché (LA vérité, cf. couche données v2) 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, // Cookie PHPSESSID présent mais fetchUser retourne null (page login SSO // intermédiaire). On garde le polling actif tant que la vraie session // (avec user identifié) n'est pas arrivée. sessionPending: 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 // v2026.5.46 — calibrage serveur du compteur de session const SESSION_SYNC_INTERVAL_MS = 90 * 1000; // recalage périodique const SESSION_SYNC_MIN_GAP_MS = 60 * 1000; // throttle entre deux recalages 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 // Tracking des iv déplacées vers une autre date. // EasyVista a un cache serveur qui peut continuer à retourner l'iv pour la // date d'origine pendant quelques secondes après le drag&drop. Pour éviter // que l'iv ré-apparaisse en boucle sur l'ancien jour pendant ce délai, on // la marque ici et on la filtre du fresh tant que la fenêtre est active. // Map const _recentlyMovedAway = new Map(); const _RECENT_MOVE_TTL_MS = 60 * 1000; function _registerRecentMove(actionId, fromDate, toDate) { if (!actionId || !fromDate || !toDate || fromDate === toDate) return; _recentlyMovedAway.set(String(actionId), { fromDate, toDate, ts: Date.now() }); } // v2026.5.46 — l'iv vient-elle d'être déplacée HORS de cette date ? // Utilisé comme garde d'INGESTION (storeApplyFresh n'ingère pas sa fausse // présence resservie par le cache serveur EV) et comme filtre d'AFFICHAGE // (storeProject). Purge automatique de la fenêtre TTL au passage ; arriver // sur la date CIBLE annule la fenêtre (l'iv y est légitime). function _recentlyMovedAwayCovers(actionId, currentDate) { if (!_recentlyMovedAway.size || actionId === undefined || actionId === null) return false; const now = Date.now(); for (const [k, e] of _recentlyMovedAway) { if (now - e.ts > _RECENT_MOVE_TTL_MS) _recentlyMovedAway.delete(k); else if (e.toDate === currentDate) _recentlyMovedAway.delete(k); } const e = _recentlyMovedAway.get(String(actionId)); return !!(e && e.fromDate === currentDate); } // ─── 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 abortCurrentRefresh() { // v2026.5.46 — « Arrêter » vise le pipeline EN COURS (propriétaire du // bouton) et LUI SEUL : sans pipeline actif il n'y a rien à annuler, et une // navigation encore pré-pipeline (jeton pris à l'entrée) survit toujours — // elle affichera son jour. if (_loadPipelineToken !== null) abortedToken = _loadPipelineToken; // Réveiller tous les loadForDate en attente (normalement un seul) for (const [token, resolve] of abortResolvers) { if (token <= currentRefreshToken) { resolve("aborted"); abortResolvers.delete(token); } } // tuer toutes les requêtes EV en vol côté background pour que // rien ne continue en arrière-plan. fire-and-forget. try { sendMessage({ type: "abortAllFetches" }).catch(() => {}); } catch (e) { /* ignore */ } // retirer l'animation "en cours" sur toutes les iv en analyse (le verdict, // lui, reste le dernier connu — l'analyse sera retentée au prochain refresh) if (state.currentData && state.currentData.techs) { for (const tech of state.currentData.techs) { for (const iv of tech.interventions || []) { if (iv._analyzing) { iv._analyzing = false; try { updateInterventionRow(iv); } catch (e) { /* ignore */ } } } } } } // 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 (typeof myToken !== "number") return abortedToken === currentRefreshToken; if (abortedToken === myToken) return true; // « Arrêter » a visé CE flux if (myToken < currentRefreshToken) return true; // un flux plus récent existe return false; } function cleanupAbortResolver(myToken) { abortResolvers.delete(myToken); } // ============================================================================ // Boot // ============================================================================ document.addEventListener("DOMContentLoaded", init); async function init() { // v2026.5.46 — onglet dédié "Mes actions" : viewer.html?view=myactions // affiche la page-liste (rendue par l'extension) au lieu du planning. try { if (new URLSearchParams(location.search).get("view") === "myactions") { await initMyActionsPage(); return; } } catch (e) { /* pas de URLSearchParams → boot planning normal */ } await initTheme(); // v2026.5.46 — hygiène du store au boot : purge unique des clés de // l'ANCIEN format (planning_cache_*, libère le quota) + purge des jours v2 // plus vieux que cfg.cacheDays (le réglage est enfin appliqué réellement, // plus seulement quand le quota déborde). Non bloquant. storePurgeLegacy().then(() => storePurgeOldDays()).catch(() => {}); // v2026.5.46 — charger la meta persistante des réservations (n°/adresse/type). await loadResMetaStore(); // v2026.5.46 — charger les assignations pompier (affichées dans les stats). await loadPompierAssignments(); // 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(); // appliquer la couleur + police de la topbar enregistrées. _initTopbarStyleFromConfig(); // exposer la hauteur réelle de la topbar (sticky) en variable CSS. _initTopbarHeightVar(); // surveille les redim. → re-trim les gaps mini si une iv tronque _attachMiniGapsResizeWatcher(); // v2026.5.45 (issue #3) : si on vient de faire une mise à jour depuis // une version qui avait le bug structurel d'écrasement des absences // récurrentes, prévenir l'utilisateur via toast. Une seule fois. _checkInstallEventAndNotify(); // v2026.5.45 (issue #4) : écouter les changements de cookie EZV // broadcastés par background.js (si la permission "cookies" est accordée). _attachCookieChangeListener(); // 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 _ivDockInit(); // dock latéral d'iv mises de côté // 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) { const wasInternal = state.networkContext === "internal"; 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; } LOG.info("viewer", `réseau détecté : ${resp.context} → ${resp.origin}`); // v2026.5.46 — passage (ou confirmation) en INTERNE → on (re)lance la // synchro dossier pour la date affichée, ce qui relance le host natif s'il // était éteint. En externe, storeSyncFolder ne fait rien. if (resp.context === "internal" && !wasInternal && state.currentDate) { reconcileCacheForDate(state.currentDate).catch(() => {}); } } } catch (e) { LOG.warn("viewer", "détection réseau échouée", e); } } async function refreshSessionAndLoad() { const resp = await sendMessage({ type: "getSession" }); if (!resp.ok || !resp.session) { // si un store existe pour le jour demandé, on l'affiche avec une // bannière "session expirée" sticky au-dessus. Sinon écran plein. const dateAuLancement = state.currentDate; const day = await storeLoad(dateAuLancement); // v2026.5.46 — l'utilisateur a pu naviguer pendant la lecture du store // (boot sans session) : si la date affichée a changé entre-temps, on ne // touche ni l'état ni l'écran (sinon les données du jour lu seraient // affichées sous l'étiquette du nouveau jour). if (state.currentDate !== dateAuLancement) return; if (day) { state.currentDay = day; renderFromData({ techs: storeProject(day, state.currentDate), targetDate: state.currentDate, captureTime: day.savedAt || null, source: "store" }); 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; LOG.info("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. LOG.warn("currentUser", "badge DOM introuvable — abandon"); return; } let success = false; let errorReason = null; try { const resp = await sendMessage({ type: "fetchCurrentUser" }); LOG.info("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); LOG.info("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; const wasPending = state.sessionPending; state.sessionPending = false; // session définitive → polling peut s'arrêter success = true; _currentUserRetryCount = 0; // reset compteur au succès // Si on était en attente d'auth définitive (page login SSO), le // planning n'a pas encore été chargé avec la vraie session — on // relance maintenant qu'on a un user valide. if (wasPending && typeof loadForDate === "function") { loadForDate(state.currentDate, { forceRefetch: true }).catch(() => {}); } } } } catch (err) { errorReason = "exception: " + String(err); LOG.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. LOG.warn("currentUser", `échec (raison: ${errorReason}) — badge reste en état "?"`); // user_null = page de login SSO intermédiaire → on a un cookie PHPSESSID // mais pas la vraie session définitive. On flag pour que le polling 2s // continue à surveiller le PHPSESSID (qui changera quand l'auth termine). if (errorReason === "user_null") { state.sessionPending = true; } // 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++; LOG.info("currentUser", `retry programmé : ${_currentUserRetryCount}/${_CURRENT_USER_MAX_RETRIES} dans ${_CURRENT_USER_RETRY_DELAY_MS / 1000}s`); setTimeout(() => { fetchAndShowCurrentUser(); }, _CURRENT_USER_RETRY_DELAY_MS); } else { LOG.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; } LOG.info("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.46 — le popup devient le panneau de contrôle de la session : // ligne « Connexion » (domaine actif) + bouton Prolonger toujours accessible // (avant : uniquement dans le chip d'alerte < 5 min). const connEl = document.createElement("div"); connEl.className = "user-name-popup-conn"; connEl.id = "user-name-popup-conn"; _renderUserPopupConnLine(connEl); popup.appendChild(connEl); const extendPopupBtn = document.createElement("button"); extendPopupBtn.type = "button"; extendPopupBtn.className = "user-name-popup-settings"; const _extendHtml = '🔄 Prolonger la session (30 min)'; extendPopupBtn.innerHTML = _extendHtml; extendPopupBtn.title = "Remet le compteur de session EasyVista à 30 minutes"; extendPopupBtn.addEventListener("click", () => _sessionExtend(extendPopupBtn, _extendHtml)); popup.appendChild(extendPopupBtn); // 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; // v2026.5.46 — état expiré affiché explicitement dans le popup. if (state.sessionExpired) { el.textContent = "Session : expirée"; el.className = "user-name-popup-session session-critical"; 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; } // v2026.5.46 — calibrage du compteur local sur le SERVEUR (timeout_ajax // session_time, le même endpoint qu'EV utilise pour sa propre modal de // timeout : il ne prolonge pas la session). Throttle interne 60 s sauf force. let _sessionSyncLast = 0; let _sessionSyncInFlight = false; async function _sessionServerSync(opts) { const force = !!(opts && opts.force); if (_sessionSyncInFlight) return false; if (!force && Date.now() - _sessionSyncLast < SESSION_SYNC_MIN_GAP_MS) return false; if (state.sessionExpired || state.reconnecting) return false; _sessionSyncInFlight = true; try { const resp = await sendMessage({ type: "getSessionRemaining" }); _sessionSyncLast = Date.now(); if (resp && resp.ok && typeof resp.remainingMs === "number") { const drift = state.sessionExpireAt ? (state.sessionExpireAt - (Date.now() + resp.remainingMs)) : null; state.sessionExpireAt = Date.now() + resp.remainingMs; if (drift !== null && Math.abs(drift) > 30000) { LOG.info("session", "compteur recalé sur le serveur (dérive " + Math.round(drift / 1000) + "s)"); } updateSessionIndicator(); return true; } if (resp && resp.error === "session_expired") handleSessionExpired(); return false; } catch (e) { return false; } finally { _sessionSyncInFlight = false; } } // v2026.5.46 — le compteur local atteint 0 : VERIFIER au serveur avant de // declarer la session expiree (un onglet EV ouvert peut la maintenir vivante // alors que notre hypothese locale 30 min est depassee). // v2026.5.46 — l'expiration n'est déclarée QUE SUR PREUVE : réponse // serveur « session_expired » (la sonde a reçu une page de login), temps // restant nul, ou « no_session » (plus AUCUN PHPSESSID — ni onglet EV ni // cookie : l'extension ne peut de toute façon plus rien fetcher). Une erreur // TRANSITOIRE (réseau, HTTP 5xx, réponse invalide, messaging) ne bascule // PLUS en « session expirée » : nouvel essai au plus tôt dans 30 s, le chip // reste à 00:00 entre-temps (cf. updateSessionIndicator). let _sessionExpireVerifying = false; let _sessionExpireNextTryAt = 0; async function _sessionExpireVerify() { if (_sessionExpireVerifying || state.sessionExpired) return; if (Date.now() < _sessionExpireNextTryAt) return; _sessionExpireVerifying = true; let verdict = "unknown"; // alive | dead | unknown let remainingMs = 0; try { const resp = await sendMessage({ type: "getSessionRemaining" }); if (resp && resp.ok && typeof resp.remainingMs === "number") { if (resp.remainingMs > 1000) { verdict = "alive"; remainingMs = resp.remainingMs; } else verdict = "dead"; } else if (resp && (resp.error === "session_expired" || resp.error === "no_session")) { verdict = "dead"; } } catch (e) { /* messaging KO → verdict unknown */ } _sessionExpireVerifying = false; if (verdict === "alive") { state.sessionExpireAt = Date.now() + remainingMs; LOG.warn("session", "compteur local a 0 mais session serveur vivante (" + Math.round(remainingMs / 1000) + "s) → recalé, pas d'expiration"); updateSessionIndicator(); return; } if (verdict === "unknown") { _sessionExpireNextTryAt = Date.now() + 30000; LOG.warn("session", "compteur local a 0, vérification serveur impossible (transitoire) → nouvel essai dans 30 s, pas d'expiration"); return; } handleSessionExpired(); } // v2026.5.46 — prolongation de session factorisée (chip topbar + popup // badge) : désactive le bouton, appelle keep_connection, recale le compteur. async function _sessionExtend(btn, restoreHtml) { if (btn) { btn.disabled = true; btn.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; showToast("Session prolongée", "30 minutes de plus"); } else { throw new Error((resp && resp.error) || "erreur inconnue"); } } catch (err) { if (err.message === "session_expired" || err.message === "no_session") { handleSessionExpired(); return; } showToast("Prolongation impossible", err.message || "erreur"); } finally { if (btn) { btn.disabled = false; if (restoreHtml) btn.innerHTML = restoreHtml; } updateSessionIndicator(); } } // v2026.5.46 — ligne « Connexion » du popup badge : domaine EV actif. function _renderUserPopupConnLine(el) { if (!el) return; const o = state.lastKnownOrigin || ""; let txt = "Connexion : —"; if (state.sessionExpired) txt = "Connexion : session à rétablir"; else if (o.includes("etat-de-vaud")) txt = "Connexion : EasyVista interne (bureau)"; else if (o.includes("itsma.vd.ch")) txt = "Connexion : EasyVista externe (télétravail)"; el.textContent = txt; } // ============================================================================ // 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) { LOG.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 // v2026.5.46 — bouton principal "Ajouter" → menu Réservation/Absence/Pompier. const addMenuBtn = document.getElementById("add-menu-btn"); const addMenu = document.getElementById("add-menu"); if (addMenuBtn && addMenu) { const addMenuWrap = document.getElementById("add-menu-wrap"); // v2026.5.46 — rétablit le menu dans son conteneur (dé-portalise) + reset. const _restoreAddMenu = () => { addMenu.classList.remove("add-menu--right"); addMenu.style.left = ""; addMenu.style.top = ""; if (addMenuWrap && addMenu.parentNode !== addMenuWrap) addMenuWrap.appendChild(addMenu); }; const closeAddMenu = () => { addMenu.classList.add("hidden"); addMenuBtn.setAttribute("aria-expanded", "false"); _restoreAddMenu(); }; addMenuBtn.addEventListener("click", (e) => { e.stopPropagation(); const willOpen = addMenu.classList.contains("hidden"); addMenuBtn.setAttribute("aria-expanded", willOpen ? "true" : "false"); // v2026.5.46 — en vue horizontale (bouton dans la sidebar gauche), // on PORTALISE le menu dans et on l'ouvre à DROITE du bouton, sinon // il est rogné/derrière les cartes (contexte d'empilement de la sidebar). const horiz = document.documentElement.classList.contains("view-horizontal"); if (willOpen && horiz) { if (addMenu.parentNode !== document.body) document.body.appendChild(addMenu); const r = addMenuBtn.getBoundingClientRect(); addMenu.classList.add("add-menu--right"); addMenu.style.left = Math.round(r.right + 10) + "px"; addMenu.style.top = Math.round(r.top) + "px"; addMenu.classList.remove("hidden"); } else if (willOpen) { _restoreAddMenu(); addMenu.classList.remove("hidden"); } else { closeAddMenu(); } }); // Clic ailleurs → fermer le menu (exclut le wrap ET le menu lui-même, // car portalisé dans en vue horizontale). document.addEventListener("click", (e) => { if (!addMenu.classList.contains("hidden") && !e.target.closest("#add-menu-wrap") && !e.target.closest("#add-menu")) closeAddMenu(); }); const wire = (id, fn) => { const b = document.getElementById(id); if (b) b.addEventListener("click", () => { closeAddMenu(); fn(); }); }; wire("add-menu-reservation", showReservationModal); wire("add-menu-absence", showAbsenceModal); wire("add-menu-pompier", showPompierModal); } const douchetteBtn = document.getElementById("douchette-btn"); // v2026.5.46 — CORRECTIF : ne PAS passer le handler direct, sinon l'Event // arrive comme preselectTechId → hideList truthy → la liste des techniciens // était masquée dans la modal douchette. 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())); // clic sur le badge rouge "Auj. JJ.MM" → revient à aujourd'hui. const todayBadgeEl = document.getElementById("today-badge"); if (todayBadgeEl) { todayBadgeEl.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(STORE_PREFIX + state.currentDate); state.currentDay = null; await loadForDate(state.currentDate, { forceRefetch: true }); return; } if (action === "clear-all") { close(); // Supprimer toutes les clés du store v2 (+ vieux planning_cache_* s'il en reste) const all = await chrome.storage.local.get(null); const toRemove = Object.keys(all).filter(k => k.startsWith(STORE_PREFIX) || k.startsWith(LEGACY_CACHE_PREFIX)); if (toRemove.length) { await chrome.storage.local.remove(toRemove); } state.currentDay = null; 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. */ // ============================================================================ // notification dans le titre de l'onglet (tab title flip) // ============================================================================ // Quand la session EasyVista va expirer, on alterne le titre de l'onglet // entre "Planification" et "Planification" (toggle 700 ms) — visible // dans la barre des onglets du navigateur même quand le tab n'est pas // au premier plan, comme Gmail/Slack qui ajoutent "(3) " devant. let _expirationTitleInterval = null; let _expirationTitleTimeout = null; let _expirationTitleOriginal = null; function _setExpirationTitleNotice(active, durationMs) { if (_expirationTitleInterval) { clearInterval(_expirationTitleInterval); _expirationTitleInterval = null; } if (_expirationTitleTimeout) { clearTimeout(_expirationTitleTimeout); _expirationTitleTimeout = null; } // Restaure l'original si on en avait sauvegardé un. if (!active) { if (_expirationTitleOriginal != null) { try { document.title = _expirationTitleOriginal; } catch (e) {} _expirationTitleOriginal = null; } return; } _expirationTitleOriginal = document.title || "Planification"; let on = true; const tick = () => { try { // remplacement complet du titre par un message d'alerte — // le mot change drastiquement entre les deux états, l'œil capte // beaucoup mieux le mouvement dans la barre d'onglets. document.title = on ? "🚨 SESSION EXPIRE 🚨" : _expirationTitleOriginal; } catch (e) {} on = !on; }; tick(); _expirationTitleInterval = setInterval(tick, 700); if (durationMs && durationMs > 0) { _expirationTitleTimeout = setTimeout(() => { _setExpirationTitleNotice(false); }, durationMs); } } 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) { LOG.exception("alert-modal", "action de bouton en échec", e); } } }); actions.appendChild(b); if (i === 0) setTimeout(() => b.focus(), 50); }); card.appendChild(actions); // opt-in pour injecter le sous-bandeau "Activer multi-onglets" // dans la modal — utilisé par les modals d'erreur fiche EZV pour que // l'utilisateur puisse activer la lecture du cookie sans naviguer // vers les paramètres. if (opts.injectCookiesPrompt && typeof _injectCookiesPromptInBanner === "function") { try { _injectCookiesPromptInBanner(card); } catch (e) {} } 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. // on déplace today-block comme un tout (qui contient nav-today + // app-clock). Ainsi, en sidebar, le bouton "Aujourd'hui" reste au-dessus // de la date+heure du jour, dans le même cadre, comme en vue classique. const ELEMENTS_TO_RELOCATE = [ "user-badge", // tout en haut de la sidebar "app-title", // juste après user-badge "today-block", // nav-today + app-clock, en bloc "date-nav", // (pas un id mais une classe, traité à part) "capture-info", "stats", // conteneur stats globales (généré dynamiquement) "topline-coord", // v2026.5.46 — bouton « Mes actions » coordinateur "add-menu-wrap", // v2026.5.46 — bouton "Ajouter" + son menu "search-zone", // v2026.5.46 — barre de recherche, entre "Ajouter" et "Actualiser" "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.46 — garantir que la ligne stats+recherche est correctement // reconstituée en classique : stats ET search-zone DANS #topline, dans le // bon ordre (stats puis search). Le restore générique pouvait inverser // l'ordre / les sortir de #topline → la grille « stats à gauche / barre au // milieu » ne s'appliquait plus (ils s'empilaient). const _tl = document.getElementById("topline"); const _st = document.getElementById("stats"); const _sz = document.getElementById("search-zone"); if (_tl && _st && _sz) { _tl.appendChild(_st); // stats en 1er (col 1) _tl.appendChild(_sz); // puis la recherche (col 2) // v2026.5.46 — le bouton coordinateur DOIT être ré-appendé EN DERNIER. // Sinon il reste 1er dans le DOM : la grille (auto-flow) place alors l'item // de colonne 3 en premier et POUSSE stats+recherche sur une 2e rangée // (la barre de recherche « tombait » sous la ligne). Ordre DOM = colonnes. const _co = document.getElementById("topline-coord"); if (_co) _tl.appendChild(_co); // col 3, à droite } } // recalage de l'empilement collant (topbar / bandeaux / ligne) if (typeof _srSyncStickyOffsets === "function") _srSyncStickyOffsets(); // libellé complet "Aujourd'hui" partout (vue classique + horizontale). const navToday = document.getElementById("nav-today"); if (navToday) { navToday.textContent = "Aujourd'hui"; } // v2026.5.46 — en (re)passant en horizontal, la règle des heures du // mode exact devient visible : la caler sur la géométrie réelle. if (typeof _alignExactRuler === "function") { requestAnimationFrame(() => { _alignExactRuler(); if (typeof _layoutExactGrid === "function") _layoutExactGrid(); if (typeof updateNowLine === "function") updateNowLine(); }); } } /** * 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 { LOG.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) { LOG.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"); const dateCustom = document.getElementById("date-custom"); if (!el) return; if (!isoDate) { el.textContent = ""; if (dateCustom) dateCustom.classList.remove("is-today"); 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(); // libellé "Vendredi 24.04.2026" — couleur fixe désormais (la // personnalisation se fait sur toute la topbar via les paramètres). el.textContent = `${day} ${dd}.${mm}.${yyyy}`; const isToday = (isoDate === todayISO()); if (dateCustom) dateCustom.classList.toggle("is-today", isToday); } 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 au tick de l'horloge (30 s) // ET, juste après chaque rendu/resize/bascule de vue (le rendu // détruit la ligne avec #cards — le commentaire historique « cf renderFromData » // décrivait un appel qui n'existait plus). 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); }); // v2026.5.46 — mode exact. : UNE seule ligne continue, calée sur // la largeur réelle des bandes (alignement garanti). : saute les // absents. : plus de tête ni de trait sur la règle des heures — la // ligne démarre au haut des cartes. document.querySelectorAll(".exact-now-global, .exact-now-head").forEach(el => el.remove()); const isExactHoriz = (typeof HORIZONTAL_EXACT_MODE !== "undefined") && HORIZONTAL_EXACT_MODE && document.documentElement.classList.contains("view-horizontal"); if (isExactHoriz) { const container = document.getElementById("cards"); const firstRow = container ? container.querySelector(".iv-exact-row") : null; if (container && firstRow) { const contRect = container.getBoundingClientRect(); const rowRect = firstRow.getBoundingClientRect(); if (rowRect.width > 0 && contRect.width > 0) { const x = (rowRect.left - contRect.left) + (pct / 100) * rowRect.width; const allCards = container.querySelectorAll(".card"); const lastCard = allCards.length ? allCards[allCards.length - 1] : null; const bottomPx = lastCard ? (lastCard.offsetTop + lastCard.offsetHeight) : container.scrollHeight; // v2026.5.46 — la ligne du temps SAUTE les techs absents // (mêmes segments que les lignes d'heures). // v2026.5.46 — elle démarre AU HAUT DES CARTES : plus de point // ni de trait sur la règle des heures. const topPx = allCards.length ? allCards[0].offsetTop : 0; const segs = (typeof _exactAbsentSegments === "function") ? _exactAbsentSegments(container, topPx, Math.max(topPx, bottomPx)) : [[topPx, Math.max(topPx, bottomPx)]]; for (const [s, e] of segs) { if (e - s <= 0) continue; const line = document.createElement("div"); line.className = "exact-now-global"; line.style.left = Math.round(x) + "px"; line.style.top = s + "px"; line.style.height = (e - s) + "px"; container.appendChild(line); } // v2026.5.46 — plus de tête sur la règle (retirée à la demande). } } } } // ============================================================================ // Surveillance du timeout de session EasyVista // ============================================================================ /** * Vérifie si une nouvelle session EV est apparue (cookie PHPSESSID différent). * Cas couverts : * 1. Reconnexion volontaire (clic « Me reconnecter ») : state.reconnecting = true * 2. Reconnexion externe (user rouvre EV / SSO refait silencieusement) : * state.sessionExpired = true, mais l'user n'a jamais cliqué sur le bouton * * Sans le cas 2, l'user devait recharger l'extension pour que le viewer voie * qu'il était à nouveau connecté (il restait bloqué sur la bannière « Session * expirée » tant qu'il ne cliquait pas). */ async function _checkForRestoredSession() { if (!state.reconnecting && !state.sessionExpired && !state.sessionPending) return; try { const resp = await sendMessage({ type: "getSession" }); if (!resp || !resp.ok || !resp.session || !resp.session.phpsessid) return; const newId = resp.session.phpsessid; const oldPhpsessid = state.session ? state.session.phpsessid : null; // v2026.5.48 — même PHPSESSID : normalement rien à faire… SAUF si // l'utilisateur vient de cliquer « Ouvrir EasyVista / Me reconnecter » // (state.reconnecting) alors qu'un écran plein est encore affiché : la // session technique est déjà dans state mais le chargement du planning // n'a jamais abouti (échec de fetch resté à l'écran). Ignorer le tick // rendait le bouton définitivement inopérant (l'id ne changera plus) ; // on retente donc le chargement complet. const relanceDemandee = state.reconnecting && _sessionFullScreenVisible(); if (newId === oldPhpsessid && !relanceDemandee) return; // v2026.1.6 — QRO : anti-boucle « session SSO intermédiaire ». Un PHPSESSID // existe dans le cookie mais l'auth SSO n'a pas abouti → page de login, // planning vide → fetchPlanningForDate remet state.session = null → au tick // suivant ce MÊME PHPSESSID est revu comme « nouveau » (oldPhpsessid=null) // → relance toutes les 2 s à l'infini. On mémorise donc le PHPSESSID qui a // échoué et on ne le re-tente qu'après 30 s (un PHPSESSID DIFFÉRENT, lui, // est tenté immédiatement → vraie reconnexion détectée tout de suite). if (newId === state._failedRestoreId && (Date.now() - (state._failedRestoreAt || 0)) < 30000) return; if (newId !== oldPhpsessid) { LOG.info("session", "nouvelle session détectée :", newId); } else { LOG.info("session", "reconnexion demandée, écran plein encore affiché → rechargement retenté"); } 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; markSessionActivity(); _maybeRetryFetchUser("session_reconnected"); await loadForDate(state.currentDate); // Bilan : si après le chargement la session est restée invalide (SSO // intermédiaire), on marque ce PHPSESSID pour le throttler ; sinon on // confirme la reconnexion (et on retire les bannières) — pas de faux // « Reconnecté » à chaque tour. if (!state.session) { state._failedRestoreId = newId; state._failedRestoreAt = Date.now(); } else { state._failedRestoreId = null; hideReconnectingBanner(); hideSessionExpiredBanner(); hideReconnectFailedBanner(); // v2026.5.48 — ne déclarer « Reconnecté » (et ne masquer les écrans // pleins) que si le planning a RÉELLEMENT rendu. Certains échecs de // fetch gardent state.session (EV inaccessible, SSO sans user) et // réaffichent leur propre écran plein : les masquer ici avec un toast // « Reconnecté » mentirait sur une page vide. Quand le rendu a eu lieu, // renderFromData a déjà masqué « session nécessaire » mais jamais // « EV inaccessible » — d'où le masquage explicite des deux. const cartes = document.getElementById("cards"); const aRendu = !!(cartes && cartes.children.length > 0); if (aRendu) { hideSessionNeeded(); hideEvUnreachable(); showToast("Reconnecté", "Session EasyVista renouvelée"); } } } catch (e) { // Silencieux : on réessayera au prochain tick / au prochain focus. } } // v2026.5.48 — un des deux écrans pleins (« Session expirée » / // « EasyVista inaccessible ») est-il affiché ? Sert au polling de reconnexion // pour distinguer « rien à faire » de « chargement jamais abouti ». function _sessionFullScreenVisible() { return ["session-needed", "ev-unreachable"].some((id) => { const el = document.getElementById(id); return el && !el.classList.contains("hidden"); }); } /** * 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 dès qu'on est en reconnexion OU en session * expirée, pour détecter automatiquement le retour de session, peu importe * comment l'user s'est reconnecté (bouton bandeau, ouverture manuelle EV, ou * SSO/IAM silencieux depuis l'extérieur). */ function initSessionTimer() { setInterval(() => { updateSessionIndicator(); }, 1000); setInterval(_checkForRestoredSession, 2000); // v2026.5.46 — recalage périodique du compteur sur le serveur (l'onglet // visible uniquement ; au retour de visibilité un recalage immédiat suit). setInterval(() => { if (!document.hidden) _sessionServerSync(); }, SESSION_SYNC_INTERVAL_MS); // calibrage initial : remplace l'hypothèse « 30 min » du boot par la vraie // valeur serveur dès que l'interface est posée. setTimeout(() => { _sessionServerSync({ force: true }); }, 5000); // Au retour de visibilité (l'user revient sur l'onglet du viewer) ou au // focus de la fenêtre, check immédiat — sans attendre le tick de 2s. Évite // de regarder une bannière « expirée » périmée pendant 2s alors qu'on est // déjà reconnecté. document.addEventListener("visibilitychange", () => { if (!document.hidden) { _checkForRestoredSession(); _sessionServerSync(); } }); window.addEventListener("focus", () => { _checkForRestoredSession(); _sessionServerSync(); }); } /** * 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" */ // v2026.5.46 — vrai si un bandeau de session (expirée / EV inaccessible / // reconnexion / reconnexion échouée) est actuellement visible. function _anySessionBannerVisible() { try { return (typeof _SESSION_BANNER_IDS !== "undefined" ? _SESSION_BANNER_IDS : []) .some(id => { const b = document.getElementById(id); return b && !b.classList.contains("hidden"); }); } catch (e) { return false; } } // v2026.5.46 — REFONTE PROPRE : une SEULE notification de session, le chip // #app-session dans la topbar. Mis à jour chaque seconde (cf. initSessionTimer). // Règles : // - masqué si > 5 min restantes, ou pas de session connue ; // - masqué si session expirée / reconnexion en cours / un bandeau de session // est déjà affiché (jamais de doublon avec la banderole / page de reco) ; // - sinon : compteur LIVE MM:SS (jaune < 5 min, rouge < 2 min) + « Prolonger ». // Plus d'alertes glissantes ni de modal automatique (qui restaient collées et // masquaient le décompte). function updateSessionIndicator() { // v2026.5.46 — le popup badge (s'il est ouvert) tique CHAQUE seconde, // quel que soit l'état (avant : figé au-dessus de 5 min). const _popupSessLine = document.getElementById("user-name-popup-session"); if (_popupSessLine) _renderUserPopupSessionLine(_popupSessLine); const _popupConnLine = document.getElementById("user-name-popup-conn"); if (_popupConnLine) _renderUserPopupConnLine(_popupConnLine); const el = document.getElementById("app-session"); if (!el) return; if (state.sessionExpired || state.reconnecting || _anySessionBannerVisible() || !state.sessionExpireAt) { el.classList.add("hidden"); return; } const remainingMs = state.sessionExpireAt - Date.now(); if (remainingMs <= 0) { _sessionExpireVerify(); // v2026.5.46 — pendant la vérification (ou son backoff 30 s après // une erreur transitoire) : chip honnête à 00:00 rouge avec « Prolonger » // (un keep_connection réussi recale tout le compteur). el.classList.remove("hidden", "session-warn"); el.classList.add("session-critical"); const tEl = el.querySelector(".session-time"); if (tEl) { tEl.textContent = "00:00"; } else { el.innerHTML = ` 00:00 `; const bEl = el.querySelector(".session-extend-btn"); if (bEl) bEl.addEventListener("click", () => _sessionExtend(bEl, "🔄 Prolonger")); } return; } if (remainingMs > SESSION_WARN_THRESHOLD_MS) { el.classList.add("hidden"); return; } // Zone d'alerte (< 5 min) : un seul ping de confirmation serveur (le compteur // local peut être désynchronisé si EV a rafraîchi sans qu'on mette à jour // notre horloge). On NE se rappelle PAS récursivement (le tick 1s suffit). if (!state.sessionPingDone) { state.sessionPingDone = true; _sessionServerSync({ force: true }); } 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"); el.classList.add(remainingMs <= SESSION_CRITICAL_THRESHOLD_MS ? "session-critical" : "session-warn"); el.innerHTML = ` ${timeStr} `; const extendBtn = el.querySelector(".session-extend-btn"); if (extendBtn) extendBtn.addEventListener("click", () => _sessionExtend(extendBtn, "🔄 Prolonger")); } /** * 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; LOG.warn("session", "session EV expirée"); showSessionExpiredBanner(); const el = document.getElementById("app-session"); if (el) el.classList.add("hidden"); } /** * 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"; } LOG.info("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) { LOG.warn("session", "reconnexion timeout 90s → bannière échec"); state.reconnecting = false; hideReconnectingBanner(); showReconnectFailedBanner(); } }, RECONNECT_TIMEOUT_MS); } catch (err) { LOG.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: [...DEFAULT_CLOSED_STATUS], resolvedStatus: [...DEFAULT_RESOLVED_STATUS], cancelledStatus: [...DEFAULT_CANCELLED_STATUS], dayStart: 8, dayEnd: 18, cacheDays: 7, // v2026.5.48 — emails des techniciens (saisis ou récupérés depuis // l'annuaire EV ; à défaut, déduits du nom « Nom, Prénom » → prenom.nom@vd.ch). teamEmails: {}, // v2026.5.48 — proposer un brouillon d'email aux 2 techs après un déplacement. moveEmailEnabled: false, // v2026.5.48 — modèle de l'email (sujet + message), éditable dans // Paramètres → Email (R14). Valeurs de PREMIER LANCEMENT : le runtime lit // toujours la config, jamais ces constantes directement. moveEmailSubject: DEFAULT_MOVE_EMAIL_SUBJECT, moveEmailBody: DEFAULT_MOVE_EMAIL_BODY, // v2026.5.48 — domaine des adresses déduites depuis le nom. emailDomain: DEFAULT_EMAIL_DOMAIN, // v2026.5.45 — diagnostic disparitions : si true, on NE retire JAMAIS // les ghosts (verdict CANCELLED/REMOVE → on log seulement). Sert à valider // les verdicts cas par cas avant de basculer en prod. Décorrélé de // LOG.isDebug() (qui ne contrôle plus que la verbosité console). keepDisappearedGhosts: false }; } 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) { LOG.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) { LOG.error("admin", "saveAdminConfig err", e); return false; } } // v5.0.0 : affiche le panel admin plein écran. // showAdminPanel accepte un id de section initiale ("appearance", // "team", "easyvista", "diagnostics", "about"). Défaut : "appearance". async function showAdminPanel(initialSection) { // 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: "email", label: "Email", render: renderAdminSectionEmail }, // v2026.5.48 { id: "easyvista", label: "EasyVista", render: renderAdminSectionEV }, { id: "sync", label: "Synchronisation",render: renderAdminSectionSync }, { id: "data", label: "Données", render: renderAdminSectionData }, { id: "diagnostics",label: "Diagnostics", render: renderAdminSectionDiagnostics }, { id: "about", label: "À propos", render: renderAdminSectionAbout } ]; // v2026.5.46 — onglet "Test (QRO)" RÉSERVÉ à Quentin Rouiller : permet de // se faire passer pour une autre personne (coordinateur) pour tester « Mes // actions » sans être réellement coordinateur. Caché pour tout autre utilisateur. if (_isQuentin()) { sections.push({ id: "qrotest", label: "🧪 Test (QRO)", render: renderAdminSectionImpersonate }); } // utilise la section demandée si valide, sinon "appearance" par défaut. let currentSection = sections.some(s => s.id === initialSection) ? initialSection : "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 = la section demandée (par défaut "appearance"). const initialSec = sections.find(s => s.id === currentSection) || sections[0]; initialSec.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) { LOG.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 = "Cochez les personnes à afficher dans le planning. La liste vient du groupe EasyVista ci-dessous."; container.appendChild(desc); // v2026.5.48 — les adresses sont ICI (colonne Email) ; l'onglet Email ne // garde que l'option « après un déplacement » et le modèle du message. const emailHint = document.createElement("p"); emailHint.className = "admin-section-desc"; emailHint.textContent = "L'email envoyé après un déplacement (option et modèle) se règle dans l'onglet « Email »."; container.appendChild(emailHint); // ---- 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 ` + `✉ Prévenir les techniciens par email` + ``, html: true, buttons: (() => { // Annuler : rien ne part vers EV, l'iv revient à sa place (dock inclus). const restoreDock = () => { if (iv && iv._fromDock && _ivDockPendingRestore) { _ivDock.items.set(String(_ivDockPendingRestore.actionId), _ivDockPendingRestore); _ivDockPendingRestore = null; _ivDockRender(); _ivDockUpdateVisibility(); } }; const doApply = async (chg) => { try { // v2026.5.48 — la décision email du moment part avec le change. await _applyReschedule(iv, Object.assign({}, chg, { sansEmail: !emailCeDeplacement })); } catch (err) { LOG.warn("reschedule", "confirm modal apply err", { err: err && err.message }); showToast("Échec", err.message || "Impossible d'appliquer le déplacement"); restoreDock(); } }; const btns = [{ label: "Annuler", variant: "secondary", action: restoreDock }]; // v2026.5.46 — conflit + créneau libre trouvé : le bouton mis en // avant déplace vers le créneau LIBRE ; « quand même » garde le choix. if (hasConflict && freeSlot) { btns.push({ label: "Déplacer quand même", variant: "secondary", action: () => doApply(change) }); btns.push({ label: "Déplacer à " + _minToHHMMSafe(freeSlot.startMin) + " (libre)", variant: "primary", action: () => doApply(Object.assign({}, change, { newStartTime: _minToHHMMSafe(freeSlot.startMin), newEndTime: _minToHHMMSafe(freeSlot.endMin) })) }); } else { btns.push({ label: hasConflict ? "Déplacer quand même" : "Confirmer", variant: "primary", action: () => doApply(change) }); } return btns; })() }); // v2026.5.48 — câblage de la case email de la modal de confirmation // (showAlertModal construit le DOM en synchrone, la case est déjà là). // v2026.5.48 : décocher = CE déplacement seulement ; cocher quand l'option est // inactive = l'activer pour de bon (comme avant). const mailCb = document.getElementById("move-email-cb"); if (mailCb) mailCb.addEventListener("change", async () => { emailCeDeplacement = mailCb.checked; if (mailCb.checked && !MOVE_EMAIL_ENABLED) { if (!(await _setMoveEmailEnabled(true))) { mailCb.checked = false; // v2026.5.48 — échec d'écriture emailCeDeplacement = false; } } }); })(); } // ─── Application : appel API + refresh ────────────────────────────────────── async function _applyReschedule(iv, change) { const oldStart = iv.startTime; const oldEnd = iv.endTime; // v2026.5.46 — capture AVANT mutation du store (les projections // partagent les references : apres storeMarkMoved, iv.techId est deja le // nouveau tech) ; sert a la resynchronisation douchette automatique. const douchOldTechId = String(iv.techId); const douchOldTechName = iv.techName || ""; const douchSourceIso = (iv._fromDock && iv._originalDate) ? iv._originalDate : state.currentDate; const newDateDDMM = _isoToDDMMYYYY(change.newDateIso); const newDateNoSep = _isoToDDMMYYYYNoSep(change.newDateIso); const sNewMin = _hhmmToMin(change.newStartTime); const eNewMin = _hhmmToMin(change.newEndTime); const sOldMin = _hhmmToMin(oldStart); const eOldMin = _hhmmToMin(oldEnd); const oldDuration = (sOldMin != null && eOldMin != null) ? (eOldMin - sOldMin) : null; const newDuration = (sNewMin != null && eNewMin != null) ? (eNewMin - sNewMin) : null; const techChanged = String(iv.techId) !== String(change.newTechId); const startChanged = oldStart !== change.newStartTime; const dateChanged = (state.currentDate || "") !== change.newDateIso; const durationChanged = (oldDuration !== null && newDuration !== null && oldDuration !== newDuration); // v2026.5.48 — rien n'a changé : aucun appel EV ne partirait, donc ni // toast « appliqué », ni douchette, ni brouillon email mensonger (« Avant = // Après »). La modal de confirmation drag & drop a déjà son garde ; celle de // modification n'en avait pas. if (!techChanged && !startChanged && !dateChanged && !durationChanged) { showToast("Aucun changement", "Le déplacement est identique à l'existant"); return; } showToast("Modification en cours", "Application sur EasyVista…"); // Étape 1 : si tech / date / heure début ont changé → Planning_schedule_action_Employee if (techChanged || startChanged || dateChanged) { const r1 = await sendMessage({ type: "rescheduleAction", actionId: iv.actionId, employeeId: change.newTechId, date: newDateDDMM, hour: Math.floor(sNewMin / 60), minute: sNewMin % 60 }); if (!r1 || !r1.ok) { const err = (r1 && r1.error) || "rescheduleAction failed"; throw new Error(err); } } // Étape 2 : si la durée a changé → fc_save_inspector pour ajuster end_time if (durationChanged) { const suffix = `act_${iv.actionId}_nb_0_date_${newDateNoSep}`; const r2 = await sendMessage({ type: "updateActionTimes", actionId: iv.actionId, suffix, startDate: newDateDDMM, endDate: newDateDDMM, startTime: change.newStartTime, endTime: change.newEndTime }); if (!r2 || !r2.ok) { const err = (r2 && r2.error) || "updateActionTimes failed"; throw new Error(err); } } showToast("Déplacement appliqué", "Rafraîchissement du planning…"); // v2026.5.46 — le déplacement passe par le STORE (storeMarkMoved) : // - autre date → verdict moved + goneAt sur le store du jour SOURCE // (mort propagée au dossier, plus d'analyse inutile) + fenêtre anti // cache-serveur EV (_registerRecentMove, via storeMarkMoved) ; // - même jour → movedAt (la version déplacée gagne la fusion) + maj // heure/tech directement sur l'enregistrement. // Le jour SOURCE est celui d'origine de l'iv : pour une iv venue du dock // (_fromDock), c'est _originalDate (elle a pu être parquée un autre jour), // sinon le jour affiché. try { const aid = String(iv.actionId); const sourceDateIso = (iv._fromDock && iv._originalDate) ? iv._originalDate : state.currentDate; const isSourceCurrent = (state.currentDate === sourceDateIso && state.currentDay && state.currentDay.date === sourceDateIso); const srcDay = isSourceCurrent ? state.currentDay : await storeLoad(sourceDateIso); const rec = srcDay ? (srcDay.ivs || []).find(r => String(r.actionId) === aid) : null; if (srcDay && rec) { // storeMarkMoved compare change.newDateIso à srcDay.date : autre date → // moved+goneAt+fenêtre ; même jour → movedAt + heures/tech. storeMarkMoved(srcDay, rec, change); await storeSave(sourceDateIso, srcDay); } else { // record introuvable (jour source jamais chargé ?) → au minimum la // fenêtre anti cache-serveur pour le jour source. if (change.newDateIso && change.newDateIso !== sourceDateIso) { _registerRecentMove(iv.actionId, sourceDateIso, change.newDateIso); } LOG.warn("reschedule", "record source introuvable dans le store (fenêtre posée)", { actionId: iv.actionId, sourceDateIso, target: change.newDateIso }); } if (state.currentDate === sourceDateIso) { try { storeRenderCurrent("reschedule"); } catch (e) { /* render fail tolérée */ } } } catch (e) { LOG.warn("reschedule", "marquage store du déplacement échoué (best-effort)", { err: e && e.message }); } // v2026.5.46 — resynchronisation douchette automatique des techniciens // impactes (jour source + jour cible), en parallele du refresh : le compte // rendu (toast confirme / modal probleme) s'affiche des que les envois // aboutissent, sans retarder le rafraichissement du planning. try { _douchetteAutoAfterMove(douchOldTechId, douchOldTechName, change, douchSourceIso).catch((e) => { LOG.warn("douchette", "resynchronisation auto apres deplacement : echec inattendu", { err: e && e.message }); }); } catch (e) { /* best-effort, le deplacement reste applique */ } // v2026.5.48 — brouillon d'email aux deux techniciens concernés (option // Paramètres → Email). Les valeurs « avant » ont été capturées en tête de // fonction, AVANT la mutation du store. _emailAfterMove({ ref: iv.ref || "", // v2026.5.48 — mêmes replis que la carte (fiche > infobulle > attr XML) lieu: iv.ficheLocation || (iv.infobulle && iv.infobulle.lieu) || iv.bulleLieu || "", contact: (iv.infobulle && iv.infobulle.contact) || iv.bulleContact || "", oldTechId: douchOldTechId, oldTechName: douchOldTechName, oldStart, oldEnd, sourceIso: douchSourceIso, change }); // Refresh local pour refléter (le fresh EV confirme — ou la fenêtre // _recentlyMovedAway neutralise son cache serveur en retard). if (typeof loadForDate === "function") { await loadForDate(state.currentDate, { forceRefetch: true }); } // Reschedule réussi : si l'iv venait du dock, on n'a plus à la restaurer. if (iv && iv._fromDock) _ivDockPendingRestore = null; } // ============================================================================ // v2026.5.48 — emails des techniciens + brouillon d'email au déplacement // ============================================================================ // v2026.5.48 — email déduit du nom EV « Nom, Prénom » → prenom.nom@vd.ch (minuscules, // accents et espaces retirés, traits d'union conservés). Sert de repli quand // aucune adresse n'est saisie/récupérée dans Paramètres → Email. function _emailDeduitDuNom(name) { const m = /^([^,]+),\s*(.+)$/.exec(String(name || "").trim()); if (!m) return ""; const slug = (s) => normalizeName(s).replace(/['’\s]+/g, ""); const nom = slug(m[1]), prenom = slug(m[2]); const domaine = EMAIL_DOMAIN || DEFAULT_EMAIL_DOMAIN; // v2026.5.48 — configurable return (nom && prenom) ? `${prenom}.${nom}@${domaine}` : ""; } // v2026.5.48 — adresse d'un technicien : la saisie (Paramètres → Email) prime, // sinon déduction depuis le nom. Chaîne vide si introuvable. function _emailForTech(techId) { const id = String(techId || ""); return TEAM_EMAILS[id] || _emailDeduitDuNom(TEAM[id]) || ""; } // v2026.5.48 — récupération de l'adresse réelle depuis l'annuaire EV : recherche du // nom (basicAutoComplete), ouverture de la fiche employé (redirectHeader → // fetchFiche, même mécanique que les fiches d'intervention : le checksum est // servi par la session), extraction du champ AM_EMPLOYEE.E_MAIL du formData // embarqué. Best-effort : null si la recherche ne mène pas à une fiche employé. // v2026.5.48 — renvoie { email, fullName } au lieu de la seule adresse : // le nom config (venu du planning EV) peut être TRONQUÉ (« Martins, Solange » // pour « De Almeida Martins, Solange ») et c'est LUI qui s'affiche sur les // cartes. Le nom complet vient de LAST_NAME/FIRST_NAME de la MÊME fiche que // l'email (même personne, pas d'homonyme croisé), sinon du champ « Nom » du // résultat de recherche. fullName vide si introuvable. async function _fetchTechEmailFromEV(techName) { const origin = (state.session && state.session.origin) || state.lastKnownOrigin; const sid = state.session && state.session.phpsessid; if (!origin || !sid || !techName) return null; try { const r1 = await fetch(`${origin}/api/v1/internal/search/basicAutoComplete?search=${encodeURIComponent(techName)}&PHPSESSID=${encodeURIComponent(sid)}`, { credentials: "include" }); const j1 = await r1.json(); // seuls les résultats de l'annuaire (TABLE_NAME AM_EMPLOYEE) nous // intéressent — la recherche EV renvoie aussi interventions, matériel, etc. const employes = []; for (const mo of ((j1 && j1.data && j1.data.METAOBJECTS) || [])) { if (mo.TABLE_NAME !== "AM_EMPLOYEE") continue; for (const line of (mo.LINES || [])) { if (line && line.PK && line.CHECKSUM && mo.TYPE_GUID) { employes.push({ pk: line.PK, guid: mo.TYPE_GUID, checksum: line.CHECKSUM, nomAnnuaire: String((typeof _srGetField === "function" && _srGetField(line, "Nom")) || "").trim() }); } } } const dec = (s) => ((typeof decodeJsonString === "function") ? decodeJsonString(s) : s).trim(); for (const hit of employes.slice(0, 2)) { // homonymes : 2 fiches au plus const formLink = await _ficheFormLinkFromHit(origin, sid, hit); if (!formLink) continue; const resp = await sendMessage({ type: "fetchFiche", formLink }); const html = (resp && resp.ok && resp.html) || ""; const m = /"dbFieldName"\s*:\s*"E_MAIL"[^}]*?"value"\s*:\s*"([^"]+@[^"]+)"/.exec(html); if (m) { const email = dec(m[1]); if (!email.includes("@")) continue; const mNom = /"dbFieldName"\s*:\s*"LAST_NAME"[^}]*?"value"\s*:\s*"([^"]{2,80})"/i.exec(html); const mPrenom = /"dbFieldName"\s*:\s*"FIRST_NAME"[^}]*?"value"\s*:\s*"([^"]{2,80})"/i.exec(html); let fullName = (mNom && mPrenom) ? (dec(mNom[1]) + ", " + dec(mPrenom[1])) : ""; if (!fullName && hit.nomAnnuaire.includes(",")) fullName = hit.nomAnnuaire; return { email, fullName }; } } } catch (e) { LOG.info("email", `annuaire EV : recherche échouée pour « ${techName} »`, { err: e && e.message }); } return null; } // v2026.5.48 — le nom complet renvoyé par l'annuaire peut-il remplacer le nom affiché // actuel ? Garde anti-homonyme : même prénom, et chaque mot du nom actuel doit // se retrouver dans le nom complet (« Martins, Solange » est complété par // « De Almeida Martins, Solange », mais jamais remplacé par un autre nom). // Les deux doivent être au format « Nom, Prénom ». function _nomCompletCompatible(actuel, complet) { const pa = /^([^,]+),\s*(.+)$/.exec(String(actuel || "").trim()); const pc = /^([^,]+),\s*(.+)$/.exec(String(complet || "").trim()); if (!pa || !pc) return false; if (normalizeName(pa[2]) !== normalizeName(pc[2])) return false; const motsComplet = normalizeName(pc[1]).split(/\s+/).filter(Boolean); return normalizeName(pa[1]).split(/\s+/).filter(Boolean) .every((mot) => motsComplet.includes(mot)); } // v2026.5.48 — remplit un modèle d'email : chaque {variable} est remplacée par sa // valeur ; les lignes de liste restées sans valeur (« - Lieu : ») sont // retirées ; les espaces doublés par une variable vide sont nettoyés. function _moveEmailRempli(modele, valeurs) { let txt = String(modele || ""); for (const [cle, val] of Object.entries(valeurs)) { txt = txt.split("{" + cle + "}").join(val || ""); } // v2026.5.48 — les puces « • » comptent comme les tirets pour la // suppression des lignes restées vides. txt = txt.split("\n").filter(l => !/^\s*[-•][^:]*:\s*$/.test(l)).join("\n"); // v2026.5.48 — ne resserrer que les espaces doublés PAR une variable // vide (au milieu d'une ligne) : l'indentation en tête de ligne du modèle // (« • … ») est de la mise en forme voulue, on n'y touche pas. return txt.split("\n").map(l => l.replace(/(\S)[ \t]{2,}/g, "$1 ")).join("\n").trim(); } // v2026.5.48 — bascule de l'option « email après déplacement » pour les fenêtres de // déplacement (modification + confirmation drag & drop) : même état partout, // persistant (admin_config) tant qu'on ne la décoche pas. La case du panneau // Équipe, elle, écrit sa copie cfg partagée (un load-modify-save d'ici pendant // que le bouton « Enregistrer » du panneau sauve la sienne perdrait ses modifs). // v2026.5.48 — renvoie true si l'option a bien été persistée : l'appelant // peut remettre sa case dans l'état réel en cas d'échec d'écriture. async function _setMoveEmailEnabled(actif) { MOVE_EMAIL_ENABLED = !!actif; const cfg = await loadAdminConfig(); cfg.moveEmailEnabled = MOVE_EMAIL_ENABLED; const ok = await saveAdminConfig(cfg); if (!ok) { LOG.warn("email", "enregistrement de l'option email échoué"); showToast("Option non enregistrée", "L'écriture des paramètres a échoué — réessaie"); } return ok; } // v2026.5.48 — après un déplacement APPLIQUÉ, si l'option est active (Paramètres → // Équipe), ouvre un brouillon d'email (mailto → Outlook) pré-adressé aux deux // techniciens concernés avec le récapitulatif. L'ENVOI reste un geste manuel // du coordinateur : mailto ne sait qu'ouvrir un brouillon, jamais envoyer. // v2026.5.48 — le sujet et le message viennent du MODÈLE éditable de Paramètres → // Équipe ({variables} remplacées ici, cf. MOVE_EMAIL_VARIABLES). function _emailAfterMove(opts) { if (!MOVE_EMAIL_ENABLED) return; try { const { ref, lieu, contact, oldTechId, oldTechName, oldStart, oldEnd, sourceIso, change } = opts; // v2026.5.48 — case décochée dans la fenêtre de déplacement : pas // d'email pour CE déplacement uniquement (l'option globale reste active, // la prochaine fenêtre revient cochée). if (change && change.sansEmail) return; const newTechId = String(change.newTechId || ""); const newTechName = change.newTechName || (TEAM && TEAM[newTechId]) || ("tech " + newTechId); const targetIso = change.newDateIso || sourceIso; // v2026.5.48 — consigne Quentin : le brouillon ne concerne que les // VRAIS déplacements — autre JOUR ou autre TECHNICIEN. Un simple // ajustement d'heures (même tech, même jour) n'ouvre rien. if (String(oldTechId) === newTechId && targetIso === sourceIso) return; // adresses validées (pas de ?/&/,/espace : une adresse malformée saisie // dans les Paramètres ne doit pas corrompre l'URL mailto). const valide = (e) => /^[^\s@?&#,;]+@[^\s@?&#,;]+$/.test(e || ""); const adrAncien = _emailForTech(oldTechId); const adrNouveau = _emailForTech(newTechId); const dests = [...new Set([adrAncien, adrNouveau].filter(valide))]; // v2026.5.48 — adresse(s) introuvable(s) : le brouillon s'ouvre QUAND // MÊME, destinataires laissés vides ou partiels — le coordinateur complète // directement dans l'email, sans passer par les Paramètres. La notification // en bas à droite dit qui manque. const manquants = []; if (!valide(adrAncien)) manquants.push(oldTechName || ("tech " + oldTechId)); if (newTechId !== String(oldTechId) && !valide(adrNouveau)) manquants.push(newTechName); if (manquants.length) { showToast(manquants.length > 1 ? "Adresses manquantes" : "Adresse manquante", manquants.join(" et ") + (manquants.length > 1 ? " n'ont" : " n'a") + " pas d'adresse — complète le destinataire dans l'email"); } const valeurs = { numero: ref || "", ancien_tech: oldTechName || ("tech " + oldTechId), nouveau_tech: newTechName, ancienne_date: _isoToDDMMYYYY(sourceIso), nouvelle_date: _isoToDDMMYYYY(targetIso), ancienne_heure_debut: oldStart || "", ancienne_heure_fin: oldEnd || "", nouvelle_heure_debut: change.newStartTime || "", nouvelle_heure_fin: change.newEndTime || "", contact: contact || "", lieu: lieu || "" }; const sujet = _moveEmailRempli(MOVE_EMAIL_SUBJECT || DEFAULT_MOVE_EMAIL_SUBJECT, valeurs); const corps = _moveEmailRempli(MOVE_EMAIL_BODY || DEFAULT_MOVE_EMAIL_BODY, valeurs); // v2026.5.48 — séparateur « ; » entre les destinataires : Outlook // (réglages FR/CH) ne reconnaît pas la virgule — vérifié par Quentin, // « a,b » finit en une seule adresse invalide. const url = "mailto:" + dests.join(";") + "?subject=" + encodeURIComponent(sujet) + "&body=" + encodeURIComponent(corps.replace(/\n/g, "\r\n")); // même motif que les exports (cache/config) : l'ancre doit être DANS le // document au moment du click, sinon Firefox n'ouvre rien. const a = document.createElement("a"); a.href = url; a.rel = "noopener"; document.body.appendChild(a); a.click(); a.remove(); LOG.info("email", "brouillon de déplacement ouvert", { destinataires: dests.length, ref: ref || null }); } catch (e) { LOG.warn("email", "ouverture du brouillon échouée (ignoré)", { err: e && e.message }); } } // v2026.5.46 — apres un deplacement reussi, la douchette des techniciens // impactes est resynchronisee automatiquement : jour SOURCE pour l'ancien // tech, jour CIBLE pour le nouveau — un seul envoi s'ils partagent le meme // jour (empl_ids CSV), un seul couple si rien d'autre n'a change que l'heure. // Compte-rendu : toast si tous les envois sont confirmes par EV (marqueur //), modal detaillee si l'un d'eux echoue ou n'est pas confirme. async function _douchetteAutoAfterMove(oldTechId, oldTechName, change, sourceIso) { const newTechId = String(change.newTechId || ""); const targetIso = change.newDateIso || sourceIso; const nameOf = (id, hint) => hint || (typeof TEAM !== "undefined" && TEAM && TEAM[id]) || ("tech " + id); // regroupe par jour : iso -> Map(techId -> nom affichable) const byDay = new Map(); const add = (iso, id, hint) => { if (!iso || !id) return; if (!byDay.has(iso)) byDay.set(iso, new Map()); byDay.get(iso).set(String(id), nameOf(String(id), hint)); }; add(sourceIso, oldTechId, oldTechName); add(targetIso, newTechId, change.newTechName); if (!byDay.size) return; const okParts = []; const koParts = []; for (const [iso, techs] of byDay) { const ids = [...techs.keys()]; const label = [...techs.values()].join(" + ") + " (" + _isoToDDMMYYYY(iso).slice(0, 5) + ")"; try { const r = await submitDouchette(ids, iso); if (r && r.okCount > 0 && r.confirmed) okParts.push(label); else if (r && r.okCount > 0) koParts.push(label + " : envoye mais sans la confirmation EV habituelle — verifie le planning"); else koParts.push(label + " : echec de l'envoi"); } catch (e) { koParts.push(label + " : " + ((e && e.message) || "erreur")); } } if (!koParts.length) { showToast("Douchette à jour — confirmé par EasyVista ✓", okParts.join(" · ")); } else { showAlertModal({ title: "Mise à jour douchette incomplète", message: '
' + "
Le déplacement est bien appliqué, mais la synchronisation douchette n'est pas entièrement confirmée :
" + okParts.map(p => "
✓ " + escapeHtml(p) + "
").join("") + koParts.map(p => '
✗ ' + escapeHtml(p) + "
").join("") + '
Tu peux relancer à tout moment via le bouton douchette.
' + "
", html: true, buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); } } // ============================================================================ // Dock latéral — interventions mises de côté // État client uniquement (vidé à chaque rechargement). Permet au coordinateur // de glisser une iv dans une zone tampon à droite, naviguer entre les jours, // puis la déposer sur une autre date. Aucun appel EV tant qu'aucun drop final // n'est validé : l'iv reste sur EV à sa date d'origine et est juste filtrée // du rendu local. // ============================================================================ const _ivDock = { items: new Map(), // actionId → snapshot iv maxItems: 12, expanded: false, visible: false, }; let _ivDockPendingRestore = null; // iv qui vient du dock, à remettre si le drop n'aboutit pas function _ivDockInit() { if (document.getElementById("iv-dock")) return; const dock = document.createElement("aside"); dock.id = "iv-dock"; dock.className = "iv-dock iv-dock--hidden"; dock.innerHTML = `
`; document.body.appendChild(dock); document.getElementById("iv-dock-clear-btn").addEventListener("click", _ivDockClearAll); // v2026.5.45 — debouncé 500 ms (anti-flicker quand le curseur effleure // le bord du dock). dock.addEventListener("mouseenter", () => { if (_ivDock.items.size > 0 || _rescheduleDrag.active) _ivDockSetExpandedDebounced(true); }); dock.addEventListener("mouseleave", () => { if (!_rescheduleDrag.active) _ivDockSetExpandedDebounced(false); }); _ivDockUpdateVisibility(); } function _ivDockUpdateVisibility() { const dock = document.getElementById("iv-dock"); if (!dock) return; const dragging = _rescheduleDrag.active; const hasItems = _ivDock.items.size > 0; // Aucune raison d'afficher : caché complet if (!dragging && !hasItems) { dock.classList.add("iv-dock--hidden"); dock.classList.remove("iv-dock--peep-min", "iv-dock--peep", "iv-dock--expanded"); _ivDock.visible = false; return; } dock.classList.remove("iv-dock--hidden"); _ivDock.visible = true; // Hover sur le dock OU curseur proche du bord droit pendant un drag // → déploiement complet if (_ivDock.expanded) { dock.classList.add("iv-dock--expanded"); dock.classList.remove("iv-dock--peep", "iv-dock--peep-min"); return; } // Drag commencé alors qu'aucune iv n'est dans le dock → simple petit // onglet pour signaler la présence du dock sans cacher le planning if (dragging && !hasItems) { dock.classList.add("iv-dock--peep-min"); dock.classList.remove("iv-dock--peep", "iv-dock--expanded"); return; } // Le dock contient au moins une iv (drag actif ou pas) → mode // intermédiaire qui laisse voir le numéro de la DS sans pour autant // masquer le planning dock.classList.add("iv-dock--peep"); dock.classList.remove("iv-dock--peep-min", "iv-dock--expanded"); } function _ivDockSetExpanded(expanded) { // v2026.5.45 — toute mutation immédiate annule un éventuel délai // debouncé en cours (cas des callers de cleanup : drop committé, abort). if (_ivDockExpandTimer) { clearTimeout(_ivDockExpandTimer); _ivDockExpandTimer = null; _ivDockExpandTarget = null; } _ivDock.expanded = !!expanded; _ivDockUpdateVisibility(); } // v2026.5.45 — délai de 500 ms pour expand/collapse, anti-flicker quand // la souris s'approche/quitte le dock (drag) ou rentre/sort par hover. // - re-call vers la même cible : ignoré (timer déjà programmé OU état stable). // - re-call vers la cible opposée : annule le timer en cours, et reprogramme // ou no-op si l'état courant est déjà la nouvelle cible. // Les callers de cleanup utilisent _ivDockSetExpanded directement (immédiat). let _ivDockExpandTimer = null; let _ivDockExpandTarget = null; const _IV_DOCK_EXPAND_DELAY_MS = 500; function _ivDockSetExpandedDebounced(expanded) { const target = !!expanded; if (_ivDockExpandTarget === target) return; // déjà en route if (_ivDockExpandTarget === null && _ivDock.expanded === target) return; // déjà arrivé if (_ivDockExpandTimer) { // changement d'avis clearTimeout(_ivDockExpandTimer); _ivDockExpandTimer = null; _ivDockExpandTarget = null; } if (_ivDock.expanded === target) return; // l'annulation suffit _ivDockExpandTarget = target; _ivDockExpandTimer = setTimeout(() => { _ivDockExpandTimer = null; _ivDockExpandTarget = null; _ivDock.expanded = target; _ivDockUpdateVisibility(); }, _IV_DOCK_EXPAND_DELAY_MS); } function _ivDockOnDragMove(ev) { if (!_rescheduleDrag.active) return; if (!_ivDock.visible) _ivDockUpdateVisibility(); const w = window.innerWidth; const nearRight = ev.clientX > w - 140; // v2026.5.45 — on alimente le wrapper debouncé avec la cible voulue. // Le wrapper gère l'annulation si l'intention change avant 500 ms (le // curseur frôle le bord puis repart sans entrer franchement). _ivDockSetExpandedDebounced(nearRight); } function _ivDockHitTest(clientX, clientY) { const dock = document.getElementById("iv-dock"); if (!dock || dock.classList.contains("iv-dock--hidden")) return false; // Pendant un drag, on est tolérant : tout drop dans la zone droite // de la fenêtre (≤200 px du bord) compte comme drop sur le dock, // peu importe que l'animation d'expansion soit terminée ou pas. // Évite les "ratés" quand l'utilisateur lâche pile pendant la // transition peep → expanded (200 ms de fenêtre sinon). if (_rescheduleDrag.active) { const w = window.innerWidth; if (clientX >= w - 200) return true; } const r = dock.getBoundingClientRect(); return clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom; } function _ivDockAddItem(iv, sourceEl) { if (!iv || iv.actionId == null) return false; const aid = String(iv.actionId); if (_ivDock.items.has(aid)) return true; if (_ivDock.items.size >= _ivDock.maxItems) { if (typeof showToast === "function") { showToast("Dock plein", `Maximum ${_ivDock.maxItems} interventions à la fois — déposez d'abord celles déjà en attente.`); } return false; } // Capture du HTML rendu de la card source : on aura un look identique au // planning (mêmes classes, même structure interne) sans devoir le ré-bâtir. // On clone d'abord pour pouvoir retirer la classe `reschedule-source-hidden` // (display:none) appliquée pendant le drag — sinon le clone serait invisible. let html = null; let srcNode = sourceEl; if (!srcNode) { srcNode = document.querySelector( `.intervention-v2[data-action-id="${aid}"], .iv-mini-card[data-action-id="${aid}"]` ); } if (srcNode) { const clone = srcNode.cloneNode(true); clone.classList.remove("reschedule-source-hidden"); clone.removeAttribute("data-click-bound"); html = clone.outerHTML; } // v2026.5.45 — couleur catégorie : on LIT directement la classe // color-* posée sur la card source du planning. C'est la source de vérité // visuelle (calculée au render, donc avec toutes les données disponibles). // Garantit que le dock affiche exactement la même couleur que la planif. let colorKeyFromSrc = null; if (srcNode && srcNode.classList) { for (const cls of srcNode.classList) { if (cls.indexOf("color-") === 0) { colorKeyFromSrc = cls.slice(6); break; } } } const snapshot = { actionId: iv.actionId, requestId: iv.requestId, techId: iv.techId, techName: iv.techName, type: iv.type, ref: iv.ref, label: iv.label, startDate: iv.startDate, endDate: iv.endDate, startTime: iv.startTime, endTime: iv.endTime, bulleContact: iv.bulleContact, bulleLieu: iv.bulleLieu, categoryLine: iv.categoryLine, formLink: iv.formLink, deadline: iv.deadline, cssClass: iv.cssClass, isPompier: iv.isPompier, // v2026.5.45 — on capture la couleur catégorie AU MOMENT de l'add, // tant qu'on a l'iv complète (avec bulleDescription/infobulle, dont // dépendent isRollOut et isRecupAction). Sans ça, _ivDockRender qui // rappelle deriveColorKey(snap) tombe sur "autre" → trait gris. colorKey: colorKeyFromSrc || (typeof deriveColorKey === "function" ? deriveColorKey(iv) : "") || "autre", // state.currentDate est toujours ISO (YYYY-MM-DD) → format sûr pour // formatDateDM. iv.startDate peut être au format DD/MM/YYYY EV → on // privilégie state.currentDate qui est l'origine effective d'affichage. _originalDate: state.currentDate || iv.startDate || null, _html: html, }; _ivDock.items.set(aid, snapshot); _ivDockRender(); _ivDockUpdateVisibility(); return true; } // v2026.5.46 — le dock est un PUR filtre d'affichage (storeProject saute // les iv parquées) : les données du store ne sont jamais trouées. Retirer une // iv du dock = simple re-projection, aucun rechargement réseau nécessaire. function _ivDockRemoveItem(actionId) { if (!_ivDock.items.delete(String(actionId))) return; _ivDockRender(); _ivDockUpdateVisibility(); storeRenderCurrent("dock"); } function _ivDockClearAll() { if (_ivDock.items.size === 0) return; _ivDock.items.clear(); _ivDockRender(); _ivDockUpdateVisibility(); storeRenderCurrent("dock"); } function _ivDockRender() { const list = document.getElementById("iv-dock-list"); if (!list) return; list.innerHTML = ""; for (const [aid, snap] of _ivDock.items) { // Layout dock minimaliste : juste la ref + la date d'origine, fond // teinté par la couleur de la catégorie de l'iv. Pas d'heure (elle // réapparaîtra au drag, dans la forme standard de la carte). const wrapper = document.createElement("div"); wrapper.className = "iv-dock-card reschedule-ghost"; wrapper.dataset.actionId = aid; // v2026.5.45 — la classe color-* + règle CSS ne s'appliquent visiblement // pas (cause non identifiée). On force le style inline du border-left avec // var(--c-) qui résout au niveau du wrapper. Inline > toute règle. // data-color-key permet d'inspecter ce qui a été détecté. const colorKey = snap.colorKey || (typeof deriveColorKey === "function" ? deriveColorKey(snap) : "") || "autre"; wrapper.classList.add("color-" + colorKey); wrapper.dataset.colorKey = colorKey; wrapper.style.borderLeft = "4px solid var(--c-" + colorKey + ")"; // v2026.5.45 — pas de title (= pas de tooltip natif au hover). const ref = snap.ref || (snap.actionId ? "#" + snap.actionId : "(sans réf)"); // v2026.5.45 — durée prévue calculée depuis snap.startTime/endTime, on // n'affiche plus la date d'origine. Format compact : "1h", "1h30", "45min". let durationLabel = ""; const sMin = _hhmmToMin(snap.startTime); const eMin = _hhmmToMin(snap.endTime); if (Number.isFinite(sMin) && Number.isFinite(eMin) && eMin > sMin) { const total = eMin - sMin; const h = Math.floor(total / 60); const m = total % 60; if (h === 0) durationLabel = m + "min"; else if (m === 0) durationLabel = h + "h"; else durationLabel = h + "h" + (m < 10 ? "0" + m : String(m)); } wrapper.innerHTML = `
${escapeHtml(ref)}
${durationLabel ? `
${escapeHtml(durationLabel)}
` : ""}
`; const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = "iv-dock-card-remove"; removeBtn.textContent = "×"; wrapper.appendChild(removeBtn); // v2026.5.45 — retrait par appui long (2 s) sur le bouton ×. // Un clic simple ne fait RIEN (le mouseup avant 2 s annule le timer). // mouseleave et bouton hors-zone annulent aussi pour éviter les retraits // accidentels si l'utilisateur garde le doigt et déplace la souris. let _holdRemoveTimer = null; const _cancelHoldRemove = () => { if (_holdRemoveTimer) { clearTimeout(_holdRemoveTimer); _holdRemoveTimer = null; } removeBtn.classList.remove("holding"); }; removeBtn.addEventListener("mousedown", (e) => { if (e.button !== 0) return; e.stopPropagation(); e.preventDefault(); _cancelHoldRemove(); removeBtn.classList.add("holding"); _holdRemoveTimer = setTimeout(() => { _holdRemoveTimer = null; removeBtn.classList.remove("holding"); _ivDockRemoveItem(aid); }, 2000); }); removeBtn.addEventListener("mouseup", (e) => { e.stopPropagation(); _cancelHoldRemove(); }); removeBtn.addEventListener("mouseleave", _cancelHoldRemove); // Bloquer le click natif (qui se déclenche après mousedown+mouseup) pour // éviter qu'il remonte au wrapper et qu'il déclenche autre chose. removeBtn.addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); }); list.appendChild(wrapper); // Drag handle = la carte entière. Maintenir + bouger > 5 px lance le // drag avec la carte sous sa forme standard. Clic seul = rien (pas // de listener click). wrapper.addEventListener("mousedown", (e) => { if (e.button !== 0) return; // Clic sur le bouton × : laisser le bouton gérer. if (e.target.closest(".iv-dock-card-remove")) return; e.preventDefault(); e.stopPropagation(); _ivDockStartDragFromDock(snap, e); }); } } function _ivDockStartDragFromDock(snap, ev) { // v2026.5.45 — on n'enlève PAS l'iv du dock au mousedown. Un clic // simple (mousedown puis mouseup sans déplacement) ne doit rien faire et // l'iv doit rester visible dans le dock — d'où l'absence de flicker. // Le retrait effectif est différé à _activateRescheduleDrag, déclenché // quand le seuil de 5 px est franchi (= vrai drag). _dockSnap permet à // l'activation de retrouver le snap exact à retirer + à mémoriser pour // restauration en cas d'annulation. const ivForDrag = { ...snap, _fromDock: true, _dockHtml: snap._html, _dockSnap: snap }; _startReschedulePotentialDrag(ivForDrag, ev); } function buildInterventionRow(iv, cardEl) { const row = document.createElement("div"); row.className = "intervention-v2"; row.dataset.actionId = iv.actionId; if (iv.ref) row.dataset.ref = String(iv.ref); // v2026.5.46 — surbrillance recherche par réf if (iv.isPompier) row.classList.add("is-pompier-line"); // v2026.5.41 : intervention en conflit avec une absence/réservation → rouge plein if (iv._absenceConflict) row.classList.add("intervention-conflict-absence"); // conflit horaire entre 2 interventions → carte rouge if (iv._hasOverlap) row.classList.add("intervention-conflict-overlap"); // indicateur visuel "en cours d'analyse" — disparue en attente/en cours de // verdict (missingSince sans verdict, ou analyse active) → carte grisée. if (iv._analyzing || (iv.missingSince && ivVStatus(iv) === "live")) row.classList.add("_checking"); // marqueur pour décaler la signature à gauche quand un ✓ est ajouté // (sinon le check se superpose à la signature). // terminated-suspended a aussi un ✓ (jaune), même décalage. // rideau horaire — on n'ajoute le marqueur que si le verdict est // révélé (sinon on n'affiche rien du tout pour cette iv). const _rowRevealed = _isVerdictRevealed(iv); if (_rowRevealed && (ivVStatus(iv) === "done" || ivVStatus(iv) === "suspended")) { row.classList.add("has-pending-check"); } if (_rowRevealed && ivVStatus(iv) === "suspended") row.classList.add("status-suspended"); 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._analyzing && iv.type !== "AL-Absence") { row.classList.add("clickable"); row.dataset.clickBound = "1"; // 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") { const _hasResMeta = !!(iv.resNumero || iv.resVille || iv.resRue || iv.resCategorie); // v2026.5.46 — titre = « Réservé » seul ; le n° DS passe sur sa propre // ligne juste en dessous (rendu dans rightCol). Sans meta (réservation créée // directement dans EV) → « Réservation ». refHeader.textContent = _hasResMeta ? "Réservé" : "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) // on ajoute aussi un ✓ pour "terminated-pending" (gris) et un // ✓ pour "terminated-suspended" (jaune) — sans rendre la carte verte. // rideau horaire — pas de ✓ tant que pas révélé. const showPendingCheck = _rowRevealed && ivVStatus(iv) === "done"; const showSuspendedCheck = _rowRevealed && ivVStatus(iv) === "suspended"; if ((statusClass || showPendingCheck || showSuspendedCheck) && iv.type !== "AL-Reservation" && iv.type !== "AL-Absence") { const statusEl = document.createElement("div"); statusEl.className = "iv-status-check"; if (showSuspendedCheck) statusEl.classList.add("suspended"); 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); } // si la fiche n'a pas pu être récupérée correctement. // v2026.5.46 — masqué si le est périmé (fiche finalement lue : verdict // réussi / statut clos). if (iv._fetchWarning && !_staleFetchWarning(iv)) { const warnEl = document.createElement("div"); warnEl.className = "iv-fetch-warning"; warnEl.textContent = "⚠"; warnEl.title = "Fiche partiellement récupérée — vérifie ta session EasyVista"; row.appendChild(warnEl); } // ─── 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 (_ivShownStart(iv) && _ivShownEnd(iv)) { const s = document.createElement("div"); s.className = "iv-time-start"; s.textContent = _ivShownStart(iv); const sep = document.createElement("div"); sep.className = "iv-time-arrow"; sep.textContent = "↓"; const e = document.createElement("div"); e.className = "iv-time-end"; e.textContent = _ivShownEnd(iv); timeEl.appendChild(s); timeEl.appendChild(sep); timeEl.appendChild(e); // v2026.5.46 — intervention sur PLUSIEURS jours : badge pilule bien // visible sous les heures (période complète dans le title + le tooltip). if (ivIsMultiDay(iv)) { timeEl.appendChild(_makeMultiDayBadge(iv, false)); } // 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 = "—"; } // (feature reschedule) : si l'iv est éditable, on attache les handlers // de clic (modal) + drag (déplacement vers autre tech / créneau). if (typeof _attachRescheduleHandlers === "function") { _attachRescheduleHandlers(timeEl, iv); } 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") { const _hasResMeta = !!(iv.resNumero || iv.resVille || iv.resRue || iv.resCategorie); if (_hasResMeta) { // v2026.5.46 — ordre demandé : n° DS (sous « Réservé »), puis adresse // (rendue comme une intervention planifiée), puis le type en pastille // colorée. Pas de « Par » ni « Sujet » pour les réservations de l'extension. if (iv.resNumero) { const numEl = document.createElement("div"); numEl.className = "iv-reservation-ds"; numEl.textContent = iv.resNumero; rightCol.appendChild(numEl); } // v2026.5.46 — séparateur « / » (et non «, ») pour que splitLieu // découpe VILLE (gras majuscules) + adresse (italique), exactement comme // une intervention planifiée. const _resAdr = [iv.resVille, iv.resRue].filter(Boolean).join(" / "); if (_resAdr && typeof renderLieuContactBlocks === "function") { try { renderLieuContactBlocks(rightCol, _resAdr, null); } catch (e) {} } if (iv.resCategorie) { const ckRes = _resTypeColorKey(iv.resCategorie) || "autre"; const tEl = document.createElement("div"); tEl.className = "iv-reservation-sujet"; tEl.style.cssText = "margin-top:3px;"; const pill = document.createElement("span"); pill.textContent = iv.resCategorie; pill.style.cssText = "display:inline-block; padding:2px 9px; border-radius:10px; font-weight:600; font-size:11px; background:var(--c-" + ckRes + "-soft); color:var(--c-" + ckRes + ");"; tEl.appendChild(pill); rightCol.appendChild(tEl); } // v2026.5.46 — qui a créé la réservation, affiché aussi sur la carte. if (iv.reservationCreator) { const parEl = document.createElement("div"); parEl.className = "iv-reservation-par"; parEl.style.marginTop = "3px"; parEl.textContent = "Par " + iv.reservationCreator; rightCol.appendChild(parEl); } } else { // Réservation créée DIRECTEMENT dans EV (pas de meta extension) → on garde // l'ancien affichage « Par … » + « Sujet : … ». if (iv.reservationCreator) { const parEl = document.createElement("div"); parEl.className = "iv-reservation-par"; parEl.textContent = "Par " + iv.reservationCreator; rightCol.appendChild(parEl); } 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; // v2026.5.46 — la Localisation de la FICHE prime sur le lieu de // l'action et sur attr2 (planif initiale). const lieuRaw = iv.ficheLocation || info.lieu || iv.bulleLieu || null; // Rendu initial de lieu + contacts dans rightCol renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, info.contactAnomalie, iv.locMismatch); // ── 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"; // v2026.5.46 — ouvre la fiche d'une intervention à partir de son n° (réf), // utilisé par une réservation liée à une intervention. Récupère un formLink frais // puis réutilise openInterventionInNewTab. async function _openInterventionByRef(ref) { const n = String(ref || "").trim(); if (!n) return; showToast("Ouverture", n); try { const formLink = await getFreshFicheFormLinkForRef(n); if (!formLink) { showToast("Introuvable", n); return; } // v2026.5.46 — openInterventionInNewTab exige un requestId (garde-fou // « identifiant manquant » qui bloquait l'ouverture depuis une réservation). // Le formLink frais porte déjà « target= » : on l'extrait pour // débloquer la garde ET pour que le fallback (si basicAutoComplete échoue) // construise une URL EV valide. const mTarget = formLink.match(/(?:^|&)target=([^&]+)/); const requestId = mTarget ? decodeURIComponent(mTarget[1]) : undefined; await openInterventionInNewTab({ ref: n, formLink, requestId }); } catch (e) { showAlertModal({ title: "Impossible d'ouvrir", message: "Intervention " + n + " introuvable : " + ((e && e.message) || e), buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); } } async function openInterventionInNewTab(iv, opts = {}) { // v2026.5.46 — une intervention FAITE/CLÔTURÉE reprise du dossier // partagé a perdu son formLink (IV_FOLDER_DROP_FIELDS : plomberie de // session, régénérée au clic via la réf). L'ancienne garde « if // (!iv.formLink) return » abandonnait alors EN SILENCE → la fiche ne // s'ouvrait plus. Or la voie préférée ci-dessous (basicAutoComplete + // redirectHeader) n'a besoin QUE de la réf. On n'abandonne donc que si NI // formLink NI réf ; le fallback fetchFiche (qui, lui, exige formLink) est // conditionné plus bas. if (!iv.formLink && !iv.ref) { showAlertModal({ title: "Impossible d'ouvrir la fiche", message: "Cette intervention n'a ni lien ni référence exploitable. Actualisez le planning (bouton Actualiser) puis réessayez.", buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); 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) { // on appelle triggerReconnect() au lieu de openEasyVista() — // déclenche la reconnexion SSO (ouvre un onglet EZV) ET surveille la // nouvelle session pour recharger le viewer auto, exactement comme // le bouton "Me reconnecter" de la bannière. showAlertModal({ title: "Impossible d'ouvrir la fiche", message: "Votre session EasyVista a expiré. Reconnectez-vous à EasyVista puis réessayez.", injectCookiesPrompt: true, buttons: [ { label: "🔄 Me reconnecter", variant: "primary", action: () => triggerReconnect() }, { 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; // v2026.5.44 Tentative préférée : basicAutoComplete + redirectHeader // (méthode officielle EV pour transformer une référence en URL fiche). // Si iv.ref est dispo, on demande à EV un checksum FRAIS sans fetcher la // fiche entière. C'est plus rapide et plus fiable. if (iv.ref) { try { LOG.info("click", "basicAutoComplete pour ref=", iv.ref); const r1 = await fetch(`${session.origin}/api/v1/internal/search/basicAutoComplete?search=${encodeURIComponent(iv.ref)}&PHPSESSID=${encodeURIComponent(session.phpsessid)}`, { credentials: "include" }); const j1 = await r1.json(); const meta = j1 && j1.data && j1.data.METAOBJECTS && j1.data.METAOBJECTS[0]; const line = meta && meta.LINES && meta.LINES[0]; if (meta && line && line.PK && meta.TYPE_GUID && line.CHECKSUM) { const r2 = await fetch(`${session.origin}/api/v1/internal/search/redirectHeader?pk=${encodeURIComponent(line.PK)}&guid=${encodeURIComponent(meta.TYPE_GUID)}&checksum=${encodeURIComponent(line.CHECKSUM)}&PHPSESSID=${encodeURIComponent(session.phpsessid)}`, { credentials: "include" }); const j2 = await r2.json(); const urlStr = (typeof j2 === "string") ? j2 : (j2 && j2.url) || ""; // Extraire target & checksum frais de l'URL retournée const mTarget = urlStr.match(/[?&]target=([^&]+)/); const mChecksum = urlStr.match(/[?&]checksum=([a-f0-9]+)/); if (mTarget && mChecksum) { target = decodeURIComponent(mTarget[1]); checksum = mChecksum[1]; LOG.info("click", `✓ checksum frais via redirectHeader : target=${target} checksum=${checksum}`); iv.ficheTarget = target; iv.ficheChecksum = checksum; } else { LOG.warn("click", "redirectHeader URL sans target/checksum :", urlStr); } } else { LOG.warn("click", "basicAutoComplete sans pk/guid/checksum, fallback sur fetch fiche"); } } catch (err) { LOG.warn("click", "erreur basicAutoComplete/redirectHeader, fallback :", err && err.message); } } // v4.1.4 (fallback navigation) : on fetch la fiche pour extraire le // checksum dans le HTML. Comportement legacy. // v2026.5.46 — ce fallback exige formLink : on ne le tente QUE s'il // est présent. Sans formLink (iv du dossier), la voie réf ci-dessus a // normalement déjà donné target+checksum ; sinon le filet plus bas ouvre // via le requestId seul. if ((!target || !checksum) && iv.formLink) { LOG.info("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.", injectCookiesPrompt: true, buttons: [ { label: "🔄 Me reconnecter", variant: "primary", action: () => triggerReconnect() }, { 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: "🔄 Me reconnecter", variant: "secondary", action: () => triggerReconnect() }, { 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)]; LOG.info("click", `Trouvé ${allMatches.length} occurrence(s) de target=${iv.requestId}&checksum=... dans HTML de la fiche (taille ${ficheResp.html.length})`); allMatches.forEach((m, idx) => LOG.info("click", `checksum ${idx} = ${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. LOG.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. LOG.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]; LOG.info("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; } } } } // v2026.5.46 — filet ultime : si aucune des voies n'a fourni de // target (iv du dossier sans formLink ET voie réf infructueuse), on ouvre // via le requestId seul (sans checksum) — EV redirige vers la fiche. if (!target) { target = iv.requestId; checksum = null; } // 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(""); LOG.info("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, onClick) { 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); // v2026.5.46 : toast cliquable (ex. erreur sync → ouvrir le journal). if (typeof onClick === "function") { toast.style.cursor = "pointer"; toast.addEventListener("click", () => { try { onClick(); } catch (e) {} }); } 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). */ // v2026.5.46 — un même « numéro » capturé par splitOneContact peut en // fait contenir PLUSIEURS numéros collés : la regex de capture est greedy sur // [chiffres espaces . -], donc deux numéros séparés par un simple espace // (« 0215571162 0791726885 ») étaient avalés d'un bloc puis affichés collés // (« 02155711620791726885 »). On re-découpe le bloc en numéros reconnus // (0X + 9, 41/33 + 9, +41/+33 + 9) ; ce qui reste (non reconnu) est rendu tel // quel. Chaque numéro repart ensuite dans formatPhone. function _splitPhoneDigits(rawTel) { const digits = String(rawTel).replace(/[^\d+]/g, ""); const out = []; let rest = digits, guard = 0; const pats = [/^\+41\d{9}/, /^\+33\d{9}/, /^41\d{9}/, /^33\d{9}/, /^0\d{9}/]; while (rest.length && guard++ < 10) { let m = null; for (const p of pats) { const x = rest.match(p); if (x) { m = x[0]; break; } } if (!m) { out.push(rest); break; } // reste non reconnu → tel quel out.push(m); rest = rest.slice(m.length); } return out.length ? out : [digits]; } 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 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(); // v2026.5.45 — tronquer au 1er label fiche colle au contact, deux cas: // 1) Label suivi de : ("Etage:", "Bureau:", etc.). // 2) Label colle a un mot minuscule sans espace ni : ("secretariatEtage") // cas frequent quand le \\n entre la valeur du contact et le label // suivant a saute dans la source EV. const _LABEL_RX = "[\u00C9E]tage|Bureau|Service|Mat[\u00E9e]riel|Probl[\u00E8e]me|TFS|Date|Heure|Lieu|Nom\\s+utilisateur|B[\u00E9e]n[\u00E9e]ficiaire"; let _cutAt = -1; const _mColon = s.match(new RegExp("(" + _LABEL_RX + ")\\s*:")); if (_mColon) _cutAt = _mColon.index; const _mGlued = s.match(new RegExp("[a-z\u00E0-\u00FF](" + _LABEL_RX + ")")); if (_mGlued) { const _gluedLabelStart = _mGlued.index + 1; if (_cutAt < 0 || _gluedLabelStart < _cutAt) _cutAt = _gluedLabelStart; } if (_cutAt >= 0) s = s.substring(0, _cutAt).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 }; // v2026.5.25 : avant d'extraire les numéros, on REMPLACE les séquences qui // sont des identifiants de matériel (LETTRES_CHIFFRES) par des espaces. // Exemples : XXXX_NNNNNNNNNNN, XNNNNNN (1-2 lettres + 5+ chiffres). // Sans ça, XXXX_NNNNNNNNNNN laisse des "NNNN NNN NN NN" qui se font prendre // pour un numéro de téléphone par le regex qui greedy sur [0-9\s.\-]. // On remplace par des espaces de même longueur pour préserver les offsets // (important pour le calcul de position du nom avant le 1er numéro). raw = String(raw); raw = raw.replace(/\b[A-Z]{1,6}_\d+/g, (m) => " ".repeat(m.length)); // Idem pour les identifiants sans underscore style XNNNNNN (1-2 lettres // majuscules suivies de 5+ chiffres collés). On garde assez permissif // pour matcher les variantes sans enlever des vrais mots. raw = raw.replace(/\b[A-Z]{1,3}\d{5,}\b/g, (m) => " ".repeat(m.length)); // v4.1.20 : regex plus permissives pour tolérer les erreurs humaines : // - pas d'espace après le numéro (ex: "0XXXXXXXXTextecoller") // - 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, XXX0123456, // 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(); // v2026.5.46 — chaque bloc capturé peut contenir plusieurs numéros // collés (espace séparateur avalé par la regex greedy) → on re-découpe. const tels = matches.flatMap(x => _splitPhoneDigits(x.tel)).map(formatPhone).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); // ex: 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: "Nom, Prénom S'annoncer à la réception" → "Nom, Prénom") * - 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 : "Nom, Prénom (" → "Nom, Prénom" s = s.replace(/\s*\([^)]*$/g, " "); // Retirer parenthèses non ouvertes en début : ")Nom" → "Nom" 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 "/ " en * { ville: "", adresse: " " } * 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 : " / / " // 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, locMismatch) { // 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"; // v2026.5.46 — adresse fiche ≠ adresse action → lieu en ROUGE, avec // l'explication au survol (comparaison ignorant casse/accents/tirets). if (locMismatch) { lieuBlock.classList.add("iv-lieu-mismatch"); lieuBlock.title = "⚠ L'adresse de la fiche ne correspond pas à celle écrite dans l'action" + String.fromCharCode(10) + "Fiche : " + locMismatch.fiche + String.fromCharCode(10) + "Action : " + locMismatch.action + String.fromCharCode(10) + "(comparaison déjà tolérante : ordre, casse, accents, abréviations et précisions en plus sont ignorés)"; } 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); // retirer/ajouter l'animation "en cours d'analyse" et le marqueur // décalage signature pour le ✓ pending. row.classList.toggle("_checking", !!(iv._analyzing || (iv.missingSince && ivVStatus(iv) === "live"))); // rideau horaire pour les marqueurs pending/suspended. const _updRevealed = _isVerdictRevealed(iv); row.classList.toggle("has-pending-check", _updRevealed && ( ivVStatus(iv) === "done" || ivVStatus(iv) === "suspended")); // dès qu'une disparue a son verdict (analyzeMissingIv retire _analyzing), // on attache le click handler s'il n'existait pas — l'user peut cliquer // sur la carte pour ouvrir la fiche, sans attendre la fin de l'actualisation. if (iv.formLink && !iv._analyzing && iv.type !== "AL-Absence" && !row.dataset.clickBound) { row.classList.add("clickable"); row.dataset.clickBound = "1"; row.addEventListener("click", (e) => { if (e.target.closest(".intervention-copy")) return; const background = !!(e.ctrlKey || e.metaKey); openInterventionInNewTab(iv, { background }); }); row.addEventListener("auxclick", (e) => { if (e.button !== 1) return; if (e.target.closest(".intervention-copy")) return; e.preventDefault(); openInterventionInNewTab(iv, { background: true }); }); row.addEventListener("mousedown", (e) => { if (e.button === 1) e.preventDefault(); }); } // si la fiche n'a pas pu être récupérée correctement (tronquée / // session expirée). On l'insère/met à jour dans le coin haut-droit de la row. // v2026.5.46 — masqué si périmé (fiche finalement lue : verdict / clos). let warnEl = row.querySelector(".iv-fetch-warning"); if (iv._fetchWarning && !_staleFetchWarning(iv)) { if (!warnEl) { warnEl = document.createElement("div"); warnEl.className = "iv-fetch-warning"; warnEl.textContent = "⚠"; warnEl.title = "Fiche partiellement récupérée — vérifie ta session EasyVista"; row.appendChild(warnEl); } } else if (warnEl) { warnEl.remove(); } // 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 // on gère AUSSI le ✓ pour terminated-pending en direct (avant on // ne créait le ✓ pending qu'au renderFromData final → l'user voyait les // ✓✓ apparaître au fur et à mesure mais les ✓ seulement à la fin). // rideau horaire — pas de ✓ tant que le seuil n'est pas atteint. let checkEl = row.querySelector(".iv-status-check"); const showPendingCheck = _updRevealed && ivVStatus(iv) === "done"; const showSuspendedCheck = _updRevealed && ivVStatus(iv) === "suspended"; // ajout du ✓ jaune pour terminated-suspended. if (sc || showPendingCheck || showSuspendedCheck) { const isDouble = (sc === "status-closed" || sc === "status-resolved"); const desiredText = isDouble ? "✓✓" : "✓"; if (!checkEl) { checkEl = document.createElement("div"); checkEl.className = "iv-status-check"; 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); checkEl.classList.toggle("suspended", showSuspendedCheck); row.classList.toggle("status-suspended", showSuspendedCheck); } else if (checkEl) { checkEl.remove(); row.classList.remove("status-suspended"); } // 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 = iv.ficheLocation || info.lieu || iv.bulleLieu || null; // renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, info.contactAnomalie, iv.locMismatch); } // 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; } // mini-card timeline (vue horizontale) — ✓/✓✓ live aussi. const miniCard = card.querySelector( `.iv-mini-card[data-iv-idx="${row.dataset.ivIdx}"]` ); if (miniCard) { miniCard.classList.remove("status-closed", "status-resolved", "status-terminated", "has-pending-check"); if (sc) miniCard.classList.add(sc); if (showPendingCheck || showSuspendedCheck) miniCard.classList.add("has-pending-check"); miniCard.classList.toggle("status-suspended", showSuspendedCheck); let miniCheck = miniCard.querySelector(".iv-mini-status-check"); if (sc || showPendingCheck || showSuspendedCheck) { const isDouble = (sc === "status-closed" || sc === "status-resolved"); if (!miniCheck) { miniCheck = document.createElement("div"); miniCheck.className = "iv-mini-status-check"; miniCard.appendChild(miniCheck); } miniCheck.textContent = isDouble ? "✓✓" : "✓"; miniCheck.classList.toggle("double", isDouble); miniCheck.classList.toggle("suspended", showSuspendedCheck); } else if (miniCheck) { miniCheck.remove(); miniCard.classList.remove("status-suspended"); } } } // régénérer le HTML des popups épinglés ouverts pour CETTE iv // — sans ça, un popup ouvert AVANT le verdict ne montre jamais le // commentaire/badge même si l'analyse a réussi en arrière-plan. try { if (typeof pinnedPopups !== "undefined" && Array.isArray(pinnedPopups)) { for (const p of pinnedPopups) { if (!p || !p.iv || !p.el) continue; const sameIv = (p.iv === iv) || (iv.actionId && String(p.iv.actionId) === String(iv.actionId)) || (iv.ref && p.iv.ref === iv.ref); if (!sameIv) continue; // Mettre à jour la référence iv (au cas où l'objet a changé) p.iv = iv; const topbar = p.el.querySelector(".pinned-popup-topbar"); const dragbar = p.el.querySelector(".pinned-popup-dragbar"); try { p.el.innerHTML = buildTooltipHTML(iv); } catch (e) { /* ignore */ } // Virer les boutons pin résiduels du tooltip de base, restaurer // topbar/dragbar propres au popup. const oldPin = p.el.querySelector('.tooltip-pinbtn[data-action="pin"]'); if (oldPin) oldPin.remove(); if (topbar) p.el.appendChild(topbar); if (dragbar) p.el.appendChild(dragbar); } } } catch (e) { /* ignore */ } // si l'iv survolée actuellement (tooltip "live") = celle qu'on // vient d'updater, on régénère son contenu aussi. try { if (state && state.currentTooltipIv && ( state.currentTooltipIv === iv || (iv.actionId && String(state.currentTooltipIv.actionId) === String(iv.actionId)) )) { const tip = tooltipEl(); if (tip && tip.classList.contains("visible")) { tip.innerHTML = buildTooltipHTML(iv); } } } catch (e) { /* ignore */ } } // ============================================================================ // 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 }; // v2026.5.39 : délai d'apparition tooltip (500ms). Permet à l'user de // passer rapidement la souris sur plusieurs cartes sans déclencher des // popups intempestifs. const TOOLTIP_SHOW_DELAY_MS = 500; let _showTooltipTimer = null; function _cancelPendingShowTooltip() { if (_showTooltipTimer) { clearTimeout(_showTooltipTimer); _showTooltipTimer = null; } } /** * Affiche un tooltip au survol d'une intervention/réservation. * * @author Quentin Rouiller */ function showTooltip(e, iv, rowEl, posOpts) { if (_techCtxMenuEl) return; // v2026.5.46 — menu contextuel ouvert : pas de popup par-dessus // v2026.5.46 — si on survole une intervention DU PLANNING (≠ un résultat // de recherche), on programme la fermeture du popup de recherche (~0,5 s). if (rowEl && !(rowEl.closest && rowEl.closest("#ev-search-results")) && typeof _srScheduleCloseFromPlanning === "function") { _srScheduleCloseFromPlanning(); } // v2026.5.39 : on planifie l'affichage à +500ms. // v2026.5.40 : on annule aussi un éventuel hide timer en cours // (cas typique : la souris revient sur la row pendant les 500ms après // mouseleave précédent → on garde la popup ouverte). _cancelPendingShowTooltip(); _cancelHideTooltip(); // Capturer les valeurs sérialisables maintenant (l'event peut être recyclé) const _ev = { clientX: e.clientX, clientY: e.clientY }; _showTooltipTimer = setTimeout(() => { _showTooltipTimer = null; _showTooltipImpl(_ev, iv, rowEl, posOpts); }, TOOLTIP_SHOW_DELAY_MS); } function _showTooltipImpl(e, iv, rowEl, posOpts) { // 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) { LOG.info("showTooltip", "ignoré : popup drag en cours"); return; } // v2026.5.27 : fermer tout popup "soft-unpinned" encore visible (il traîne // parce que la souris était dessus). Dès qu'on survole autre chose, on veut // que seul le popup actuel ou les popups épinglés restent. // v2026.5.34 : le softUnpin ne supprime plus le popup au mouseleave, donc // ici on les supprime explicitement quand un nouveau tooltip démarre. const softUnpinned = document.querySelectorAll(".soft-unpinned"); if (softUnpinned.length) { LOG.info("showTooltip", `suppression de ${softUnpinned.length} popup(s) soft-unpinned`); softUnpinned.forEach(el => { try { el.remove(); } catch (err) {} }); } // 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) { LOG.info("showTooltip", "ignoré : tooltip épinglé sur une autre iv"); return; } const el = tooltipEl(); el.innerHTML = buildTooltipHTML(iv); // v2026.5.46 — flag : ce tooltip provient-il du popup de RECHERCHE ? // (sert à coordonner la fermeture groupée recherche + tooltip). if (posOpts && posOpts.anchorRowEl) el.dataset.fromSearch = "1"; else if (el.dataset) delete el.dataset.fromSearch; 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), posOpts || {}); } // 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 = {}) { // v2026.5.40 : version SIMPLE. // - annuler les éventuels timers de show planifiés // - sauf si épinglé (clic explicite), démarrer un timer 500ms qui ferme // - si la souris revient sur la row/popup, le timer est annulé via les // listeners mouseenter (pas via flags / elementFromPoint) _cancelPendingShowTooltip(); _cancelPendingTimelinePopover(); if (bulleState.pinned && !opts.force) return; if (bulleState.hideTimer) clearTimeout(bulleState.hideTimer); bulleState.hideTimer = setTimeout(() => { bulleState.hideTimer = null; if (bulleState.pinned) return; // Sélection de texte active : on garde (sauf si force). if (!opts.force && hasTextSelectionInTooltip()) return; const el = tooltipEl(); el.classList.remove("visible", "pinned"); el.classList.add("hidden"); if (el.dataset) delete el.dataset.mode; state.currentTooltipIv = null; currentTooltipPos = null; tooltipPositionMode = null; // reset l'ancre tooltip pour ne pas reposition sur une source // obsolète au prochain scroll. _currentTooltipAnchorEl = null; _currentTooltipAnchorOpts = null; bulleState.hoveredInBulle = false; bulleState.hoveredInRow = false; }, 500); } /** v2026.5.40 : annule un timer de fermeture en cours. Appelé par les * mouseenter sur row et sur popup pour garder la popup ouverte. */ function _cancelHideTooltip() { if (bulleState.hideTimer) { clearTimeout(bulleState.hideTimer); bulleState.hideTimer = null; } } // 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"; LOG.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. // référence à la source actuellement ancrée + options, pour permettre // au scroll handler de réancrer le tooltip live à chaque scroll. Sans ça, // position:fixed gardait le tooltip collé au viewport pendant que la source // (absence/réservation/iv) défilait avec le contenu → décalage visuel. let _currentTooltipAnchorEl = null; let _currentTooltipAnchorOpts = null; function reapplyTooltipPosition() { const el = tooltipEl(); if (!el || !el.classList.contains("visible")) return; // Si la popup est épinglée (.pinned-popup), elle a sa propre logique de // positionnement (le user peut la déplacer) — on ne touche pas. if (el.classList.contains("pinned-popup")) return; // repositionner par rapport à la source ancrée pour suivre le // scroll de la page (la source bouge, le tooltip suit). if (_currentTooltipAnchorEl && _currentTooltipAnchorEl.isConnected) { // Si la source est sortie de la zone visible → cacher le tooltip // (sinon il flotterait collé en haut/bas du viewport hors contexte). const r = _currentTooltipAnchorEl.getBoundingClientRect(); const visible = r.bottom > 0 && r.top < window.innerHeight && r.right > 0 && r.left < window.innerWidth; if (!visible) { hideTooltip({ force: true }); return; } positionTooltipAnchored(_currentTooltipAnchorEl, _currentTooltipAnchorOpts || {}); return; } // Fallback ancien chemin : si pas d'ancre stockée, on garde la // compensation manuelle (mode "abs") qui existait déjà. if (!currentTooltipPos) return; if (tooltipPositionMode !== "abs") return; 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"; } /** * v2026.5.34 : fonction UNIQUE et unifiée de positionnement ancré du tooltip. * * Utilisée par : * - showTooltip() — hover d'une row intervention en vue classique * - openPersistentTimelinePopup() — clic (classique) ou hover (horizontal) * d'un segment timeline * - showTooltip() pour les cartes/badges absence * * Algorithme : * 1. Essaie 4 positions dans l'ordre : droite, gauche, dessous, dessus * 2. Chaque position : padding de 8px min par rapport à la source * 3. Chaque position : doit tenir dans la safe area (pas sous topbar/dock) * 4. Chaque position : ne doit pas chevaucher les popups épinglés existants * 5. Première position qui satisfait tout → on la prend * 6. Fallback si aucune ne marche : droite clampée (la moins pire) * * La popup ne couvre JAMAIS la source (pad >= 8px en distance euclidienne). * * @param {HTMLElement} sourceEl - l'élément déclencheur (row, card, segment) * @param {object} opts - options { anchorBelow: true pour préférer dessous } * * @author Quentin Rouiller */ function positionTooltipAnchored(sourceEl, opts) { opts = opts || {}; const el = tooltipEl(); if (!el) { LOG.warn("positionTooltip", "tooltip DOM introuvable"); return; } if (!sourceEl) { LOG.warn("positionTooltip", "sourceEl null — pas de positionnement"); return; } // on mémorise la source ancrée pour pouvoir repositionner le // tooltip à chaque scroll (sinon il reste collé au viewport en // position:fixed alors que la source bouge avec le scroll → décalage). _currentTooltipAnchorEl = sourceEl; _currentTooltipAnchorOpts = opts; const pad = 10; // padding entre source et popup const viewportMargin = 8; // marge par rapport aux bords // v2026.5.46 — rect d'ancrage custom possible (ex. tooltip d'un résultat // de recherche : X = bords du POPUP de recherche (sourceEl), Y = la carte // survolée (opts.anchorRowEl) → le tooltip sort À CÔTÉ du popup, jamais // par-dessus les autres résultats, mais aligné verticalement sur la carte. let srcRect; if (opts.anchorRowEl) { const br = sourceEl.getBoundingClientRect(); const rr = opts.anchorRowEl.getBoundingClientRect(); srcRect = { left: br.left, right: br.right, top: rr.top, bottom: rr.bottom, width: br.width, height: rr.height }; } else { srcRect = opts.anchorRect || sourceEl.getBoundingClientRect(); } const tipRect = el.getBoundingClientRect(); const tipW = tipRect.width || 320; // fallback taille si pas encore rendu const tipH = tipRect.height || 200; // Safe area : respecter topbar en haut, dock en bas const safe = (typeof _getPopupSafeArea === "function") ? _getPopupSafeArea() : { left: viewportMargin, top: viewportMargin, right: window.innerWidth - viewportMargin, bottom: window.innerHeight - viewportMargin }; // 4 candidats (ordre : droite → gauche → dessous → dessus) // Préférence opts.anchorBelow = true : dessous en premier (ex: clic timeline) // v2026.5.41 : en vue horizontale, on n'autorise QUE dessous/dessus (la // sidebar à gauche et la timeline pleine largeur rendent gauche/droite // peu praticables et le popup masquerait la row source). const rightCandidate = { x: srcRect.right + pad, y: srcRect.top, label: "droite" }; const leftCandidate = { x: srcRect.left - tipW - pad, y: srcRect.top, label: "gauche" }; const belowCandidate = { x: srcRect.left, y: srcRect.bottom + pad, label: "dessous" }; const aboveCandidate = { x: srcRect.left, y: srcRect.top - tipH - pad, label: "dessus" }; const isHorizontal = document.documentElement.classList.contains("view-horizontal"); let candidates; if (opts.forceSides) { // v2026.5.46 — priorité DROITE puis GAUCHE, dans toutes les vues (cas // du tooltip d'un résultat de recherche : doit sortir à côté du popup, // jamais par-dessus les autres résultats — y compris en horizontal). candidates = [rightCandidate, leftCandidate]; } else if (isHorizontal) { candidates = [belowCandidate, aboveCandidate]; } else if (opts.anchorBelow) { candidates = [belowCandidate, aboveCandidate, rightCandidate, leftCandidate]; } else { candidates = [rightCandidate, leftCandidate, belowCandidate, aboveCandidate]; } const pinnedRects = (typeof _getPinnedPopupsViewportRects === "function") ? _getPinnedPopupsViewportRects() : []; let chosen = null; for (const c of candidates) { // Clamp dans la safe area let cx = c.x, cy = c.y; if (cx < safe.left) cx = safe.left; if (cx + tipW > safe.right) cx = safe.right - tipW; if (cx < safe.left) continue; // popup plus large que safe area — skip if (cy < safe.top) cy = safe.top; if (cy + tipH > safe.bottom) cy = safe.bottom - tipH; if (cy < safe.top) continue; // Ne chevauche PAS la source (garantit qu'on ne la cache pas) const candRect = { left: cx, top: cy, right: cx + tipW, bottom: cy + tipH }; if (_rectsOverlap(candRect, srcRect)) continue; // Ne chevauche pas les popups épinglés existants const hitsPinned = pinnedRects.some(pr => _rectsOverlap(candRect, pr)); if (hitsPinned) continue; chosen = { x: cx, y: cy, label: c.label }; break; } if (!chosen) { // Fallback : en horizontal, dessous clampé. En classique, droite clampée. // Même si ça chevauche (cas rare : écran minuscule ou beaucoup de popups épinglés). let fx, fy; if (isHorizontal) { fx = srcRect.left; fy = srcRect.bottom + pad; } else { fx = srcRect.right + pad; fy = srcRect.top; } if (fx + tipW > safe.right) fx = safe.right - tipW; if (fx < safe.left) fx = safe.left; if (fy + tipH > safe.bottom) fy = safe.bottom - tipH; if (fy < safe.top) fy = safe.top; chosen = { x: fx, y: fy, label: "fallback" }; LOG.info("positionTooltip", "fallback utilisé (aucun candidat optimal trouvé)"); } else { LOG.info("positionTooltip", `position choisie : ${chosen.label} (${Math.round(chosen.x)}, ${Math.round(chosen.y)})`); } setTooltipViewportPosition(chosen.x, chosen.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); } /** * v2026.5.45 (issue #6) : amène un popup épinglé au premier plan en * réinitialisant le z-index de tous les autres popups épinglés. Le popup * cliqué passe à z-index 9 (juste sous la topbar à 10), les autres reprennent * le z-index par défaut hérité du CSS (5). * * IMPORTANT : la règle CSS `.tooltip.pinned-popup { z-index: 5 !important; }` * empêche un simple `style.zIndex = "9"` de prendre effet. Il FAUT utiliser * `setProperty(..., 'important')` pour battre le `!important` du CSS. * * @author Quentin Rouiller */ function _bringPinnedPopupToFront(popup) { if (!popup) return; document.querySelectorAll('.pinned-popup').forEach(p => { if (p !== popup) p.style.removeProperty('z-index'); }); popup.style.setProperty('z-index', '9', 'important'); } // v2026.5.45 : mémorisation des dimensions resizées par l'utilisateur, // keyée par "actionId|date". Persiste durant la session (pas en storage). // Permet à un popup re-créé (re-pin après unpin, retour depuis le dock, etc.) // de retrouver la taille que l'utilisateur lui avait donnée précédemment. const _userPopupSizes = new Map(); function _userPopupSizeKey(iv, date) { if (!iv || !iv.actionId) return null; return iv.actionId + "|" + (date || state.currentDate || ""); } function _rememberPopupSize(popup, iv) { if (!popup || !iv) return; const key = _userPopupSizeKey(iv, popup.dataset.originDate); if (!key) return; if (typeof ResizeObserver === "undefined") return; if (popup._sizeObserver) { try { popup._sizeObserver.disconnect(); } catch (_) {} } // box-sizing global est border-box → quand on lit la taille pour // la ré-appliquer plus tard via style.width/height, il faut prendre la // dimension BORDER-BOX (= offsetWidth/offsetHeight) sinon on stocke la // taille du content-box (sans padding+border) → au re-pin on applique // une valeur trop petite et le popup rétrécit à chaque cycle. const ro = new ResizeObserver((entries) => { for (const e of entries) { const target = e.target; const w = Math.round(target.offsetWidth); const h = Math.round(target.offsetHeight); if (w > 0 && h > 0 && (target.classList.contains("pinned-popup") || target.classList.contains("soft-unpinned"))) { _userPopupSizes.set(key, { w, h }); } } }); ro.observe(popup); popup._sizeObserver = ro; } function _applyRememberedPopupSize(popup, iv, date) { if (!popup || !iv) return false; const key = _userPopupSizeKey(iv, date); if (!key) return false; const sz = _userPopupSizes.get(key); if (!sz) return false; popup.style.width = sz.w + "px"; popup.style.height = sz.h + "px"; return true; } /** * 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. * * @author Quentin Rouiller */ 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). // v2026.5.41 : en vue horizontale, on n'autorise QUE dessous/dessus. const isHorizontal = document.documentElement.classList.contains("view-horizontal"); const candidates = isHorizontal ? [ { x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" }, { x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" } ] : [ { 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. * * @author Quentin Rouiller */ 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 pour le positionnement. // v2026.5.35 : en vue horizontale, les rows .intervention-v2 sont CACHÉES // (display: none via CSS .view-horizontal). getBoundingClientRect d'un // élément caché retourne (0,0,0,0) → popup part en haut à gauche. // Solution : priorité au tooltip actuellement visible (qui EST la vraie // source visuelle du clic/hover). Fallback sur le segment timeline. Enfin // la row (qui marche en vue classique). let rowEl = null; const _isHorizontalView = document.documentElement.classList.contains("view-horizontal"); if (_isHorizontalView) { LOG.info("pinTooltip", "vue horizontale → source = tooltip visible ou segment timeline"); // Priorité 1 : le tooltip actuellement affiché (meilleure source visuelle) const currentTip = tooltipEl(); if (currentTip && currentTip.classList.contains("visible")) { const r = currentTip.getBoundingClientRect(); if (r.width > 0 && r.height > 0) { rowEl = currentTip; LOG.info("pinTooltip", `source = tooltip visible (${Math.round(r.left)}, ${Math.round(r.top)})`); } } // Priorité 2 : segment timeline pour cette actionId if (!rowEl && iv.actionId) { // Il peut y avoir plusieurs .timeline-slot avec le même ivIdx si plusieurs // cartes. On prend celui dont la row parente a le bon actionId. const allSlots = document.querySelectorAll(".timeline-slot[data-iv-idx]"); for (const slot of allSlots) { const card = slot.closest(".card"); if (!card) continue; const ivIdx = slot.dataset.ivIdx; const row = card.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`); if (row && row.dataset.actionId === iv.actionId) { const r = slot.getBoundingClientRect(); if (r.width > 0 && r.height > 0) { rowEl = slot; LOG.info("pinTooltip", `source = segment timeline actionId=${iv.actionId}`); break; } } } } } else { // Vue classique : chercher la row .intervention-v2 (comportement d'origine) if (iv.actionId) { rowEl = document.querySelector(`.intervention-v2[data-action-id="${iv.actionId}"]`); if (rowEl) { LOG.info("pinTooltip", `vue classique → source = row intervention-v2`); } } } // Fallback final : le tooltip live (même s'il n'est pas visible) — position actuelle if (!rowEl) { LOG.warn("pinTooltip", "aucune source visible trouvée → fallback 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; // Largeur/hauteur du popup épinglé. Priorité à la taille préservée // d'un cycle dépingle → ré-épingle (pour que pinning/unpinning multiples // ne change pas la taille). Sinon, on prend la largeur du tooltip live. if (_pinTooltipPreservedSize) { popup.style.width = _pinTooltipPreservedSize.width + "px"; popup.style.height = _pinTooltipPreservedSize.height + "px"; _pinTooltipPreservedSize = null; } else { try { const liveRect = srcEl.getBoundingClientRect(); if (liveRect && liveRect.width > 100) { popup.style.width = Math.round(liveRect.width) + "px"; } } catch (e) { /* ignore — fallback CSS 520px */ } } // 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.25 : mémoriser aussi lieu + service pour la pastille enrichie try { const info = iv.infobulle || {}; const lieuRaw = info.lieu || iv.bulleLieu || ""; // Format pour pastille : "VILLE, Adresse" (on extrait les 2 premières parties) const { ville, adresse } = (typeof splitLieu === "function") ? splitLieu(lieuRaw) : { ville: null, adresse: null }; let lieuFmt = ""; if (ville && adresse) lieuFmt = ville.toUpperCase() + ", " + adresse; else if (adresse) lieuFmt = adresse; else if (ville) lieuFmt = ville.toUpperCase(); popup.dataset.lieu = lieuFmt; // Service : prendre les 2 dernières parties séparées par "/" // Ex: "AdmCV/DJES/SSCM/SSCM - Admin/TEO - OS" → "SSCM - Admin/TEO - OS" // "AdmCV/OJV/JPX/JPX Lavaux-Oron" → "JPX/JPX Lavaux-Oron" // v2026.5.26 : dans le DERNIER segment seulement, couper au " - " (espace // tiret espace) pour retirer les descriptions longues. Les // noms propres comme "Lavaux-Oron" (sans espaces) sont préservés. // Ex: "AdmCV/DJES/SSCM/SPOP - Centre administratif" → "SSCM/SPOP" // "AdmCV/OJV/JPX/JPX Lavaux-Oron" → "JPX/JPX Lavaux-Oron" (inchangé) const serviceRaw = (info.service) || iv.categoryLine || ""; if (serviceRaw) { const parts = serviceRaw.split("/").map(s => s.trim()).filter(Boolean); let last2 = parts.slice(-2); // Couper au " - " dans le DERNIER élément de last2 if (last2.length > 0) { const lastIdx = last2.length - 1; const lastSeg = last2[lastIdx]; const dashIdx = lastSeg.indexOf(" - "); if (dashIdx > 0) { last2[lastIdx] = lastSeg.substring(0, dashIdx).trim(); } } popup.dataset.service = last2.join("/"); } else { popup.dataset.service = ""; } } catch (err) { LOG.warn("pin", "lieu/service build failed", err); } // 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); // v2026.5.45 (issue #6) : amener au PREMIER PLAN le popup au clic. // Avant, tous les popups épinglés partageaient z-index 5 (sous la topbar) // et l'ordre était figé par l'ordre d'épinglage → cliquer sur un popup en // arrière ne le ramenait pas devant. Maintenant, au mousedown, on remet // tous les autres au défaut et on monte celui cliqué à z-index 9 (juste // sous la topbar qui est à 10). popup.addEventListener("mousedown", () => { _bringPinnedPopupToFront(popup); }); // 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. // v2026.5.45 : on gère aussi `copy-ref` (issue #5) et `reload` à // l'intérieur du popup épinglé. Avant, seul `pin` était traité ; le // bouton copie de référence ne réagissait pas aux clics dans un // popup épinglé (le handler était uniquement sur le tooltip live). 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); return; } if (action === "copy-ref") { e.stopPropagation(); e.preventDefault(); 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(err => LOG.warn("clipboard", "copie ref échouée (popup épinglé)", { ref, err: err && err.message })); } return; } if (action === "reload") { e.stopPropagation(); e.preventDefault(); const iv = popup._linkedIv; if (iv) reloadSingleIntervention(iv, btn); return; } if (action === "hide-iv") { e.stopPropagation(); e.preventDefault(); _triggerHideIv(btn.dataset.actionId || (popup._linkedIv && popup._linkedIv.actionId), btn); return; } if (action === "open-fiche") { e.stopPropagation(); e.preventDefault(); const iv = popup._linkedIv; if (iv) { const background = !!(e.ctrlKey || e.metaKey || e.button === 1); openInterventionInNewTab(iv, { background }); } return; } if (action === "delete-item") { e.stopPropagation(); e.preventDefault(); const actionId = btn.dataset.actionId; const kind = btn.dataset.kind || "absence"; _triggerDeleteItem(actionId, kind, btn); return; } }); // 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; // v2026.5.40 : on essaie d'abord de garder le popup à la MÊME POSITION // que le tooltip live actuel (pour que l'utilisateur ne voie pas le popup // sauter à droite/gauche/haut/bas après le clic). Si pour une raison // quelconque la position du tooltip n'est pas exploitable, fallback sur // _findFreePopupPosition. let pos = null; // v2026.5.46 — priorité ABSOLUE : position préservée d'un cycle // désépingle → ré-épingle → le popup reste EXACTEMENT au même endroit. if (_pinTooltipPreservedPos) { pos = _pinTooltipPreservedPos; _pinTooltipPreservedPos = null; } try { const liveTip = tooltipEl(); if (!pos && liveTip && liveTip.classList.contains("visible")) { const r = liveTip.getBoundingClientRect(); if (r.width > 0 && r.height > 0) { pos = { docX: r.left + window.scrollX, docY: r.top + window.scrollY, rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom } }; } } } catch (e) { /* on retombera sur _findFreePopupPosition */ } if (!pos) { 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 }); // v2026.5.34 : stocker une référence à l'iv sur l'élément DOM — utilisée // par _softUnpinPopup pour pouvoir ré-épingler via le bouton restauré. popup._linkedIv = iv; // v2026.5.45 : restaurer la taille mémorisée de l'utilisateur (issue // resize qui se perdait après pin → unpin → re-pin), puis brancher l'observer // pour mémoriser les futures modifications de taille. _applyRememberedPopupSize(popup, iv, popup.dataset.originDate); _rememberPopupSize(popup, iv); // v2026.5.40 : si la souris passe sur un popup épinglé, on ferme // IMMÉDIATEMENT toute petite popup timeline ou tooltip ouvert. Évite // que le contenu derrière le popup épinglé reste affiché parasitement. popup.addEventListener("mouseenter", () => { _cancelPendingShowTooltip(); _cancelPendingTimelinePopover(); const tip = tooltipEl(); // On ne ferme que les tooltips "live" (hover/anchored), pas les // popups épinglés eux-mêmes. if (tip && !tip.classList.contains("pinned") && tip.classList.contains("visible")) { tip.classList.remove("visible"); tip.classList.add("hidden"); if (tip.dataset) delete tip.dataset.mode; } }); // 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; } } /** * 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; // v2026.5.46 — feedback immédiat : l'utilisateur signalait que la roue // « ne réagissait pas ». On confirme le clic (toast + log diag) AVANT le // fetch, pour qu'il y ait toujours un retour visuel même si le fetch est lent // ou ne change rien. showToast("Actualisation", iv.ref || iv.reservationLabel || iv.actionId || "intervention"); LOG.info("refresh-popup", "refresh iv unique (popup épinglé)", { actionId: iv.actionId, type: iv.type, ref: iv.ref, verdict: ivVStatus(iv), hasFormLink: !!iv.formLink }); 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; // refresh manuel d'une seule iv depuis le popup. // - iv DISPARUE du planning (missingSince / verdict d'analyse) → analyse // complète (fiche + statut + commentaire tech) via analyzeMissingIv // pour rafraîchir le verdict ✓✓ / ✓ et le commentaire du popup ; // - sinon → fetchAndUpdateIntervention classique (xhr2 + fiche). if (iv.missingSince || ivVStatus(iv) !== "live") { iv._analyzing = true; await analyzeMissingIv(iv); if (state.currentDate && state.currentDay) { await storeSave(state.currentDate, state.currentDay); } } else { await fetchAndUpdateIntervention(iv, token, { useFreshChecksum: true }); } // 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) { LOG.warn("refresh-popup", "échec du rafraîchissement", err); } } /** * Désépinglage "mou" (v4.3.3) : transforme un popup épinglé en tooltip simple. * * v2026.5.34 : refonte — le popup ne disparaît PLUS au mouseleave. À la place, * il redevient un tooltip normal avec ses boutons ↻ (Actualiser) et * (Épingler) restaurés dans .tooltip-actions, et reste visible à l'écran * comme un tooltip classique. L'user peut le re-cliquer sur pour le * ré-épingler, ou cliquer ailleurs pour s'en débarrasser normalement. * * @param {HTMLElement} el - le popup à désépingler * * @author Quentin Rouiller */ function _softUnpinPopup(el) { if (!el) { LOG.warn("softUnpin", "elément null — abandon"); return; } LOG.info("softUnpin", "désépinglage du popup", el.dataset.actionId || "(sans actionId)"); // 1. Retirer de la liste des popups épinglés (pour Ctrl×2 etc.) const idx = pinnedPopups.findIndex(p => p.el === el); if (idx >= 0) { pinnedPopups.splice(idx, 1); LOG.info("softUnpin", `retiré de pinnedPopups (reste ${pinnedPopups.length})`); } // 2. Reset le flag global bulleState.pinned si plus aucun popup épinglé. // Sans ça, le tooltip live "croit" toujours qu'il est pinned et son // handler click (notamment ↻ reload) peut partir en cacahuète. if (pinnedPopups.length === 0) { bulleState.pinned = false; } // 3. Retirer les éléments propres au mode épinglé : topbar, dragbar, // close btn, et classes minimized/reduced. const dragbar = el.querySelector(".pinned-popup-dragbar"); if (dragbar) dragbar.remove(); const closeBtn = el.querySelector(".pinned-popup-close"); if (closeBtn) closeBtn.remove(); const topbar = el.querySelector(".pinned-popup-topbar"); if (topbar) topbar.remove(); el.classList.remove("pinned-popup-minimized"); el.classList.remove("pinned-popup-reduced"); // 4. Retirer la pastille du dock si elle existe if (el._linkedPill) { try { el._linkedPill.remove(); } catch (e) { LOG.warn("softUnpin", "erreur remove pill:", 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(); } // 5. Restaurer le bouton dans .tooltip-actions // Lors de l'épinglage, ce bouton a été SUPPRIMÉ du DOM (voir pinTooltip // v2026.5.17 ligne 6921). Il faut le recréer pour que l'user puisse // ré-épingler ou que l'icône revienne visible. const actionsEl = el.querySelector('.tooltip-actions'); if (actionsEl) { const existingPin = actionsEl.querySelector('.tooltip-pinbtn[data-action="pin"]'); if (!existingPin) { LOG.info("softUnpin", "restauration du bouton dans .tooltip-actions"); const pinBtn = document.createElement('div'); pinBtn.className = 'tooltip-actionbtn tooltip-pinbtn'; pinBtn.setAttribute('data-action', 'pin'); pinBtn.title = 'Épingler la bulle (ou double-Ctrl). Cliquer à nouveau pour libérer.'; pinBtn.textContent = '📌'; actionsEl.appendChild(pinBtn); } else { LOG.info("softUnpin", "bouton déjà présent, mise à jour visuelle"); existingPin.textContent = '📌'; existingPin.classList.remove('tooltip-pinbtn-active'); } } else { LOG.warn("softUnpin", ".tooltip-actions introuvable dans le popup — boutons pas restaurés"); } // 5bis. v2026.5.34 : attacher un nouveau handler click sur .tooltip-actions // du popup soft-unpinned pour ré-épinglement / actualisation. // L'ancien handler du popup épinglé (qui faisait softUnpin au clic // sur pin) est encore attaché, mais notre nouveau handler utilise // stopPropagation avant pour l'empêcher de s'exécuter. // On garde une référence à l'iv pour pouvoir ré-épingler proprement. const ivForRepin = el._linkedIv || null; if (actionsEl && ivForRepin) { // Retirer d'éventuels handlers précédents en remplaçant l'element const newActions = actionsEl.cloneNode(true); actionsEl.parentNode.replaceChild(newActions, actionsEl); newActions.addEventListener("click", (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; e.stopPropagation(); e.preventDefault(); if (action === "pin") { LOG.info("softUnpin", "clic sur d'un popup soft-unpinned → ré-épinglage"); // Capture la taille du popup soft-unpinned pour la transmettre au // nouveau popup épinglé (sinon il repart à la taille du tooltip live). try { const r = el.getBoundingClientRect(); if (r && r.width > 100 && r.height > 50) { _pinTooltipPreservedSize = { width: Math.round(r.width), height: Math.round(r.height), }; // v2026.5.46 — garder aussi la position exacte (coords document). _pinTooltipPreservedPos = { docX: Math.round(r.left + window.scrollX), docY: Math.round(r.top + window.scrollY), rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom }, }; } } catch (err) { _pinTooltipPreservedSize = null; _pinTooltipPreservedPos = null; } try { el.remove(); } catch (err) {} state.currentTooltipIv = ivForRepin; pinTooltip(); } else if (action === "reload") { LOG.info("softUnpin", "clic sur ↻ d'un popup soft-unpinned → recharger intervention"); if (typeof reloadSingleIntervention === "function") { reloadSingleIntervention(ivForRepin, btn); } else { LOG.warn("softUnpin", "reloadSingleIntervention indisponible"); } } }); } // 6. Basculer visuellement : retirer pinned-popup, mettre soft-unpinned. // La classe .soft-unpinned applique les styles "tooltip normal" // (pas de bordure bleue, pas de padding-top pour topbar disparue, etc.) // On capture la taille AVANT le changement de classe pour pouvoir la // restaurer si l'utilisateur ré-épingle (sinon le popup repart à la // taille par défaut du tooltip live). try { const r = el.getBoundingClientRect(); if (r && r.width > 100 && r.height > 50) { el._lastPinnedSize = { width: Math.round(r.width), height: Math.round(r.height) }; } } catch (e) { /* ignore */ } el.classList.remove("pinned-popup"); el.classList.add("soft-unpinned"); // Conserve la largeur/hauteur en inline pour ne pas que la perte de // .pinned-popup (resize:both, min-width/height) fasse rétrécir le popup. if (el._lastPinnedSize) { el.style.width = el._lastPinnedSize.width + "px"; el.style.height = el._lastPinnedSize.height + "px"; } // v2026.5.45 — auto-fermeture réactivée : le popup se ferme quand la // souris quitte la zone (popup + cartes liées), ainsi que sur clic ailleurs // / Échap. Pour ne pas interférer avec le drag&drop des cartes, le hit-test // est ignoré tant que la classe documentElement.reschedule-dragging est posée. _attachSoftUnpinAutoHide(el); // 7. Régénérer le tooltip hover si on est en train de survoler la même iv // (pour synchroniser l'icône /dans le tooltip live). // Aussi, reset iv._reloading qui pourrait rester bloqué à true. const tip = tooltipEl(); if (tip && tip.classList.contains("visible") && state.currentTooltipIv) { try { state.currentTooltipIv._reloading = false; tip.innerHTML = buildTooltipHTML(state.currentTooltipIv); } catch (err) { LOG.warn("softUnpin", "buildTooltipHTML failed:", err); } } // 8. v2026.5.34 : ne PAS programmer de suppression automatique au mouseleave. // Le popup reste visible comme un tooltip normal. L'user le ferme en // cliquant ailleurs ou en appuyant sur Échap. Il peut aussi re-cliquer // sur pour le ré-épingler. // // Pour que le popup disparaisse quand l'user clique ailleurs, on ajoute // un handler de clic document qui supprime tous les .soft-unpinned si // le clic est hors du popup. Ce handler ne s'arme QU'UNE FOIS (au premier // désépinglage) et reste globalement. _ensureSoftUnpinnedCleanupHandler(); LOG.info("softUnpin", "terminé — popup reste visible en mode tooltip normal"); } // Taille préservée à travers un cycle dépingle → ré-épingle. Sans ça, le // nouveau popup épinglé reprend la taille du tooltip live (par défaut), // pas celle qu'avait le popup épinglé que l'utilisateur venait de // désépingler — donc la taille "saute" à chaque cycle pin/unpin. let _pinTooltipPreservedSize = null; // v2026.5.46 — pareil pour la POSITION : au cycle désépingle → ré-épingle, // on garde le popup EXACTEMENT où il était (sinon _findFreePopupPosition le // déplace à chaque fois — surtout pour un popup issu de la recherche). let _pinTooltipPreservedPos = null; /** * Auto-fermeture du popup soft-unpinned quand la souris quitte sa zone * et n'est plus sur aucune carte de la même iv. Utilise un mousemove * document avec hit-test géométrique (plus fiable que mouseleave qui * peut rater si la souris quitte par un bord en diagonale). */ function _attachSoftUnpinAutoHide(el) { if (!el || el._autoHideAttached) return; el._autoHideAttached = true; const aid = el.dataset.actionId || ""; let hideTimer = null; const cancel = () => { if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } }; const onMove = (e) => { if (!document.body.contains(el) || !el.classList.contains("soft-unpinned")) { cancel(); document.removeEventListener("mousemove", onMove); return; } // v2026.5.45 — pendant un drag de carte, on ne touche à rien : pas de // hit-test, pas de timer démarré ni annulé. Le popup reste tel quel le // temps du drag, on reprend l'évaluation à la mousemove suivante. if (document.documentElement.classList.contains("reschedule-dragging")) { return; } // Souris dans le popup ? const r = el.getBoundingClientRect(); const inPopup = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom; // Souris dans une carte qui matche la même iv ? let inCard = false; if (!inPopup && aid) { const cards = document.querySelectorAll( `.intervention-v2[data-action-id="${aid}"], .iv-mini-card[data-action-id="${aid}"]` ); for (const c of cards) { const cr = c.getBoundingClientRect(); if (cr.width === 0 && cr.height === 0) continue; if (e.clientX >= cr.left && e.clientX <= cr.right && e.clientY >= cr.top && e.clientY <= cr.bottom) { inCard = true; break; } } } if (inPopup || inCard) { cancel(); } else if (!hideTimer) { hideTimer = setTimeout(() => { if (!document.body.contains(el)) return; el.classList.add("unpinning"); setTimeout(() => { try { el.remove(); } catch (e2) {} document.removeEventListener("mousemove", onMove); }, 180); }, 300); } }; document.addEventListener("mousemove", onMove); } /** * v2026.5.34 : pose un handler global (une seule fois) qui ferme les popups * en état .soft-unpinned quand l'user clique ailleurs, ou appuie sur Échap. */ let _softUnpinnedCleanupHandlerInstalled = false; function _ensureSoftUnpinnedCleanupHandler() { if (_softUnpinnedCleanupHandlerInstalled) return; _softUnpinnedCleanupHandlerInstalled = true; // Clic hors d'un popup soft-unpinned → supprimer document.addEventListener("mousedown", (e) => { const softPopups = document.querySelectorAll(".soft-unpinned"); if (softPopups.length === 0) return; softPopups.forEach(el => { // Ne pas fermer si le clic est DANS le popup lui-même if (el.contains(e.target)) return; LOG.info("softUnpin cleanup", "clic hors du popup — suppression"); el.classList.add("unpinning"); setTimeout(() => { try { el.remove(); } catch (err) {} }, 180); }); }, true); // phase capture pour attraper avant d'autres handlers LOG.info("softUnpin", "handler cleanup global installé"); } // ============================================================================ // 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 // v2026.5.25 : pastille à 3 lignes — lieu (fort), service (petit), date (petit) // La référence est affichée dans le mini-menu au survol. const pill = document.createElement("button"); pill.type = "button"; pill.className = "pinned-popup-dock-pill color-" + colorKey; pill.title = "Cliquer pour agrandir"; // Ligne 1 : lieu (ou ref si pas de lieu) const pillLieu = document.createElement("span"); pillLieu.className = "pinned-popup-dock-pill-lieu"; pillLieu.textContent = popup.dataset.lieu || label || "—"; pill.appendChild(pillLieu); // Ligne 2 : service (2 dernières parties) const serviceTxt = popup.dataset.service || ""; if (serviceTxt) { const pillService = document.createElement("span"); pillService.className = "pinned-popup-dock-pill-service"; pillService.textContent = serviceTxt; pill.appendChild(pillService); } // Ligne 3 : date "Mardi 22.04" const originDate = popup.dataset.originDate || ""; if (originDate) { const pillDate = document.createElement("span"); pillDate.className = "pinned-popup-dock-pill-date"; pillDate.textContent = _formatDateForPill(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) => { // v2026.5.23 : si on était en train de drag, ne pas déclencher le clic if (pill._justDragged) { pill._justDragged = false; return; } e.stopPropagation(); // v2026.5.45 : fermer le mini-menu de survol s'il était visible. // Sans ça, cliquer directement sur la pill restaurait le popup mais le // menu (Agrandir / Fermer) restait orphelin à l'écran. _hidePillHoverMenu(); _restorePinnedPopupFromDock(popup); }); // v2026.5.20 : mini-menu au survol (Agrandir / Fermer) pill.addEventListener("mouseenter", () => { if (pill._dragging) return; // pas de menu pendant le drag _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(); }); // v2026.5.23 : drag & drop — réordonne dans le dock, ou restaure hors du dock _attachPillDragHandler(pill, popup); 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; // v2026.5.25 : REF en haut du menu (info seulement, centrée) const refText = popup.dataset.ref || ""; if (refText) { const refLabel = document.createElement("div"); refLabel.className = "pill-hover-menu-ref"; refLabel.textContent = refText; menu.appendChild(refLabel); } 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); // v2026.5.43 (Firefox-fix) : positionner le menu HORS écran d'abord pour // qu'il soit layouté sans flash, puis mesurer ses dimensions, puis poser // la position finale. Sans ça, Firefox lit parfois des dimensions à 0 // (timing de l'animation `pill-hover-menu-appear` + transform initial), // ce qui projette le menu n'importe où sur l'écran. menu.style.left = "-9999px"; menu.style.top = "-9999px"; menu.style.visibility = "hidden"; document.body.appendChild(menu); // Force le navigateur à calculer la mise en page maintenant (Firefox ne // le fait pas toujours sur getBoundingClientRect immédiat après append). void menu.offsetHeight; 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"; menu.style.visibility = ""; // 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; } // v2026.5.46 — la ligne collante stats+recherche (#topline, vue classique) // fait AUSSI partie du « mur » : aucun popup (épinglé, tooltip, drag) ne doit // passer derrière. On prend le bas de l'élément collant le plus bas. const topline = document.getElementById("topline"); if (topline && topline.offsetParent !== null) { const r = topline.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; } // v2026.5.40 : en vue horizontale, on bloque le bord gauche pour ne pas // laisser un popup glisser sous la sidebar fixe (200px, ou 170px en // breakpoint étroit < 1400px). let leftLimit = 4; if (document.documentElement.classList.contains("view-horizontal")) { const sidebar = document.querySelector(".horizontal-sidebar"); if (sidebar) { const r = sidebar.getBoundingClientRect(); if (r.right > leftLimit) leftLimit = r.right + 4; } else { leftLimit = (window.innerWidth <= 1400) ? 174 : 204; } } return { top: topLimit, bottom: bottomLimit, left: leftLimit, 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. * * @author Quentin Rouiller */ 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 // v2026.5.28 : si le popup est plus large que la zone dispo (fenêtre rétrécie // depuis le côté droit), on le garde à sa position actuelle — // l'user redimensionnera ou bougera manuellement. On préfère // "déborder" à droite plutôt que rétrécir le popup. let newVLeft = vLeft; let newVTop = vTop; const safeWidth = safe.right - safe.left; const safeHeight = safe.bottom - safe.top; if (w <= safeWidth) { // Popup rentre : on clamp normalement if (newVLeft < safe.left) newVLeft = safe.left; if (newVLeft + w > safe.right) newVLeft = safe.right - w; if (newVLeft < safe.left) newVLeft = safe.left; } // Sinon : popup plus large que la zone → on laisse où il est, user libre if (h <= safeHeight) { 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.23 : drag & drop d'une pastille du dock. * - Drag dans le dock : réordonne les pastilles (drop = insérer à la nouvelle * position). Les autres se décalent en live. * - Drop HORS du dock : restaure le popup en mode Normal à la position de la * souris. * Semi-transparent pendant le drag. */ function _attachPillDragHandler(pill, popup) { const DRAG_THRESHOLD = 4; // px avant de considérer un vrai drag let isDown = false; let isDragging = false; let startX = 0, startY = 0; let ghostEl = null; let pillOriginalNext = null; // voisin d'origine pour restaurer l'ordre const onMouseMove = (e) => { if (!isDown) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (!isDragging) { if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return; // Début du drag isDragging = true; pill._dragging = true; pill._justDragged = true; _hidePillHoverMenu(); pill.classList.add("pill-dragging"); // Créer un "ghost" flottant qui suit la souris const r = pill.getBoundingClientRect(); ghostEl = pill.cloneNode(true); ghostEl.style.position = "fixed"; ghostEl.style.left = r.left + "px"; ghostEl.style.top = r.top + "px"; ghostEl.style.width = r.width + "px"; ghostEl.style.height = r.height + "px"; ghostEl.style.zIndex = "1100"; ghostEl.style.opacity = "0.85"; ghostEl.style.pointerEvents = "none"; ghostEl.classList.add("pill-dragging-ghost"); document.body.appendChild(ghostEl); // Mémoriser le voisin d'origine pour restaurer si drop hors dock pillOriginalNext = pill.nextSibling; } // Déplacer le ghost avec la souris if (ghostEl) { ghostEl.style.left = (e.clientX - ghostEl.offsetWidth / 2) + "px"; ghostEl.style.top = (e.clientY - ghostEl.offsetHeight / 2) + "px"; } // Détecter si on est au-dessus du dock const dock = document.getElementById("pinned-popups-dock"); if (!dock) return; const dockRect = dock.getBoundingClientRect(); const insideDock = ( e.clientX >= dockRect.left && e.clientX <= dockRect.right && e.clientY >= dockRect.top && e.clientY <= dockRect.bottom ); if (insideDock) { // Trouver où insérer parmi les autres pastilles const pills = Array.from(dock.querySelectorAll(".pinned-popup-dock-pill")) .filter(p => p !== pill); let inserted = false; for (const other of pills) { const or = other.getBoundingClientRect(); const midX = or.left + or.width / 2; if (e.clientX < midX) { dock.insertBefore(pill, other); inserted = true; break; } } if (!inserted) { // Insérer juste avant le bouton "Fermer tous" s'il existe, sinon en fin const closeAllBtn = document.getElementById("pinned-popups-close-all"); if (closeAllBtn) { dock.insertBefore(pill, closeAllBtn); } else { dock.appendChild(pill); } } } }; const onMouseUp = (e) => { if (!isDown) return; isDown = false; document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); if (!isDragging) return; // juste un clic simple, pas un drag isDragging = false; setTimeout(() => { pill._dragging = false; }, 10); pill.classList.remove("pill-dragging"); if (ghostEl) { try { ghostEl.remove(); } catch (err) {} ghostEl = null; } // Vérifier si drop dans le dock ou hors const dock = document.getElementById("pinned-popups-dock"); let insideDock = false; if (dock) { const dockRect = dock.getBoundingClientRect(); insideDock = ( e.clientX >= dockRect.left && e.clientX <= dockRect.right && e.clientY >= dockRect.top && e.clientY <= dockRect.bottom ); } if (insideDock) { // Drop dans le dock = réordonnage déjà fait pendant onMouseMove. OK. return; } // Drop HORS du dock : restaurer le popup en mode Normal à la position souris // Le popup est actuellement en état "réduit" — on le réaffiche. popup.classList.remove("pinned-popup-reduced"); popup.classList.remove("pinned-popup-minimized"); // Positionner à la souris (coords document) const popupW = popup.offsetWidth || 320; const popupH = popup.offsetHeight || 200; const docX = _viewportToDocumentX(e.clientX - popupW / 2); const docY = _viewportToDocumentY(e.clientY - 20); popup.style.left = docX + "px"; popup.style.top = docY + "px"; // Clamper dans la safe area _clampPopupInSafeArea(popup); // Retirer la pastille et nettoyer le dock si vide try { pill.remove(); } catch (err) {} popup._linkedPill = null; 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(); } // Restaurer le bouton Minimiser (si c'était en mode minimisé avant réduction) 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", (ev) => { ev.stopPropagation(); _minimizePinnedPopup(popup); }); } // v2026.5.23 : mettre à jour le rect dans pinnedPopups pour que l'anti- // chevauchement tienne compte de la nouvelle position 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 }; } }; pill.addEventListener("mousedown", (e) => { if (e.button !== 0) return; isDown = true; isDragging = false; startX = e.clientX; startY = e.clientY; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); } /** * 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.25 : formatte une date ISO en "Mardi 22.04" pour la pastille dock. */ function _formatDateForPill(iso) { if (!iso) return ""; const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!m) return iso; try { const d = new Date(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3])); const dayName = (typeof DAY_NAMES_FULL !== "undefined" && DAY_NAMES_FULL[d.getDay()]) ? DAY_NAMES_FULL[d.getDay()] : ""; return (dayName ? dayName + " " : "") + m[3] + "." + m[2]; } catch (e) { 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; // v2026.5.40 : bouton "Fermer tous" seulement si PLUS DE DEUX (>= 3) // popups réduits dans le dock. Avec 1 ou 2, le user les ferme à la main. const reducedCount = document.querySelectorAll(".pinned-popup-dock-pill").length; let closeAllBtn = document.getElementById("pinned-popups-close-all"); if (reducedCount >= 3) { 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 () => { const labelBtnAvant = triggerBtn ? triggerBtn.textContent : ""; 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(); // v2026.5.46 — tombstone AVANT le refresh : empêche la sync // dossier de réinjecter la résa/absence supprimée (réapparition // fantôme de quelques secondes au retour sur la date). await _markDeletedInCache(actionId, state.currentDate); 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; // v2026.5.48 — libellé d'origine (une réservation redevenait // « Supprimer l'absence » après un échec). triggerBtn.textContent = labelBtnAvant; } } } } ] }); } // v2026.5.46 — pose un tombstone (deletedAt + verdict gone) sur une iv du // store d'un jour, via storeMarkDeleted. Réutilisé par la suppression EV // (résa/absence) et par _triggerHideIv. Le tombstone empêche la sync dossier // de réinjecter l'élément supprimé (modèle vie/mort) et est filtré au rendu. // Renvoie true si l'iv a été trouvée et marquée. async function _markDeletedInCache(actionId, date) { return storeMarkDeleted(date, actionId); } // suppression PURE CLIENT d'une intervention Fait/Clos : tombstone deletedAt // dans le store → l'iv disparaît du planning et la suppression se propage au // dossier partagé. AUCUN effet sur EasyVista. function _triggerHideIv(actionId, triggerBtn) { if (!actionId) return; showAlertModal({ title: "Retirer cette intervention ?", message: "Voulez-vous vraiment retirer cette intervention du planning ? Sans effet sur EasyVista.", buttons: [ { label: "Annuler", variant: "secondary", action: () => {} }, { label: "Retirer", variant: "danger", action: async () => { try { const found = await storeMarkDeleted(state.currentDate, actionId); if (found) storeRenderCurrent("store"); unpinTooltip(); closeAllPinnedPopups(); showToast("Intervention retirée", found ? "Retirée du planning" : "introuvable"); } catch (err) { LOG.warn("hide-iv", "suppression locale échouée", { actionId, err: err && err.message }); showToast("Erreur", "Impossible de retirer (voir console F12)"); } } } ] }); } 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"); // v2026.5.19 : flag global pour que showTooltip ignore les mouseenter // pendant le drag. Ajout d'une classe sur qui désactive les // pointer-events sur les cartes. state._popupDragging = true; document.body.classList.add("popup-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) return; // v2026.5.46 — réservation : pas de fiche EV à actualiser, mais on ne // reste plus SILENCIEUX (la roue semblait « ne pas réagir »). On donne un // retour visuel explicite. if (iv.type === "AL-Reservation") { showToast("Réservation", "Aucune fiche à actualiser"); 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) { LOG.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) { LOG.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 store — l'iv affichée EST l'enregistrement du store // (projection par référence), il suffit de persister le jour. try { iv.updatedAt = Date.now(); if (state.currentDay) { await storeSave(state.currentDate, state.currentDay); } } catch (err) { LOG.warn("reloadSingle", "écriture store en échec", err); } // v4.1.19 : toast de succès showToast("Mis à jour", iv.ref || iv.actionId); } catch (err) { LOG.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; // v2026.5.27 : quand la souris entre dans un popup épinglé, fermer tout // popup non-épinglé (tooltip live ou soft-unpinned) pour garder l'écran clair. document.addEventListener("mouseover", (e) => { const target = e.target; if (!target || !target.closest) return; const pinned = target.closest(".pinned-popup"); if (!pinned) return; // On survole un popup épinglé → fermer tooltip live s'il n'est pas pinned if (!bulleState.pinned) { const tip = tooltipEl(); if (tip && tip.classList.contains("visible")) { tip.classList.remove("visible"); tip.classList.add("hidden"); if (tip.dataset) delete tip.dataset.mode; state.currentTooltipIv = null; currentTooltipPos = null; tooltipPositionMode = null; } } // Fermer les soft-unpinned qui traînent document.querySelectorAll(".soft-unpinned").forEach(el => { try { el.remove(); } catch (err) {} }); }); // 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 }); // v2026.5.40 : Hover sur la bulle empêche la fermeture (pour la // sélection de texte). Quand on quitte la bulle, hideTooltip démarre // un nouveau timer 500ms — si la souris revient sur la row avant 500ms, // showTooltip annule le timer (via _cancelHideTooltip). el.addEventListener("mouseenter", () => { _cancelHideTooltip(); }); el.addEventListener("mouseleave", () => { if (!bulleState.pinned) hideTooltip(); }); // v2026.5.40 : raccourci double-Ctrl pour épingler/désépingler RETIRÉ // (peu utilisé, source de confusion). Pour épingler : clic sur la card / // mini-card ou bouton du tooltip. Pour désépingler : bouton du popup. // 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") { // v2026.5.22 : clic sur /dans le tooltip hover = TOUJOURS réépingler // à la position actuelle. Si un popup existe déjà pour cette iv+date, // il est supprimé avant d'en créer un nouveau à côté de la carte survolée. // (La suppression de l'ancien est faite dans pinTooltip() qui gère // l'unicité actionId+date — v2026.5.21.) // Pour désépingler : bouton dans la topbar du popup, double-Ctrl, 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(e => LOG.warn("clipboard", "copie ref échouée (permission ?)", { ref, err: e && e.message })); } } 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); } else if (action === "edit-reservation") { // v2026.5.46 — modifier les infos (n°/catégorie/adresse) d'une réservation. e.preventDefault(); e.stopPropagation(); const iv = state.currentTooltipIv; if (iv) showReservationEditModal(iv); } else if (action === "open-res-iv") { // v2026.5.46 — ouvrir la fiche de l'intervention liée à la réservation. e.preventDefault(); e.stopPropagation(); _openInterventionByRef(btn.dataset.ref); } else if (action === "copy-res-num") { // v2026.5.46 — copier le n° d'intervention de la réservation. e.preventDefault(); e.stopPropagation(); copyRef(btn.dataset.ref, btn); } else if (action === "hide-iv") { // v2026.1.6 — QRO : retirer une intervention Fait/Clos (pur client). _triggerHideIv(btn.dataset.actionId, btn); } else if (action === "open-fiche") { // clic sur la référence → utilise openInterventionInNewTab qui // génère un checksum FRAIS via basicAutoComplete + redirectHeader. e.preventDefault(); e.stopPropagation(); const iv = state.currentTooltipIv; if (iv) { const background = !!(e.ctrlKey || e.metaKey || e.button === 1); openInterventionInNewTab(iv, { background }); } } }); // 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(); }); } /** * Construit le HTML du tooltip détaillé pour une intervention/réservation. * * @author Quentin Rouiller */ function buildTooltipHTML(iv) { if (!iv) return '
Info
'; const i = iv.infobulle || {}; const rows = []; // Cas spécial : réservation (créneau bloqué par un coordinateur) if (iv.type === "AL-Reservation") { const _hasResMeta = !!(iv.resNumero || iv.resVille || iv.resRue || iv.resCategorie); rows.push(`
Type
Réservation
`); if (iv.startTime && iv.endTime) { rows.push(row("Horaire", ivIsMultiDay(iv) ? `du ${iv.startDate} ${iv.startTime} au ${iv.endDate} ${iv.endTime}` : `${iv.startTime}–${iv.endTime}`)); } // v2026.5.46 — « Sujet » affiché UNIQUEMENT pour les réservations créées // directement dans EasyVista (sans meta de l'extension). Pour celles faites // via l'extension (n°/adresse/type), on montre ces infos à la place. if (!_hasResMeta && iv.reservationLabel) rows.push(row("Sujet", iv.reservationLabel)); if (iv.reservationCreator) rows.push(row("Par", iv.reservationCreator)); // v2026.1.6 — QRO : infos client de la réservation (n°, catégorie, adresse) if (iv.resNumero) rows.push(row("N° intervention", iv.resNumero)); if (iv.resCategorie) rows.push(row("Catégorie", iv.resCategorie)); if (iv.resVille) rows.push(row("Ville", iv.resVille)); if (iv.resRue) rows.push(row("Rue / route", iv.resRue)); // v2026.5.46 — si un n° d'intervention est renseigné : ouvrir la fiche // de cette intervention + copier le n°. if (iv.resNumero) { rows.push(`
`); } // v2026.5.46 — bouton Modifier (édite n°/catégorie/adresse) + Supprimer. rows.push(`
`); 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", ivIsMultiDay(iv) ? `du ${iv.startDate} ${iv.startTime} au ${iv.endDate} ${iv.endTime}` : `${iv.startTime}–${iv.endTime}`)); } // Pour les absences récurrentes (configurées par tech), 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) // v2026.5.46 — rideau horaire : tant que le verdict n'est pas révélé // (12h matin / 15h aprèm), on masque un statut clos/résolu — sinon le tooltip // trahit le verdict de l'après-midi avant 15h. Les statuts non-verdict // (En cours, À faire…) restent visibles. if (iv.status) { const _statusIsVerdict = isClosedStatus(iv.status) || isResolvedStatus(iv.status); if (!_statusIsVerdict || _isVerdictRevealed(iv)) { 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", ivIsMultiDay(iv) ? `du ${iv.startDate} ${iv.startTime} au ${iv.endDate} ${iv.endTime}` : `${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 = _sanitizeActionText(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 = iv.ficheLocation || i.lieu || iv.bulleLieu; // fiche d'abord if (lieu) rows.push(row("Lieu", lieu)); if (iv.locMismatch) { rows.push(row("⚠ Adresse", `Fiche : ${iv.locMismatch.fiche} — Action : ${iv.locMismatch.action}`)); } 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(`
`); // v2026.5.40 : la référence devient cliquable pour ouvrir la fiche EV. // Plus besoin du message "Cliquer pour ouvrir" — la ref est maintenant // un lien stylé (souligné + couleur accent + cursor pointer). const refSafe = escapeHtml(iv.ref); const canOpen = !iv._analyzing && !!iv.formLink; if (canOpen) { rows.push(`
Référence
${refSafe}
`); } else { rows.push(`
Référence
${refSafe}
`); } // séparateur après la Référence pour aérer le tooltip. rows.push(`
`); } if (iv._analyzing) { rows.push(`
`); rows.push(`
Intervention disparue d'EasyVista — analyse en cours (clôturée, déplacée ou annulée ?)
`); } else if (iv.missingSince && ivVStatus(iv) === "unknown") { rows.push(`
`); rows.push(`
Intervention disparue d'EasyVista — sort indéterminé (fiche illisible)
`); } // v2026.5.46 — retirer du planning (pur client) : iv FAITE/SUSPENDUE/ // CLOSE (verdict v2 ou statut EV clos/résolu), ou disparue au sort // indéterminé (unknown) que l'utilisateur veut écarter à la main. const _vsTooltip = ivVStatus(iv); const _canHide = _vsTooltip === "done" || _vsTooltip === "suspended" || _vsTooltip === "closed" || (_vsTooltip === "unknown" && !!iv.missingSince) || isClosedStatus(iv.status) || isResolvedStatus(iv.status); if (iv.actionId && _canHide) { rows.push(`
`); } // v2026.5.21 : icône épingle adaptative // (épingle couchée) = pas encore épinglée, clic pour épingler // (épingle plantée, rouge) = déjà épinglée pour cette (ref + date) // — clic pour désépingler. NB : le popup a sa propre topbar avec un // bouton explicite ; celui-ci dans le tooltip hover sert surtout // à montrer visuellement l'état à l'user au survol. const _pinState = _getPinStateForIv(iv); const _pinIcon = _pinState.isPinned ? "📍" : "📌"; const _pinTitle = _pinState.isPinned ? "Cette intervention est déjà épinglée pour ce jour. Cliquer pour désépingler." : "Épingler la bulle (ou double-Ctrl). Cliquer à nouveau pour libérer."; const _pinClass = _pinState.isPinned ? " tooltip-pinbtn-active" : ""; if (rows.length === 0) { return `
${_pinIcon}
Info
Aucun détail disponible
`; } // commentaire tech extrait par l'analyse de disparition (verdict v2). // - Statut officiellement clos/résolu → vert (terminé + validé) // - Statut pas clos → "Fait" / "Suspendu" (jaune) // rideau horaire — on n'affiche le bloc commentaire qu'une fois le // seuil franchi (12h matin / 15h aprèm). Avant ça, le commentaire // existe en mémoire mais reste invisible. const _vd = iv.verdict || {}; if (_vd.techComment && _isVerdictRevealed(iv)) { // retire le préfixe "login: " du commentaire. const stripped = _vd.techComment.replace(/^[a-z0-9_]{3,12}\s*:\s*/, ""); const cmtSafe = escapeHtml(stripped); const info = _vd.actionInfo || {}; // on retire l'heure du méta (dateCreation/dateFin) — l'utilisateur // veut juste la date, pas le HH:MM:SS qu'EasyVista accole. const _stripTime = (s) => String(s || "").replace(/[\sT]+\d{1,2}:\d{2}(:\d{2})?\s*$/, "").trim(); const metaDate = _stripTime(info.dateFin || info.dateCreation || ""); // badge selon le verdict : // - closed → "Terminé (clos)" + // - suspended → "Suspendu" en JAUNE // - done → juste "Fait" const isSuspended = _vd.status === "suspended"; const labelText = _vd.officialClosed ? "Terminé (clos)" : (isSuspended ? "Suspendu" : "Fait"); const badgeContent = _vd.officialClosed ? `${escapeHtml(labelText)}
✅` : (isSuspended ? `${escapeHtml(labelText)}` : escapeHtml(labelText)); // layout 2 colonnes — // gauche : badge "Terminé (clos)" centré vertical // droite : meta (auteur+date) + commentaire empilés const metaHtml = (info.intervenant || metaDate) ? `
${escapeHtml(info.intervenant || "")}${(info.intervenant && metaDate) ? " — " : ""}${escapeHtml(metaDate)}
` : ""; const dd = `
${badgeContent}
${metaHtml}
${cmtSafe}
`; rows.push(`
Commentaire
${dd}`); } // v4.1.13/14 : boutons d'action en haut à droite (recharger + épingler) return `
${_pinIcon}
${rows.join("")}
`; } /** * v2026.5.21 : retourne l'état d'épinglage pour une intervention donnée * (pour la date actuellement affichée). Utilisé pour afficher vs * dans le tooltip hover. */ function _getPinStateForIv(iv) { if (!iv || !iv.actionId) return { isPinned: false }; const date = state.currentDate || ""; const key = iv.actionId + "|" + date; for (const p of pinnedPopups) { if (!p || !p.el) continue; const aid = p.el.dataset.actionId || ""; const d = p.el.dataset.originDate || ""; if (aid + "|" + d === key) { return { isPinned: true, popup: p.el }; } } return { isPinned: false }; } /** * 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..." */ // v2026.5.46 — Assainit le texte d'action affiché dans le popup. Certaines // actions (ex. mail automatique EZV « Votre demande a été effectuée ») ont une // description ÉNORME : en-tête mail + résumé complet de la demande + formulaire + // pied de page. On retire ce bruit pour ne garder que la partie utile au tech // (Note / Information complémentaire / Historique), on décode les entités et on // borne la longueur. Pour un texte déjà propre (cas normal) → quasi no-op. function _sanitizeActionText(raw) { if (!raw) return raw; let t = String(raw); const isAutoMail = /Ce message est envoyé automatiquement|Résumé de la demande de service|easyvista@vd\.ch/i.test(t); if (isAutoMail) { // Garder à partir de la 1re section réellement utile au tech. const m = t.match(/(?:Note\s*:|Information Compl[ée]mentaire\s*:|Historique\s*:)[\s\S]*/i); if (m) t = m[0]; // Retirer le pied de page EZV récurrent. t = t.replace(/Vous accéderez à EasyVista[\s\S]*$/i, "") .replace(/Support DGNSI[\s\S]*$/i, "") .replace(/Direction générale certifiée[\s\S]*$/i, "") .replace(/D[ÉE]PARTEMENT DE[\s\S]*$/i, ""); } t = t.replace(/�?D;?/gi, "").replace(/ /gi, " ") .replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&") .replace(/[ \t]{2,}/g, " ").replace(/\n{3,}/g, "\n\n").trim(); if (t.length > 1500) t = t.slice(0, 1500).trim() + " […]"; return t; } 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"); } // v2026.5.45 — passe additionnelle pour les labels colles a un mot // minuscule SANS : derriere (cas "secretariatEtage"). On exige une lettre // minuscule juste avant le label en majuscule pour eviter les faux // positifs ("Marie Bureau" a un espace, donc pas de match). for (const label of newlineLabels) { if (!/^[A-Z\u00C9\u00C8\u00C0\u00C2\u00CE\u00D4\u00DB\u00C7]/.test(label)) continue; const rxG = new RegExp(`([a-z\u00E0-\u00FF])(${escapeRegex(label)})\\b`, "g"); result = result.replace(rxG, "$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) { // v2026.5.29 : chercher .intervention-v2 (nouveau nom) et fallback .intervention const row = cardEl.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`) || cardEl.querySelector(`.intervention[data-iv-idx="${ivIdx}"]`); const slot = cardEl.querySelector(`.timeline-slot[data-iv-idx="${ivIdx}"]`); // v2026.5.40 : highlight aussi la mini-card en vue horizontale const miniCard = cardEl.querySelector(`.iv-mini-card[data-iv-idx="${ivIdx}"]`); if (row) row.classList.toggle("highlight", on); if (slot) slot.classList.toggle("highlight", on); if (miniCard) miniCard.classList.toggle("highlight", on); // v2026.5.29 : quand on active le highlight (typiquement depuis un slot // timeline), faire scroller la row dans la vue pour que l'user voie la // carte correspondante sans avoir à chercher. On évite de scroller le // body, on scroll juste la row à l'intérieur de la carte si elle déborde. if (on && row && typeof row.scrollIntoView === "function") { try { row.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); } catch (e) {} } } // ============================================================================ // 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 = ""; } // showError accepte un 2e argument optionnel. // options.centered : true → carte centrée avec icône, titre, description. // options.icon, options.title : pour le mode centré. // options.actionLabel + options.actionHandler : bouton d'action. // Mode par défaut (sans options.centered) = bandeau rouge classique. function showError(msg, options) { 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.innerHTML = ""; const isCentered = !!(options && options.centered); box.classList.toggle("error-box-centered", isCentered); if (isCentered) { if (options.icon) { const iconEl = document.createElement("div"); iconEl.className = "error-icon"; iconEl.textContent = options.icon; box.appendChild(iconEl); } if (options.title) { const titleEl = document.createElement("div"); titleEl.className = "error-title"; titleEl.textContent = options.title; box.appendChild(titleEl); } const descEl = document.createElement("div"); descEl.className = "error-description"; descEl.textContent = msg; box.appendChild(descEl); } else { const span = document.createElement("span"); span.textContent = msg; span.style.marginRight = "10px"; box.appendChild(span); } if (options && options.actionLabel && typeof options.actionHandler === "function") { const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn btn-primary"; btn.textContent = options.actionLabel; btn.addEventListener("click", (e) => { e.preventDefault(); try { options.actionHandler(); } catch (err) { LOG.warn("showError", "action handler en échec", err); } }); box.appendChild(btn); } 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"); // éviter le doublon — quand on affiche l'écran plein "session // nécessaire", on cache la bannière qui dirait la même chose. if (typeof hideSessionExpiredBanner === "function") hideSessionExpiredBanner(); document.getElementById("cards").innerHTML = ""; const sn = document.getElementById("session-needed"); sn.classList.remove("hidden"); // on injecte le sous-bandeau "Activer multi-onglets" dans // l'écran plein lui aussi — sinon en arrivant sur un jour sans // cache + sans session, l'utilisateur perdait l'invitation à // activer la lecture du cookie. if (typeof _injectCookiesPromptInBanner === "function") { try { _injectCookiesPromptInBanner(sn); } catch (e) {} } } 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. // v2026.5.46 — coordinateur : UN SEUL bandeau de session visible à la fois. // Toutes les fonctions showXBanner passent par _hideAllSessionBanners() en // premier (sauf le leur), puis recalculent l'empilement. Fini les bandeaux // superposés au même moment. const _SESSION_BANNER_IDS = [ "session-expired-banner", "ev-unreachable-banner", "session-reconnecting-banner", "session-reconnect-failed-banner", ]; function _hideAllSessionBanners(exceptId) { _SESSION_BANNER_IDS.forEach(id => { if (id === exceptId) return; const b = document.getElementById(id); if (b) b.classList.add("hidden"); }); } function showSessionExpiredBanner() { // symétrique de showSessionNeeded — si l'écran plein "session // nécessaire" est visible, on le cache pour ne pas avoir un doublon // (la bannière en haut + l'écran plein en dessous). if (typeof hideSessionNeeded === "function") hideSessionNeeded(); _hideAllSessionBanners("session-expired-banner"); 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. const btn = b.querySelector("#session-banner-reconnect"); if (btn && !btn.dataset.boundReconnect) { btn.dataset.boundReconnect = "1"; btn.textContent = "🔄 Me reconnecter"; const clone = btn.cloneNode(true); btn.parentNode.replaceChild(clone, btn); clone.addEventListener("click", () => triggerReconnect()); } // v2026.5.45 (issue #4) : si la permission "cookies" n'est pas // accordée ET que l'utilisateur n'a pas dit "ne plus afficher", on // injecte un sous-bandeau invitant à activer la gestion multi-onglets. _injectCookiesPromptInBanner(b); } _srSyncStickyOffsets(); } // retire TOUTES les copies actuellement affichées du sous-bandeau // "Activer multi-onglets" (bannière session expirée + écran plein // "session nécessaire" + modals d'erreur fiche). Appelé quand // l'utilisateur active la permission ou choisit "Ne plus afficher" : // on évite que l'invitation reste visible ailleurs après l'action. function _removeAllCookiesPrompts() { document.querySelectorAll(".session-banner-cookies-prompt").forEach(el => { try { el.remove(); } catch (e) {} }); } async function _injectCookiesPromptInBanner(banner) { try { // Si déjà injecté, ne rien faire (évite doublons aux ré-affichages). if (banner.querySelector(".session-banner-cookies-prompt")) return; const has = await new Promise(r => chrome.permissions.contains({ permissions: ["cookies"] }, r)); if (has) return; // permission OK, rien à proposer // Lire le flag dismiss depuis admin_config const cfg = await loadAdminConfig(); if (cfg.cookiesPromptDismissed) return; // sous-bandeau centré, plus court et factuel — titre courte // + bouton clair + lien dismiss discret, dans un encart blanc // semi-translucide qui se détache bien du rouge de la bannière. const prompt = document.createElement("div"); prompt.className = "session-banner-cookies-prompt"; const inner = document.createElement("div"); inner.className = "session-banner-cookies-inner"; const promptTitle = document.createElement("div"); promptTitle.className = "session-banner-cookies-title"; promptTitle.innerHTML = "🔗 Multi-onglets EasyVista"; inner.appendChild(promptTitle); const enableBtn = document.createElement("button"); enableBtn.type = "button"; enableBtn.className = "btn session-banner-cookies-btn"; enableBtn.textContent = "Activer"; enableBtn.title = "Permet à l'extension de lire le cookie de session EasyVista."; enableBtn.addEventListener("click", async () => { enableBtn.disabled = true; LOG.warn("permissions", "demande de permission cookies (bouton bannière session expirée)"); try { const granted = await new Promise(r => chrome.permissions.request({ permissions: ["cookies"] }, r)); if (granted) { LOG.warn("permissions", "permission cookies ACCORDÉE via bannière"); showToast("Multi-onglets activé", "L'extension lit désormais le cookie session."); const adminCb = document.querySelector(".admin-cookie-row input[type='checkbox']"); if (adminCb) adminCb.checked = true; cfg.cookiesPromptDismissed = false; await saveAdminConfig(cfg); // retirer TOUS les prompts ouverts (bannière + écran plein // + modal). Avant on ne retirait que celui-ci, donc s'il y avait // un sous-bandeau dans la modal et un autre dans la bannière, // l'autre restait visible inutilement. _removeAllCookiesPrompts(); } else { LOG.warn("permissions", "permission cookies REFUSÉE via bannière"); showToast("Refusé", "L'extension reste sur l'ancien comportement."); enableBtn.disabled = false; } } catch (err) { LOG.warn("permissions", "request err", { err: err && err.message }); enableBtn.disabled = false; } }); const dismissBtn = document.createElement("button"); dismissBtn.type = "button"; dismissBtn.className = "btn btn-icon session-banner-cookies-dismiss"; dismissBtn.textContent = "Ne plus afficher"; dismissBtn.title = "Cache cette suggestion. Réactivable depuis Paramètres → Diagnostics."; dismissBtn.addEventListener("click", async () => { LOG.warn("permissions", "utilisateur a cliqué « Ne plus afficher » dans la bannière"); cfg.cookiesPromptDismissed = true; await saveAdminConfig(cfg); // retirer toutes les copies du sous-bandeau, pas juste celle-ci. _removeAllCookiesPrompts(); if (typeof showToast === "function") { showToast("Suggestion masquée", "Réactivable dans Paramètres → Diagnostics → Multi-onglets"); } }); // explication en 2 lignes — chaque info sur sa propre ligne pour // éviter une coupure disgracieuse du mot "Lit" en fin de 1re ligne. const promptExplain = document.createElement("div"); promptExplain.className = "session-banner-cookies-explain"; const line1 = document.createElement("div"); line1.textContent = "Améliore nettement la liaison entre l'extension et votre session EasyVista."; const line2 = document.createElement("div"); line2.className = "session-banner-cookies-explain-sub"; line2.textContent = "Lit uniquement le cookie de session correspondant."; promptExplain.appendChild(line1); promptExplain.appendChild(line2); inner.appendChild(promptExplain); const btnsRow = document.createElement("div"); btnsRow.className = "session-banner-cookies-actions"; btnsRow.appendChild(enableBtn); btnsRow.appendChild(dismissBtn); inner.appendChild(btnsRow); prompt.appendChild(inner); banner.appendChild(prompt); } catch (e) { LOG.warn("permissions", "_injectCookiesPromptInBanner err", { err: e && e.message }); } } function hideSessionExpiredBanner() { const b = document.getElementById("session-expired-banner"); if (b) b.classList.add("hidden"); _srSyncStickyOffsets(); } // 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()); _hideAllSessionBanners("session-reconnecting-banner"); b.classList.remove("hidden"); _srSyncStickyOffsets(); } function hideReconnectingBanner() { const b = document.getElementById("session-reconnecting-banner"); if (b) b.classList.add("hidden"); _srSyncStickyOffsets(); } // 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(); }); _hideAllSessionBanners("session-reconnect-failed-banner"); b.classList.remove("hidden"); _srSyncStickyOffsets(); } function hideReconnectFailedBanner() { const b = document.getElementById("session-reconnect-failed-banner"); if (b) b.classList.add("hidden"); _srSyncStickyOffsets(); } // v4.2.5 : bannière non bloquante "EasyVista inaccessible" function showEvUnreachableBanner() { // 1 seul bandeau à la fois → on masque tous les autres d'abord. _hideAllSessionBanners("ev-unreachable-banner"); const b = document.getElementById("ev-unreachable-banner"); if (b) b.classList.remove("hidden"); _srSyncStickyOffsets(); } function hideEvUnreachableBanner() { const b = document.getElementById("ev-unreachable-banner"); if (b) b.classList.add("hidden"); _srSyncStickyOffsets(); } // =========================================================================== // v2026.5.46 — Recherche EasyVista (basicAutoComplete) + cache (DOSSIER puis // LOCAL). Portage du prototype ev-search-tester, branché sur les primitives // existantes. AUCUN fetch de fiche lourde (mémoire). Le cache est indexé // d'abord depuis le DOSSIER partagé (hôte natif + réseau interne), et SINON // (rien / inaccessible / externe) depuis le cache local chrome.storage. // =========================================================================== const SEARCH_DEBOUNCE_MS = 350; // v2026.5.46 — attendre la fin de frappe const SEARCH_MIN_CHARS = 2; let _searchSeq = 0; let _searchInflight = null; let _searchDebTimer = null; // v2026.5.48 — vrai pendant une recherche (index + fetch + rendu lancé) : // bloque la relance au survol/focus qui tuait la recherche en vol. let _srSearchEnCours = false; let _searchCacheByRef = new Map(); let _searchCacheList = []; let _searchCacheBuiltAt = 0; let _searchCacheSource = null; // "dossier" | "local" | null let _searchCacheDate = null; // v2026.5.46 — jour indexé (rebuild si changé) let _searchInited = false; function _srNorm(s) { return String(s == null ? "" : s).toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim(); } function _srCleanLabel(key) { if (typeof key !== "string") return key; if (key.startsWith("@@hidden_")) return key.slice(9); if (key.startsWith("@@")) { const i = key.lastIndexOf("$$"); if (i >= 0) return key.slice(i + 2); } return key; } function _srGetField(line, wanted) { if (line[wanted] !== undefined) return line[wanted]; const t = wanted.trim().toLowerCase(); for (const k of Object.keys(line)) if (_srCleanLabel(k).trim().toLowerCase() === t) return line[k]; return undefined; } function _srParseFrDate(s) { if (!s) return null; const m = String(s).match(/(\d{2})\/(\d{2})\/(\d{4})/); return m ? new Date(+m[3], +m[2] - 1, +m[1]) : null; } const _SR_JOURS = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"]; function _srDayWithWeekday(s) { const d = _srParseFrDate(s); if (!d) return s || ""; const j = _SR_JOURS[d.getDay()]; return j.charAt(0).toUpperCase() + j.slice(1) + " " + s; } function _srFrToIso(fr) { const m = String(fr || "").match(/(\d{2})\/(\d{2})\/(\d{4})/); return m ? `${m[3]}-${m[2]}-${m[1]}` : null; } // v2026.5.46 — statut SIMPLE : seulement « Clôturé », « Fait » ou // « En cours ». Tout ce qui n'est ni clôturé ni fait (y compris inconnu / // annulé / en cours / exécution) → « En cours » par défaut. function _srHumanStatus(iv) { if (!iv) return "En cours"; const vs = ivVStatus(iv); const st = String(iv.status || "").toLowerCase(); if (vs === "closed" || st === "clôturé" || st === "cloturé") return "Clôturé"; if (vs === "done" || vs === "suspended" || st === "terminé" || st === "résolu" || st === "resolu" || st === "finalisation") return "Fait"; return "En cours"; } function _srWhenOf(day) { const d = _srParseFrDate(day); if (!d) return null; const t = new Date(); t.setHours(0, 0, 0, 0); if (d < t) return "past"; if (d.getTime() === t.getTime()) return "today"; return "future"; } function _srStatusKey(label) { const l = String(label).toLowerCase(); if (l.includes("clôtur") || l.includes("clotur")) return "clos"; if (l.includes("fait")) return "fait"; if (l.includes("cours")) return "cours"; if (l.includes("annul")) return "annule"; return "autre"; } function _srStatusIcon(k) { return k === "clos" ? "✓" : k === "fait" ? "✓" : k === "cours" ? "⏳" : k === "annule" ? "✕" : "•"; } function _srWhenLabel(w) { return w === "past" ? "Planifiée (passé)" : w === "today" ? "Aujourd'hui" : "À venir"; } const _SR_TYPE_CONFIG = { SD_REQUEST: { title: "Intervention / demande", fields: ["N° de demande", "Demandeur", "Bénéficiaire"] }, AM_ASSET: { title: "Matériel", fields: ["Code matériel", "Modèle", "Marque", "Statut", "N° de série", "Login de l'utilisateur principal"] }, AM_EMPLOYEE: { title: "Employé", fields: ["Nom", "Téléphone", "Fonction", "Login"] }, }; // --- index cache : DOSSIER d'abord, SINON local --------------------------- function _srPushTechs(techs, dayLabel, seen) { for (const t of (techs || [])) { for (const iv of (t.interventions || [])) { if (iv && (ivIsDead(iv) || ((ivVStatus(iv) === "gone" || ivVStatus(iv) === "moved")))) continue; // tombstones/retirées masqués // v2026.5.46 — dédup multi-source (jour courant + dossier + local) par // identité stable, pour ne pas indexer/afficher la même iv deux fois. const key = iv.requestId ? ("q:" + iv.requestId) : (iv.actionId != null ? ("a:" + iv.actionId) : (iv.ref ? ("r:" + iv.ref) : null)); if (key && seen) { if (seen.has(key)) continue; seen.add(key); } const e = Object.assign({}, iv, { techName: iv.techName || t.name, techId: iv.techId || t.id, _day: dayLabel || iv.currentDate }); _searchCacheList.push(e); if (e.ref) _searchCacheByRef.set(String(e.ref), e); } } } // v2026.5.46 — indexe une entrée jour v2 (liste plate d'ivs, annuaire // techs séparé). Mêmes filtres/dédup que _srPushTechs. function _srPushDay(day, dayLabel, seen) { const nameById = new Map((day.techs || []).map(t => [String(t.id), t.name])); for (const iv of (day.ivs || [])) { if (iv && (ivIsDead(iv) || ivVStatus(iv) === "gone" || ivVStatus(iv) === "moved")) continue; const key = iv.requestId ? ("q:" + iv.requestId) : (iv.actionId != null ? ("a:" + iv.actionId) : (iv.ref ? ("r:" + iv.ref) : null)); if (key && seen) { if (seen.has(key)) continue; seen.add(key); } const e = Object.assign({}, iv, { techName: iv.techName || nameById.get(String(iv.techId)) || "", _day: dayLabel || iv.currentDate }); _searchCacheList.push(e); if (e.ref) _searchCacheByRef.set(String(e.ref), e); } } // v2026.5.48 — construction à VOL UNIQUE : en réseau interne, l'index // relit tous les jours du dossier partagé (lent). Deux appels concurrents // (focus + recherche, ou deux recherches rapprochées) se réinitialisaient // mutuellement les listes en cours de remplissage : le second se greffe // désormais sur la construction en vol. let _srCacheBuildInflight = null; function _srBuildCacheIndex(force) { if (_srCacheBuildInflight) return _srCacheBuildInflight; const p = _srBuildCacheIndexInterne(force); _srCacheBuildInflight = p.finally(() => { _srCacheBuildInflight = null; }); return _srCacheBuildInflight; } async function _srBuildCacheIndexInterne(force) { const FRESH_MS = 120000; const curDate = (state && state.currentDate) || null; // v2026.5.46 — reconstruit si périmé (2 min) OU si on a changé de jour // (sinon le jour courant fraîchement chargé manquerait à l'index). if (!force && _searchCacheBuiltAt && (Date.now() - _searchCacheBuiltAt) < FRESH_MS && _searchCacheDate === curDate) return; _searchCacheByRef = new Map(); _searchCacheList = []; _searchCacheSource = null; const seen = new Set(); // 0) JOUR COURANT (state.currentData) : données les + fraîches et + complètes // (descriptions/contacts déjà chargés au survol) → garantit que les // interventions affichées sont TROUVABLES (réf, contact/tél, lieu, description). try { if (state && state.currentData && Array.isArray(state.currentData.techs)) { _srPushTechs(state.currentData.techs, _fmtDateFR(curDate || todayISO()), seen); } } catch (e) {} let dossierOk = false; // 1) DOSSIER d'abord (hôte natif présent + réseau interne) try { if (state.networkContext === "internal" && typeof nmProbe === "function" && await nmProbe()) { const path = await nmSharePath(); if (path) { const lst = await nmSend({ action: "list", path }); const days = (lst && lst.ok && Array.isArray(lst.days)) ? lst.days : []; if (days.length) { let read = 0; for (const d of days) { const iso = (d && d.date) || d; if (!iso) continue; try { const day = await nmReadDay(iso, path); if (day && Array.isArray(day.ivs)) { _srPushDay(day, _fmtDateFR(iso), seen); read++; } } catch (e) {} } if (read > 0) { dossierOk = true; _searchCacheSource = "dossier"; LOG.info("search", `index cache DOSSIER : ${read} jour(s), ${_searchCacheList.length} iv, ${_searchCacheByRef.size} réfs`); } } } } } catch (e) { LOG.info("search", "lecture dossier échouée → repli cache local", { err: e && e.message }); } // 2) SINON store LOCAL (v2) if (!dossierOk) { try { const all = await chrome.storage.local.get(null); let read = 0; for (const k of Object.keys(all)) { if (!k.startsWith(STORE_PREFIX)) continue; const day = all[k]; if (day && Array.isArray(day.ivs)) { _srPushDay(day, _fmtDateFR(k.slice(STORE_PREFIX.length)), seen); read++; } } _searchCacheSource = "local"; LOG.info("search", `index store LOCAL : ${read} jour(s), ${_searchCacheList.length} iv, ${_searchCacheByRef.size} réfs`); } catch (e) { LOG.warn("search", "lecture store local échouée", { err: e && e.message }); } } // v2026.5.46 — ne PAS mémoriser un index vide (sinon il resterait vide 2 min // et la recherche cache ne trouverait rien) : on retentera au prochain appel. _searchCacheBuiltAt = _searchCacheList.length ? Date.now() : 0; _searchCacheDate = curDate; } // --- recherche EV (basicAutoComplete) ------------------------------------- async function _srSearchEV(query, signal) { const sess = state.session; if (!sess || !sess.origin || !sess.phpsessid) { LOG.info("search", "pas de session EV — partie EV ignorée"); return []; } const url = `${sess.origin}/api/v1/internal/search/basicAutoComplete?search=${encodeURIComponent(query)}&PHPSESSID=${encodeURIComponent(sess.phpsessid)}`; let res; try { res = await fetch(url, { method: "GET", credentials: "include", headers: { "Accept": "application/json, text/plain, */*", "X-Requested-With": "XMLHttpRequest" }, signal }); } catch (e) { if (e && e.name === "AbortError") return null; LOG.warn("search", "réseau basicAutoComplete", { err: e && e.message }); return []; } const text = await res.text(); if (/type="password"|iamlogin|\/iam\/accueil/i.test(text)) { LOG.warn("search", "réponse = page login (session expirée)"); if (typeof showSessionExpiredBanner === "function") showSessionExpiredBanner(); return []; } let json = null; try { json = JSON.parse(text); } catch (e) {} if (!json) { LOG.warn("search", "réponse basicAutoComplete non-JSON"); return []; } const metas = (json.data && json.data.METAOBJECTS) || []; const out = []; for (const meta of metas) { const table = meta.TABLE_NAME; const cfg = _SR_TYPE_CONFIG[table]; for (const line of (meta.LINES || [])) { const fields = []; let ref = null, day = null, statusLabel = null; if (cfg) { for (const lbl of cfg.fields) { const v = _srGetField(line, lbl); if (table === "AM_EMPLOYEE" && (meta.LINES || []).length <= 1 && v != null && v !== "" && query && _srNorm(v).includes(_srNorm(query))) continue; fields.push([lbl, v]); } if (table === "AM_EMPLOYEE") { try { const av = line.AVATAR_INFO && JSON.parse(line.AVATAR_INFO); if (av && av.userInitials) fields.push(["Initiales", av.userInitials]); } catch (e) {} } if (table === "SD_REQUEST") { ref = _srGetField(line, "N° de demande"); const cached = ref && _searchCacheByRef.get(String(ref)); if (cached) { const add = [["Catégorie", deriveShortTitle(cached)], ["Adresse", cached.bulleLieu], ["Tech", cached.techName], ["Horaire", (cached.startTime || "") + (cached.endTime ? "–" + cached.endTime : "")], ["Jour", _srDayWithWeekday(cached.currentDate || cached._day)]]; for (const [k, v] of add) if (v != null && v !== "" && v !== "–") fields.push([k, v]); day = cached.currentDate || cached._day || null; statusLabel = _srHumanStatus(cached); } else { // v2026.5.46 — pas d'info cache → statut « En cours » par défaut // (le statut doit toujours être l'un de En cours / Fait / Clôturé). statusLabel = "En cours"; } } } else { for (const k of Object.keys(line)) { const v = line[k]; if (v !== null && typeof v !== "object") fields.push([_srCleanLabel(k), v]); } } out.push({ source: "ev", table, ref: ref ? String(ref) : null, typeLabel: meta.TYPE_LABEL || (cfg && cfg.title) || table, icon: meta.ICON ? sess.origin + meta.ICON : "", fields, day, statusLabel, open: { pk: line.PK, guid: meta.TYPE_GUID, checksum: line.CHECKSUM }, raw: line }); } } return out; } // v2026.5.46 — concatène TOUS les champs texte utiles d'une iv pour la // recherche, y compris les descriptions (où sont écrits les n° de matériel, // ex. « M917469 »). Permet de retrouver les interventions LIÉES à un appareil. function _srIvHaystack(iv) { const parts = [iv.ref, iv.requestId, iv.type, iv.categoryLine, iv.bulleContact, iv.bulleLieu, iv.bulleDescription, iv.ficheActionText, iv.label, iv.techName, iv.resNumero, iv.resRue, iv.resVille]; if (iv.infobulle && typeof iv.infobulle === "object") { for (const v of Object.values(iv.infobulle)) if (v && typeof v === "string") parts.push(v); } return parts.filter(Boolean).join("  "); } // Extrait un court passage autour du terme trouvé dans la description (pour // montrer POURQUOI l'intervention est liée — ex. le n° matériel en contexte). function _srLinkSnippet(iv, query) { const raw = String(iv.bulleDescription || iv.ficheActionText || (iv.infobulle && (iv.infobulle.materiel || iv.infobulle.aFaire)) || "") .replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim(); if (!raw) return null; const needle = String(query).trim(); const i = raw.toLowerCase().indexOf(needle.toLowerCase()); if (i < 0) return null; const start = Math.max(0, i - 25); const end = Math.min(raw.length, i + needle.length + 40); return (start > 0 ? "…" : "") + raw.slice(start, end) + (end < raw.length ? "…" : ""); } // ============================================================================ // v2026.5.46 — OPÉRATEURS DE RECHERCHE (côté cache) // ============================================================================ // mot1 mot2 ET : chaque mot doit être présent (ordre libre) // % joker dans un terme : I2606%123 (dans cet ordre), %123 // "expression" expression exacte (espaces compris) // -mot exclusion (le mot ne doit PAS apparaître) // champ:valeur cibler un champ : tech: lieu: ref: cat: contact: // La recherche EV (basicAutoComplete) reçoit la requête NETTOYÉE (mots // positifs sans opérateurs) — son moteur ne les comprend pas. /** Parse la requête en termes structurés [{neg, field, parts[]}]. @author QRO */ function _srParseQuery(query) { const terms = []; const rx = /"([^"]+)"|(\S+)/g; let m; while ((m = rx.exec(String(query || '')))) { let raw, exact = false; if (m[1] !== undefined) { raw = m[1]; exact = true; } else raw = m[2]; let neg = false, field = null; if (!exact) { if (raw.startsWith('-') && raw.length > 1) { neg = true; raw = raw.slice(1); } const fm = raw.match(/^(tech|lieu|ref|cat|contact):(.*)$/i); if (fm) { field = fm[1].toLowerCase(); raw = fm[2]; } } if (!raw) continue; // % = joker → morceaux à trouver DANS L'ORDRE ; expression exacte = 1 morceau const parts = exact ? [_srNorm(raw)] : _srNorm(raw).split('%').filter(Boolean); if (!parts.length) continue; terms.push({ neg, field, parts }); } return terms; } // les morceaux (joker %) doivent apparaître dans l'ordre dans le texte normalisé function _srPartsMatch(hayNorm, parts) { let idx = 0; for (const p of parts) { const i = hayNorm.indexOf(p, idx); if (i < 0) return false; idx = i + p.length; } return true; } // texte du champ ciblé (champ:) ou botte de foin complète function _srTermHay(iv, field, hayNormFull) { switch (field) { case 'tech': return _srNorm(iv.techName || ''); case 'lieu': return _srNorm([iv.bulleLieu, iv.resVille, iv.resRue].filter(Boolean).join(' ')); case 'ref': return _srNorm(String(iv.ref || '')); case 'cat': return _srNorm([iv.categoryLine, (typeof deriveShortTitle === 'function' ? deriveShortTitle(iv) : ''), iv.resCategorie].filter(Boolean).join(' ')); case 'contact': return _srNorm(iv.bulleContact || ''); default: return hayNormFull; } } /** Tous les termes doivent matcher (les -mot ne doivent PAS matcher). */ function _srQueryMatches(iv, terms) { const hayFull = _srNorm(_srIvHaystack(iv)); for (const t of terms) { const ok = _srPartsMatch(_srTermHay(iv, t.field, hayFull), t.parts); if (t.neg ? ok : !ok) return false; } return true; } // premier morceau positif (pour l'extrait « Lié à » et la surbrillance) function _srFirstNeedle(terms) { for (const t of terms) if (!t.neg && t.parts.length) return t.parts[0]; return ''; } // requête nettoyée pour EV : mots positifs non ciblés, jokers retirés function _srCleanQueryForEV(query) { const words = []; for (const t of _srParseQuery(query)) { if (t.neg || t.field) continue; words.push(t.parts.join(' ')); } const clean = words.join(' ').trim(); return clean || String(query || '').replace(/[%"-]/g, ' ').replace(/\s+/g, ' ').trim(); } function _srCacheMatches(query) { if (!_searchCacheList.length) return []; // v2026.5.46 — opérateurs + PLUS de plafond à 50 (le rendu pagine 15 // par 15 au scroll, l'index est en mémoire → instantané). const terms = _srParseQuery(query); if (!terms.length) return []; const needle = _srFirstNeedle(terms); const hits = _searchCacheList.filter(iv => _srQueryMatches(iv, terms)); return hits.map(iv => { const fields = [["Réf", iv.ref], ["Catégorie", deriveShortTitle(iv)], ["Contact", iv.bulleContact], ["Adresse", iv.bulleLieu], ["Tech", iv.techName], ["Horaire", (iv.startTime || "") + (iv.endTime ? "–" + iv.endTime : "")], ["Jour", _srDayWithWeekday(iv.currentDate || iv._day)]]; // si le terme est dans la description → on l'affiche en contexte (« Lié à »). const snip = _srLinkSnippet(iv, needle || query); if (snip) fields.push(["Lié à", snip]); return { source: "cache", table: "SD_REQUEST", ref: iv.ref ? String(iv.ref) : null, typeLabel: "Cache — " + deriveShortTitle(iv), icon: "", fields: fields.filter(([k, v]) => v != null && v !== "" && v !== "–"), day: iv.currentDate || iv._day || null, statusLabel: _srHumanStatus(iv), open: { ref: iv.ref }, raw: iv, }; }); } function _srIsLocationQuery(q) { if (!_searchCacheList.length || !q || q.trim().length < 3) return false; const n = _srNorm(q); return _searchCacheList.some(iv => iv.bulleLieu && _srNorm(iv.bulleLieu).includes(n)); } async function _srDoSearch(query) { const mySeq = ++_searchSeq; // jeton anti-périmé (toutes branches) // v2026.5.48 — marquer la recherche EN COURS dès l'entrée : la relance // au survol/focus (_srReopenIfQuery) tuait la recherche en vol via le jeton // et repartait de zéro — souris sur la barre = recherches qui s'entretuent, // « je ne tape plus et rien ne part ». La roue tourne AUSSI pendant la // construction de l'index (phase lente en réseau interne), pas seulement // pendant le fetch EV. _srSearchEnCours = true; _srSpin(true); try { await _srBuildCacheIndex(false); if (mySeq !== _searchSeq) return; await _srDoSearchSuite(query, mySeq); } finally { if (mySeq === _searchSeq) _srSearchEnCours = false; } } async function _srDoSearchSuite(query, mySeq) { // v2026.5.46 — EV reçoit la requête NETTOYÉE (sans opérateurs % " - champ:) const evQuery = _srCleanQueryForEV(query); if (_srIsLocationQuery(evQuery)) { _srRender(_srCacheMatches(query), query, "résultats du cache (lieu)"); // le rendu gère la roue return; } // requête purement négative / ciblée → rien d'exploitable pour EV : cache seul if (evQuery.length < SEARCH_MIN_CHARS) { _srRender(_srCacheMatches(query), query, "résultats du cache"); return; } if (_searchInflight) { try { _searchInflight.abort(); } catch (e) {} } _searchInflight = new AbortController(); _srSpin(true); const ev = await _srSearchEV(evQuery, _searchInflight.signal); if (ev === null) return; // annulée par une frappe plus récente if (mySeq !== _searchSeq) return; // réponse périmée const evRefs = new Set(ev.filter(it => it.ref).map(it => String(it.ref))); const extras = _srCacheMatches(query).filter(it => !it.ref || !evRefs.has(String(it.ref))); _srRender([...ev, ...extras], query); // la roue tourne pendant le rendu progressif } // v2026.5.46 — iv MINIMALE à partir d'un résultat de recherche (pour le // tooltip de survol quand l'iv n'est pas dans le cache). ficheFetched=true pour // éviter le « Chargement des détails… » trompeur. function _srMinimalIvFromResult(it) { const get = (k) => { const f = (it.fields || []).find(([kk]) => kk === k); return f ? f[1] : null; }; return { ref: it.ref || null, type: "AL-Intervention", status: it.statusLabel || null, bulleContact: get("Demandeur") || get("Contact") || get("Bénéficiaire") || null, bulleLieu: get("Adresse") || null, label: get("Catégorie") || it.typeLabel || null, currentDate: it.day || null, ficheFetched: true, }; } // v2026.5.46 — construit une carte de résultat (extrait pour le rendu // progressif). Survol = tooltip de l'intervention ; boutons EasyVista / Jour. function _srBuildCard(it) { const card = document.createElement("div"); card.className = "sr-card"; const main = document.createElement("div"); main.className = "sr-main"; main.innerHTML = it.fields.map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(v == null || v === "" ? "—" : String(v))}
`).join(""); if (it.statusLabel) { const sk = _srStatusKey(it.statusLabel); const w = _srWhenOf(it.day); let acc = sk; if (sk === "cours") acc = (w === "past") ? "retard" : "cours"; card.classList.add("acc-" + acc); // v2026.5.46 — badge si l'intervention tombe sur le JOUR AFFICHÉ // (le badge À venir/Aujourd'hui/Passé reste calé sur la date réelle). const _hereIso = _srFrToIso(it.day); const _onShownDay = !!(_hereIso && state.currentDate && _hereIso === state.currentDate); // v2026.5.48 — la pastille de statut ne s'affiche que pour un état // FINAL (Clôturé / Fait / Terminée) ; « En cours » et autres n'apportent // rien (demande Quentin). Le liseré de couleur (acc-*) reste. const pillHtml = (sk === "clos" || sk === "fait") ? `${_srStatusIcon(sk)} ${escapeHtml(it.statusLabel)}` : ""; if (pillHtml || w || _onShownDay) { const badge = `
${pillHtml}${w ? `${_srWhenLabel(w)}` : ""}${_onShownDay ? `📍 Jour affiché` : ""}
`; main.insertAdjacentHTML("afterbegin", badge); } } const actions = document.createElement("div"); actions.className = "sr-actions"; const evBtn = document.createElement("button"); evBtn.className = "sr-btn"; evBtn.textContent = "EasyVista ↗"; evBtn.addEventListener("click", () => { _srOpenRecord(it); _srClear(); }); actions.appendChild(evBtn); if (it.day) { const iso = _srFrToIso(it.day); if (iso) { const dBtn = document.createElement("button"); dBtn.className = "sr-btn"; dBtn.textContent = "Jour ↗"; dBtn.title = `Ouvrir le planning du ${it.day}`; const ref = it.ref; dBtn.addEventListener("click", () => { _srClear(); _srRequestHighlight(iso, ref); }); actions.appendChild(dBtn); } } // v2026.5.46 — au survol d'un résultat d'intervention, afficher le popup // de l'intervention après 0,5 s (showTooltip a déjà ce délai). Source de l'iv : // l'iv complète du cache si dispo (it.raw / par réf), sinon une iv MINIMALE // construite depuis les champs du résultat → marche aussi pour un résultat EV // pur (pas encore dans le cache). let ivForTip = null; if (it.table === "SD_REQUEST") { ivForTip = (it.raw && it.raw.actionId) ? it.raw : (it.ref && _searchCacheByRef.get(String(it.ref))) || _srMinimalIvFromResult(it); } if (ivForTip && typeof showTooltip === "function") { // v2026.5.46 — le tooltip s'ancre sur le POPUP de recherche (droite en // priorité, gauche sinon), jamais par-dessus les autres résultats. card.addEventListener("mouseenter", (ev) => { const box = document.getElementById("ev-search-results") || card; showTooltip(ev, ivForTip, box, { forceSides: true, anchorRowEl: card }); }); card.addEventListener("mouseleave", () => { if (typeof hideTooltip === "function") hideTooltip(); }); } card.appendChild(main); card.appendChild(actions); return card; } // v2026.5.46 — rendu PROGRESSIF : on prépare la séquence ordonnée (entêtes + // séparateurs + cartes), puis on l'ajoute par petits paquets via rAF. La roue // (#ev-search-spin) tourne tant que tout n'est pas affiché → l'utilisateur voit // que ce n'est pas fini et les résultats apparaissent au fur et à mesure. function _srRender(items, query, note) { const box = document.getElementById("ev-search-results"); if (!box) return; const mySeq = _searchSeq; box.innerHTML = ""; _srShowResults(true); if (!items.length) { const m = document.createElement("div"); m.className = "sr-empty"; m.textContent = `Aucun résultat pour « ${query} »`; box.appendChild(m); _srSpin(false); _srPositionResults(); return; } let ivs = items.filter(it => it.table === "SD_REQUEST"); const others = items.filter(it => it.table !== "SD_REQUEST"); const ops = []; const groups = new Map(); for (const it of others) { if (!groups.has(it.typeLabel)) groups.set(it.typeLabel, []); groups.get(it.typeLabel).push(it); } for (const [label, list] of groups) { ops.push({ head: `${list[0].icon ? `` : ""}${escapeHtml(label)} — ${list.length}`, right: (list[0].source || "").toUpperCase() }); for (const it of list) ops.push({ card: it }); } // v2026.5.46 — PAGINATION 15 par 15 au scroll (plus de troncature) : // • les interventions À VENIR / aujourd'hui d'abord (tri croissant), puis // « sans date » ; le PASSÉ est REPLIÉ par défaut derrière un en-tête // cliquable et ne consomme pas la liste tant qu'il est fermé ; // • 15 cartes max sont rendues ; arrivé en bas, une barre de chargement // apparaît (~300 ms) puis les 15 suivantes s'ajoutent, jusqu'au bout. if (ivs.length) { const fut = ivs.filter(it => { const w = _srWhenOf(it.day); return w && w !== "past"; }).sort((a, b) => (_srParseFrDate(a.day) || 0) - (_srParseFrDate(b.day) || 0)); const past = ivs.filter(it => _srWhenOf(it.day) === "past").sort((a, b) => (_srParseFrDate(b.day) || 0) - (_srParseFrDate(a.day) || 0)); const nodate = ivs.filter(it => !_srWhenOf(it.day)); if (fut.length) { ops.push({ head: `À venir / aujourd'hui — ${fut.length}` }); for (const it of fut) ops.push({ card: it }); } if (nodate.length) { ops.push({ head: `Sans date — ${nodate.length}` }); for (const it of nodate) ops.push({ card: it }); } _srPager.pastCards = past; } else { _srPager.pastCards = []; } _srPager.seq = mySeq; _srPager.box = box; _srPager.queue = ops; _srPager.budget = SR_PAGE_SIZE; _srPager.pastOpen = false; _srPager.pastHead = null; _srPager.loading = false; _srSpin(true); // scroll near-bottom → page suivante (handler unique, réassigné à chaque rendu) box.onscroll = () => { if (_srPager.seq !== _searchSeq) return; if (box.scrollTop + box.clientHeight >= box.scrollHeight - 60) _srPagerLoadMore(); }; _srPagerStep(); } // ── v2026.5.46 — pager de la liste de résultats ────────────────────────── const SR_PAGE_SIZE = 15; // cartes par « page » de scroll let _srPager = { seq: -1, box: null, queue: [], pastCards: [], pastOpen: false, pastHead: null, budget: SR_PAGE_SIZE, loading: false }; function _srPagerRenderOp(op) { const box = _srPager.box; if (op.head !== undefined) { const h = document.createElement("div"); h.className = "sr-head"; h.innerHTML = `${op.head}${op.right ? `${escapeHtml(op.right)}` : ""}`; box.appendChild(h); } else if (op.sep) { const s = document.createElement("div"); s.className = "sr-sep"; s.textContent = op.sep; box.appendChild(s); } else if (op.note) { const n = document.createElement("div"); n.className = "sr-note"; n.textContent = "ℹ " + op.note; box.appendChild(n); } else if (op.card) { box.appendChild(_srBuildCard(op.card)); } } // nb de cartes encore non rendues dans la file courante function _srPagerRemaining() { return _srPager.queue.filter(op => op.card).length; } // retire l'indicateur « N de plus » / barre de chargement function _srPagerClearFooter() { const box = _srPager.box; if (!box) return; box.querySelectorAll(".sr-loadmore").forEach(el => el.remove()); } // pose le pied de liste : « ▼ N de plus » (sentinelle visuelle du scroll, // cliquable aussi). Si la liste ne déborde pas encore (pas de scrollbar → // impossible de scroller), on charge automatiquement la page suivante. function _srPagerFooter() { _srPagerClearFooter(); const rest = _srPagerRemaining(); if (!rest) return; const f = document.createElement("div"); f.className = "sr-loadmore"; f.style.cursor = "pointer"; f.title = "Afficher les suivants"; f.innerHTML = `▼ ${rest} de plus — faire défiler`; f.addEventListener("click", () => _srPagerLoadMore()); _srPager.box.appendChild(f); const box = _srPager.box; if (box.scrollHeight <= box.clientHeight + 4) { setTimeout(() => { if (_srPager.seq === _searchSeq) _srPagerLoadMore(); }, 60); } } // en-tête « Planifiée (passé) — N », repliée par défaut, cliquable function _srPagerPastHeader() { if (_srPager.pastHead || !_srPager.pastCards.length) return; const h = document.createElement("div"); h.className = "sr-head sr-past-toggle"; h.setAttribute("role", "button"); h.innerHTML = `▸ Planifiée (passé) — ${_srPager.pastCards.length}AFFICHER`; h.addEventListener("click", () => _srPagerTogglePast()); _srPager.box.appendChild(h); _srPager.pastHead = h; } function _srPagerTogglePast() { const p = _srPager; if (!p.pastHead) return; if (!p.pastOpen) { p.pastOpen = true; p.pastHead.innerHTML = `▾ Planifiée (passé) — ${p.pastCards.length}MASQUER`; p.queue = p.pastCards.map(it => ({ card: it })); p.budget = SR_PAGE_SIZE; _srPagerStep(); } else { p.pastOpen = false; p.pastHead.innerHTML = `▸ Planifiée (passé) — ${p.pastCards.length}AFFICHER`; // retire les cartes passé rendues (tout ce qui suit l'en-tête) let el = p.pastHead.nextSibling; while (el) { const nx = el.nextSibling; el.remove(); el = nx; } p.queue = []; _srPagerClearFooter(); } } // rendu progressif (paquets de 2 / 45 ms) jusqu'au plafond de la page function _srPagerStep() { const p = _srPager; if (p.seq !== _searchSeq || !p.box) return; // recherche plus récente _srPagerClearFooter(); const CHUNK = 2, DELAY = 45; let n = 0; while (n < CHUNK && p.queue.length) { const op = p.queue[0]; if (op.card) { if (p.budget <= 0) break; // plafond de page atteint p.budget--; } p.queue.shift(); _srPagerRenderOp(op); n++; } _srPositionResults(); if (p.queue.length && p.budget > 0) { setTimeout(_srPagerStep, DELAY); return; } // fin de page (plafond) ou fin de file if (!p.queue.length && !p.pastOpen) _srPagerPastHeader(); // file principale finie → en-tête passé _srPagerFooter(); _srSpin(false); } // arrivée en bas : barre de chargement brève (retour visuel) puis +15 function _srPagerLoadMore() { const p = _srPager; if (p.loading || p.seq !== _searchSeq) return; if (!_srPagerRemaining()) return; p.loading = true; _srPagerClearFooter(); const bar = document.createElement("div"); bar.className = "sr-loadmore"; bar.innerHTML = '
'; p.box.appendChild(bar); p.box.scrollTop = p.box.scrollHeight; // garder la barre visible setTimeout(() => { if (p.seq !== _searchSeq) { p.loading = false; return; } bar.remove(); p.budget = SR_PAGE_SIZE; p.loading = false; _srPagerStep(); }, 300); } let _srOpening = false; async function _srOpenRecord(it) { if (_srOpening) return; _srOpening = true; try { const sess = state.session; if (!sess || !sess.origin || !sess.phpsessid) { LOG.warn("search", "ouverture impossible : pas de session"); return; } let pk = it.open && it.open.pk, guid = it.open && it.open.guid, checksum = it.open && it.open.checksum; if ((!pk || !guid || !checksum) && it.open && it.open.ref) { const r = await fetch(`${sess.origin}/api/v1/internal/search/basicAutoComplete?search=${encodeURIComponent(it.open.ref)}&PHPSESSID=${encodeURIComponent(sess.phpsessid)}`, { credentials: "include" }); const j = await r.json(); const meta = j && j.data && j.data.METAOBJECTS && j.data.METAOBJECTS[0]; const line = meta && meta.LINES && meta.LINES[0]; if (meta && line) { pk = line.PK; guid = meta.TYPE_GUID; checksum = line.CHECKSUM; } } if (!pk || !guid || !checksum) { LOG.warn("search", "pk/guid/checksum manquants", it.open); return; } const rh = await fetch(`${sess.origin}/api/v1/internal/search/redirectHeader?pk=${encodeURIComponent(pk)}&guid=${encodeURIComponent(guid)}&checksum=${encodeURIComponent(checksum)}&PHPSESSID=${encodeURIComponent(sess.phpsessid)}`, { credentials: "include" }); const txt = await rh.text(); let target; try { const j = JSON.parse(txt); target = (typeof j === "string") ? j : (j.url || j.URL || ""); } catch (e) { target = txt; } target = String(target || "").replace(/^"+|"+$/g, "").trim(); if (target && !/^https?:\/\//i.test(target)) target = sess.origin.replace(/\/+$/, "") + "/" + target.replace(/^\/+/, ""); if (!target || !/^https?:\/\//i.test(target)) { LOG.warn("search", "redirectHeader sans URL exploitable"); return; } window.open(target, "_blank", "noopener"); } catch (e) { LOG.warn("search", "openRecord", { err: e && e.message }); } finally { _srOpening = false; } } function _srSpin(on) { const s = document.getElementById("ev-search-spin"); if (s) s.classList.toggle("hidden", !on); } function _srShowResults(on) { const b = document.getElementById("ev-search-results"); if (!b) return; // v2026.5.46 — PORTAIL : on déplace le popup dans pour qu'il ne // soit ni rogné par l'overflow de la sidebar (vue horizontale), ni piégé dans // un contexte d'empilement → garanti au PREMIER PLAN (position fixed + z-index). if (on && b.parentNode !== document.body) document.body.appendChild(b); b.classList.toggle("hidden", !on); if (on) _srPositionResults(); } // v2026.5.46 — positionne le popup : ancré sous la barre (vue classique), // ou en FIXED à DROITE de la barre (vue horizontale, barre dans la sidebar). function _srPositionResults() { const b = document.getElementById("ev-search-results"); const zone = document.getElementById("search-zone"); if (!b || !zone) return; const horiz = document.documentElement.classList.contains("view-horizontal"); if (horiz) { // Popup en OVERLAY, en haut à droite : juste à droite de la sidebar et calé // au sommet de la zone planning → passe AU-DESSUS des noms/interventions. const sidebar = document.getElementById("horizontal-sidebar"); const bar = zone.querySelector(".search-bar") || zone; const tb = document.querySelector("header.topbar"); const topbarBottom = tb ? Math.round(tb.getBoundingClientRect().bottom) : 56; const left = Math.round((sidebar ? sidebar.getBoundingClientRect().right : bar.getBoundingClientRect().right) + 12); const top = topbarBottom + 8; const width = Math.max(300, Math.min(640, window.innerWidth - left - 16)); b.style.left = left + "px"; b.style.top = top + "px"; b.style.right = "auto"; b.style.width = width + "px"; b.style.maxHeight = Math.max(200, window.innerHeight - top - 16) + "px"; } else { // Vue classique : popup ancré SOUS la barre (coords fixed car portalisé). const bar = zone.querySelector(".search-bar") || zone; const r = bar.getBoundingClientRect(); const width = Math.max(360, Math.round(r.width)); let left = Math.round(r.left); if (left + width > window.innerWidth - 12) left = Math.max(12, window.innerWidth - 12 - width); const top = Math.round(r.bottom + 6); b.style.left = left + "px"; b.style.top = top + "px"; b.style.right = "auto"; b.style.width = width + "px"; b.style.maxHeight = Math.max(180, window.innerHeight - top - 16) + "px"; } } // v2026.5.46 — hauteur réelle de la topbar → variable CSS (--topbar-h) pour // que la ligne sticky stats+recherche se cale juste dessous. // v2026.5.46 — recalcule l'empilement collant en haut : // --topbar-h = hauteur de la topbar // --stack-top = topbar + hauteur des bandeaux VISIBLES (session/EV/reconnexion/ // progress) → la ligne stats+recherche (classique) et la sidebar // (horizontal) se collent JUSTE dessous, sans chevauchement. function _srSyncStickyOffsets() { const tb = document.querySelector("header.topbar") || document.querySelector(".topbar"); const tbH = tb ? tb.offsetHeight : 56; let extra = 0; // v2026.5.46 — la barre de progression NE compte PLUS dans --stack-top : // elle est désormais SOUS la ligne stats+recherche (#topline), donc elle ne // pousse plus #topline vers le bas. Seuls les bandeaux session/EV comptent. ["session-expired-banner", "ev-unreachable-banner", "session-reconnecting-banner", "session-reconnect-failed-banner"] .forEach(id => { const el = document.getElementById(id); if (el && !el.classList.contains("hidden") && el.offsetParent !== null) extra += el.offsetHeight; }); const root = document.documentElement.style; root.setProperty("--topbar-h", tbH + "px"); root.setProperty("--topbar-height", tbH + "px"); root.setProperty("--stack-top", (tbH + extra) + "px"); // v2026.5.46 — hauteur de #topline : la barre de progression se cale juste // dessous (top: --stack-top + --topline-h). 0 si #topline masqué (vue horizontale). const tl = document.getElementById("topline"); const tlH = (tl && tl.offsetParent !== null) ? tl.offsetHeight : 0; root.setProperty("--topline-h", tlH + "px"); // v2026.5.47 — largeur RÉELLE de la sidebar horizontale → --sidebar-w : // la barre de progression se cale à sa DROITE (au-dessus du contenu, plus // par-dessus la sidebar/zone de reconnexion). Mesure robuste aux breakpoints // (200/160px) et au zoom. On ne l'écrit que si la sidebar est visible, sinon // on garde le repli CSS (200px) — évite un left:0 transitoire qui rechevaucherait. const sb = document.getElementById("horizontal-sidebar"); const sbW = (sb && sb.offsetParent !== null) ? sb.offsetWidth : 0; if (sbW > 0) root.setProperty("--sidebar-w", sbW + "px"); } // alias rétro-compat (anciens appels) function _srSyncTopbarHeight() { _srSyncStickyOffsets(); } // ─────────────────────────────────────────────────────────────────────────── // v2026.5.46 — MISE EN AVANT (refonte propre). Principe robuste : on ne // devine plus le timing du rendu. On enregistre une DEMANDE de surbrillance // (réf + date), on navigue si besoin via loadForDate, et c'est le RENDU // (renderFromData → _srApplyPendingHighlight) qui applique la surbrillance dès // que la ligne d'intervention existe dans le DOM. Repli : ré-essais courts. // ─────────────────────────────────────────────────────────────────────────── let _srPendingHighlight = null; // { iso, ref, scrolled, until } let _srHlRetry = 0; let _srHlEndTimer = null; function _srCssEsc(s) { return (window.CSS && CSS.escape) ? CSS.escape(String(s)) : String(s).replace(/["\\]/g, "\\$&"); } // résout l'actionId vivant (rendu courant) par la réf function _srLiveActionIdForRef(ref) { if (!ref || !state.currentData || !state.currentData.techs) return null; for (const t of state.currentData.techs) { for (const iv of (t.interventions || [])) { if (iv.ref && String(iv.ref) === String(ref)) return iv.actionId; } } return null; } // Trouve la ligne d'intervention dans le DOM par 3 stratégies (de la plus fiable // à la plus permissive) : data-ref → data-action-id → SCAN du texte (la réf est // affichée dans la carte). La 3e garantit de trouver si l'iv est rendue. // IMPORTANT : ne retenir que les éléments RÉELLEMENT VISIBLES. En vue classique // les .iv-mini-card (vue horizontale) peuvent rester en display:none dans le // DOM → scroller/surligner un élément caché ne fait rien à l'écran (bug v.préc). function _srIvElVisible(el) { return !!(el && el.offsetParent !== null && el.getClientRects().length > 0); } function _srFindIvEl(ref) { if (!ref) return null; const r = _srCssEsc(ref); const pick = (sel) => { for (const el of document.querySelectorAll(sel)) if (_srIvElVisible(el)) return el; return null; }; let el = pick(`.intervention-v2[data-ref="${r}"], .iv-mini-card[data-ref="${r}"]`); if (el) return el; const aid = _srLiveActionIdForRef(ref); if (aid) { const a = _srCssEsc(aid); el = pick(`.intervention-v2[data-action-id="${a}"], .iv-mini-card[data-action-id="${a}"]`); if (el) return el; } const needle = String(ref); for (const row of document.querySelectorAll(".intervention-v2, .iv-mini-card")) { if (_srIvElVisible(row) && row.textContent && row.textContent.indexOf(needle) >= 0) return row; } return null; } // Demande de mise en avant depuis un résultat de recherche (bouton « Jour ↗ »). function _srRequestHighlight(iso, ref) { if (!ref) return; // v2026.5.46 — la demande reste ACTIVE 15 s : chaque re-rendu du // pipeline (fresh, dossier, séquentiel) détruit/reconstruit les cartes, on // ré-applique donc la surbrillance sur le nouvel élément à chaque rendu. _srEndHighlight(); _srPendingHighlight = { iso: iso || null, ref: String(ref), scrolled: false, until: Date.now() + 15000 }; _srHlRetry = 0; const sameDay = !iso || (state && state.currentDate === iso); if (sameDay) { _srApplyPendingHighlight(); // déjà le bon jour → tout de suite } else if (typeof loadForDate === "function") { LOG.info("search", `mise en avant : navigation vers ${iso} pour réf=${ref}`); loadForDate(iso); // renderFromData appliquera la demande } } // Applique la demande en attente : scroll visible + contour couleur (retiré au // survol). Appelé en fin de rendu (renderFromData) ET en repli temporisé. function _srApplyPendingHighlight() { const req = _srPendingHighlight; if (!req || !req.ref) return; // si une date précise est visée, n'appliquer que si on est bien dessus if (req.iso && state && state.currentDate && state.currentDate !== req.iso) return; const el = _srFindIvEl(req.ref); if (!el) { if (_srHlRetry++ < 25) { setTimeout(_srApplyPendingHighlight, 120); return; } // rendu pas encore prêt // diagnostic : pourquoi on ne trouve pas la ligne ? const all = document.querySelectorAll(".intervention-v2, .iv-mini-card"); const sample = Array.from(all).slice(0, 4).map(r => (r.dataset.ref || r.dataset.actionId || (r.textContent || "").trim().slice(0, 24))); LOG.warn("search", `mise en avant : réf=${req.ref} INTROUVABLE`, { currentDate: state && state.currentDate, isoVisé: req.iso, lignesRendues: all.length, exemples: sample }); _srPendingHighlight = null; return; } _srHlRetry = 0; if (Date.now() > req.until) { _srEndHighlight(); return; } // échéance passée // v2026.5.46 — la demande N'est PAS consommée : les re-rendus du // pipeline détruisent l'élément surligné, renderFromData nous rappelle et on // re-pose la classe sur le NOUVEL élément. La surbrillance ne disparaît plus // « toute seule » : fin au clic sur la carte, ou à l'échéance (15 s). el.classList.add("iv-search-highlight"); el.addEventListener("click", _srEndHighlight, { once: true }); // UN SEUL défilement animé (le premier). Ensuite, seulement si un re-rendu a // déplacé le layout au point de sortir la carte de l'écran → recalage // INSTANTANÉ (pas d'animation → la page ne « danse » plus). // v2026.5.46 — block:"start" + scroll-margin-top (topbar + bandeaux + // ligne stats/recherche) : la carte n'est jamais cachée derrière les barres. const scrollTarget = el.closest(".card") || el; if (!req.scrolled) { req.scrolled = true; try { scrollTarget.scrollIntoView({ behavior: "smooth", block: "start", inline: "nearest" }); } catch (e) { try { scrollTarget.scrollIntoView(); } catch (_) {} } } else { const r = scrollTarget.getBoundingClientRect(); if (r.bottom < 0 || r.top > window.innerHeight) { try { scrollTarget.scrollIntoView({ behavior: "auto", block: "start", inline: "nearest" }); } catch (e) { /* ignore */ } } } // fin de la mise en avant à l'échéance (timer global unique, re-armé à // chaque ré-application). if (_srHlEndTimer) clearTimeout(_srHlEndTimer); _srHlEndTimer = setTimeout(_srEndHighlight, Math.max(250, req.until - Date.now())); const _r = el.getBoundingClientRect(); LOG.info("search", `mise en avant : réf=${req.ref} surlignée`, { classe: el.className, visible: _srIvElVisible(el), top: Math.round(_r.top), height: Math.round(_r.height) }); } // fin de la mise en avant : demande annulée + classe retirée partout. function _srEndHighlight() { _srPendingHighlight = null; if (_srHlEndTimer) { clearTimeout(_srHlEndTimer); _srHlEndTimer = null; } document.querySelectorAll(".iv-search-highlight").forEach(e => e.classList.remove("iv-search-highlight")); } function _srClear() { const inp = document.getElementById("ev-search"); if (inp) inp.value = ""; _srShowResults(false); _srSpin(false); const c = document.getElementById("ev-search-clear"); if (c) c.classList.add("hidden"); } // v2026.5.46 — ferme les popups TRANSITOIRES (tooltip de survol + grande // popup ancrée non épinglée) au clic dans la recherche. On NE TOUCHE PAS aux // popups épinglés flottants (.pinned-popup) ni au dock (.pinned-popup-reduced). // v2026.5.46 — relance la recherche (réaffiche le popup) si la zone contient // déjà une requête et que le popup est fermé. Utilisé au re-focus / re-survol. function _srReopenIfQuery() { const box = document.getElementById("ev-search-results"); if (box && !box.classList.contains("hidden")) return; // déjà affiché // v2026.5.48 — une recherche est déjà en vol (ou programmée par le // debounce) : ne PAS la tuer en la relançant — c'était la cause du // « je ne tape plus et la recherche ne part pas » (chaque passage de souris // sur la barre repartait de zéro pendant la lecture lente de l'index). if (_srSearchEnCours || _searchDebTimer) return; const inp = document.getElementById("ev-search"); const v = inp && inp.value.trim(); if (v && v.length >= SEARCH_MIN_CHARS) _srDoSearch(v); } // v2026.5.46 — programme la fermeture du popup de recherche ~0,5 s après // que la souris est passée sur une intervention DU PLANNING (comme un tooltip). let _srCloseTimer = null; function _srScheduleCloseFromPlanning() { const box = document.getElementById("ev-search-results"); if (!box || box.classList.contains("hidden")) return; if (_srCloseTimer) clearTimeout(_srCloseTimer); _srCloseTimer = setTimeout(() => { _srShowResults(false); _srCloseTimer = null; }, 500); } function _srCancelCloseTimer() { if (_srCloseTimer) { clearTimeout(_srCloseTimer); _srCloseTimer = null; } } // v2026.5.46 — fermeture GROUPÉE « popup de recherche + tooltip issu de la // recherche ». Tant que la souris est dans l'un OU l'autre, on annule. Quand // elle quitte les DEUX, on ferme après 1 s (le tooltip non épinglé suit ; // les popups épinglés / le dock sont conservés = « le reste »). let _srGroupCloseTimer = null; function _srGroupCancelClose() { if (_srGroupCloseTimer) { clearTimeout(_srGroupCloseTimer); _srGroupCloseTimer = null; } } function _srGroupScheduleClose() { const box = document.getElementById("ev-search-results"); if (!box || box.classList.contains("hidden")) return; // popup déjà fermé _srGroupCancelClose(); _srGroupCloseTimer = setTimeout(() => { _srGroupCloseTimer = null; _srShowResults(false); // ferme le popup de recherche const tip = document.getElementById("tooltip"); if (tip && tip.dataset && tip.dataset.fromSearch === "1" && typeof hideTooltip === "function") { hideTooltip(); // ferme le tooltip (sauf s'il a été épinglé) } }, 1000); } function _srCloseTransientPopups() { try { if (typeof hideTooltip === "function") hideTooltip({ force: true }); } catch (e) {} // Popups ancrées ouvertes mais NON épinglées (sécurité — celles épinglées et // celles réduites dans le dock sont conservées). document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced):not(.pinned-popup-pinned)").forEach(p => { // une popup réellement épinglée porte .pinned-popup-pinned OU est suivie // dans pinnedPopups ; on ne ferme que celles qui ne sont ni l'un ni l'autre. try { const tracked = (typeof pinnedPopups !== "undefined") && pinnedPopups.some(pp => pp.el === p); if (!tracked) p.remove(); } catch (e) {} }); } function initSearchBar() { if (_searchInited) return; const inp = document.getElementById("ev-search"); const zone = document.getElementById("search-zone"); if (!inp || !zone) return; _searchInited = true; inp.addEventListener("input", () => { const v = inp.value.trim(); const c = document.getElementById("ev-search-clear"); if (c) c.classList.toggle("hidden", v.length === 0); // v2026.5.46 — CHAQUE frappe arrête immédiatement la recherche en cours : // on invalide le jeton (réponses périmées ignorées), on abort le fetch EV en // vol, on coupe la roue et on annule le debounce. La recherche n'est (re)lancée // qu'après une pause de frappe → « cherche quand j'ai fini d'écrire ». _searchSeq++; if (_searchInflight) { try { _searchInflight.abort(); } catch (e) {} _searchInflight = null; } if (_searchDebTimer) { clearTimeout(_searchDebTimer); _searchDebTimer = null; } _srSearchEnCours = false; // v2026.5.48 — la frappe invalide la recherche en vol _srSpin(false); if (v.length < SEARCH_MIN_CHARS) { _srShowResults(false); return; } // v2026.5.48 — le timer est remis à null au déclenchement : un id // périmé resté truthy bloquerait la relance au survol (_srReopenIfQuery). _searchDebTimer = setTimeout(() => { _searchDebTimer = null; _srDoSearch(v); }, SEARCH_DEBOUNCE_MS); }); inp.addEventListener("keydown", (e) => { // v2026.5.48 — Entrée : recherche immédiate, sans attendre le debounce. if (e.key === "Enter") { if (_searchDebTimer) { clearTimeout(_searchDebTimer); _searchDebTimer = null; } const v = inp.value.trim(); if (v.length >= SEARCH_MIN_CHARS) _srDoSearch(v); } // v2026.5.46 — Échap : ferme le popup de recherche (sans vider le texte, // pour pouvoir le rouvrir) + ferme les popups non épinglés. else if (e.key === "Escape") { _srShowResults(false); _srCloseTransientPopups(); } }); inp.addEventListener("focus", () => { _srBuildCacheIndex(false); _srCloseTransientPopups(); // v2026.5.46 — re-clic dans la zone avec une recherche déjà faite → // on relance la recherche pour ré-afficher le popup. _srReopenIfQuery(); }); const clr = document.getElementById("ev-search-clear"); if (clr) clr.addEventListener("click", () => { _srClear(); inp.focus(); }); // v2026.5.46 — bouton : popover d'aide sur la syntaxe de recherche. const helpBtn = document.getElementById("ev-search-help"); if (helpBtn) { const closeHelp = () => { const p = document.getElementById("ev-search-help-pop"); if (p) p.remove(); }; helpBtn.addEventListener("click", (e) => { e.stopPropagation(); if (document.getElementById("ev-search-help-pop")) { closeHelp(); return; } _srShowResults(false); const pop = document.createElement("div"); pop.id = "ev-search-help-pop"; pop.className = "sr-help-pop"; pop.innerHTML = `
🔍 Comment chercher
mot1 mot2tous les mots doivent être présents (ordre libre)
%joker : I2606%123 (dans cet ordre), %123 (contient)
"…"expression exacte, espaces compris
-motexclure : ecran -reservation
champ:cibler : tech: lieu: ref: cat: contact:
Sources : planning en cache (réf, contact, lieu, description chargée, tech…) + recherche EasyVista (demandes, matériel, employés). Le passé est replié en bas de liste — cliquer pour l'afficher. Résultats par 15 : faire défiler pour charger la suite.
`; document.body.appendChild(pop); // ancré sous la barre de recherche (mêmes coordonnées que les résultats) const bar = zone.querySelector(".search-bar") || zone; const r = bar.getBoundingClientRect(); const width = Math.max(340, Math.min(520, Math.round(r.width))); let left = Math.round(r.left); if (left + width > window.innerWidth - 12) left = Math.max(12, window.innerWidth - 12 - width); pop.style.left = left + "px"; pop.style.top = Math.round(r.bottom + 6) + "px"; pop.style.width = width + "px"; const onDoc = (ev2) => { if (!pop.contains(ev2.target) && ev2.target !== helpBtn) { closeHelp(); document.removeEventListener("click", onDoc, true); } }; setTimeout(() => document.addEventListener("click", onDoc, true), 0); const onEsc = (ev2) => { if (ev2.key === "Escape") { closeHelp(); document.removeEventListener("keydown", onEsc); } }; document.addEventListener("keydown", onEsc); }); } // v2026.5.46 — re-survol de la zone de recherche avec du texte → relance. const _bar = zone.querySelector(".search-bar"); if (_bar) _bar.addEventListener("mouseenter", () => _srReopenIfQuery()); // v2026.5.46 — souris dans le popup de résultats : annule la fermeture // programmée et ferme les autres popups (sauf épinglés / dans le dock). const _box = document.getElementById("ev-search-results"); if (_box) { _box.addEventListener("mouseenter", () => { _srGroupCancelClose(); _srCancelCloseTimer(); _srCloseTransientPopups(); }); // v2026.5.46 — sortie du popup de recherche : on programme la fermeture // groupée (1 s). Annulée si la souris entre dans le tooltip issu de la recherche. _box.addEventListener("mouseleave", () => _srGroupScheduleClose()); } // v2026.5.46 — le tooltip issu de la recherche fait partie du « groupe » : // y entrer garde tout ouvert ; en sortir programme la fermeture (1 s). const _tip = document.getElementById("tooltip"); if (_tip) { _tip.addEventListener("mouseenter", () => { if (_tip.dataset && _tip.dataset.fromSearch === "1") { _srGroupCancelClose(); _cancelHideTooltip(); } }); _tip.addEventListener("mouseleave", () => { if (_tip.dataset && _tip.dataset.fromSearch === "1") _srGroupScheduleClose(); }); } document.addEventListener("click", (e) => { // v2026.5.48 — un clic traité DANS les résultats peut retirer // l'élément cliqué du DOM avant que l'événement remonte ici (bascule // « Planifiée (passé) » qui réécrit son innerHTML, pied « N de plus » // retiré au chargement) : l'élément détaché n'est plus contenu par la // zone → la recherche se fermait à tort. Un clic sur un élément détaché // n'est jamais un clic « à l'extérieur ». if (e.target && e.target.isConnected === false) return; const box = document.getElementById("ev-search-results"); if (!zone.contains(e.target) && !(box && box.contains(e.target))) _srShowResults(false); }); window.addEventListener("resize", () => { _srSyncTopbarHeight(); const b = document.getElementById("ev-search-results"); if (b && !b.classList.contains("hidden")) _srPositionResults(); }); _srSyncTopbarHeight(); LOG.info("search", "barre de recherche initialisée"); }