commit c59abbed23eee6834801740dbae54d318829a636 Author: Quentin Rouiller Date: Mon Apr 20 09:13:20 2026 +0200 v4.3.3 — Soft unpin popup + nettoyage tooltip persistance diff --git a/README.md b/README.md new file mode 100644 index 0000000..3855aee --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# Planning techniciens — Vue claire (v4.1.2) + +Extension Chrome/Brave/Edge pour afficher le planning techniciens EasyVista +(`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) dans une vue plus lisible. + +## Nouveautés v4.1.2 + +- **Vraies infos contact/lieu dans les cartes** : les attributs attr1/attr2 du + XML contiennent les infos saisies à la *planification*, qui ne sont pas + toujours à jour (le tech a pu corriger le contact/lieu avant intervention). + Désormais, pour chaque intervention, on fetch AUSSI le xhr2 en arrière-plan + (en plus de la fiche), ce qui apporte les **vraies** infos validées. La + carte se met à jour automatiquement quand elles arrivent. +- **Clic ouverture restauré** : retour à la logique v4 (fetch fiche à la volée + + extraction checksum + construction URL avec sender adéquat). Le checksum + est pré-rempli pendant le fetch arrière-plan, donc au clic l'ouverture est + instantanée dans la plupart des cas. + +## Nouveautés v4.1 + +- **Fetch des fiches séquentiel (1 par 1)** au lieu de 5 workers en parallèle. + Le serveur EasyVista sérialise les requêtes de toute façon, donc le parallélisme + n'apporte rien. Et surtout : quand tu changes de date pendant le fetch, l'abort + est **instantané** car il n'y a qu'une seule requête en vol au maximum. +- **Cache incrémental** : le cache est sauvé toutes les 5 fiches pendant le fetch, + pas juste à la fin. Si tu changes de date avant que tout soit fini, les statuts + déjà récupérés sont conservés. + +## Nouveautés v4 + +**Chargement ~50× plus rapide.** Le nombre de requêtes au serveur EasyVista passe +de ~100 par chargement à **1 seule requête** pour l'affichage principal. + +Concrètement, en v3 un chargement initial faisait : +- 1 fetch XML planning (`calendar_block`) +- ~40 fetches `planning_xhr_2.php` pour les lieux/contacts +- ~40 fetches de fiches HTML pour les catégories/refs/statuts +- jusqu'à ~40 fetches de l'API timeline + +Total : ~120 requêtes, 10+ Mo, 8 à 15 secondes selon la charge serveur. + +En v4, on a découvert que le XML initial `calendar_block` contient **déjà** +dans ses attributs `attr1`/`attr2`/`attr3` le contact, le lieu et la catégorie +complète de chaque intervention, et la ref dans le textContent du nœud. +Toutes ces infos qu'on allait chercher ailleurs étaient en fait dans la toute +première réponse, ignorées par le code. + +Résultat : le premier rendu complet arrive en **moins d'une seconde**. Les +fiches individuelles ne sont plus fetchées qu'en arrière-plan, uniquement +pour le statut "Clôturé/Résolu" et le commentaire technicien. + +**Lazy-load au survol.** Le texte détaillé d'une intervention (Problème, À faire, +Matériel, TFS ancien/nouveau poste...) n'est chargé qu'au premier survol de la +ligne, seulement pour l'intervention survolée. Imperceptible pour l'utilisateur, +énorme pour le serveur. + +**Concurrence réduite.** Le pic de requêtes parallèles passe de 15 à 5 workers, +pour ménager le serveur EasyVista qui a tendance à saturer sous les rafales. + +Toute l'interface utilisateur est **strictement identique** à la v3 — on n'a +changé que ce qu'il y a sous le capot. + +## Hérité des versions précédentes + +- Navigation par date : ◀ ▶ et sélecteur +- Détection automatique des interventions closes (✓ vert, fond vert) +- Cache persistant 7 jours +- Ghosts : les interventions disparues d'EasyVista restent visibles dans la vue +- Refresh auto 12h et 15h +- Annulation coopérative (bouton "Arrêter") +- Thème clair/sombre + +## Installation + +1. Décompresser le zip +2. Ouvrir Chrome, `chrome://extensions/` +3. Activer **Mode développeur** (en haut à droite) +4. **Charger l'extension non empaquetée** → sélectionner le dossier `planning-extension-v4` + +Si tu avais déjà la v3 installée, tu peux la supprimer avant — les caches des +deux versions sont compatibles (même format). + +## Utilisation + +1. Se connecter à EasyVista dans un onglet (`itsma.etat-de-vaud.ch` ou `itsma.vd.ch`) +2. Cliquer sur l'icône de l'extension (depuis n'importe quel onglet) +3. La vue claire s'ouvre dans un nouvel onglet + +## Comment ça marche techniquement + +- `background.js` fait les fetches en arrière-plan (via le cookie de session EasyVista). +- L'extension détecte automatiquement le `PHPSESSID` depuis un onglet EasyVista ouvert. +- **v4 : le XML `planning_xhr.php?div=calendar_block` suffit à afficher tout + l'essentiel.** Les champs `attr1`/`attr2`/`attr3` contiennent contact, lieu + et catégorie. Le `textContent` du nœud contient la ref (S260.../I260...). +- Les fiches individuelles (`index.php?formEvent=...`) ne sont fetchées que pour + obtenir le statut Clôturé/Résolu et le commentaire technicien. +- Le texte d'action détaillé (Problème/À faire/Matériel/...) est récupéré en + lazy-load via `planning_xhr_2.php?id=ACTIONID` au premier survol. +- Le cache est stocké dans `chrome.storage.local` (local à ta machine). +- Aucune donnée n'est envoyée ailleurs que vers `itsma.etat-de-vaud.ch` et `itsma.vd.ch`. + +## Limitations connues + +- Nécessite un onglet EasyVista ouvert (même en arrière-plan) pour fonctionner +- Fonctionne uniquement sur l'intranet cantonal (les fetches échoueront en externe) +- Les 8 IDs des techs sont en dur dans le code (si quelqu'un quitte/arrive dans + l'équipe, il faut mettre à jour `viewer.js` ligne ~22) +- Le statut "Clôturé/Résolu" met quelques secondes à apparaître après le + chargement initial (fetch des fiches en arrière-plan, concurrence 5) diff --git a/background.js b/background.js new file mode 100644 index 0000000..86e6345 --- /dev/null +++ b/background.js @@ -0,0 +1,654 @@ +// background.js — Service worker (Manifest V3) — v4 +// +// Rôles : +// 1. Au clic sur l'icône : ouvrir le viewer +// 2. Répondre aux messages du viewer : +// - getSession : trouve l'onglet EasyVista ouvert, renvoie {phpsessid, origin} +// - fetchPlanning : fetch le XML du planning pour une date (1 requête = tout) +// - fetchXhr2 : fetch un texte d'action détaillé (utilisé en lazy-load au survol) +// - fetchFiche : fetch une fiche individuelle (HTML) pour statut + commentaire tech +// 3. Nettoyer les vieux caches (>7 jours) +// (v4.2 : l'auto-refresh 12h/15h a été retiré) +// +// v4 : suppression de fetchTimeline (pu utilisé). Le calendar_block contient +// directement ref/contact/lieu/catégorie dans ses attributs attr1/attr2/attr3, +// donc on n'a plus besoin ni de xhr2 en masse, ni de l'API timeline. + +// Domaines EasyVista reconnus (interne d'abord, externe en fallback) +const EV_ORIGINS = [ + "https://itsma.etat-de-vaud.ch", + "https://itsma.vd.ch" +]; + +// ============================================================================ +// Clic sur l'icône → ouvrir le viewer +// ============================================================================ + +chrome.action.onClicked.addListener(async () => { + const viewerUrl = chrome.runtime.getURL("viewer.html"); + // Si le viewer est déjà ouvert, on focus cet onglet plutôt que d'en ouvrir un autre + const existing = await chrome.tabs.query({ url: viewerUrl + "*" }); + if (existing.length > 0) { + await chrome.tabs.update(existing[0].id, { active: true }); + await chrome.windows.update(existing[0].windowId, { focused: true }); + } else { + await chrome.tabs.create({ url: viewerUrl }); + } +}); + +// ============================================================================ +// Trouver l'onglet EasyVista actif et en extraire le PHPSESSID +// ============================================================================ + +async function findEasyVistaSession() { + // Chercher tous les onglets sur un domaine EasyVista + for (const origin of EV_ORIGINS) { + const tabs = await chrome.tabs.query({ url: origin + "/*" }); + for (const tab of tabs) { + const m = (tab.url || "").match(/[?&]PHPSESSID=([a-zA-Z0-9]+)/); + if (m) { + return { phpsessid: m[1], origin: origin, tabId: tab.id }; + } + } + } + return null; +} + +// ============================================================================ +// Fetch helpers (s'exécutent dans le contexte du service worker, +// les cookies du domaine sont automatiquement inclus via credentials: include) +// ============================================================================ + +/** + * Fetch du XML retourné par planning_xhr.php?div=calendar_block. + * Contient les interventions de nos 8 techs pour la date donnée (~40 ko). + * + * Ce n'est PAS le HTML de la page Planning — le serveur ne rend pas les données + * dans le HTML, elles arrivent via cet endpoint AJAX. + */ +async function fetchPlanningXml(origin, phpsessid, unixDate) { + const techIds = "76272,83725,66635,92235,90070,40944,72485,86874"; + const groupId = "191"; + const url = + `${origin}/planning_xhr.php` + + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + + `&div=calendar_block` + + `&mode=day` + + `&group_id=${groupId}` + + `&event_name=HelpDesk_PlanningItem` + + `&sql_param=${techIds}` + + `&unix_date=${unixDate}` + + `&start_date_label=Date` + + `&end_date_label=Date` + + `&click_here_label=Ici` + + `&mail_title=mail` + + `&day_start_hour=8` + + `&day_end_hour=19`; + console.log("[bg] fetchPlanningXml →", url.substring(0, 140)); + const r = await fetch(url, { credentials: "include" }); + console.log("[bg] status =", r.status); + if (!r.ok) { + // v4.2 : classifier l'erreur HTTP pour que le viewer affiche le bon + // écran (session expirée vs EV inaccessible). + const err = new Error("HTTP " + r.status); + err.kind = classifyHttpStatus(r.status); + err.status = r.status; + throw err; + } + const xml = await r.text(); + console.log("[bg] taille XML =", xml.length); + return xml; +} + +/** + * v4.2 : classifie un statut HTTP comme "session_expired" ou "ev_unreachable". + * - 401, 403, 404 → session_expired (EV renvoie souvent 404 au lieu de rediriger + * vers la page de login quand PHPSESSID n'est plus valide) + * - 5xx, autres → ev_unreachable (service down, surcharge, etc.) + */ +function classifyHttpStatus(status) { + if (status === 401 || status === 403 || status === 404) return "session_expired"; + return "ev_unreachable"; +} + +/** + * Fetch planning_xhr_2.php?id=ACTIONID pour UNE intervention. + * Retourne ~400 octets au format custom : + * @@DESCRIPTION_S@@...@@DESCRIPTION_E@@@@LABEL_S@@... + */ +async function fetchXhr2(origin, phpsessid, actionId) { + const url = `${origin}/planning_xhr_2.php?PHPSESSID=${encodeURIComponent(phpsessid)}&id=${encodeURIComponent(actionId)}`; + const r = await fetch(url, { credentials: "include" }); + if (!r.ok) { + const err = new Error("HTTP " + r.status); + err.kind = classifyHttpStatus(r.status); + err.status = r.status; + throw err; + } + return await r.text(); +} + +async function fetchFicheHtml(origin, phpsessid, formLink) { + const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`; + console.log("[bg] fetchFicheHtml →", url.substring(0, 120)); + const r = await fetch(url, { credentials: "include" }); + if (!r.ok) { + const err = new Error("HTTP " + r.status); + err.kind = classifyHttpStatus(r.status); + err.status = r.status; + throw err; + } + const html = await r.text(); + console.log("[bg] fiche status =", r.status, "| taille =", html.length); + return html; +} + +// v4.1.7 : API timeline EasyVista. Renvoie le JSON des actions d'une fiche, +// avec pour chaque action : intervenant, ACTION_ID, AM_DONE_BY_ID, description +// complète (bien plus riche que le xhr2 tronqué). +// Utilisé pour afficher le texte complet de l'action dans le tooltip. +// v4.1.9 : le GUID du form est passé en paramètre (extrait dynamiquement du +// HTML de la fiche par le viewer). Il est différent pour une demande S... +// ({C99ECD05}) vs un incident I... ({07ED9C68}). +async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum) { + // Sécurité : GUID doit être de la forme %7B...%7D ou {...} + if (!/^(%7B|\{)[A-F0-9\-]{36}(%7D|\})$/i.test(guid)) { + throw new Error("Invalid GUID: " + guid); + } + // S'assurer qu'on a la forme encodée %7B...%7D + const encodedGuid = guid.startsWith("%7B") ? guid : `%7B${guid.replace(/[{}]/g, "")}%7D`; + const url = + `${origin}/api/v1/internal/forms/${encodedGuid}/timeline` + + `?target=${encodeURIComponent(formId)}` + + `&checksum=${encodeURIComponent(formChecksum)}` + + `&type=todo§ionId=1&navigator=&nbRecord=0` + + `&PHPSESSID=${encodeURIComponent(phpsessid)}`; + const r = await fetch(url, { credentials: "include" }); + if (!r.ok) { + const err = new Error("HTTP " + r.status); + err.kind = classifyHttpStatus(r.status); + err.status = r.status; + throw err; + } + return await r.text(); +} + +// ============================================================================ +// Détection "session invalide" +// ============================================================================ + +function looksLikeLoginPage(text) { + // La page de login EasyVista contient cette chaîne + return /customer_login|my\.policy/i.test((text || "").substring(0, 3000)); +} + +// ============================================================================ +// v4.2 : récupération de l'utilisateur connecté +// ============================================================================ + +/** + * Essaie de récupérer le nom de l'utilisateur EasyVista connecté en fetchant + * la page d'accueil avec la session active. EasyVista n'exposant pas + * d'endpoint public simple, on cherche des patterns typiques dans le HTML : + * - ...Nom, Prénom... + * - éléments avec data-user-name, data-user-login + * - balises cachées ou variables JS EV.User.name + * - champ "Bienvenue Nom Prénom" + * Retourne { name: "Nom Prénom" | null, login: "..." | null } ou null si + * tout a échoué. + */ +async function fetchCurrentUser(origin, phpsessid) { + const url = `${origin}/index.php?PHPSESSID=${encodeURIComponent(phpsessid)}`; + const resp = await fetch(url, { + method: "GET", + credentials: "include", + headers: { "Accept": "text/html,*/*" } + }); + // v4.2 : cette fonction est lancée en tâche de fond au démarrage. Si la + // session est expirée ou EV inaccessible, on retourne juste null — le + // planning lui-même déclenchera l'écran d'erreur approprié. + if (!resp.ok) return null; + const html = await resp.text(); + if (looksLikeLoginPage(html)) return null; + + // v4.2.2 : patterns spécifiques à la structure EasyVista réelle du Canton + // de Vaud (identifiés à partir du HTML de la page d'accueil). L'user est + // affiché dans un dropdown ".ev-employee-dropdown" avec ces éléments : + // + // Nom, Prénom + // 3.3 DGNSI-ServiceDesk + // ... + // + // Le title du parent contient aussi "Nom, Prénom / Service / Société". + const patterns = [ + // 1) Le plus fiable : span class="h5" dans profile-info (structure EV 2026) + /]*>\s*]*title=["']([^"']{2,80})["']/i, + // 2) Fallback : span class="h5" avec title= même hors profile-info + /]*title=["']([^"']{2,80})["'][^>]*>\s*([^<]{2,80})<\/span>/i, + // 3) Fallback : title= de ev-employee-dropdown (format "Nom, Prénom / Service / Société") + /class=["'][^"']*ev-employee-dropdown[^"']*["'][^>]*title=["']([^"'\/]+?)(?:\s*\/\s*[^"']+)?["']/i, + // 4) Anciens patterns génériques (autres instances EasyVista éventuelles) + /data-user-name\s*=\s*["']([^"']+)["']/i, + /data-username\s*=\s*["']([^"']+)["']/i, + /data-user-fullname\s*=\s*["']([^"']+)["']/i, + /EV\.User\.name\s*=\s*["']([^"']+)["']/, + /EV\.User\.fullname\s*=\s*["']([^"']+)["']/, + /userFullName\s*[:=]\s*["']([^"']+)["']/, + // 5) "Bienvenue" / "Welcome" + /(?:Bienvenue|Welcome)[,\s]+(?:M\.?\s+|Mme\s+)?([A-ZÀÁÂÄÇÉÈÊËÍÎÏÓÔÖÚÛÜÑ][\wÀ-ÿ'.\-]+(?:\s*,?\s+[A-ZÀÁÂÄÇÉÈÊËÍÎÏÓÔÖÚÛÜÑ][\wÀ-ÿ'.\-]+){0,3})/ + ]; + + let name = null; + for (const rx of patterns) { + const m = html.match(rx); + if (m && m[1]) { + const candidate = m[1].trim() + .replace(/\s+/g, " ") + .replace(/^(?:EasyVista|EV|Accueil|Home|Planning|ITSMA)[\s\-|•]+/i, "") + .replace(/[\s\-|•]+(?:EasyVista|EV|ITSMA)$/i, "") + .trim(); + if (candidate && candidate.length >= 3 && candidate.length <= 80 + && /[A-Za-zÀ-ÿ]/.test(candidate) + && !/\b(login|connexion|sign\s*in|easyvista|ITSMA)\b/i.test(candidate)) { + name = candidate; + break; + } + } + } + + // v4.2.2 : on extrait aussi le service/unité si disponible (h6 à côté du h5) + let service = null; + const serviceMatch = html.match( + /]*>[\s\S]{0,500}?]*title=["']([^"']{2,80})["']/i + ); + if (serviceMatch && serviceMatch[1]) { + service = serviceMatch[1].trim(); + } + + // Login / identifiant court (optionnel) + let login = null; + const loginPatterns = [ + /data-user-login\s*=\s*["']([^"']+)["']/i, + /data-login\s*=\s*["']([^"']+)["']/i, + /EV\.User\.login\s*=\s*["']([^"']+)["']/, + /userLogin\s*[:=]\s*["']([^"']+)["']/ + ]; + for (const rx of loginPatterns) { + const m = html.match(rx); + if (m && m[1]) { + login = m[1].trim(); + break; + } + } + + if (!name && !login && !service) return null; + return { name, login, service }; +} + +// ============================================================================ +// v4.2.6 : Création d'absence +// ============================================================================ + +/** + * Envoie un POST vers plan_set_holidays_popup.php pour créer une absence. + * Format attendu (analysé depuis le HTML EasyVista) : + * Query params : PHPSESSID, MAIN_DIRECTORY, ROOT_DIRECTORY, current_date, + * empl_ids, begin_hour, end_hour, plagehoraire + * Body : start_date, start_time, end_date, end_time, label_guid, dialog_action + * + * @param {string} origin - "https://itsma.vd.ch" ou similaire + * @param {string} phpsessid + * @param {Object} opts - { techIds: string[], startDate: "DD/MM/YYYY", + * startTime: "HH:MM:SS", endDate, endTime, + * typeGuid, currentDate } + */ +async function submitAbsence(origin, phpsessid, opts) { + const emplIds = (opts.techIds || []).join(","); + if (!emplIds) throw new Error("Aucun technicien sélectionné"); + + const internalurltime = Math.floor(Date.now() / 1000); + const url = `${origin}/include/components/staff/planning/plan_set_holidays_popup.php` + + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + + `&internalurltime=${internalurltime}` + + `&MAIN_DIRECTORY=${encodeURIComponent("/")}` + + `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}` + + `¤t_date=${encodeURIComponent(opts.currentDate)}` + + `&empl_ids=${encodeURIComponent(emplIds)}` + + `&begin_hour=8` + + `&end_hour=18` + + `&plagehoraire=0`; + + const body = new URLSearchParams(); + body.set("start_date", opts.startDate); + body.set("start_time", opts.startTime); + body.set("end_date", opts.endDate); + body.set("end_time", opts.endTime); + body.set("label_guid", opts.typeGuid); + body.set("dialog_action", "save_holidays"); + + console.log("[bg] submitAbsence →", url.substring(0, 140)); + console.log("[bg] body:", body.toString()); + + const r = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString() + }); + + console.log("[bg] status =", r.status); + + if (!r.ok) { + throw new Error("HTTP " + r.status); + } + + const responseText = await r.text(); + if (looksLikeLoginPage(responseText)) { + throw new Error("session_expired"); + } + + // Succès : on ne sait pas le format exact de la réponse EasyVista, on + // considère qu'un HTTP 200 non-login signifie succès. + return { status: r.status }; +} + +// ============================================================================ +// v4.2.6 : Envoi sur douchette +// ============================================================================ + +/** + * Envoie la planification du jour sur la douchette des techs sélectionnés. + * + * Endpoint identifié (via l'inspection de la page EasyVista) : + * POST /include/components/staff/planning/plan_set_tech_planif_popup.php + * Query : PHPSESSID, current_date, empl_ids (CSV), begin_hour, end_hour, + * plagehoraire + * Body : dialog_action=save_planif + * + * Contrairement à l'absence, un seul POST suffit pour tous les techs (empl_ids + * est une CSV), pas besoin de boucler. + * + * @param {string} origin + * @param {string} phpsessid + * @param {Object} opts - { techIds, currentDate } + * @returns {{ okCount, errors }} + */ +async function submitDouchette(origin, phpsessid, opts) { + const techIds = opts.techIds || []; + if (techIds.length === 0) throw new Error("Aucun technicien sélectionné"); + + const emplIds = techIds.join(","); + const internalurltime = Math.floor(Date.now() / 1000); + const url = `${origin}/include/components/staff/planning/plan_set_tech_planif_popup.php` + + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + + `&internalurltime=${internalurltime}` + + `&MAIN_DIRECTORY=${encodeURIComponent("/")}` + + `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}` + + `¤t_date=${encodeURIComponent(opts.currentDate)}` + + `&empl_ids=${encodeURIComponent(emplIds)}` + + `&begin_hour=8` + + `&end_hour=18` + + `&plagehoraire=0`; + + const body = new URLSearchParams(); + body.set("dialog_action", "save_planif"); + + console.log("[bg] submitDouchette →", url.substring(0, 160)); + console.log("[bg] body:", body.toString()); + console.log("[bg] techs:", emplIds); + + try { + const r = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString() + }); + console.log("[bg] status =", r.status); + + if (r.status === 401 || r.status === 403) { + return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) }; + } + if (!r.ok) { + return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "HTTP " + r.status })) }; + } + const responseText = await r.text(); + if (looksLikeLoginPage(responseText)) { + return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) }; + } + return { okCount: techIds.length, errors: [] }; + } catch (err) { + const msg = err && err.message ? err.message : String(err); + return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: msg })) }; + } +} + +// ============================================================================ +// Messages du viewer +// ============================================================================ + +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + (async () => { + try { + if (msg.type === "getSession") { + const session = await findEasyVistaSession(); + sendResponse({ ok: true, session }); + return; + } + + if (msg.type === "fetchPlanning") { + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + // Fetch XML calendar_block du planning (rapide ~40 ko) + const xml = await fetchPlanningXml(session.origin, session.phpsessid, msg.unixDate); + if (looksLikeLoginPage(xml)) { + sendResponse({ ok: false, error: "session_expired" }); + return; + } + sendResponse({ ok: true, xml, session }); + } catch (err) { + // v4.2 : classification de l'erreur pour afficher le bon écran + const errorCode = err.kind || ( + /network|fetch|typeerror/i.test(err.message) ? "ev_unreachable" : "ev_unreachable" + ); + sendResponse({ ok: false, error: errorCode, httpStatus: err.status, detail: err.message }); + } + return; + } + + if (msg.type === "fetchXhr2") { + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const body = await fetchXhr2(session.origin, session.phpsessid, msg.actionId); + sendResponse({ ok: true, body }); + } catch (err) { + sendResponse({ + ok: false, + error: err.kind || "fetch_failed", + httpStatus: err.status, + detail: err.message || String(err) + }); + } + return; + } + + if (msg.type === "fetchFiche") { + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const html = await fetchFicheHtml(session.origin, session.phpsessid, msg.formLink); + if (looksLikeLoginPage(html)) { + sendResponse({ ok: false, error: "session_expired" }); + return; + } + sendResponse({ ok: true, html, session }); + } catch (err) { + sendResponse({ + ok: false, + error: err.kind || "fetch_failed", + httpStatus: err.status, + detail: err.message || String(err) + }); + } + return; + } + + if (msg.type === "fetchTimelineApi") { + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const body = await fetchTimelineApi( + session.origin, session.phpsessid, + msg.guid, msg.formId, msg.formChecksum + ); + if (looksLikeLoginPage(body)) { + sendResponse({ ok: false, error: "session_expired" }); + return; + } + sendResponse({ ok: true, body }); + } catch (err) { + sendResponse({ + ok: false, + error: err.kind || "fetch_failed", + httpStatus: err.status, + detail: err.message || String(err) + }); + } + return; + } + + if (msg.type === "fetchCurrentUser") { + // v4.2 : essaie d'identifier l'utilisateur EasyVista connecté en + // fetchant la page d'accueil et en cherchant dans le HTML un champ + // contenant son nom. Si on trouve rien, on renvoie { ok: true, + // user: null } pour que l'UI sache qu'on n'a pas pu. + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const user = await fetchCurrentUser(session.origin, session.phpsessid); + sendResponse({ ok: true, user }); + } catch (err) { + sendResponse({ ok: false, error: String(err) }); + } + return; + } + + if (msg.type === "submitAbsence") { + // v4.2.6 : crée une absence dans EasyVista via POST vers + // /include/components/staff/planning/plan_set_holidays_popup.php + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const result = await submitAbsence(session.origin, session.phpsessid, msg); + sendResponse({ ok: true, result }); + } catch (err) { + sendResponse({ ok: false, error: err.message || String(err) }); + } + return; + } + + if (msg.type === "submitDouchette") { + // v4.2.6 : envoie la planification sur la douchette de chaque tech. + // On teste plusieurs URLs possibles (l'endpoint exact n'est pas dans + // le HTML statique que nous avons analysé). + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const result = await submitDouchette(session.origin, session.phpsessid, msg); + sendResponse({ ok: true, okCount: result.okCount, errors: result.errors }); + } catch (err) { + sendResponse({ ok: false, error: err.message || String(err) }); + } + return; + } + + if (msg.type === "cleanupOldCaches") { + const removed = await cleanupOldCaches(msg.daysToKeep || 7); + sendResponse({ ok: true, removed }); + return; + } + + sendResponse({ ok: false, error: "unknown_message" }); + } catch (err) { + console.error("background error:", err); + sendResponse({ ok: false, error: err.message || String(err) }); + } + })(); + + // Retourner true pour garder sendResponse asynchrone + return true; +}); + +// ============================================================================ +// v4.2 : les alarmes d'auto-refresh 12h/15h ont été supprimées. Seul le +// nettoyage quotidien des caches > 7 jours reste. +// On supprime aussi activement les anciennes alarmes créées par les +// versions précédentes pour éviter qu'elles restent programmées. +// ============================================================================ + +async function clearLegacyRefreshAlarms() { + try { + await chrome.alarms.clear("refresh_12h"); + await chrome.alarms.clear("refresh_15h"); + } catch (e) { + console.warn("clearLegacyRefreshAlarms:", e); + } +} + +// ============================================================================ +// Nettoyage caches > 7 jours +// ============================================================================ + +async function cleanupOldCaches(daysToKeep) { + const all = await chrome.storage.local.get(null); + const threshold = new Date(); + threshold.setDate(threshold.getDate() - daysToKeep); + const thresholdStr = threshold.toISOString().substring(0, 10); // YYYY-MM-DD + + const toRemove = []; + for (const key of Object.keys(all)) { + // Nos clés de cache sont planning_cache_YYYY-MM-DD + const m = key.match(/^planning_cache_(\d{4}-\d{2}-\d{2})$/); + if (m && m[1] < thresholdStr) { + toRemove.push(key); + } + } + if (toRemove.length > 0) { + await chrome.storage.local.remove(toRemove); + } + return toRemove.length; +} + +// Au démarrage, nettoyer les anciennes alarmes et les anciens caches +chrome.runtime.onInstalled.addListener(() => { + clearLegacyRefreshAlarms(); + cleanupOldCaches(7).catch(err => console.warn("cleanup:", err)); +}); + +chrome.runtime.onStartup.addListener(() => { + clearLegacyRefreshAlarms(); + cleanupOldCaches(7).catch(err => console.warn("cleanup:", err)); +}); diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000..87db8b0 Binary files /dev/null and b/icons/icon128.png differ diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000..b4fff8c Binary files /dev/null and b/icons/icon16.png differ diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000..8e50109 Binary files /dev/null and b/icons/icon48.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..a447128 --- /dev/null +++ b/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 3, + "name": "Planification", + "version": "4.3.3", + "description": "Vue claire et rapide du planning des techniciens EasyVista. Regroupe interventions et réservations par tech, affiche horaires, contact, lieu, catégorie et statut en un coup d'œil.", + "permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"], + "host_permissions": [ + "https://itsma.etat-de-vaud.ch/*", + "https://itsma.vd.ch/*" + ], + "action": { + "default_title": "Ouvrir la Planification" + }, + "background": { + "service_worker": "background.js" + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "web_accessible_resources": [ + { + "resources": ["viewer.html", "viewer.js", "viewer.css"], + "matches": [ + "https://itsma.etat-de-vaud.ch/*", + "https://itsma.vd.ch/*" + ] + } + ] +} diff --git a/viewer.css b/viewer.css new file mode 100644 index 0000000..b59f349 --- /dev/null +++ b/viewer.css @@ -0,0 +1,1918 @@ +/* ========================================================================== + Thème clair (défaut) + ========================================================================== */ +:root { + --bg: #f4f5f7; + --bg-elevated: #ffffff; + --bg-muted: #f0f1f3; + --bg-hover: #f7f8fa; + --border: #e2e4e8; + --border-strong: #cfd3da; + --text: #1a1f2b; + --text-muted: #5b6573; + --text-faint: #8892a0; + --accent: #0f4f8b; + --accent-soft: #e1ecf7; + --danger: #b03030; + --danger-soft: #fbe6e6; + --warn: #b87a00; + --warn-soft: #fff2d6; + --ok: #2e7b4a; + --ok-soft: #dff0e4; + + /* Palette par type d'intervention */ + --c-livraison: #2563eb; + --c-livraison-soft: #dbeafe; + --c-recup: #16a34a; + --c-recup-soft: #dcfce7; + --c-remplacement: #ea580c; + --c-remplacement-soft: #fed7aa; + --c-incident: #8b5cf6; + --c-incident-soft: #ede9fe; + --c-installation: #2563eb; + --c-installation-soft: #dbeafe; + --c-rollout: #92400e; /* brun */ + --c-rollout-soft: #fde68a; + --c-reservation: #f59e0b; /* jaune/ambre */ + --c-reservation-soft: #fef3c7; + --c-autre: #6b7280; + --c-autre-soft: #e5e7eb; + + /* Statuts clos */ + --c-closed: #15803d; /* vert foncé = Clôturé */ + --c-closed-soft: #bbf7d0; + --c-resolved: #4ade80; /* vert clair = Résolu */ + --c-resolved-soft: #dcfce7; + + --shadow: 0 1px 3px rgba(20, 30, 50, 0.06), 0 1px 2px rgba(20, 30, 50, 0.04); + --shadow-hover: 0 2px 8px rgba(20, 30, 50, 0.08); + --radius: 8px; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace; +} + +[data-theme="dark"] { + --bg: #16181d; + --bg-elevated: #21242b; + --bg-muted: #1c1f25; + --bg-hover: #2a2e36; + --border: #2e333c; + --border-strong: #414754; + --text: #e6e8ec; + --text-muted: #9ba2ad; + --text-faint: #6a727e; + --accent: #5ea8e8; + --accent-soft: #223348; + --danger: #e87878; + --danger-soft: #3b2626; + --warn: #d9a753; + --warn-soft: #3a2e1a; + --ok: #78c59a; + --ok-soft: #1f3a2b; + + --c-livraison: #60a5fa; + --c-livraison-soft: #1e3a5f; + --c-recup: #4ade80; + --c-recup-soft: #14432a; + --c-remplacement: #fb923c; + --c-remplacement-soft: #4a2512; + --c-incident: #a78bfa; + --c-incident-soft: #2e1065; + --c-installation: #60a5fa; + --c-installation-soft: #1e3a5f; + --c-autre: #9ca3af; + --c-autre-soft: #2a2e36; + + --c-closed: #22c55e; + --c-closed-soft: #14432a; + --c-resolved: #86efac; + --c-resolved-soft: #0f3320; + + --shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-hover: 0 2px 10px rgba(0, 0, 0, 0.4); +} + +/* ========================================================================== + Base + ========================================================================== */ +* { box-sizing: border-box; } +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 14px; + line-height: 1.5; +} + +.hidden { display: none !important; } + +/* ========================================================================== + Topbar + ========================================================================== */ +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 20px; + background: var(--bg-elevated); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow); + gap: 12px; + flex-wrap: wrap; +} + +.topbar-left { + display: flex; + align-items: center; + gap: 14px; + flex: 1; + min-width: 0; +} + +.topbar h1 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text); + white-space: nowrap; +} + +.capture-info { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; +} + +.refresh-check { + font-size: 14px; + color: var(--c-recup); /* vert */ + font-weight: 700; + opacity: 0; + transform: scale(0.5); + transition: opacity 0.25s ease, transform 0.25s ease; + pointer-events: none; +} +.refresh-check.visible { + opacity: 1; + transform: scale(1); +} +.refresh-check.hidden { + display: none; +} + +.topbar-right { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +/* Bannière de session expirée (v4.1.12) — sticky sous la topbar, non bloquante */ +.session-banner { + position: sticky; + top: 56px; + z-index: 8; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 18px; + /* v4.2.5 : rouge plus vif + bord plus épais pour visibilité max */ + background: linear-gradient(90deg, #c93030, #d84848); + color: #fff; + border-top: 2px solid #ff6060; + border-bottom: 2px solid #7a1515; + font-size: 14px; + font-weight: 500; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + /* petite animation d'apparition pour attirer l'œil */ + animation: session-banner-in 0.22s ease-out; +} +@keyframes session-banner-in { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} +/* v4.2.5 : variante ORANGE pour "EV inaccessible" (distinct de session expirée) */ +.session-banner.ev-banner { + background: linear-gradient(90deg, #c77920, #e09a3a); + border-top: 2px solid #ffbb60; + border-bottom: 2px solid #7a4a15; +} +.session-banner.ev-banner .btn-primary { + color: #8a4a10; +} +.session-banner.hidden { + display: none; +} +.session-banner-icon { + font-size: 20px; + flex-shrink: 0; +} +.session-banner-text { + flex: 1; + line-height: 1.4; +} +.session-banner-text strong { + font-weight: 600; +} +.session-banner .btn-primary { + background: #fff; + color: #9a2020; + border: 0; + font-weight: 600; +} +.session-banner .btn-primary:hover { + background: #f0f0f0; +} +.session-banner .btn-sm { + padding: 5px 12px; + font-size: 12px; + /* v4.2.5 : btn-sm non-primary dans la bannière = contour blanc */ + background: transparent; + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.5); + font-weight: 500; +} +.session-banner .btn-sm:hover { + background: rgba(255, 255, 255, 0.12); +} +.session-banner .btn-primary.btn-sm { + /* reset : le primary override le style du btn-sm */ + background: #fff; + color: #9a2020; + border: 0; + font-weight: 600; +} +.session-banner.ev-banner .btn-primary.btn-sm { + color: #8a4a10; +} +.session-banner .btn-icon { + background: transparent; + color: #fff; + border: 0; + font-size: 20px; + line-height: 1; + padding: 4px 8px; + cursor: pointer; +} +.session-banner .btn-icon:hover { + background: rgba(255,255,255,0.15); +} + +/* Barre de progression pendant le rafraichissement — v4.1.12 : texte + toujours lisible, que la zone verte l'ait atteint ou non (utilise + mix-blend-mode:difference pour inverser la couleur du texte là où la + barre verte est dessous). */ +.progress-bar { + position: sticky; + top: 56px; + z-index: 9; + height: 22px; + /* v4.1.17 : backdrop-blur sur toute la barre → ce qui défile derrière + est légèrement flouté sur TOUTE la largeur. Pas d'opacité sombre + ajoutée, transparence préservée. */ + background: rgba(128, 128, 128, 0.08); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); + border-bottom: 1px solid var(--border, rgba(128, 128, 128, 0.2)); + overflow: hidden; +} +.progress-bar.hidden { + display: none; +} +.progress-bar-fill { + position: absolute; + left: 0; + top: 0; + bottom: 0; + background: linear-gradient(90deg, #2ea043, #3fb950); + width: 0%; + transition: width 240ms ease-out; + box-shadow: 0 0 8px rgba(63, 185, 80, 0.3); +} +.progress-bar-label { + position: relative; + display: block; + text-align: center; + line-height: 22px; + font-size: 12px; + font-weight: 700; + color: #fff; + pointer-events: none; + letter-spacing: 0.3px; + /* v4.1.14/17 : text-shadow multi-directionnel (halo sombre autour du + texte). Le backdrop-blur est sur toute la barre, plus besoin de pill. */ + text-shadow: + 0 0 2px rgba(0, 0, 0, 0.95), + 0 0 3px rgba(0, 0, 0, 0.85), + 0 1px 2px rgba(0, 0, 0, 0.75), + 0 -1px 2px rgba(0, 0, 0, 0.75), + 1px 0 2px rgba(0, 0, 0, 0.75), + -1px 0 2px rgba(0, 0, 0, 0.75); + z-index: 2; +} + +/* Navigation de date */ +.date-nav { + display: flex; + align-items: center; + gap: 4px; +} + +.btn-nav { + padding: 6px 10px; + font-size: 13px; + min-width: 32px; +} + +.btn-today { + padding: 6px 10px; + font-size: 12px; +} + +.date-input { + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-muted); + color: var(--text); + font-family: inherit; + font-size: 13px; + cursor: pointer; +} +.date-input:hover { + border-color: var(--border-strong); +} +.date-input:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--bg-muted); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: background 0.1s, border-color 0.1s; +} + +.btn:hover { + background: var(--bg-hover); + border-color: var(--border-strong); +} + +.btn:active { + transform: translateY(1px); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-icon { + padding: 6px 10px; + font-size: 15px; +} + +.btn-subtle { + opacity: 0.75; + font-size: 12px; +} +.btn-subtle:hover { + opacity: 1; +} + +/* v4.1.12 : boutons refresh plus clairs visuellement. + - "Vérifier" (partiel) : style discret, icône demi-rotation + - "Tout recharger" (total) : plus affirmé, icône double-flèche circulaire */ +.btn-refresh { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; +} +.btn-refresh-icon { + width: 15px; + height: 15px; + flex-shrink: 0; + color: currentColor; +} +.btn-refresh-label { + font-size: 12px; + line-height: 1; +} +.btn-refresh-strong { + background: var(--bg-subtle, rgba(63, 185, 80, 0.08)); + border-color: var(--border-strong); +} +.btn-refresh-strong:hover { + background: rgba(63, 185, 80, 0.18); + border-color: rgba(63, 185, 80, 0.5); +} +.btn-refresh-icon.spinning { + animation: refresh-spin 0.9s linear infinite; + transform-origin: 50% 50%; +} +@keyframes refresh-spin { + to { transform: rotate(360deg); } +} + +.btn-primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} +.btn-primary:hover { + background: var(--accent); + opacity: 0.9; +} + +/* Bouton "Arrêter" (apparaît pdt un refresh manuel) */ +.btn-abort { + background: var(--danger-soft); + color: var(--danger); + border-color: var(--danger); +} +.btn-abort:hover { + background: var(--danger); + color: white; + border-color: var(--danger); +} + +#refresh-icon.spinning { + display: inline-block; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ========================================================================== + Écrans d'état + ========================================================================== */ +.loading { + padding: 40px 20px; + text-align: center; + color: var(--text-muted); + font-size: 14px; +} + +.error-box { + margin: 20px; + padding: 14px 18px; + background: var(--danger-soft); + color: var(--danger); + border: 1px solid var(--danger); + border-radius: var(--radius); + font-size: 14px; + line-height: 1.55; +} + +.session-needed { + max-width: 500px; + margin: 60px auto; + padding: 28px 32px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + text-align: center; +} +.session-needed h2 { + margin: 0 0 12px 0; + color: var(--text); +} +.session-needed p { + margin: 10px 0; + color: var(--text-muted); +} +.session-needed code { + background: var(--bg-muted); + padding: 2px 6px; + border-radius: 3px; + font-family: var(--mono); + font-size: 12px; +} +.session-needed button { + margin-top: 14px; +} + +/* ========================================================================== + Stats globales + ========================================================================== */ +.stats { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + padding: 12px 20px 4px 20px; + font-size: 13px; + color: var(--text-muted); +} +.global-stat b { + color: var(--text); + font-weight: 600; +} +.global-stat-main b { + font-size: 16px; +} +.global-stat-sub { + color: var(--text-faint); + font-size: 12px; +} +.global-stat-sep { + color: var(--text-faint); + opacity: 0.5; +} + +/* ========================================================================== + Grille de cartes + ========================================================================== */ +.cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 14px; + padding: 14px 20px 40px 20px; +} + +.card { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.card-header { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + background: var(--bg-muted); + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.card-tech-name { + font-weight: 600; + font-size: 14px; + color: var(--text); +} + +.card-tech-badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; +} + +.badge-pompier { + background: var(--danger-soft); + color: var(--danger); +} + +.badge-absent { + background: var(--bg-muted); + color: var(--text-faint); + border: 1px solid var(--border); +} + +.badge-count { + background: var(--accent-soft); + color: var(--accent); +} + +.card-body { + padding: 0; + flex: 1; +} + +.card-empty { + padding: 14px; + color: var(--text-faint); + font-size: 13px; + font-style: italic; + text-align: center; +} + +.card.is-pompier { + border-left: 3px solid var(--danger); +} +.card.is-absent { + opacity: 0.85; +} +.card.is-absent .card-header { + background: var(--bg); +} + +/* ========================================================================== + Frise de temps + ========================================================================== */ +.timeline { + padding: 12px 14px 6px 14px; + background: var(--bg-muted); + border-bottom: 1px solid var(--border); + position: relative; +} +.timeline-pompier { + background: var(--danger-soft); +} + +.timeline-bar { + position: relative; + height: 20px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; +} + +.timeline-hole { + position: absolute; + top: 0; + bottom: 0; + background: repeating-linear-gradient( + 45deg, + transparent 0 4px, + rgba(0, 0, 0, 0.035) 4px 8px + ); + cursor: help; + transition: background 0.1s; +} +[data-theme="dark"] .timeline-hole { + background: repeating-linear-gradient( + 45deg, + transparent 0 4px, + rgba(255, 255, 255, 0.04) 4px 8px + ); +} +.timeline-hole:hover { + background: var(--ok-soft); +} + +.timeline-slot { + position: absolute; + top: 0; + bottom: 0; + cursor: help; + transition: filter 0.1s; + border-right: 1px solid var(--bg-elevated); +} + +.timeline-slot.color-livraison { background: var(--c-livraison); } +.timeline-slot.color-installation { background: var(--c-installation); } +.timeline-slot.color-recup { background: var(--c-recup); } +.timeline-slot.color-remplacement { background: var(--c-remplacement); } +.timeline-slot.color-incident { background: var(--c-incident); } +.timeline-slot.color-rollout { background: var(--c-rollout); } +.timeline-slot.color-reservation { background: var(--c-reservation); } +.timeline-slot.color-autre { background: var(--c-autre); } + +/* Statuts clos sur la timeline */ +.timeline-slot.status-closed { background: var(--c-closed); } +.timeline-slot.status-resolved { background: var(--c-resolved); } + +.timeline-slot.kind-absence { + background: repeating-linear-gradient( + 45deg, + var(--text-faint) 0 6px, + var(--bg-muted) 6px 12px + ); + opacity: 0.6; +} + +.timeline-slot:hover, +.timeline-slot.highlight { + filter: brightness(1.12); + outline: 2px solid var(--text); + outline-offset: -2px; + z-index: 2; +} + +.timeline-noon { + position: absolute; + top: -2px; + bottom: -2px; + width: 1px; + background: var(--border-strong); + z-index: 1; + pointer-events: none; +} + +.timeline-scale { + position: relative; + height: 14px; + margin-top: 4px; +} + +.timeline-tick { + position: absolute; + transform: translateX(-50%); + font-size: 10px; + color: var(--text-faint); + font-family: var(--mono); +} + +/* Stats par carte */ +.card-stats { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 10px 14px; + background: var(--bg-muted); + border-bottom: 1px solid var(--border); +} +.stat-total { + display: flex; + align-items: baseline; + gap: 6px; +} +.stat-total-num { + font-size: 22px; + font-weight: 700; + color: var(--text); + line-height: 1; +} +.stat-total-lbl { + font-size: 12px; + color: var(--text-muted); +} +.stat-split { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-faint); +} +.stat-split-item b { + color: var(--text-muted); + font-weight: 600; +} +.stat-split-sep { + opacity: 0.4; +} + +/* Notes de statut */ +.card-status-note { + padding: 8px 14px; + font-size: 12px; + font-weight: 500; + text-align: center; +} +.card-status-note.pompier { + background: var(--danger-soft); + color: var(--danger); + border-bottom: 1px solid var(--border); +} +.card-status-note.absent { + background: var(--bg); + color: var(--text-muted); + border-bottom: 1px solid var(--border); + font-style: italic; +} + +.card-empty.subtle { + font-size: 12px; + opacity: 0.7; +} + +.intervention.highlight { + background: var(--bg-hover); +} + +/* ========================================================================== + Interventions — layout v2 (heures verticales) + ========================================================================== */ +.intervention-v2 { + display: grid; + grid-template-columns: 4px 58px 1fr auto; + grid-template-rows: auto auto; + /* v4.1.17 : la ligne du bas (right) s'étend maintenant sur les 2 colonnes + droite (right + status) pour que la signature aille vraiment jusqu'au + bord droit. Le ✓ status est positionné en absolute par-dessus. */ + grid-template-areas: + "dot time ref copy" + "dot time right right"; + gap: 2px 10px; + align-items: start; + padding: 10px 12px 12px 8px; + border-top: 1px solid var(--border); + cursor: default; + transition: background 0.08s; + position: relative; +} +.intervention-v2:first-child { border-top: none; } +.intervention-v2:hover { background: var(--bg-hover); } + +/* Pastille colorée (barre verticale) */ +.intervention-v2 .intervention-dot { + grid-area: dot; + width: 4px; + height: 100%; + border-radius: 2px; + align-self: stretch; +} +.intervention-v2.color-livraison .intervention-dot { background: var(--c-livraison); } +.intervention-v2.color-installation .intervention-dot { background: var(--c-installation); } +.intervention-v2.color-recup .intervention-dot { background: var(--c-recup); } +.intervention-v2.color-remplacement .intervention-dot { background: var(--c-remplacement); } +.intervention-v2.color-incident .intervention-dot { background: var(--c-incident); } +.intervention-v2.color-rollout .intervention-dot { background: var(--c-rollout); } +.intervention-v2.color-reservation .intervention-dot { background: var(--c-reservation); } +.intervention-v2.color-autre .intervention-dot { background: var(--c-autre); } + +.intervention-v2.clickable { cursor: pointer; } +.intervention-v2.clickable:active { transform: translateY(1px); } + +.intervention-v2.status-closed { + background: var(--c-closed-soft); + box-shadow: inset 4px 0 0 var(--c-closed); +} +.intervention-v2.status-closed:hover { + background: var(--c-closed-soft); + filter: brightness(0.96); +} +.intervention-v2.status-closed .intervention-dot { + background: var(--c-closed); + width: 5px; +} + +.intervention-v2.status-resolved { + background: var(--c-resolved-soft); + box-shadow: inset 4px 0 0 var(--c-resolved); +} +.intervention-v2.status-resolved:hover { + background: var(--c-resolved-soft); + filter: brightness(0.96); +} +.intervention-v2.status-resolved .intervention-dot { + background: var(--c-resolved); + width: 5px; +} + +/* v4.2.5 : statut "terminée par le tech" (commentaire LOGIN: détecté). + Vert PLUS CLAIR que status-closed (distinction visuelle du ✓ simple + vs ✓✓ double). */ +.intervention-v2.status-terminated { + background: var(--c-recup-soft, rgba(63, 185, 80, 0.12)); + box-shadow: inset 4px 0 0 var(--c-recup, #3fb950); +} +.intervention-v2.status-terminated:hover { + background: var(--c-recup-soft, rgba(63, 185, 80, 0.12)); + filter: brightness(0.96); +} +.intervention-v2.status-terminated .intervention-dot { + background: var(--c-recup, #3fb950); + width: 5px; +} +.intervention-v2.status-terminated .iv-status-check { + color: var(--c-recup, #3fb950); +} +.timeline-slot.status-terminated { background: var(--c-recup, #3fb950); } + +/* v4.2.5 : carte "en cours d'analyse" (ghost juste disparu, on re-fetch la + fiche pour décider du sort). Opacité réduite + petit spinner discret. */ +.intervention-v2._checking { + opacity: 0.6; + position: relative; +} +.intervention-v2._checking::after { + content: ""; + position: absolute; + right: 10px; + top: 50%; + width: 12px; + height: 12px; + margin-top: -6px; + border: 2px solid var(--border, #ccc); + border-top-color: var(--text-muted, #666); + border-radius: 50%; + animation: iv-check-spin 0.9s linear infinite; +} +@keyframes iv-check-spin { + to { transform: rotate(360deg); } +} + +/* .intervention-v2.is-ghost : retirée en v4.3.3 — on ne barre plus les + cartes. La gestion des tickets disparus se fait via _disappearStatus + (vert ✓/✓✓) ou _disappearRemove (retrait total). */ + +/* Ligne 1 : REF en titre centré gros gras */ +.iv-ref-header { + grid-area: ref; + font-family: var(--mono); + font-size: 15px; + font-weight: 700; + color: var(--text); + letter-spacing: 0.03em; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 2px 0; +} +.iv-ref-header.no-ref { + font-family: var(--font); + font-weight: 500; + color: var(--text-faint); +} + +.iv-status-check { + /* v4.1.17 : absolute en bas à droite (la grid-area "status" a été + fusionnée avec "right" pour étendre la signature jusqu'au bord). */ + position: absolute; + right: 10px; + bottom: 10px; + font-size: 16px; + font-weight: 700; + color: var(--c-closed); + pointer-events: none; + /* Au-dessus de la signature, mais discret */ + z-index: 1; +} +.intervention-v2.status-resolved .iv-status-check { color: var(--c-resolved); } + +/* v4.2.5 : ✓✓ double check (clôturé/résolu) — un peu plus petit pour tenir + les 2 caractères. Espacement négatif pour les rapprocher. */ +.iv-status-check.double { + font-size: 14px; + letter-spacing: -3px; + padding-right: 3px; /* compenser le letter-spacing côté droit */ +} + +.intervention-copy { + grid-area: copy; + align-self: start; + padding: 2px 6px; + background: transparent; + color: var(--text-faint); + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + opacity: 0; + transition: opacity 0.1s, background 0.1s, color 0.1s; + font-family: inherit; +} +.intervention-v2:hover .intervention-copy { opacity: 1; } +.intervention-copy:hover { + background: var(--bg-muted); + color: var(--text); + border-color: var(--border); +} +.intervention-copy.copied { + color: var(--ok); + background: var(--ok-soft); + opacity: 1; +} + +/* Ligne 2 GAUCHE : heures VERTICALES, centrées par rapport au bloc droit */ +.iv-time-vertical { + grid-area: time; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; /* centrage vertical */ + align-self: center; /* centrage dans la cellule grille */ + gap: 1px; + font-family: var(--mono); + font-size: 12px; + color: var(--text); + height: 100%; +} +.iv-time-start, .iv-time-end { + font-weight: 600; + letter-spacing: 0.02em; +} +.iv-time-arrow { + font-size: 11px; + color: var(--text-muted); + line-height: 1; +} +.intervention-v2.is-pompier-line .iv-time-start, +.intervention-v2.is-pompier-line .iv-time-end { + color: var(--danger); + font-weight: 700; +} + +/* Ligne 2 DROITE : lieu / contact+tél / bas (catégorie + signature) */ +.iv-right { + grid-area: right; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Lieu : ville (MAJ gras) + adresse (italique noir) */ +.iv-lieu-block { + display: flex; + flex-direction: column; + gap: 0; +} +.iv-lieu-ville { + font-size: 13px; + font-weight: 700; + color: var(--text); + letter-spacing: 0.04em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.iv-lieu-adresse { + font-size: 12.5px; + font-style: italic; + font-weight: 400; + color: var(--text); /* noir, pas gris */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Contact + tél — wrap intelligent (pas de coupure de mot) */ +.iv-contact-line { + font-size: 13px; + word-break: normal; + overflow-wrap: break-word; + white-space: normal; + line-height: 1.35; +} +.iv-contact { + font-weight: 600; + color: var(--text); +} +.iv-sep { + color: var(--text-faint); + font-weight: 400; + margin: 0 2px; +} +.iv-phone { + font-family: var(--mono); + color: var(--text-muted); + font-weight: 400; + font-size: 12px; + white-space: nowrap; /* numéro pas coupé */ +} + +/* Bas : catégorie à gauche + signature à droite */ +.iv-bottom-line { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + font-size: 12px; + /* v4.1.14 : forcer la ligne à occuper 100% de largeur du parent */ + width: 100%; +} +.iv-category { + color: var(--text-muted); + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + /* v4.1.15 : taille naturelle (pas de flex:1 qui étirait le texte et + rendait la signature juste à côté). Sans flex, la catégorie reste à + son contenu + justify-content:space-between pousse la signature à + l'extrême droite du parent. */ + min-width: 0; + flex: 0 1 auto; + max-width: calc(100% - 70px); +} +.iv-signature { + color: var(--text-faint); + font-size: 11px; + font-family: var(--mono); + flex-shrink: 0; + letter-spacing: 0.02em; + text-align: right; + /* v4.1.15/17 : margin-left: auto pour collage garanti à droite */ + margin-left: auto; + white-space: nowrap; +} +/* v4.1.17 : si statut clos/résolu, le ✓ est à droite en absolute → décaler + la signature pour ne pas se chevaucher */ +.intervention-v2.status-closed .iv-signature, +.intervention-v2.status-resolved .iv-signature { + padding-right: 22px; +} + +/* Réservation (créneau bloqué par un coordinateur) */ +.iv-ref-header.is-reservation-title { + color: var(--c-reservation); + font-family: var(--font); + letter-spacing: 0.02em; +} +.iv-reservation-par { + font-size: 13px; + font-weight: 600; + color: var(--text); +} +.iv-reservation-sujet { + font-size: 12.5px; + color: var(--text-muted); + font-style: italic; +} + +/* ────────────────────────────────────────────────────────────────────────── + Anciens styles .intervention (v1) — gardés pour ne pas casser le reste + ────────────────────────────────────────────────────────────────────────── */ +.intervention { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px 8px 10px; + border-top: 1px solid var(--border); + cursor: default; + transition: background 0.08s; + position: relative; +} +.intervention:first-child { border-top: none; } +.intervention:hover { background: var(--bg-hover); } +.intervention-dot { + flex-shrink: 0; + width: 4px; + align-self: stretch; + margin: 2px 4px 2px 0; + border-radius: 2px; +} + +/* ========================================================================== + Tooltip + ========================================================================== */ +.tooltip { + position: fixed !important; + /* v4.2.4 : forcer un stacking context propre et l'isolation pour que le + tooltip ne soit pas affecté par un éventuel filter/transform/contain + sur un ancêtre (qui casserait position:fixed). `contain: layout` et + `will-change: transform` garantissent aussi que le navigateur traite + ce tooltip indépendamment. */ + isolation: isolate; + contain: layout; + z-index: 100; + max-width: 620px; + max-height: calc(100vh - 40px); + overflow-y: auto; + padding: 12px 14px; + background: var(--bg-elevated); + color: var(--text); + border: 1px solid var(--border-strong); + border-radius: 8px; + box-shadow: var(--shadow-hover); + font-size: 13px; + line-height: 1.5; + pointer-events: none; + opacity: 0; + transition: opacity 0.1s; + user-select: text; + -webkit-user-select: text; +} +.tooltip.visible { + opacity: 1; + /* v4.1.10 : permet à la souris d'entrer dans la bulle pour la garder + visible (persistance au hover) et, en mode pinned, pour sélectionner. */ + pointer-events: auto; + /* v4.2 : curseur texte par défaut (pour signaler que c'est sélectionnable) */ + cursor: text; +} +.tooltip.pinned { + /* Bulle épinglée : bordure verte pour indiquer le mode */ + border-color: var(--c-accent, #3fb950); + box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.15), var(--shadow-hover); +} + +/* v4.1.13/14 : barre d'actions en haut à droite de la bulle + (recharger cette iv + épingler) */ +.tooltip-actions { + position: absolute; + top: 6px; + right: 6px; + display: flex; + gap: 2px; + z-index: 5; +} +.tooltip-actionbtn { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 4px; + font-size: 13px; + opacity: 0.55; + transition: opacity 0.15s, background 0.15s, transform 0.15s; + user-select: none; + color: var(--text-muted); +} +.tooltip-actionbtn svg { + width: 14px; + height: 14px; +} +.tooltip-actionbtn:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.08); + color: var(--text); +} +.tooltip-actionbtn.spinning svg { + animation: refresh-spin 0.8s linear infinite; + transform-origin: 50% 50%; +} +/* L'ancien .tooltip-pinbtn garde ses variantes */ +.tooltip-pinbtn { + filter: grayscale(100%); +} +.tooltip-pinbtn:hover { + filter: grayscale(0%); +} +.tooltip.pinned .tooltip-pinbtn { + opacity: 1; + filter: grayscale(0%); + transform: rotate(-30deg); + background: rgba(63, 185, 80, 0.15); +} + +/* v4.1.15 : référence dans la bulle avec bouton copier inline */ +.tt-ref-cell { + display: inline-flex; + align-items: center; + gap: 8px; +} +.tt-ref-val { + font-family: var(--mono, monospace); +} +.tt-copy-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + width: 26px; + height: 22px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} +.tt-copy-btn:hover { + background: var(--bg-hover); + color: var(--text); + border-color: var(--border-strong); +} +.tt-copy-btn.copied { + background: rgba(63, 185, 80, 0.2); + border-color: #3fb950; + color: #3fb950; +} + +.tooltip dl { + margin: 0; + display: grid; + grid-template-columns: auto 1fr; + column-gap: 10px; + row-gap: 4px; +} + +.tooltip dt { + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +.tooltip dd { + margin: 0; + color: var(--text); + font-size: 13px; + word-break: break-word; +} + +.tooltip dd.description, +.tooltip dd.commentaire { + white-space: pre-wrap; +} + +.tooltip dd.commentaire { + padding: 6px 8px; + background: var(--bg-muted); + border-radius: 4px; + border-left: 2px solid var(--c-closed); + font-style: italic; +} + +.tooltip hr { + border: none; + border-top: 1px solid var(--border); + margin: 8px 0; + grid-column: 1 / -1; +} + +/* Badge de statut dans le tooltip */ +.status-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; +} +.status-pill.closed { + background: var(--c-closed-soft); + color: var(--c-closed); +} +.status-pill.resolved { + background: var(--c-resolved-soft); + color: var(--c-resolved); +} +.status-pill.ongoing { + background: var(--accent-soft); + color: var(--accent); +} +.status-pill.other { + background: var(--bg-muted); + color: var(--text-muted); +} + +/* ========================================================================== + Toasts de notification (ouverture d'intervention) + ========================================================================== */ +.toast-stack { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 200; + display: flex; + flex-direction: column-reverse; /* les nouveaux en bas, les anciens au-dessus */ + gap: 8px; + pointer-events: none; +} +.toast { + min-width: 200px; + max-width: 340px; + padding: 10px 14px; + background: var(--bg-elevated); + color: var(--text); + border: 1px solid var(--border-strong); + border-left: 3px solid var(--c-livraison); + border-radius: 6px; + box-shadow: var(--shadow-hover); + font-size: 13px; + opacity: 0; + transform: translateX(40px); + transition: opacity 0.18s ease-out, transform 0.18s ease-out; + pointer-events: auto; +} +.toast.visible { + opacity: 1; + transform: translateX(0); +} +.toast.leaving { + opacity: 0; + transform: translateX(40px); +} +.toast-label { + color: var(--text-muted); + font-size: 11px; + margin-right: 6px; +} +.toast-ref { + font-family: var(--mono); + font-weight: 700; + letter-spacing: 0.02em; +} + +/* ───────────────────────────────────────────────────────────────────────── + v4.1.20 : Modal central de confirmation (vider cache) + ───────────────────────────────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + /* Flou + assombrissement léger de l'arrière-plan */ + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + animation: modal-fade-in 0.15s ease-out; +} +@keyframes modal-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal-card { + background: var(--bg, #ffffff); + color: var(--text, #111); + border: 1px solid var(--border, rgba(128, 128, 128, 0.25)); + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25), + 0 2px 8px rgba(0, 0, 0, 0.15); + padding: 24px 24px 20px; + width: min(440px, 92vw); + max-height: 90vh; + overflow-y: auto; + animation: modal-card-in 0.18s cubic-bezier(0.16, 1, 0.3, 1); +} +@keyframes modal-card-in { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.modal-title { + margin: 0 0 12px 0; + font-size: 18px; + font-weight: 700; + color: var(--text, #111); +} +.modal-message { + margin: 0 0 20px 0; + font-size: 13px; + line-height: 1.5; + color: var(--text-muted, #555); +} +.modal-actions { + display: flex; + flex-direction: column; + gap: 8px; +} +.modal-actions .btn { + width: 100%; + padding: 10px 14px; + font-size: 13px; + font-weight: 600; + text-align: center; + border-radius: 8px; + cursor: pointer; + transition: background 0.12s, transform 0.06s; + border: 1px solid transparent; +} +.modal-actions .btn:active { transform: translateY(1px); } + +/* Vider le cache du jour : danger modéré (orange) */ +.btn-modal-danger { + background: rgba(234, 128, 38, 0.12); + color: #c85a00; + border-color: rgba(234, 128, 38, 0.3); +} +.btn-modal-danger:hover { + background: rgba(234, 128, 38, 0.22); +} +/* Vider tout le cache : danger fort (rouge) */ +.btn-modal-danger-strong { + background: rgba(220, 60, 60, 0.12); + color: #c03030; + border-color: rgba(220, 60, 60, 0.3); +} +.btn-modal-danger-strong:hover { + background: rgba(220, 60, 60, 0.22); +} +/* Annuler : neutre */ +.btn-modal-cancel { + background: transparent; + color: var(--text-muted, #666); + border-color: var(--border, rgba(128, 128, 128, 0.3)); + margin-top: 4px; +} +.btn-modal-cancel:hover { + background: var(--bg-hover, rgba(128, 128, 128, 0.08)); +} +/* v4.2.5 : bouton primaire (action principale) pour modals d'alerte */ +.btn-modal-primary { + background: var(--c-accent, #3fb950); + color: #fff; + border-color: var(--c-accent, #3fb950); +} +.btn-modal-primary:hover { + filter: brightness(1.08); +} + +/* ───────────────────────────────────────────────────────────────────────── + v4.1.20 : Message d'absence récurrente (Pillonel vendredi) + ───────────────────────────────────────────────────────────────────────── */ +.tech-absence-recurring { + padding: 14px 12px; + text-align: center; + font-size: 13px; + font-style: italic; + color: var(--text-faint, #888); + background: rgba(128, 128, 128, 0.04); + border-top: 1px solid var(--border, rgba(128, 128, 128, 0.15)); + border-bottom: 1px solid var(--border, rgba(128, 128, 128, 0.15)); +} + +/* v4.2 : contact en rouge quand anomalie détectée (Contact + Personne de + contact présents tous les deux dans l'action = situation suspecte). + On signale visuellement pour que l'user aille vérifier dans la fiche. */ +.iv-contact-line.iv-contact-anomalie { + color: #dc3030; +} +.iv-contact-line.iv-contact-anomalie .iv-contact, +.iv-contact-line.iv-contact-anomalie .iv-phone { + color: #dc3030; +} + +/* v4.2 : badge utilisateur EasyVista connecté (en haut à droite de la topbar) */ +.current-user { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + font-size: 12px; + font-weight: 500; + color: var(--text-muted, #666); + background: rgba(128, 128, 128, 0.08); + border: 1px solid var(--border, rgba(128, 128, 128, 0.2)); + border-radius: 999px; + margin-right: 8px; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.current-user::before { + content: "👤"; + font-size: 11px; + opacity: 0.7; +} +.current-user.hidden { + display: none; +} + +/* ───────────────────────────────────────────────────────────────────────── + v4.2.3 : pastille utilisateur (initiales) dans la topbar gauche + ───────────────────────────────────────────────────────────────────────── */ +.user-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + margin-right: 10px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + color: #fff; + background: var(--user-badge-color, #5b6372); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 50%; + cursor: pointer; + transition: transform 0.1s, box-shadow 0.12s; + flex-shrink: 0; + user-select: none; +} +.user-badge:hover { + transform: scale(1.06); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} +.user-badge:active { + transform: scale(0.97); +} +.user-badge.hidden { + display: none; +} +.user-badge.open { + /* Indication visuelle quand la popup nom est ouverte */ + box-shadow: 0 0 0 2px var(--user-badge-color, #5b6372) inset, + 0 0 0 2px rgba(255, 255, 255, 0.15); +} + +/* Popup du nom complet, affichée juste sous la pastille au clic */ +.user-name-popup { + position: fixed; + z-index: 10050; + padding: 8px 14px; + background: var(--bg, #ffffff); + color: var(--text, #111); + border: 1px solid var(--border, rgba(128, 128, 128, 0.25)); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18), + 0 2px 6px rgba(0, 0, 0, 0.12); + font-size: 13px; + font-weight: 500; + white-space: nowrap; + animation: user-popup-in 0.12s ease-out; +} +.user-name-popup.hidden { + display: none; +} +@keyframes user-popup-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ───────────────────────────────────────────────────────────────────────── + v4.2.6 : boutons d'action topbar (Absence, Douchette) + ───────────────────────────────────────────────────────────────────────── */ +.btn-action { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 13px; + font-weight: 500; + background: transparent; + color: var(--text, #e0e0e0); + border: 1px solid var(--border, rgba(128, 128, 128, 0.3)); + border-radius: 6px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} +.btn-action:hover { + background: var(--bg-hover, rgba(128, 128, 128, 0.12)); + border-color: var(--border-strong, rgba(128, 128, 128, 0.5)); +} +.btn-action:active { + transform: translateY(1px); +} +.btn-action-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} +.btn-action-emoji { + font-size: 14px; + line-height: 1; +} +.btn-action-label { + white-space: nowrap; +} + +/* ───────────────────────────────────────────────────────────────────────── + v4.2.6 : modals Absence et Douchette + ───────────────────────────────────────────────────────────────────────── */ +.modal-card.modal-wide { + width: min(520px, 92vw); +} +.modal-form-group { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 14px; +} +.modal-form-row { + display: flex; + gap: 8px; + align-items: center; +} +.modal-form-row > * { + flex: 1; +} +.modal-form-label { + font-size: 12px; + font-weight: 500; + color: var(--text-muted, #888); + text-transform: uppercase; + letter-spacing: 0.3px; +} +.modal-form-input, +.modal-form-select { + padding: 8px 10px; + font-size: 13px; + background: var(--bg, #fff); + color: var(--text, #111); + border: 1px solid var(--border, rgba(128, 128, 128, 0.3)); + border-radius: 6px; + font-family: inherit; +} +.modal-form-input:focus, +.modal-form-select:focus { + outline: none; + border-color: var(--c-accent, #3fb950); + box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.15); +} + +/* Liste checkboxes techniciens */ +.modal-tech-list { + display: flex; + flex-direction: column; + gap: 4px; + /* v4.2.8 : plus de max-height → tous les techs (max 8 + "Tout") visibles + d'un coup sans avoir à scroller dans la liste. */ + padding: 6px; + background: var(--bg-muted, rgba(128, 128, 128, 0.06)); + border: 1px solid var(--border, rgba(128, 128, 128, 0.2)); + border-radius: 6px; +} +.modal-tech-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + transition: background 0.1s; +} +.modal-tech-item:hover { + background: var(--bg-hover, rgba(128, 128, 128, 0.12)); +} +.modal-tech-item input[type="checkbox"] { + width: 15px; + height: 15px; + cursor: pointer; + accent-color: var(--c-accent, #3fb950); +} +.modal-tech-item.tech-selectall { + font-weight: 600; + border-bottom: 1px solid var(--border, rgba(128, 128, 128, 0.2)); + padding-bottom: 8px; + margin-bottom: 2px; +} +.modal-tech-item.tech-selectall:hover { + background: var(--bg-hover, rgba(128, 128, 128, 0.12)); +} + +/* Boutons Appliquer/Envoyer/Annuler côte à côte */ +.modal-actions.horizontal { + flex-direction: row; + gap: 8px; +} +.modal-actions.horizontal .btn { + flex: 1; +} + +/* ───────────────────────────────────────────────────────────────────────── + v4.2.9 : blocage du scroll arrière quand une modal est ouverte. + La classe body.modal-open est ajoutée/retirée automatiquement par + initModalScrollLock() dans viewer.js dès qu'un .modal-overlay existe. + ───────────────────────────────────────────────────────────────────────── */ +body.modal-open { + overflow: hidden; +} + +/* v4.2.9 : pied de page discret en bas à droite — affiche auteur + date + version */ +.app-footer { + position: fixed; + right: 8px; + bottom: 4px; + font-size: 10px; + color: var(--text-faint, #8892a0); + opacity: 0.55; + pointer-events: none; /* ne capture pas les clics */ + user-select: none; + font-variant-numeric: tabular-nums; + letter-spacing: 0.2px; + z-index: 1; /* sous les modals (qui sont à 10000) */ +} +.app-footer:hover { + opacity: 0.85; +} + +/* ───────────────────────────────────────────────────────────────────────── + v4.3.0 : conflit d'horaire entre 2 interventions d'un même tech. + Les heures s'affichent en rouge + icône ⚠ à côté. + ───────────────────────────────────────────────────────────────────────── */ +.iv-time-vertical.iv-time-overlap .iv-time-start, +.iv-time-vertical.iv-time-overlap .iv-time-end, +.iv-time-vertical.iv-time-overlap .iv-time-arrow { + color: var(--danger, #b03030) !important; + font-weight: 700; +} +.iv-time-overlap-warn { + color: var(--danger, #b03030); + font-size: 14px; + font-weight: 700; + line-height: 1; + margin-top: 2px; + cursor: help; + text-align: center; +} + +/* ───────────────────────────────────────────────────────────────────────── + v4.3.0 : popups épinglés détachés + Ancrés au contenu (position:absolute coord document) → scrollent avec + la page. Persistent jusqu'à fermeture explicite. + ───────────────────────────────────────────────────────────────────────── */ +.tooltip.pinned-popup { + position: absolute !important; /* override le fixed du .tooltip */ + /* v4.3.3 corr : les popups épinglées doivent passer DERRIÈRE la topbar + quand on scrolle (topbar sticky z-index 10). Donc on met 5 : au-dessus + du contenu normal, mais sous la topbar / bannières / modals. */ + z-index: 5 !important; + opacity: 1 !important; + pointer-events: auto !important; + /* Bordure plus visible pour distinguer du tooltip live */ + border: 2px solid var(--accent, #0f4f8b); + box-shadow: 0 8px 24px rgba(0,0,0,0.18); + /* Pas de contain: layout (hérité) car ça limite le rendu ; on laisse */ + animation: pinned-popup-in 0.15s ease-out; + /* Le padding-top est augmenté pour accueillir la barre de drag. */ + padding-top: 28px !important; +} +@keyframes pinned-popup-in { + from { opacity: 0; transform: scale(0.96); } + to { opacity: 1; transform: scale(1); } +} + +/* v4.3.3 : animation de sortie (symétrique à l'apparition) quand on + désépingle. Appliquée par la classe .unpinning. */ +.tooltip.pinned-popup.unpinning, +.tooltip.soft-unpinned.unpinning { + animation: pinned-popup-out 0.18s ease-in forwards !important; +} +@keyframes pinned-popup-out { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.94); } +} + +/* v4.3.3 corr : quand une popup est désépinglée "mou", elle perd son look + "épinglé" et redevient un tooltip normal visuellement, tout en gardant + sa position absolute (pour ne pas sauter). */ +.tooltip.soft-unpinned { + position: absolute !important; + z-index: 5 !important; + opacity: 1 !important; + pointer-events: auto !important; + /* Pas de bordure bleue, pas de padding-top (plus de dragbar), juste les + styles de base du tooltip (hérités de .tooltip). */ + border: 1px solid var(--border-strong) !important; + box-shadow: var(--shadow-hover) !important; + padding-top: 12px !important; + animation: none !important; +} + +/* v4.3.3 : Barre de drag en haut de la popup épinglée, permet de la + déplacer (le contenu lui-même garde la sélection de texte possible). */ +.pinned-popup-dragbar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient( + to bottom, + var(--bg-muted, rgba(128,128,128,0.08)) 0%, + transparent 100% + ); + border-bottom: 1px solid var(--border, rgba(128,128,128,0.15)); + border-radius: 6px 6px 0 0; + cursor: grab; + user-select: none; + -webkit-user-select: none; +} +.pinned-popup-dragbar:active, +.pinned-popup.dragging .pinned-popup-dragbar { + cursor: grabbing; +} +/* Petite grippe visuelle au milieu pour signaler que c'est déplaçable */ +.pinned-popup-dragbar::before { + content: ""; + width: 32px; + height: 3px; + border-radius: 3px; + background: var(--border-strong, rgba(128,128,128,0.35)); +} +/* Pendant le drag, on fige l'animation pour éviter les tremblements */ +.pinned-popup.dragging { + animation: none !important; + transition: none !important; + cursor: grabbing !important; + box-shadow: 0 12px 32px rgba(0,0,0,0.28); +} + +/* Bouton × de fermeture du popup épinglé */ +.pinned-popup-close { + position: absolute; + top: 3px; + right: 6px; + width: 22px; + height: 22px; + padding: 0; + line-height: 1; + font-size: 18px; + font-weight: 400; + color: var(--text-muted, #888); + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background 0.1s, color 0.1s; + z-index: 2; /* au-dessus de la dragbar */ +} +.pinned-popup-close:hover { + background: var(--danger-soft, #fbe6e6); + color: var(--danger, #b03030); +} diff --git a/viewer.html b/viewer.html new file mode 100644 index 0000000..bb01067 --- /dev/null +++ b/viewer.html @@ -0,0 +1,120 @@ + + + + + Planification + + + +
+
+ + +

Planification

+
+ + + + +
+ + +
+
+ + + + + + + + + +
+
+ + + + + + + + + + +
+ + + +
Chargement…
+ +
+
+ + + + + + + +
+ + + + diff --git a/viewer.js b/viewer.js new file mode 100644 index 0000000..94f70f9 --- /dev/null +++ b/viewer.js @@ -0,0 +1,5850 @@ +// ============================================================================ +// viewer.js — vue claire du planning techniciens +// ============================================================================ +// Idée de base : on récupère tout depuis le XML EasyVista (calendar_block) en +// 1 seule requête. attr1/attr2/attr3 + textContent contiennent déjà ref, +// contact, lieu, catégorie, formLink, deadline. Plus besoin de faire 74 +// requêtes xhr2 au chargement comme la v3. Le texte complet de l'action +// (Problème / À faire / Matériel) est lazy-load au hover, seulement si +// l'user survole la ligne. +// +// Fetch des fiches : séquentiel (1 par 1) au lieu d'en paralléliser. Le +// serveur EasyVista sérialise de toute façon, et ça rend l'abort instantané +// si l'user change de date en cours. +// Le cache est écrit toutes les 5 fiches (incrémental), pas juste à la fin. +// Comme ça si l'user change de date au milieu, ce qu'on a déjà fetché est +// pas perdu. +// ============================================================================ + +// ============================================================================ +// Configuration +// ============================================================================ + +// Équipe : ID EasyVista → nom affiché +const TEAM = { + "76272": "Ciuppa, Mathieu", + "83725": "De Almeida Martins, Solange", + "66635": "Makonda, Yannick", + "92235": "Mamouni, Anas", + "90070": "Paisana, David", + "40944": "Pillonel, Olivier", + "72485": "Rosset, Pascal", + "86874": "Rouiller, Quentin" +}; + +// Absences récurrentes (id tech → [jour JS, 0=dim..6=sam]) +const RECURRING_ABSENCES = { + "40944": [5] // Pillonel absent tous les vendredis +}; + +// Statuts EasyVista qui déclenchent l'affichage "clos" +const CLOSED_STATUS = ["Clôturé", "Cloture", "Clôture"]; +const RESOLVED_STATUS = ["Résolu", "Resolu"]; +// Statuts qui indiquent qu'une intervention a été supprimée/annulée +// → si présente dans le cache mais disparue du planning : on retire +const CANCELLED_STATUS = ["Annulé", "Annule", "Supprimé", "Supprime"]; + +// Clés de stockage +const LS_THEME = "planning_theme"; +const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD +const CACHE_DAYS = 7; + +// v4.1 : plus de constante de concurrence. Les fiches sont fetchées +// séquentiellement (1 à la fois) car le serveur EasyVista est lent de toute +// façon, et ça garantit un abort instantané + pas de race sur le DOM. + +// ============================================================================ +// Mapping de catégorie → titre court + couleur +// ============================================================================ + +const CATEGORY_TO_TITLE = [ + // Arrivées / nouvelles installations → Installation (bleu) + [/Arriv[ée]e\s+ou\s+mutation/i, "Installation", "installation"], + [/Accessoire\s+pour\s+PC/i, "Installation", "installation"], + [/Nouveau\s+Poste\s+Windows/i, "Installation", "installation"], + [/Nouveau\s+Poste\s+macOS/i, "Installation", "installation"], + // Récupération / départ (vert) + [/D[ée]part\s+d[\u2018\u2019']un\s+utilisateur/i, "Récupération", "recup"], + [/Reprise\s+du\s+mat[ée]riel/i, "Récupération", "recup"], + // Remplacement (orange) + [/Remplacement\s+de\s+mat[ée]riel/i, "Remplacement", "remplacement"], +]; + +/** + * Détecte si le texte de l'action commence par "Roll Out". + */ +function isRollOut(iv) { + const texts = [ + iv.bulleDescription, + iv.infobulle && iv.infobulle.aFaire, + iv.label + ]; + for (const t of texts) { + if (!t) continue; + if (/^\s*[«"']?\s*roll[\s\-]*out/i.test(String(t))) return true; + if (/(?:^|\bA faire\s*:\s*)roll[\s\-]*out/i.test(String(t))) return true; + } + return false; +} + +/** + * Détecte si le texte de l'action mentionne une récupération de matériel. + * Accepté : "RÉCUPÉRATION DE MATÉRIEL" ou "Récupération" au début de l'action, + * ou dans "A faire : Récupération ...". + */ +function isRecupAction(iv) { + const texts = [ + iv.bulleDescription, + iv.infobulle && iv.infobulle.aFaire, + iv.label + ]; + for (const t of texts) { + if (!t) continue; + const s = String(t); + if (/^\s*r[ée]cup[ée]ration/i.test(s)) return true; + if (/\bA\s+faire\s*:\s*r[ée]cup[ée]ration/i.test(s)) return true; + } + return false; +} + +/** + * Dérive un titre court et une clé de couleur à partir d'une intervention. + * Priorité : + * 1. Si la ref commence par I260 → "Incident" (violet) + * 2. Si l'action commence par "Roll Out" → "Roll Out" (brun) + * 3. Si l'action mentionne récupération → "Récupération" (vert) + * 4. Sinon, mapping par catégorie (fiche) + * 5. Sinon, "Autres" (gris) + */ +function deriveShortTitle(iv) { + if (iv.type === "AL-Reservation") return "Réservation"; + if (iv.ref && /^I\d/.test(iv.ref)) return "Incident"; + if (isRollOut(iv)) return "Roll Out"; + if (isRecupAction(iv)) return "Récupération"; + const cat = iv.categoryLine || ""; + if (!cat) return "Autres"; + for (const [regex, title] of CATEGORY_TO_TITLE) { + if (regex.test(cat)) return title; + } + return "Autres"; +} + +function deriveColorKey(iv) { + if (iv.type === "AL-Reservation") return "reservation"; + if (iv.ref && /^I\d/.test(iv.ref)) return "incident"; + if (isRollOut(iv)) return "rollout"; + if (isRecupAction(iv)) return "recup"; + const cat = iv.categoryLine || ""; + if (!cat) return "autre"; + for (const [regex, , colorKey] of CATEGORY_TO_TITLE) { + if (regex.test(cat)) return colorKey; + } + return "autre"; +} + +// ============================================================================ +// État global +// ============================================================================ + +let state = { + session: null, // { phpsessid, origin, tabId } + currentDate: null, // "YYYY-MM-DD" affiché + currentData: null, // résultat parsé (techs, stats, ...) + loading: false +}; + +// ─── Annulation coopérative d'un refresh manuel (v3.1) ────────────────────── +// Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent +// isRefreshAborted() avant chaque fetch : si le jeton a changé ou si +// l'utilisateur a cliqué sur "Arrêter", ils s'arrêtent proprement. +// +// v3.2 : on ajoute une "abortPromise" par refresh. loadForDate race cette +// promesse avec son Promise.all, donc dès qu'on clique Arrêter, loadForDate +// sort immédiatement (masque le bouton, fait un toast), même si les fetches +// en cours continuent silencieusement. Le changement de token les rend +// inoffensifs (ils ne peuvent plus écrire le cache ni updater le DOM). +let currentRefreshToken = 0; +let abortedToken = -1; +let abortResolvers = new Map(); // token → resolve fn of the abort promise + +function startNewRefresh() { + currentRefreshToken++; + return currentRefreshToken; +} +function makeAbortPromise(myToken) { + return new Promise(resolve => { + abortResolvers.set(myToken, resolve); + }); +} +function abortCurrentRefresh() { + abortedToken = currentRefreshToken; + // Réveiller tous les loadForDate en attente (normalement un seul) + for (const [token, resolve] of abortResolvers) { + if (token <= currentRefreshToken) { + resolve("aborted"); + abortResolvers.delete(token); + } + } +} +// v4.1.9 : isRefreshAborted(myToken) retourne true si : +// - un nouveau refresh a été lancé (currentRefreshToken > myToken), OU +// - l'utilisateur a explicitement cliqué "Arrêter" (abortedToken). +// Sans myToken fourni (compat), on ne teste que l'abort explicite. +function isRefreshAborted(myToken) { + if (abortedToken === currentRefreshToken) return true; + if (typeof myToken === "number" && myToken < currentRefreshToken) return true; + return false; +} +function cleanupAbortResolver(myToken) { + abortResolvers.delete(myToken); +} + +// ============================================================================ +// Boot +// ============================================================================ + +document.addEventListener("DOMContentLoaded", init); + +async function init() { + initTheme(); + bindTopbar(); + bindTooltipInteractions(); + initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal + initAppFooter(); // v4.2.9 : pied de page discret bas-droite + + // Initialiser la date = aujourd'hui + state.currentDate = todayISO(); + document.getElementById("date-picker").value = state.currentDate; + + // v4.2 : l'auto-refresh 12h/15h a été supprimé. Les rafraîchissements sont + // désormais soit manuels (boutons Actualiser / Tout recharger), soit au + // premier chargement si aucun cache n'existe pour la date. + + // Charger la sesson puis le planning + await refreshSessionAndLoad(); +} + +async function refreshSessionAndLoad() { + const resp = await sendMessage({ type: "getSession" }); + if (!resp.ok || !resp.session) { + // v4.2.5 : si un cache existe pour le jour demandé, on l'affiche avec + // une bannière "session expirée" sticky au-dessus. Sinon écran plein. + const cached = await readCache(state.currentDate); + if (cached) { + renderFromData({ + techs: cached.techs, + targetDate: state.currentDate, + captureTime: cached.savedAt || null, + source: "cache" + }); + showSessionExpiredBanner(); + } else { + showSessionNeeded(); + } + return; + } + state.session = resp.session; + hideSessionNeeded(); + hideEvUnreachable(); + hideSessionExpiredBanner(); + hideEvUnreachableBanner(); + fetchAndShowCurrentUser(); + await loadForDate(state.currentDate); +} + +// v4.2 : fetche l'utilisateur EasyVista connecté (via background.js) et +// l'affiche dans la topbar. En cas d'échec ou si aucun nom n'est trouvé, +// le badge reste caché. +async function fetchAndShowCurrentUser() { + try { + const resp = await sendMessage({ type: "fetchCurrentUser" }); + if (!resp || !resp.ok || !resp.user) return; + const badge = document.getElementById("user-badge"); + if (!badge) return; + const fullName = resp.user.name || resp.user.login || null; + if (!fullName) return; + const initials = computeUserInitials(fullName); + badge.textContent = initials; + badge.title = fullName; + // v4.2.3 : couleur unique dérivée du nom, dans la palette neutre du thème + badge.style.setProperty("--user-badge-color", colorFromName(fullName)); + badge.classList.remove("hidden"); + state.currentUser = resp.user; + } catch (err) { + console.warn("[currentUser] fetch failed:", err); + } +} + +// v4.2.3 : calcule les initiales depuis un nom au format "Nom, Prénom" ou +// "Nom Prénom" ou "Prénom Nom". On prend la 1re lettre majuscule de chaque +// mot/segment significatif, limité à 2 caractères. +function computeUserInitials(fullName) { + if (!fullName) return "?"; + // Format "Nom, Prénom" → prendre initiale avant virgule et après + let parts; + if (fullName.includes(",")) { + parts = fullName.split(",").map(s => s.trim()).filter(Boolean); + } else { + parts = fullName.split(/\s+/).filter(Boolean); + } + const letters = parts + .map(p => p.charAt(0)) + .filter(c => /[A-Za-zÀ-ÿ]/.test(c)) + .slice(0, 2) + .join("") + .toUpperCase(); + return letters || (fullName.charAt(0).toUpperCase() || "?"); +} + +// v4.2.3 : couleur déterministe à partir du nom. Palette neutre et sobre +// (tons tamisés), compatible avec les thèmes clair et sombre de l'extension. +function colorFromName(name) { + // Hash simple (djb2) pour dériver un index stable + let h = 5381; + for (let i = 0; i < name.length; i++) { + h = ((h << 5) + h + name.charCodeAt(i)) & 0xffffffff; + } + const palette = [ + "#5b6372", // gris bleuté + "#6b7280", // gris neutre + "#4a5568", // ardoise + "#3b5a72", // bleu profond tamisé + "#4f6a5e", // vert sauge sombre + "#6b5a4f", // brun taupe + "#5d4a6b", // prune sombre + "#6a5a3a", // kaki bronze + "#3a5a5e", // sarcelle sombre + "#6c5c67" // mauve grisé + ]; + return palette[Math.abs(h) % palette.length]; +} + +// v4.2.3 : affiche/masque la popup nom complet sous la pastille +function toggleUserNamePopup() { + const badge = document.getElementById("user-badge"); + const popup = document.getElementById("user-name-popup"); + if (!badge || !popup) return; + if (!popup.classList.contains("hidden")) { + hideUserNamePopup(); + return; + } + if (!state.currentUser || !state.currentUser.name) return; + popup.textContent = state.currentUser.name; + popup.classList.remove("hidden"); + badge.classList.add("open"); + // Positionne juste en dessous de la pastille + const r = badge.getBoundingClientRect(); + popup.style.left = Math.max(8, r.left) + "px"; + popup.style.top = (r.bottom + 6) + "px"; +} +function hideUserNamePopup() { + const popup = document.getElementById("user-name-popup"); + const badge = document.getElementById("user-badge"); + if (popup) popup.classList.add("hidden"); + if (badge) badge.classList.remove("open"); +} + +// ============================================================================ +// Thème clair/sombre +// ============================================================================ + +function initTheme() { + const saved = localStorage.getItem(LS_THEME); + const theme = (saved === "light" || saved === "dark") ? saved : detectDefaultTheme(); + applyTheme(theme); +} + +function detectDefaultTheme() { + if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { + return "dark"; + } + return "light"; +} + +function applyTheme(theme) { + document.documentElement.setAttribute("data-theme", theme); + const icon = document.getElementById("theme-icon"); + if (icon) icon.textContent = theme === "dark" ? "☀️" : "🌙"; +} + +function toggleTheme() { + const current = document.documentElement.getAttribute("data-theme") || "light"; + const next = current === "dark" ? "light" : "dark"; + applyTheme(next); + localStorage.setItem(LS_THEME, next); +} + +// ============================================================================ +// Topbar handlers +// ============================================================================ + +function bindTopbar() { + document.getElementById("theme-toggle").addEventListener("click", toggleTheme); + // v4.1.10 : 2 boutons de rafraichissement. + // - refresh-btn (Total) : force le re-fetch de toutes les fiches (même celles + // déjà enrichies), utile pour voir les statuts évoluer. + // - refresh-partial-btn (Partiel) : re-fetch juste le XML planning pour + // détecter nouvelles/disparues interventions, mais ne refetch PAS les + // fiches déjà connues → rapide. + document.getElementById("refresh-btn").addEventListener("click", () => { + setActiveRefreshButton("total"); + refreshPlanning({ total: true }); + }); + const partialBtn = document.getElementById("refresh-partial-btn"); + if (partialBtn) { + partialBtn.addEventListener("click", () => { + setActiveRefreshButton("partial"); + refreshPlanning({ partial: true }); + }); + } + document.getElementById("abort-btn").addEventListener("click", () => { + // Feedback visuel instantané : masquer le bouton tout de suite, sans + // attendre que loadForDate finisse sa race. + showAbortButton(false); + abortCurrentRefresh(); + showAbortToast(); + }); + document.getElementById("clear-cache-btn").addEventListener("click", onClearCache); + + // v4.2.6 : boutons Absence et Douchette + const absenceBtn = document.getElementById("absence-btn"); + if (absenceBtn) absenceBtn.addEventListener("click", showAbsenceModal); + const douchetteBtn = document.getElementById("douchette-btn"); + if (douchetteBtn) douchetteBtn.addEventListener("click", showDouchetteModal); + + document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1)); + document.getElementById("nav-next").addEventListener("click", () => navigateDate(+1)); + document.getElementById("nav-today").addEventListener("click", () => loadForDate(todayISO())); + + document.getElementById("date-picker").addEventListener("change", (e) => { + if (e.target.value) loadForDate(e.target.value); + }); + + // v4.2.3 : clic sur la pastille d'initiales → toggle popup nom complet + const userBadge = document.getElementById("user-badge"); + if (userBadge) { + userBadge.addEventListener("click", (e) => { + e.stopPropagation(); + toggleUserNamePopup(); + }); + } + // Clic ailleurs ou touche Escape ferme la popup user + document.addEventListener("click", (e) => { + const popup = document.getElementById("user-name-popup"); + if (popup && !popup.classList.contains("hidden")) { + // Ne pas fermer si le clic est dans la popup elle-même ou sur le badge + if (!e.target.closest("#user-name-popup") && !e.target.closest("#user-badge")) { + hideUserNamePopup(); + } + } + // v4.2.4 : clic ailleurs ferme aussi la grande bulle d'interventoin + // quand elle est ouverte via clic timeline (mode "anchored"). Clic sur + // la bulle elle-même ou sur une timeline-slot ne ferme pas. + const tip = tooltipEl(); + if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) { + if (!e.target.closest("#tooltip") && !e.target.closest(".timeline-slot")) { + hideTooltip({ force: true }); + } + } + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + hideUserNamePopup(); + // v4.2.4 : Échap ferme aussi la grande bulle anchored + const tip = tooltipEl(); + if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) { + hideTooltip({ force: true }); + } + // v4.3.0 : Échap ferme TOUS les popups épinglés (le user veut tout fermer) + if (typeof closeAllPinnedPopups === "function") { + closeAllPinnedPopups(); + } + } + }); + + document.getElementById("open-ev-btn").addEventListener("click", openEasyVista); + + // v4.2 : écran "EasyVista inaccessible" + const openEvBtn2 = document.getElementById("open-ev-btn-2"); + if (openEvBtn2) openEvBtn2.addEventListener("click", openEasyVista); + const retryBtn = document.getElementById("retry-btn"); + if (retryBtn) retryBtn.addEventListener("click", async () => { + hideEvUnreachable(); + document.getElementById("loading").classList.remove("hidden"); + await refreshSessionAndLoad(); + }); + + // v4.1.12 : bindings bannière session expirée + const reconnectBtn = document.getElementById("session-banner-reconnect"); + if (reconnectBtn) reconnectBtn.addEventListener("click", openEasyVista); + const closeBtn = document.getElementById("session-banner-close"); + if (closeBtn) closeBtn.addEventListener("click", hideSessionExpiredBanner); + + // 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() { + // Ouvrir sur le domaine externe (accessible depuis l'extérieur). + // Le domaine interne (itsma.etat-de-vaud.ch) n'est accessible que depuis le réseau VD. + // Une fois connecté, l'extension détectera automatiquement le PHPSESSID quel que + // soit le domaine où tu es connecté. + await chrome.tabs.create({ url: "https://itsma.vd.ch/" }); +} + +// Navigation ±1 jour en sautant les week-ends +function navigateDate(direction) { + const d = isoToDate(state.currentDate); + d.setDate(d.getDate() + direction); + // Sauter les week-ends + while (d.getDay() === 0 || d.getDay() === 6) { + d.setDate(d.getDate() + direction); + } + loadForDate(dateToISO(d)); +} + +async function onClearCache() { + // v4.1.20 : modal central avec 2 choix (jour / tout) + annuler + showClearCacheModal(); +} + +// v4.1.20 : modal central de confirmation pour vider le cache. L'arrière-plan +// est flouté, l'utilisateur a deux choix explicites + Annuler. +function showClearCacheModal() { + // Ne pas ouvrir 2x si déjà affiché + if (document.getElementById("clear-cache-modal")) return; + + const dateTxt = formatDateDM(state.currentDate); + + const overlay = document.createElement("div"); + overlay.id = "clear-cache-modal"; + overlay.className = "modal-overlay"; + overlay.innerHTML = ` + + `; + document.body.appendChild(overlay); + + const close = () => { + overlay.remove(); + }; + + overlay.addEventListener("click", async (e) => { + const action = e.target.closest("[data-action]")?.dataset.action; + if (!action) { + // Clic sur le fond (pas sur la carte) → fermer + if (e.target === overlay) close(); + return; + } + if (action === "cancel") { + close(); + return; + } + if (action === "clear-day") { + close(); + await chrome.storage.local.remove(CACHE_PREFIX + state.currentDate); + await loadForDate(state.currentDate, { forceRefetch: true }); + return; + } + if (action === "clear-all") { + close(); + // Supprimer toutes les clés CACHE_PREFIX* + const all = await chrome.storage.local.get(null); + const toRemove = Object.keys(all).filter(k => k.startsWith(CACHE_PREFIX)); + if (toRemove.length) { + await chrome.storage.local.remove(toRemove); + } + await loadForDate(state.currentDate, { forceRefetch: true }); + return; + } + }); + + // Échap ferme la modale + const escHandler = (e) => { + if (e.key === "Escape") { + close(); + document.removeEventListener("keydown", escHandler); + } + }; + document.addEventListener("keydown", escHandler); +} + +// ============================================================================ +// v4.2.5 : modal d'alerte générique (session expirée / EV inaccessible / +// erreur d'ouverture). Remplace les alert() natives par une vraie popup +// avec flou autour, titre, message et boutons personnalisables. +// ============================================================================ + +/** + * Affiche un modal d'alerte. + * @param {Object} opts + * @param {string} opts.title - Titre + * @param {string} opts.message - Message (HTML autorisé si opts.html=true) + * @param {boolean} [opts.html=false] - Si true, message interprété comme HTML + * @param {Array<{label:string, variant:"primary"|"secondary"|"danger", action:(()=>void|Promise)}>} opts.buttons + * Boutons (en bas du modal). Le 1er = focus par défaut. + */ +function showAlertModal(opts) { + // Si un alert modal est déjà affiché, l'enlever d'abord + const existing = document.getElementById("alert-modal"); + if (existing) existing.remove(); + + const overlay = document.createElement("div"); + overlay.id = "alert-modal"; + overlay.className = "modal-overlay"; + + const card = document.createElement("div"); + card.className = "modal-card"; + card.setAttribute("role", "dialog"); + card.setAttribute("aria-labelledby", "alert-modal-title"); + + const h = document.createElement("h2"); + h.id = "alert-modal-title"; + h.className = "modal-title"; + h.textContent = opts.title || ""; + card.appendChild(h); + + const p = document.createElement("p"); + p.className = "modal-message"; + if (opts.html) { + p.innerHTML = opts.message || ""; + } else { + p.textContent = opts.message || ""; + } + card.appendChild(p); + + const actions = document.createElement("div"); + actions.className = "modal-actions"; + (opts.buttons || []).forEach((btn, i) => { + const b = document.createElement("button"); + b.type = "button"; + b.className = "btn"; + if (btn.variant === "primary") b.classList.add("btn-modal-primary"); + else if (btn.variant === "danger") b.classList.add("btn-modal-danger-strong"); + else b.classList.add("btn-modal-cancel"); + b.textContent = btn.label; + b.addEventListener("click", async () => { + overlay.remove(); + if (typeof btn.action === "function") { + try { await btn.action(); } catch (e) { console.error("[alert-modal]", e); } + } + }); + actions.appendChild(b); + if (i === 0) setTimeout(() => b.focus(), 50); + }); + card.appendChild(actions); + + overlay.appendChild(card); + document.body.appendChild(overlay); + + // Clic sur le fond (flou) → fermer + overlay.addEventListener("click", (e) => { + if (e.target === overlay) overlay.remove(); + }); + + // Échap ferme la modale + const escHandler = (e) => { + if (e.key === "Escape") { + overlay.remove(); + document.removeEventListener("keydown", escHandler); + } + }; + document.addEventListener("keydown", escHandler); +} + +// ============================================================================ +// v4.2.9 : blocage du scroll en arrière-plan quand un modal est ouvert +// ============================================================================ +// +// Un MutationObserver surveille l'apparition/disparition de tout élément +// .modal-overlay dans le body. Dès qu'il y en a au moins un, on ajoute la +// classe `modal-open` sur body → CSS bloque le scroll. Quand le dernier +// modal disparaît, la classe est retirée. +// +// Centralisé ici pour que TOUS les modals (existants et futurs) en profitent +// sans modification individuelle. + +function initModalScrollLock() { + const updateLock = () => { + const hasModal = document.querySelector(".modal-overlay") !== null; + document.body.classList.toggle("modal-open", hasModal); + }; + const observer = new MutationObserver(updateLock); + observer.observe(document.body, { childList: true, subtree: false }); + updateLock(); // au cas où un modal serait déjà là au boot +} + +// v4.2.9 : pied de page discret "QRO / vX.X.X" en bas à droite. +// La version est lue depuis le manifest (source unique de vérité). +function initAppFooter() { + if (document.querySelector(".app-footer")) return; + let version = ""; + try { + const manifest = chrome && chrome.runtime && chrome.runtime.getManifest + ? chrome.runtime.getManifest() : null; + if (manifest && manifest.version) version = "v" + manifest.version; + } catch (e) {} + const el = document.createElement("div"); + el.className = "app-footer"; + el.textContent = `QRO${version ? " / " + version : ""}`; + document.body.appendChild(el); +} + +// ============================================================================ +// v4.2.6 : Modals Absence et Douchette +// ============================================================================ + +// Types d'absence EasyVista (extraits du HTML plan_set_holidays_popup.php) +const HOLIDAY_TYPES = [ + { guid: "{EF51F439-441E-4A68-9D1A-A6E0A85F32FE}", label: "Congés" }, + { guid: "{B5B887A7-DE5D-4CAB-B55E-7D01E5D0DF84}", label: "Déménagement" }, + { guid: "{8476B26C-DFE4-4256-B2B5-3CE1C9EC3479}", label: "Ecrans" }, + { guid: "{E7432422-55CB-4DB9-8A26-619D036E2155}", label: "Evènements spéciaux" }, + { guid: "{F9B8FFC6-5D64-4339-AAAF-166D6D3801DA}", label: "MAC" }, + { guid: "{0554F45A-9B31-43D7-A1E2-0407D74F3BB5}", label: "Maladie" }, + { guid: "{E8301A0F-B246-420A-863C-3837F1B581E0}", label: "PC" }, + { guid: "{60D70502-063D-45AD-9415-25C1C556105F}", label: "Pompier" }, + { guid: "{B343C590-1446-45BF-9CE6-790C759BA999}", label: "Réunion" }, + { guid: "{7E63F472-677E-4EFD-B822-1AF4DC163AEC}", label: "Rollout" }, + { guid: "{D45DEF80-9DDA-46BA-957E-B5B6D7F9D46A}", label: "Téléphones" }, + { guid: "{06BCAC52-5A8A-4D6D-9BC6-566AAF18666A}", label: "UTP" } +]; + +/** + * Formate une date ISO YYYY-MM-DD en DD/MM/YYYY (format EasyVista). + */ +function isoToEvDate(iso) { + if (!iso) return ""; + const parts = iso.split("-"); + if (parts.length !== 3) return iso; + return `${parts[2]}/${parts[1]}/${parts[0]}`; +} + +/** + * Construit un bloc liste de techniciens avec checkboxes. + * @param {Object} opts + * @param {boolean} [opts.selectAll] - Afficher la case "Tout sélectionner" + * @returns {HTMLElement} + */ +function buildTechCheckboxList(opts = {}) { + const container = document.createElement("div"); + container.className = "modal-tech-list"; + + const techIds = Object.keys(TEAM); + + if (opts.selectAll) { + const allRow = document.createElement("label"); + allRow.className = "modal-tech-item tech-selectall"; + const allBox = document.createElement("input"); + allBox.type = "checkbox"; + allBox.className = "tech-select-all"; + const allLabel = document.createElement("span"); + allLabel.textContent = "Tout sélectionner"; + allRow.appendChild(allBox); + allRow.appendChild(allLabel); + container.appendChild(allRow); + + allBox.addEventListener("change", () => { + container.querySelectorAll(".tech-checkbox").forEach(cb => { + cb.checked = allBox.checked; + }); + }); + } + + for (const id of techIds) { + const row = document.createElement("label"); + row.className = "modal-tech-item"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.className = "tech-checkbox"; + cb.value = id; + const label = document.createElement("span"); + label.textContent = TEAM[id]; + row.appendChild(cb); + row.appendChild(label); + container.appendChild(row); + + // Cocher "Tout" si toutes les cases sont cochées (et décocher sinon) + cb.addEventListener("change", () => { + const allBox = container.querySelector(".tech-select-all"); + if (!allBox) return; + const boxes = [...container.querySelectorAll(".tech-checkbox")]; + allBox.checked = boxes.every(b => b.checked); + allBox.indeterminate = !allBox.checked && boxes.some(b => b.checked); + }); + } + + return container; +} + +/** + * Récupère la liste des techIds cochés dans une liste de checkboxes. + */ +function getCheckedTechIds(container) { + return [...container.querySelectorAll(".tech-checkbox:checked")].map(cb => cb.value); +} + +/** + * Ouvre la modal "Créer une absence". + */ +function showAbsenceModal() { + const existing = document.getElementById("absence-modal"); + if (existing) existing.remove(); + + const overlay = document.createElement("div"); + overlay.id = "absence-modal"; + overlay.className = "modal-overlay"; + + const card = document.createElement("div"); + card.className = "modal-card modal-wide"; + card.setAttribute("role", "dialog"); + + const title = document.createElement("h2"); + title.className = "modal-title"; + title.textContent = "Créer une absence"; + card.appendChild(title); + + // Liste des techs (sans "Tout sélectionner" : on ne met quasi jamais tout + // le monde en absence, et c'est trop dangereux par erreur) + const techGroup = document.createElement("div"); + techGroup.className = "modal-form-group"; + const techLabel = document.createElement("label"); + techLabel.className = "modal-form-label"; + techLabel.textContent = "Technicien(s)"; + techGroup.appendChild(techLabel); + const techList = buildTechCheckboxList({ selectAll: false }); + techGroup.appendChild(techList); + card.appendChild(techGroup); + + // Dates et heures : aujourd'hui ou le jour affiché, 08:00-18:00 + const today = state.currentDate || todayISO(); + + const dateGroup = document.createElement("div"); + dateGroup.className = "modal-form-group"; + const dateLabel = document.createElement("label"); + dateLabel.className = "modal-form-label"; + dateLabel.textContent = "Date et heure de début"; + dateGroup.appendChild(dateLabel); + const dateRow1 = document.createElement("div"); + dateRow1.className = "modal-form-row"; + const startDate = document.createElement("input"); + startDate.type = "date"; + startDate.className = "modal-form-input"; + startDate.id = "absence-start-date"; + startDate.value = today; + const startTime = document.createElement("input"); + startTime.type = "time"; + startTime.className = "modal-form-input"; + startTime.id = "absence-start-time"; + startTime.value = "08:00"; + dateRow1.appendChild(startDate); + dateRow1.appendChild(startTime); + dateGroup.appendChild(dateRow1); + card.appendChild(dateGroup); + + const endGroup = document.createElement("div"); + endGroup.className = "modal-form-group"; + const endLabel = document.createElement("label"); + endLabel.className = "modal-form-label"; + endLabel.textContent = "Date et heure de fin"; + endGroup.appendChild(endLabel); + const endRow = document.createElement("div"); + endRow.className = "modal-form-row"; + const endDate = document.createElement("input"); + endDate.type = "date"; + endDate.className = "modal-form-input"; + endDate.id = "absence-end-date"; + endDate.value = today; + const endTime = document.createElement("input"); + endTime.type = "time"; + endTime.className = "modal-form-input"; + endTime.id = "absence-end-time"; + endTime.value = "18:00"; + endRow.appendChild(endDate); + endRow.appendChild(endTime); + endGroup.appendChild(endRow); + card.appendChild(endGroup); + + // Type d'absence + const typeGroup = document.createElement("div"); + typeGroup.className = "modal-form-group"; + const typeLabel = document.createElement("label"); + typeLabel.className = "modal-form-label"; + typeLabel.textContent = "Type d'absence"; + typeGroup.appendChild(typeLabel); + const typeSelect = document.createElement("select"); + typeSelect.className = "modal-form-select"; + typeSelect.id = "absence-type-select"; + const emptyOpt = document.createElement("option"); + emptyOpt.value = ""; + emptyOpt.textContent = "— Choisir un type —"; + typeSelect.appendChild(emptyOpt); + for (const t of HOLIDAY_TYPES) { + const opt = document.createElement("option"); + opt.value = t.guid; + opt.textContent = t.label; + typeSelect.appendChild(opt); + } + typeGroup.appendChild(typeSelect); + card.appendChild(typeGroup); + + // Boutons Appliquer / Annuler + const actions = document.createElement("div"); + actions.className = "modal-actions horizontal"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "btn btn-modal-cancel"; + cancelBtn.textContent = "Annuler"; + cancelBtn.addEventListener("click", () => overlay.remove()); + const applyBtn = document.createElement("button"); + applyBtn.type = "button"; + applyBtn.className = "btn btn-modal-primary"; + applyBtn.textContent = "Appliquer"; + applyBtn.addEventListener("click", async () => { + // Validation + const techIds = getCheckedTechIds(techList); + if (techIds.length === 0) { + showAlertModal({ + title: "Sélection manquante", + message: "Choisissez au moins un technicien.", + buttons: [{ label: "OK", variant: "secondary", action: () => {} }] + }); + return; + } + if (!typeSelect.value) { + showAlertModal({ + title: "Sélection manquante", + message: "Choisissez un type d'absence.", + buttons: [{ label: "OK", variant: "secondary", action: () => {} }] + }); + return; + } + const sd = startDate.value, st = startTime.value; + const ed = endDate.value, et = endTime.value; + if (!sd || !st || !ed || !et) { + showAlertModal({ + title: "Dates/heures manquantes", + message: "Remplissez toutes les dates et heures.", + buttons: [{ label: "OK", variant: "secondary", action: () => {} }] + }); + return; + } + // Désactiver le bouton pendant l'envoi + applyBtn.disabled = true; + applyBtn.textContent = "Envoi…"; + try { + await submitAbsence({ + techIds: techIds, + startDate: sd, + startTime: st, + endDate: ed, + endTime: et, + typeGuid: typeSelect.value + }); + overlay.remove(); + showToast("Absence créée", techIds.length + " tech" + (techIds.length > 1 ? "s" : "")); + // Reload le planning du jour pour voir l'absence + if (state.session) { + await loadForDate(state.currentDate, { forceRefetch: true }); + } + } catch (err) { + applyBtn.disabled = false; + applyBtn.textContent = "Appliquer"; + showAlertModal({ + title: "Erreur lors de la création", + message: "Impossible de créer l'absence : " + (err.message || err), + buttons: [{ label: "OK", variant: "secondary", action: () => {} }] + }); + } + }); + actions.appendChild(cancelBtn); + actions.appendChild(applyBtn); + card.appendChild(actions); + + overlay.appendChild(card); + document.body.appendChild(overlay); + + overlay.addEventListener("click", (e) => { + if (e.target === overlay) overlay.remove(); + }); + const escHandler = (e) => { + if (e.key === "Escape") { + overlay.remove(); + document.removeEventListener("keydown", escHandler); + } + }; + document.addEventListener("keydown", escHandler); +} + +/** + * Envoie la requête de création d'absence à EasyVista. + * Appelle le background script qui fait le POST avec la bonne session. + */ +async function submitAbsence(opts) { + const resp = await sendMessage({ + type: "submitAbsence", + techIds: opts.techIds, + startDate: isoToEvDate(opts.startDate), + startTime: opts.startTime + ":00", // HH:MM:SS + endDate: isoToEvDate(opts.endDate), + endTime: opts.endTime + ":00", + typeGuid: opts.typeGuid, + currentDate: isoToEvDate(opts.startDate) + }); + if (!resp || !resp.ok) { + throw new Error(resp && resp.error ? resp.error : "erreur inconnue"); + } + return resp; +} + +/** + * Ouvre la modal "Envoyer la planification sur la douchette". + */ +function showDouchetteModal() { + const existing = document.getElementById("douchette-modal"); + if (existing) existing.remove(); + + const overlay = document.createElement("div"); + overlay.id = "douchette-modal"; + overlay.className = "modal-overlay"; + + const card = document.createElement("div"); + card.className = "modal-card"; + card.setAttribute("role", "dialog"); + + const title = document.createElement("h2"); + title.className = "modal-title"; + title.textContent = "Envoyer la planification sur la douchette"; + card.appendChild(title); + + const msg = document.createElement("p"); + msg.className = "modal-message"; + msg.textContent = "Choisissez le ou les techniciens qui recevront la planification du jour sur leur douchette."; + card.appendChild(msg); + + const techGroup = document.createElement("div"); + techGroup.className = "modal-form-group"; + const techList = buildTechCheckboxList({ selectAll: true }); + techGroup.appendChild(techList); + card.appendChild(techGroup); + + // Boutons + const actions = document.createElement("div"); + actions.className = "modal-actions horizontal"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "btn btn-modal-cancel"; + cancelBtn.textContent = "Annuler"; + cancelBtn.addEventListener("click", () => overlay.remove()); + const sendBtn = document.createElement("button"); + sendBtn.type = "button"; + sendBtn.className = "btn btn-modal-primary"; + sendBtn.textContent = "Envoyer"; + sendBtn.addEventListener("click", async () => { + const techIds = getCheckedTechIds(techList); + if (techIds.length === 0) { + showAlertModal({ + title: "Sélection manquante", + message: "Choisissez au moins un technicien.", + buttons: [{ label: "OK", variant: "secondary", action: () => {} }] + }); + return; + } + sendBtn.disabled = true; + sendBtn.textContent = "Envoi…"; + try { + const result = await submitDouchette(techIds); + overlay.remove(); + if (result && result.okCount > 0) { + showToast( + "Envoyé sur douchette", + result.okCount + "/" + techIds.length + " tech" + (techIds.length > 1 ? "s" : "") + ); + } + if (result && result.errors && result.errors.length > 0) { + showAlertModal({ + title: "Envoi partiellement échoué", + message: result.errors.length + " tech(s) n'ont pas pu recevoir : " + + result.errors.map(e => TEAM[e.techId] || e.techId).join(", "), + buttons: [{ label: "OK", variant: "secondary", action: () => {} }] + }); + } + } catch (err) { + sendBtn.disabled = false; + sendBtn.textContent = "Envoyer"; + showAlertModal({ + title: "Erreur lors de l'envoi", + message: "Impossible d'envoyer sur la douchette : " + (err.message || err), + buttons: [{ label: "OK", variant: "secondary", action: () => {} }] + }); + } + }); + actions.appendChild(cancelBtn); + actions.appendChild(sendBtn); + card.appendChild(actions); + + overlay.appendChild(card); + document.body.appendChild(overlay); + + overlay.addEventListener("click", (e) => { + if (e.target === overlay) overlay.remove(); + }); + const escHandler = (e) => { + if (e.key === "Escape") { + overlay.remove(); + document.removeEventListener("keydown", escHandler); + } + }; + document.addEventListener("keydown", escHandler); +} + +/** + * Envoie la planification sur la douchette de plusieurs techniciens. + * Retourne { okCount, errors: [{techId, error}] }. + */ +async function submitDouchette(techIds) { + const resp = await sendMessage({ + type: "submitDouchette", + techIds: techIds, + currentDate: isoToEvDate(state.currentDate || todayISO()) + }); + if (!resp || !resp.ok) { + throw new Error(resp && resp.error ? resp.error : "erreur inconnue"); + } + return resp; +} + +// ============================================================================ +// Date helpers +// ============================================================================ + +function todayISO() { + const d = new Date(); + return dateToISO(d); +} + +function dateToISO(d) { + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +} + +function isoToDate(iso) { + const [y, m, d] = iso.split("-").map(n => parseInt(n, 10)); + return new Date(y, m - 1, d); +} + +function isoToDDMMYYYY(iso) { + const [y, m, d] = iso.split("-"); + return `${d}/${m}/${y}`; +} + +function formatDateDM(iso) { + const [, m, d] = iso.split("-"); + return `${d}/${m}`; +} + +function isoToUnixDate(iso) { + // Renvoie le timestamp Unix à midi local du jour (pour que le serveur comprenne bien le jour demandé) + const d = isoToDate(iso); + d.setHours(12, 0, 0, 0); + return Math.floor(d.getTime() / 1000); +} + +// ============================================================================ +// Messages → background +// ============================================================================ + +function sendMessage(msg) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(msg, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + resolve(response || {}); + }); + }); +} + +// ============================================================================ +// Cache (chrome.storage.local) +// ============================================================================ + +async function readCache(isoDate) { + const key = CACHE_PREFIX + isoDate; + const obj = await chrome.storage.local.get(key); + return obj[key] || null; +} + +async function writeCache(isoDate, data) { + const key = CACHE_PREFIX + isoDate; + await chrome.storage.local.set({ [key]: { ...data, savedAt: Date.now() } }); +} + +// ============================================================================ +// Flux principal : charger une date +// ============================================================================ + +async function loadForDate(isoDate, opts = {}) { + // v4.3.1 : changer de date ferme tous les popups épinglés. Ils réfèrent à + // des interventions du jour courant, ils n'ont aucun sens sur un autre jour. + const previousDate = state.currentDate; + if (previousDate && previousDate !== isoDate && typeof closeAllPinnedPopups === "function") { + closeAllPinnedPopups(); + } + + state.currentDate = isoDate; + document.getElementById("date-picker").value = isoDate; + + if (!state.session) { + // v4.2.5 : afficher le cache + bannière, plutôt que l'écran "session" + const cached = await readCache(isoDate); + if (cached) { + renderFromData({ + techs: cached.techs, + targetDate: isoDate, + captureTime: cached.savedAt || null, + source: "cache" + }); + showSessionExpiredBanner(); + } else { + showSessionNeeded(); + } + return; + } + + // (v3.1.1) Tout chargement = un nouveau jeton d'annulation. Le bouton + // "Arrêter" apparaît pour TOUT refresh (clic manuel, navigation date, + // ouverture vue claire), pas juste refreshPlanning(). Le bouton disparaît + // quand le chargement est vraiment fini (finally). + const myToken = startNewRefresh(); + showAbortButton(true); + const t0 = performance.now(); + console.log(`[load] début pour ${isoDate} (token=${myToken})`); + + // v4.1.14 : choix du bouton qui tourne + // - Clic explicite "Actualiser" → _fromPartialBtn → "partial" + // - Clic explicite "Tout recharger" → doStatusRefresh → "total" + // - Sinon (nav date / chargement auto) : + // - cache présent → "partial" (c'est juste un diff XML) + // - cache absent → "total" (on charge tout pour la 1re fois) + // La détermination se fait APRÈS readCache. + + try { + // 1. Afficher immédiatement depuis le cache si disponible + const cached = await readCache(isoDate); + + if (!opts._fromPartialBtn) { + if (opts.doStatusRefresh) { + setActiveRefreshButton("total"); + } else { + setActiveRefreshButton(cached ? "partial" : "total"); + } + } + if (cached && !opts.forceRefetch) { + renderFromData({ + techs: cached.techs, + targetDate: isoDate, + captureTime: cached.savedAt || null, + source: "cache" + }); + // v4.1.9 : on NE retourne PAS ici. On continue pour refetch le XML + // du planning afin de détecter les nouvelles iv et celles disparues + // (diff avec le cache). Les iv déjà présentes dans le cache gardent + // leur enrichissement (ficheActionText, statut) → pas de re-fetch + // inutile, seules les nouvelles passent par refreshStatuses. + } else { + showLoading(); + } + + if (isRefreshAborted(myToken)) return; + + // 2. Fetch frais du planning (XML calendar_block — rapide, ~40 ko) + const tXml = performance.now(); + const fresh = await fetchPlanningForDate(isoDate); + console.log(`[load] XML planning récupéré en ${Math.round(performance.now() - tXml)} ms`); + if (!fresh) return; + if (isRefreshAborted(myToken)) return; + + // 3. Fusionner cache + frais + const merged = mergeCacheAndFresh(cached, fresh); + + // v4.2.5 : AVANT de retirer les ghosts, on lance une analyse de chaque + // ghost pour déterminer si c'est : + // - un ticket TERMINÉ par le tech (→ garder en vert ✓ simple) + // - un ticket CLÔTURÉ/RÉSOLU dans EasyVista (→ garder en vert ✓✓ double) + // - un ticket DÉPLACÉ (action ouverte au même tech autre jour) → retirer + // - un ticket ANNULÉ / autre → retirer + // L'analyse est asynchrone (re-fetch de chaque fiche) : on la lance en + // arrière-plan APRÈS le rendu initial pour ne pas bloquer l'UI. + // En attendant, les ghosts restent visibles avec un indicateur "en cours + // d'analyse" (petit spinner / opacité réduite). + const ghostsToAnalyze = []; + for (const tech of merged.techs) { + for (const iv of tech.interventions) { + if (iv.ghost) { + iv._disappearChecking = true; // marquer "en cours d'analyse" + ghostsToAnalyze.push({ tech, iv }); + } + } + } + + // 4. Afficher immédiatement (v4 : tout est déjà rempli depuis le XML !) + // Le calendar_block contient attr1/attr2/attr3 = contact/lieu/catégorie, + // et textContent = ref. Donc ce 1er rendu est DÉJÀ complet visuellement + // (manquent juste : statut clos/résolu, et détails dans le tooltip au + // survol). Plus d'étapes 5a et 5b successives comme en v3. + renderFromData({ + techs: merged.techs, + targetDate: isoDate, + captureTime: Date.now(), + source: "fresh", + lastRefreshKind: activeRefreshButton // v4.1.20 + }); + console.log(`[load] 1er rendu complet à ${Math.round(performance.now() - t0)} ms`); + + // v4.2.5 : analyser les ghosts (tickets disparus du planning) pour décider + // s'il faut les garder en vert (terminés par tech / clôturés) ou les + // retirer définitivement (déplacés / annulés). Asynchrone en arrière-plan. + if (ghostsToAnalyze.length > 0 && !isRefreshAborted(myToken)) { + console.log(`[load] analyse de ${ghostsToAnalyze.length} ticket(s) disparu(s)…`); + analyzeDisappearedInterventions(merged.techs, ghostsToAnalyze, myToken) + .then(() => { + if (!isRefreshAborted(myToken)) { + renderFromData({ + techs: merged.techs, + targetDate: isoDate, + captureTime: Date.now(), + source: "fresh", + lastRefreshKind: activeRefreshButton + }); + writeCache(isoDate, { techs: merged.techs }).catch(() => {}); + } + }) + .catch(err => console.error("[disappear-analysis]", err)); + } + + // 5. Fetch des fiches en arrière-plan UNIQUEMENT pour obtenir : + // - le statut Clôturé/Résolu (pour le ✓ vert et le fond vert) + // - le commentaire technicien (affiché dans le tooltip) + // - le checksum pour ouvrir la fiche (en vrai déjà dans formLink, mais + // on garde la fiche comme source de vérité pour le statut) + // + // v4.1 : fetch séquentiel (1 à la fois) avec cache écrit tous les 5 fiches. + // Voir refreshStatuses() pour les détails. + const needFetch = merged.techs.some(tech => + tech.interventions.some(iv => + iv.type === "AL-Intervention" && !iv.ficheFetched + ) + ); + + // v4.3.2 : avant de lancer le fetch des fiches (lourd, ~460 KB chacune), + // on fait d'abord un batch xhr2 (léger, ~2-5 KB chacun) pour récupérer + // les vraies infos contact/lieu de toutes les interventions en parallèle. + // Comme ça les cartes s'enrichissent en 1-3 secondes au lieu d'attendre + // que l'utilisateur les survole une par une. + if (!isRefreshAborted(myToken)) { + await prefetchAllXhr2(merged.techs, myToken, opts.doStatusRefresh); + } + + if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted(myToken)) { + const tFiches = performance.now(); + const nFiches = merged.techs.flatMap(t=>t.interventions).filter(i=>i.type==="AL-Intervention").length; + console.log(`[load] début fetch des ${nFiches} fiches (statuts)…`); + // forceAll : uniquement si refresh manuel (bouton "rafraichir"). + // À la navigation normale entre dates, on ne refetch que les iv non + // encore enrichies (ficheFetched=false) — ça reprend là où on s'était + // arrêté si un refresh précédent a été interrompu par un changement de + // date. + await refreshStatuses(merged.techs, isoDate, { forceAll: !!opts.doStatusRefresh, myToken }); + console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`); + } + + // 6. Sauvegarder dans le cache (une seule fois, après que tout soit enrichi) + if (!isRefreshAborted(myToken)) { + await writeCache(isoDate, { techs: merged.techs }); + } + + if (!isRefreshAborted(myToken)) { + showRefreshDone(); + console.log(`[load] TOTAL: ${Math.round(performance.now() - t0)} ms`); + } else { + // v4.1.9 : toast "annulé" uniquement si c'était un vrai clic "Arrêter", + // pas un simple changement de date (qui abort l'ancien silencieusement). + const wasExplicitAbort = (abortedToken === myToken); + console.log(`[load] annulé à ${Math.round(performance.now() - t0)} ms (explicite=${wasExplicitAbort})`); + if (wasExplicitAbort) showAbortToast(); + } + } finally { + // Masquer le bouton "Arrêter" uniquement si c'est NOTRE chargement qui + // se termine (pas un chargement postérieur que l'utilisateur aurait lancé + // entre-temps en naviguant ailleurs). + if (currentRefreshToken === myToken) { + showAbortButton(false); + } + cleanupAbortResolver(myToken); + } +} + +async function refreshPlanning(opts = {}) { + if (!state.session) { + await refreshSessionAndLoad(); + return; + } + if (opts.partial) { + // v4.1.13 : _fromPartialBtn empêche loadForDate de reset activeRefreshButton à "total" + await loadForDate(state.currentDate, { doStatusRefresh: false, _fromPartialBtn: true }); + } else { + await loadForDate(state.currentDate, { doStatusRefresh: true }); + } +} + +// ============================================================================ +// Fetch du planning (via background) +// ============================================================================ + +async function fetchPlanningForDate(isoDate) { + setRefreshing(true); + try { + const unixDate = isoToUnixDate(isoDate); + const resp = await sendMessage({ + type: "fetchPlanning", + session: state.session, + unixDate: unixDate + }); + if (!resp.ok) { + // v4.2.5 : si le planning du jour est DÉJÀ rendu (cache), on affiche + // une bannière non bloquante en haut, le cache reste visible. + // Si rien n'est rendu (1er chargement, pas de cache), on affiche + // l'écran plein comme avant. + const hasCacheRendered = + document.getElementById("cards") && + document.getElementById("cards").children.length > 0; + if (resp.error === "no_session" || resp.error === "session_expired") { + state.session = null; + if (hasCacheRendered) { + showSessionExpiredBanner(); + } else { + showSessionNeeded(); + } + } else if (resp.error === "ev_unreachable") { + if (hasCacheRendered) { + showEvUnreachableBanner(); + } else { + showEvUnreachable(); + } + } else { + showError("Erreur de fetch : " + (resp.error || "inconnue")); + } + return null; + } + + // Safeguard (v3.1) : le serveur EasyVista répond parfois 200 avec un + // corps vide — typiquement quand la sesson vient d'être invalidée, ou + // quand il soupçonne du scraping (trop de requêtes parallèles). Dans + // les deux cas, on traite ça comme une sesson expirée : inutile de + // parser (ça ferait "Document is empty") ni de retry en boucle. + if (!resp.xml || resp.xml.length < 20) { + console.warn("[viewer] XML planning vide — session probablement invalide"); + state.session = null; + const hasCacheRendered = + document.getElementById("cards") && + document.getElementById("cards").children.length > 0; + if (hasCacheRendered) { + showSessionExpiredBanner(); + } else { + showSessionNeeded(); + } + return null; + } + + // Parser le HTML complet du planning (contient TOUT : ref, catégorie, + // contact, lieu, description, formLinks, request_id + checksum) + const techs = parsePlanningXml(resp.xml, isoDate); + + return { techs }; + } catch (err) { + showError("Erreur inattendue : " + (err.message || err)); + return null; + } finally { + setRefreshing(false); + } +} + +// ============================================================================ +// Parsing du XML du planning +// ============================================================================ + +/** + * Parse le XML retourné par planning_xhr.php?div=calendar_block. + * Contient les interventions (actions) par technicien, avec : + * - action_id, done_by_id, action_label (parfois juste "AL-Intervention"), + * - start_time / end_time, start_date / end_date, + * - formLink (eventName=formEvent&target=ACTIONID&checksum=...) pour ouvrir l'action, + * - request_id (ID de la fiche SD_REQUEST, utilisé pour ouvrir la fiche). + */ +function parsePlanningXml(xml, isoDate) { + const doc = new DOMParser().parseFromString(xml, "text/xml"); + + const parserError = doc.querySelector("parsererror"); + if (parserError) { + console.warn("Parser error:", parserError.textContent); + } + + const actionNodes = doc.querySelectorAll("action"); + const byTechId = new Map(); + for (const id of Object.keys(TEAM)) { + byTechId.set(id, { id, name: TEAM[id], interventions: [] }); + } + + for (const node of actionNodes) { + const iv = actionNodeToIntervention(node); + if (!iv) continue; + if (!byTechId.has(iv.techId)) continue; + if (!actionCoversDate(iv, isoDate)) continue; + byTechId.get(iv.techId).interventions.push(iv); + } + + for (const tech of byTechId.values()) { + tech.interventions.sort((a, b) => + (a.startTime || "").localeCompare(b.startTime || "") + ); + } + + return [...byTechId.values()]; +} + +function actionNodeToIntervention(node) { + const get = name => node.getAttribute(name) || ""; + + const actionId = get("action_id"); + if (!actionId) return null; + + const actionType = get("action_type"); + const techId = get("done_by_id"); + const label = get("action_label"); + const cssClass = get("Css_Class"); + const startDate = get("start_date"); + const endDate = get("end_date"); + const startTime = get("start_time"); + const endTime = get("end_time"); + const currentDate = get("current_date"); + const formLink = get("formLink"); + const deadline = get("max_resolution_date") || get("max_intervention_date"); + const requestId = get("request_id"); + + // ─── v4 : infos enrichies disponibles directement dans le XML ────────────── + // EasyVista envoie déjà contact/lieu/catégorie dans attr1/attr2/attr3. + // La ref est dans le textContent du nœud (format "SYYMMDD_NNNNN (CM)" ou + // "IYYMMDD_NNNNN (SD)"). Plus besoin de fetcher xhr2 ni la fiche pour ça. + const attr1 = get("attr1"); // contact + const attr2 = get("attr2"); // lieu + const attr3 = get("attr3"); // catégorie complète + const nodeText = (node.textContent || "").trim(); + + // Extraire la ref en priorité du textContent (où elle est complète), sinon + // fallback sur le label. v4.1.9 : pattern générique [SI]\d+_\d+ (plus + // hardcodé sur "2..." qui était pour 2020-2029). + let ref = null; + const refFromText = nodeText.match(/\b([SI]\d{5,8}_\d{4,6})\b/); + if (refFromText) { + ref = refFromText[1]; + } else { + const refFromLabel = label.match(/\b([SI]\d{5,8}_\d{4,6})\b/); + if (refFromLabel) ref = refFromLabel[1]; + } + + // Détection du type "Réservation" : un coordinateur a bloqué un créneau. + // Dans le XML, action_type = "AL-Absence" pour ce genre de créneau, mais + // action_label contient le vrai pattern : + // action_label = "Xxxxx / Créé par : Nom, Prénom" + // Ex: "Ecrans / Créé par : Nom20, Prénom20" + // "Rollout / Créé par : Nom24, Prénom24" + // "Congés / Créé par : ..." → pas une réservation, c'est une absence + // "Maladie / Créé par : ..." → idem + // "Pompier / Créé par : ..." → idem + let effectiveType = actionType; + let reservationLabel = null; + let reservationCreator = null; + const reservationMatch = label.match(/^([^/]+?)\s*\/\s*Créé par\s*:\s*(.+)$/i); + if (reservationMatch) { + const label1 = reservationMatch[1].trim(); + const creator = reservationMatch[2].trim(); + // Les "absences" connues (Congés/Maladie/Pompier) restent des absences + if (/^(cong[ée]s|maladie|pompier)$/i.test(label1)) { + effectiveType = "AL-Absence"; + } else { + // Tout autre label (Ecrans, Rollout, ...) → Réservation + effectiveType = "AL-Reservation"; + reservationLabel = label1; + reservationCreator = creator; + } + } + + // ─── v4 : pré-remplissage immédiat depuis les attributs XML ───────────────── + // On renseigne bulleContact/bulleLieu/categoryLine DÈS la création de l'objet. + // Plus besoin d'attendre xhr2 ou la fiche pour avoir l'affichage de base. + // Seuls restent à fetcher (en arrière-plan, sur fiche) : status. + // Et sur hover (lazy, seulement si l'user survole) : bulleDescription complet. + const isIntervention = effectiveType === "AL-Intervention"; + const bulleContact = isIntervention && attr1 ? attr1 : null; + const bulleLieu = isIntervention && attr2 ? attr2 : null; + const categoryLine = isIntervention && attr3 ? attr3 : null; + + return { + actionId: actionId, + requestId: requestId, + techId: techId, + label: label, + type: effectiveType, // "AL-Intervention" | "AL-Absence" | "AL-Reservation" + originalType: actionType, // type brut (pour debug) + reservationLabel: reservationLabel, // "Ecrans", "Rollout", etc. + reservationCreator: reservationCreator, // "Nom, Prénom" du coordinateur + cssClass: cssClass, + isPompier: /pompier/i.test(label) || /pompier/i.test(actionType), + ref: ref, + startDate: startDate, + endDate: endDate, + startTime: startTime, + endTime: endTime, + currentDate: currentDate, + formLink: formLink, + deadline: deadline, + // v4 : renseignés directement depuis le XML (plus d'attente de xhr2) + bulleContact: bulleContact, + bulleLieu: bulleLieu, + categoryLine: categoryLine, + bulleDescription: null, // reste null, rempli lazy au premier hover (xhr2) + infobulle: null, // reste null, rempli lazy aussi + status: null, // toujours rempli par fetch fiche (en arrière-plan) + // v4 : ficheTarget/Checksum déjà présents dans formLink (extraits à la demande) + ficheTarget: null, + ficheChecksum: null, + ficheFetched: false, + ficheFetchError: null, + xhr2Fetched: false, // lazy : passe à true après le 1er hover + xhr2Fetching: false, // évite les doubles fetchs simultanés + ghost: false + }; +} + +/** + * Parse le body de planning_xhr_2.php?id=ACTIONID (ou similaire). + * Format observé : + * @@DESCRIPTION_S@@...texte complet de l'action...@@DESCRIPTION_E@@ + * @@LABEL_S@@AL-Intervention@@LABEL_E@@ + * @@LAST_S@@Nom, Prénom@@LAST_E@@ + * @@PLANNED_TIME_S@@@@PLANNED_TIME_E@@ + * @@PLANNED_CHANGE_S@@@@PLANNED_CHANGE_E@@ + */ +function parseXhr2Body(body) { + if (!body || typeof body !== "string") return null; + const out = { description: null, label: null, last: null }; + const rxD = /@@DESCRIPTION_S@@([\s\S]*?)@@DESCRIPTION_E@@/; + const rxL = /@@LABEL_S@@([\s\S]*?)@@LABEL_E@@/; + const rxLa = /@@LAST_S@@([\s\S]*?)@@LAST_E@@/; + const md = body.match(rxD); + const ml = body.match(rxL); + const mla = body.match(rxLa); + if (md) out.description = md[1].trim(); + if (ml) out.label = ml[1].trim(); + if (mla) out.last = mla[1].trim(); + return out; +} + +// v4 : fetchBullesForInterventions (fetch xhr2 en masse au chargement) a été +// supprimée. Le contact/lieu/catégorie viennent maintenant directement des +// attributs attr1/attr2/attr3 du calendar_block. Pour le TEXTE complet de +// l'action (Problème/À faire/Matériel/TFS/...), voir ensureBulleDescription() +// qui lazy-load UNIQUEMENT au premier hover de l'intervention. + +function actionCoversDate(iv, isoDate) { + if (!iv.startDate || !iv.endDate) return true; // manque info → on garde + const target = isoToDDMMYYYY(isoDate); + return ddmmyyyyLE(iv.startDate, target) && ddmmyyyyLE(target, iv.endDate); +} + +function ddmmyyyyLE(a, b) { + // Compare deux dates JJ/MM/AAAA + const toNum = s => { + const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + return m ? parseInt(m[3] + m[2] + m[1], 10) : 0; + }; + return toNum(a) <= toNum(b); +} + +// ============================================================================ +// Fusion cache ↔ fresh +// ============================================================================ + +function mergeCacheAndFresh(cached, fresh) { + // fresh.techs : liste des techs avec interventions d'aujourd'hui (depuis EasyVista) + // cached.techs : dernière liste sauvegardée pour ce jour (avec statuts) + // + // Règles v4 : + // - Le fresh APPORTE (depuis le XML calendar_block) : actionId, type, + // startTime/endTime, formLink, ref (textContent), bulleContact (attr1), + // bulleLieu (attr2), categoryLine (attr3), deadline. + // - Le cache APPORTE : status (clôturé/résolu), + // bulleDescription (lazy-load xhr2 au hover) + infobulle, ficheFetched, + // xhr2Fetched. + // - Règle générale : fresh wins sur les champs live, cache wins sur les + // champs enrichis qui ne sont pas dans le fresh. + // - Une interventoin en cache mais plus en fresh → marquée "ghost" + + if (!cached || !cached.techs) { + return { techs: fresh.techs }; + } + + // Indexer le cache par actionId + const cachedByAction = new Map(); + for (const tech of cached.techs) { + for (const iv of tech.interventions || []) { + cachedByAction.set(iv.actionId, iv); + } + } + + const resultTechs = fresh.techs.map(t => ({ ...t, interventions: [] })); + const freshActionIds = new Set(); + + for (const tech of fresh.techs) { + const outTech = resultTechs.find(t => t.id === tech.id); + for (const iv of tech.interventions) { + freshActionIds.add(iv.actionId); + const cachedIv = cachedByAction.get(iv.actionId); + if (cachedIv) { + // On part du cache (qui a les champs enrichis), puis on remplace + // les champs "live" depuis le fresh (horaires, type, formLink). + const merged = { + ...cachedIv, + // Champs live venant du fresh (le planning peut avoir bougé) + techId: iv.techId || cachedIv.techId, + type: iv.type || cachedIv.type, + label: iv.label || cachedIv.label, + cssClass: iv.cssClass || cachedIv.cssClass, + isPompier: iv.isPompier, + startDate: iv.startDate || cachedIv.startDate, + endDate: iv.endDate || cachedIv.endDate, + startTime: iv.startTime || cachedIv.startTime, + endTime: iv.endTime || cachedIv.endTime, + currentDate: iv.currentDate || cachedIv.currentDate, + formLink: iv.formLink || cachedIv.formLink, + deadline: iv.deadline || cachedIv.deadline, + requestId: iv.requestId || cachedIv.requestId, + // v4 : la ref du fresh est maintenant FIABLE (textContent XML), + // on la privilégie sur le cache (inversé vs v3). + ref: iv.ref || cachedIv.ref, + // v4 : categoryLine vient désormais du XML (attr3), on la privilégie. + categoryLine: iv.categoryLine || cachedIv.categoryLine, + // Contact/lieu : fresh est plus à jour (attr1/attr2 du XML) + bulleContact: iv.bulleContact || cachedIv.bulleContact, + bulleLieu: iv.bulleLieu || cachedIv.bulleLieu, + // bulleDescription : on privilégie le cache, qui contient le texte + // lazy-load au hover. Le fresh n'a pas ce texte (null au chargement). + bulleDescription: cachedIv.bulleDescription || iv.bulleDescription, + infobulle: cachedIv.infobulle || iv.infobulle, + xhr2Fetched: cachedIv.xhr2Fetched || iv.xhr2Fetched, + // ghost : on retire (cette intervention est bien là dans le fresh) + ghost: false + }; + outTech.interventions.push(merged); + } else { + outTech.interventions.push(iv); + } + } + } + + // Ajouter les interventions qui sont en cache mais plus en fresh + for (const tech of cached.techs) { + const outTech = resultTechs.find(t => t.id === tech.id); + if (!outTech) continue; + for (const iv of tech.interventions || []) { + if (!freshActionIds.has(iv.actionId)) { + const ghost = { ...iv, ghost: true }; + outTech.interventions.push(ghost); + } + } + // Retrier + outTech.interventions.sort((a, b) => + (a.startTime || "").localeCompare(b.startTime || "") + ); + } + + return { techs: resultTechs }; +} + +// ============================================================================ +// v4.2.5 : analyse des tickets disparus du planning +// ============================================================================ +// +// Pour chaque ticket qui était dans le cache mais n'est plus dans le XML +// fresh, on doit décider s'il faut : +// 1. Le GARDER en vert double ✓✓ → clôturé / résolu dans EasyVista +// 2. Le GARDER en vert simple ✓ → terminé par le tech (commentaire LOGIN:) +// 3. Le RETIRER → déplacé sur un autre jour / annulé / autre +// +// Logique (validée avec l'utilisateur) : +// a) Re-fetch la fiche +// b) Si statut global = CLOS ou RÉSOLU → garder, vert ✓✓ +// c) Sinon parcourir les actions OUVERTES de la fiche : +// - Si action ouverte au nom du tech sur JOUR DIFFÉRENT → retirer (déplacée) +// - Sinon passer à l'étape d +// d) Parcourir les actions FERMÉES au nom du tech : +// - Si une action fermée contient un commentaire tech (pattern `LOGIN: +// commentaire` où LOGIN = alphanumérique 3-12 chars minuscule) → garder, vert ✓ +// - Sinon → retirer +// +// Distinction action ouverte/fermée : +// Observation sur les HTML fournis : dans le JSON timeline de la fiche, +// l'action "AL-Intervention" apparaît SEULEMENT si elle a été complétée +// (fermée). Si elle est toujours ouverte, elle n'est pas dans le timeline. +// Les autres types d'actions ("Ajout d'informations", "Envoi de mail", etc.) +// apparaissent dès leur création. + +// Regex pour détecter un commentaire tech dans le texte d'une action. +// Pattern : début de ligne OU
suivi d'un login court (3-12 caractères +// alphanumériques MINUSCULES) + ":" + espace + texte. +// Exemples qui matchent : "vyjuva: Casque remplacé", "awr: ok". +// Exemples qui NE matchent PAS : +// - "Service : X" (majuscule + pas un login) +// - "Nom2, Prénom2" (contient une virgule, pas un login) +// - "AWR 16/04/26" (pas de deux-points) +// - "Date : vendredi 17.04" (majuscule au début, c'est un champ) +const RX_LOGIN_COMMENTAIRE = /(?:^|\n|)\s*([a-z0-9_]{3,12})\s*:\s+(\S[^\n<]{2,})/im; + +/** + * Extrait toutes les actions d'une fiche en parsant les blocs "rows" du HTML. + * Chaque action a 14 values : + * [2] = Intervenant (ex: "Nom, Prénom" ou "EZV_WS_REST_USER") + * [4] = Type d'action (ex: "AL-Intervention", "Ajout d'informations") + * [8] = Date de création (JJ/MM/AAAA HH:MM:SS) + * [9] = Date de fin + * [11] = Description HTML (contient le texte de l'action + commentaire tech) + * + * Retourne : [ { intervenant, type, dateCreation, dateFin, description }, ... ] + */ +function parseAllActionsFromFicheHtml(html) { + if (!html) return []; + // Décoder : dans le HTML, les JSON imbriqués ont \u0022 pour " et \/ pour / + const decoded = html + .replace(/\\u0022/g, '"') + .replace(/\\\//g, '/'); + + const actions = []; + // Chercher chaque bloc "rows":[...] + const rowsRegex = /"rows":\[/g; + let m; + while ((m = rowsRegex.exec(decoded)) !== null) { + const start = m.index + m[0].length; + // Trouver la fin du array [...] correspondant (balance des crochets) + let j = start; + let depth = 1; + while (j < decoded.length && depth > 0) { + const c = decoded[j]; + if (c === '[') depth++; + else if (c === ']') depth--; + j++; + } + const block = decoded.substring(start, j - 1); + const values = extractValuesFromRowBlock(block); + if (values.length < 12) continue; + // Une "vraie" action a 14 valeurs. On se contente de 12 minimum + // pour avoir au moins la description. + actions.push({ + intervenant: decodeUnicodeEscapes(values[2] || ""), + type: decodeUnicodeEscapes(values[4] || ""), + dateCreation: values[8] || "", + dateFin: values[9] || "", + description: values[11] || "" + }); + } + return actions; +} + +/** + * Extrait les valeurs "value":"..." d'un bloc JSON row, gère les guillemets + * échappés (\"). + */ +function extractValuesFromRowBlock(block) { + const values = []; + let i = 0; + while (i < block.length) { + const mIdx = block.indexOf('"value":"', i); + if (mIdx < 0) break; + const start = mIdx + '"value":"'.length; + let j = start; + while (j < block.length) { + if (block[j] === '\\') { j += 2; continue; } + if (block[j] === '"') break; + j++; + } + values.push(block.substring(start, j)); + i = j + 1; + } + return values; +} + +/** + * Décode les échappements Unicode \u00XX présents dans les valeurs extraites. + */ +function decodeUnicodeEscapes(s) { + if (!s) return s; + return s.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16))); +} + +/** + * Détermine si une action est "fermée" ou "ouverte". + * - Pour AL-Intervention : on cherche sa présence dans le JSON timeline de + * la fiche (via la valeur [13] qui contient un JSON avec "NAME".) Si cette + * action existe dans le JSON, elle est considérée fermée. + * - Pour les autres types : on considère fermée si dateFin est remplie et + * différente de dateCreation (approximation raisonnable observée sur les + * HTML fournis). + * - Actions système (Intervenant = "EZV_WS_REST_USER" ou vide) : ignorées + * dans le matching "action au nom du tech". + * + * Pour notre logique, ce qui compte vraiment : + * - Actions "AL-Intervention" fermées = présentes dans le bloc JSON + * "timeline" de la fiche (pas dans les "rows" HTML, qui les listent toutes) + * + * Plus simplement, je détecte la présence de AL-Intervention dans le HTML + * comme indicateur : si `"NAME":"AL-Intervention"` figure dans le JSON + * timeline, alors l'AL-Intervention est fermée. + */ +function hasClosedAlInterventionInHtml(html) { + if (!html) return false; + // Chercher dans le HTML brut (non décodé) le pattern de timeline + // `\u0022NAME\u0022:\u0022AL-Intervention\u0022` + return /\\u0022NAME\\u0022:\\u0022AL-Intervention\\u0022/.test(html); +} + +/** + * Vérifie si le texte d'une action contient un commentaire tech au format + * `LOGIN: commentaire`. Nettoie d'abord le HTML de la description. + */ +function hasTechCommentInDescription(description) { + if (!description) return false; + // Décoder unicode puis remplacer les
par \n pour faciliter le regex + const txt = decodeUnicodeEscapes(description) + .replace(//gi, '\n') + .replace(/<\/?p[^>]*>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&'); + return RX_LOGIN_COMMENTAIRE.test(txt); +} + +/** + * Normalise un nom "Nom, Prénom" (insensible à la casse, accents ignorés) + * pour comparaison. + */ +function normalizeName(s) { + if (!s) return ""; + return s + .toLowerCase() + .normalize("NFD").replace(/[\u0300-\u036f]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Détermine si une action est au nom du technicien donné. + * Compare l'intervenant de l'action avec le nom du tech (insensible casse/accents). + * Ignore les actions système (EZV_WS_REST_USER, vide). + */ +function actionBelongsToTech(action, techName) { + const interv = normalizeName(action.intervenant); + if (!interv || interv === "ezv_ws_rest_user") return false; + const tech = normalizeName(techName); + if (!tech) return false; + // Le nom du tech dans notre config est souvent "Prénom Nom" alors que + // l'EasyVista affiche "Nom, Prénom". On accepte les deux ordres. + // Simple test : au moins un mot du nom tech (longueur > 2) est dans l'intervenant. + const techParts = tech.split(/[\s,]+/).filter(p => p.length >= 3); + if (techParts.length === 0) return false; + // Exiger que TOUS les mots significatifs du nom tech soient dans l'intervenant + return techParts.every(p => interv.includes(p)); +} + +/** + * Analyse les tickets disparus du planning et décide pour chacun s'il faut + * le garder en vert (terminé tech ou clôturé) ou le retirer. + * + * Modifie directement les tech.interventions en place (retire les ghosts à + * retirer, met à jour les propriétés des ghosts à garder). + */ +async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken) { + // Traiter en parallèle pour rester rapide (max 3 fiches en parallèle) + const concurrency = 3; + const queue = [...ghostsToAnalyze]; + const workers = []; + for (let w = 0; w < concurrency; w++) { + workers.push((async () => { + while (queue.length > 0) { + if (isRefreshAborted(myToken)) return; + const { tech, iv } = queue.shift(); + try { + await analyzeOneDisappearedIv(tech, iv); + } catch (err) { + console.warn("[disappear] analyse échouée pour", iv.actionId, err); + // En cas d'erreur, on garde l'iv visible mais sans marquage spécial + iv._disappearChecking = false; + iv.ghost = false; // on la laisse visible plutôt que perdre de l'info + iv._disappearStatus = "error"; + } + } + })()); + } + await Promise.all(workers); + + // Filtrer les iv qui doivent être retirées définitivement + for (const tech of techs) { + tech.interventions = tech.interventions.filter(iv => !iv._disappearRemove); + } +} + +/** + * Analyse une seule intervention disparue. + * Met à jour iv._disappearStatus ("closed" | "terminated" | "moved" | "cancelled") + * et iv._disappearRemove (true si à retirer). + */ +async function analyzeOneDisappearedIv(tech, iv) { + // v4.3.0 : court-circuit pour les réservations (AL-Reservation). Elles n'ont + // pas de notion de "terminé par tech" ni de statut clos/résolu à afficher + // (pas de fiche à ouvrir). Quand une réservation disparaît du planning, + // elle est juste retirée — inutile de re-fetcher sa fiche. + if (iv.type === "AL-Reservation") { + iv._disappearChecking = false; + iv._disappearStatus = "cancelled"; + iv._disappearRemove = true; + return; + } + + // Étape 1 : re-fetch la fiche + const resp = await sendMessage({ + type: "fetchFiche", + formLink: iv.formLink + }); + if (!resp || !resp.ok) { + // En cas d'erreur fetch : on garde visible (pas de décision) + iv._disappearChecking = false; + iv._disappearStatus = "error"; + iv.ghost = false; + return; + } + const html = resp.html; + + // Étape 2 : statut global de la fiche + const ficheData = parseFicheHtml(html); + const status = ficheData.status || iv.status || null; + iv.status = status; // garder à jour + + if (isClosedStatus(status) || isResolvedStatus(status)) { + // CAS 1 : clôturé / résolu → garder, vert ✓✓ (double check) + iv._disappearChecking = false; + iv._disappearStatus = "closed"; + iv._disappearRemove = false; + iv.ghost = false; + return; + } + + // Étape 3 : parser toutes les actions de la fiche + const actions = parseAllActionsFromFicheHtml(html); + + // Identifier les actions AL-Intervention au nom du tech. + // + // Pour savoir si une AL-Intervention spécifique est fermée ou ouverte, + // on utilise l'indicateur global `hasClosedAlInterventionInHtml` : + // - SI la fiche contient "AL-Intervention" dans le JSON timeline + // → l'action AL-Intervention est fermée (terminée par le tech) + // - SINON → elle est encore ouverte + const alActionsForTech = actions.filter(a => + a.type === "AL-Intervention" && actionBelongsToTech(a, tech.name || tech.label || "") + ); + const hasClosedAl = hasClosedAlInterventionInHtml(html); + + // CAS 2 : action AL-Intervention encore ouverte au nom du tech + if (alActionsForTech.length > 0 && !hasClosedAl) { + // Vérifier sur quel jour elle est planifiée maintenant. Si on ne peut + // pas déterminer, on retire par prudence (elle a été bougée, sinon + // elle serait encore dans le fresh). + // On regarde si une action ouverte référence explicitement notre jour. + // Simple heuristique : on regarde les dates dans les descriptions. + iv._disappearChecking = false; + iv._disappearStatus = "moved"; + iv._disappearRemove = true; // retirer (déplacée) + return; + } + + // CAS 3 : action AL-Intervention FERMÉE au nom du tech → chercher un + // commentaire tech dans les descriptions des actions du tech. + if (alActionsForTech.length > 0 && hasClosedAl) { + const anyHasComment = alActionsForTech.some(a => + hasTechCommentInDescription(a.description) + ); + if (anyHasComment) { + // Terminée par le tech → garder, vert ✓ simple + iv._disappearChecking = false; + iv._disappearStatus = "terminated"; + iv._disappearRemove = false; + iv.ghost = false; + return; + } + // Pas de commentaire détecté → retirer (annulée) + iv._disappearChecking = false; + iv._disappearStatus = "cancelled"; + iv._disappearRemove = true; + return; + } + + // CAS 4 : aucune action AL-Intervention au nom du tech dans la fiche → + // vérifier si une action quelconque au nom du tech existe avec commentaire. + // Si oui, on considère que le tech a travaillé dessus. + const anyActionForTech = actions.filter(a => + actionBelongsToTech(a, tech.name || tech.label || "") + ); + const anyHasComment = anyActionForTech.some(a => + hasTechCommentInDescription(a.description) + ); + if (anyHasComment) { + iv._disappearChecking = false; + iv._disappearStatus = "terminated"; + iv._disappearRemove = false; + iv.ghost = false; + return; + } + + // CAS 5 (défaut) : aucune trace claire du tech → retirer + iv._disappearChecking = false; + iv._disappearStatus = "cancelled"; + iv._disappearRemove = true; +} + +// ============================================================================ +// Fetch des fiches individuelles (pour obtenir le statut et les détails) +// ============================================================================ + +async function refreshStatuses(techs, isoDate, opts = {}) { + const forceAll = !!opts.forceAll; + const myToken = opts.myToken; + + // Construire la liste des interventions à fetcher, dans l'ordre de priorité : + // 1. Interventions du (des) pompier(s) en premier + // 2. Puis les autres techs par ordre alphabétique du nom de famille + // 3. (Les absents n'ont pas d'interventions à fetcher) + const sortedTechs = [...techs].sort((a, b) => compareTechs(a, b, isoDate)); + + const toFetch = []; + for (const tech of sortedTechs) { + for (const iv of tech.interventions) { + if (iv.type !== "AL-Intervention") continue; + if (!iv.formLink) continue; + // v4 : on skip les interventions déjà closes/résolues dont la fiche a + // déjà été fetchée une fois (statut + commentaire tech déjà récupérés). + // Le statut "Clôturé" ne change plus une fois atteint, pas la peine de + // refetcher à chaque refresh. + const statusClosed = isClosedStatus(iv.status) || isResolvedStatus(iv.status); + if (statusClosed && iv.ficheFetched) continue; + // v4.1.7 : pause/reprise par date. Sans forceAll (= chargement normal + // au retour sur une date), on skip les iv déjà enrichies (ficheFetched) + // pour ne pas refetcher inutilement. Un clic sur "rafraichir" active + // forceAll, ce qui refetche les non-closes même si déjà enrichies (pour + // voir passer les statuts "En cours" → "Exécution" → "Clôturé"). + if (!forceAll && iv.ficheFetched) continue; + toFetch.push(iv); + } + } + + if (toFetch.length === 0) return; + + setRefreshing(true); + + // v4.1.7 : barre de progression visible uniquement si on est en train de + // rafraichir la date actuellement affichée. Si l'user change de date + // pdt le refresh, isRefreshAborted() deviendra true et on sortira. + const showBar = (state.currentDate === isoDate); + if (showBar) { + updateProgressBar(0, toFetch.length); + showProgressBar(); + } + + try { + // v4.1 : SÉQUENTIEL (1 fiche à la fois) au lieu de 5 workers en parallèle. + // Raisons : + // - Le serveur EasyVista est lent et sérialise les requêtes de toute façon + // - L'abort devient instantané : un seul fetch en vol, si l'user change + // de date, le prochain await sendMessage() n'est même pas lancé + // - Plus de races de DOM (5 workers qui écrivaient la même carte en + // concurrence, ça générait des artefacts visuels) + // + // Cache incrémental : on sauve le cache toutes les CACHE_WRITE_EVERY fiches + // ET à la fin. Comme ça si l'user change de date en cours, on ne perd pas + // les statuts déjà récupérés. + const CACHE_WRITE_EVERY = 5; + let sinceLastCacheWrite = 0; + + for (let i = 0; i < toFetch.length; i++) { + if (isRefreshAborted(myToken)) break; + await fetchAndUpdateIntervention(toFetch[i], myToken); + sinceLastCacheWrite++; + + // Progression — uniquement si la barre concerne la date visible + if (showBar && state.currentDate === isoDate) { + updateProgressBar(i + 1, toFetch.length); + } + + // Sauvegarde périodique du cache pdt le fetch + if (sinceLastCacheWrite >= CACHE_WRITE_EVERY) { + try { + await writeCache(isoDate, { techs }); + sinceLastCacheWrite = 0; + } catch (err) { + console.warn("[cache] écriture intermédiaire échouée:", err); + } + } + } + + // Si annulé : on laisse les résultats partiels dans le DOM et on sauve + // quand même ce qu'on a déjà récupéré (cache incrémental). + if (isRefreshAborted(myToken)) { + try { await writeCache(isoDate, { techs }); } catch {} + return; + } + + // Résoudre le sort des ghosts + for (const tech of techs) { + tech.interventions = tech.interventions.filter(iv => { + if (!iv.ghost) return true; + if (CANCELLED_STATUS.includes(iv.status)) return false; + return true; + }); + } + + // Sauvegarde finale du cache + await writeCache(isoDate, { techs }); + + // Re-rendre pour afficher les mises à jour finales (ghosts filtrés, + // tri à jour, etc.). updateInterventionRow a déjà patché chaque ligne, + // mais ce re-render final garantit la cohérence globale. + renderFromData({ + techs, + targetDate: isoDate, + captureTime: Date.now(), + source: "fresh+statuses", + lastRefreshKind: activeRefreshButton // v4.1.20 + }); + } finally { + setRefreshing(false); + if (showBar) hideProgressBar(); + } +} + +async function fetchAndUpdateIntervention(iv, myToken) { + try { + // Bail-out coopératif : si l'utilisateur a cliqué sur "Arrêter" ou a + // changé de date, on ne fetch pas cette intervention. + if (isRefreshAborted(myToken)) { + iv.ficheFetched = true; + iv.ficheFetchError = "aborted"; + return; + } + + // v4.1.2 : pour chaque interventoin on fait xhr2 PUIS fiche. + // - xhr2 : récupère les VRAIES infos contact/lieu (attr1/attr2 du XML + // sont parfois erronées si le tech a corrigé après planif). + // On met à jour la carte tout de suite avec les vraies infos. + // - fiche : récupère statut Clôturé/Résolu + commentaire tech + checksum + // valide pour l'ouverture au clic. + + // ─── Étape 1 : xhr2 (rapide, ~400 o) ──────────────────────────────── + if (!iv.xhr2Fetched && !isRefreshAborted(myToken)) { + try { + const xhr2Resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId }); + // v4.1.9 : si on a été aborté pendant l'attente, ne PAS appliquer + // le résultat au DOM (on ne doit plus toucher à une ligne qui + // appartient à la date précédente). + if (isRefreshAborted(myToken)) return; + if (xhr2Resp && xhr2Resp.ok) { + const parsed = parseXhr2Body(xhr2Resp.body); + if (parsed) { + if (parsed.description) { + iv.bulleDescription = parsed.description; + const infob = parseActionText(parsed.description); + if (infob) iv.infobulle = infob; + } + if (parsed.label) iv.label = parsed.label; + iv.xhr2Fetched = true; + // Met à jour la carte avec les vraies infos xhr2 + updateInterventionRow(iv); + } + } + } catch (err) { + console.warn("[xhr2] erreur iv", iv.actionId, err); + } + } + + if (isRefreshAborted(myToken)) return; + + // ─── Étape 2 : fetch fiche (statut + commentaire + checksum) ────────── + // Retry léger : une erreur réseau ponctuelle (timeout, 5xx) ne doit pas + // perdre la ligne. 1 seul retry après 400ms. Session expirée n'est PAS + // retryée (ça ne passera pas mieux la 2e fois). + let ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink }); + if (isRefreshAborted(myToken)) return; + if (!ficheResp.ok && ficheResp.error !== "session_expired" && !isRefreshAborted(myToken)) { + await new Promise(r => setTimeout(r, 400)); + if (!isRefreshAborted(myToken)) { + ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink }); + } + } + if (isRefreshAborted(myToken)) return; + + if (!ficheResp.ok) { + iv.ficheFetched = true; + iv.ficheFetchError = ficheResp.error || "fetch_failed"; + if (ficheResp.error === "session_expired") { + state.session = null; + // v4.1.12 : afficher immédiatement la bannière de session expirée + // pour que l'utilisateur voie pourquoi le fetch s'arrête. + showSessionExpiredBanner(); + } + return; + } + + const fiche = parseFicheHtml(ficheResp.html); + iv.status = fiche.status; + // v4.2.5 : on retire définitivement le champ commentaireTech (obsolète + // depuis qu'on récupère l'action complète via l'API timeline). + delete iv.commentaireTech; + // Fallback : si l'XML n'avait pas la ref (rare, mais possible pour des + // actions hors-standard), on prend celle de la fiche. + if (fiche.rfc && !iv.ref) { + iv.ref = fiche.rfc; + } + // v4.1.18 : persister le formSenderGuid sur l'iv pour qu'il soit + // disponible au clic pour ouvrir la fiche avec le bon sender (S vs I). + if (fiche.formSenderGuid) { + iv.formSenderGuid = fiche.formSenderGuid; + } + + // ─── Étape 3 : API timeline → texte complet de l'action ───────────── + // Le HTML brut de la fiche ne contient PAS les valeurs d'action (elles + // sont injectées côté client par Angular via un apel REST). On appelle + // donc le même endpoint REST qu'Angular pour récupérer la description + // complète, match par ACTION_ID === iv.actionId (fiable, numérique). + // + // Ce texte REMPLACE le texte xhr2 tronqué dans le tooltip. + // Si l'appel échoue ou ne trouve rien, on garde le fallback xhr2 dans + // iv.bulleDescription (déjà stocké à l'étape 1). + if (fiche.formId && fiche.formChecksum && fiche.formSenderGuid && + iv.actionId && !isRefreshAborted(myToken)) { + try { + const tlResp = await sendMessage({ + type: "fetchTimelineApi", + guid: fiche.formSenderGuid, + formId: fiche.formId, + formChecksum: fiche.formChecksum + }); + if (isRefreshAborted(myToken)) return; + if (tlResp && tlResp.ok) { + const fullText = parseTimelineJsonForAction(tlResp.body, iv.actionId); + if (fullText) { + iv.ficheActionText = fullText; + } + } else if (tlResp && tlResp.error === "session_expired") { + state.session = null; + showSessionExpiredBanner(); + } + } catch (err) { + console.warn("[timeline] erreur iv", iv.actionId, err); + } + } + + // ─── Extraire le checksum pour ouvrir la fiche ───────────────────── + // STRICTEMENT IDENTIQUE à v4 originale (qui fonctionne pour l'ouverture) : + // - On n'extrait QUE si ficheChecksum n'est pas déjà là (une fois trouvé + // c'est bon, pas la peine de ré-extraire à chaque refresh et risquer + // de l'écraser avec une mauvaise valeur). + // - Pas de "Tentative 3" ultime : elle peut matcher le checksum du form + // principal qui n'est PAS le bon pour l'action → casse l'ouverture. + if (iv.requestId && !iv.ficheChecksum) { + // Tentative 1 : target=ID&checksum=... (pattern le plus courant) + const rx1 = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`); + const m1 = ficheResp.html.match(rx1); + if (m1) { + iv.ficheTarget = iv.requestId; + iv.ficheChecksum = m1[1]; + } else { + // Tentative 2 : JSON formData + const rx2a = new RegExp(`"id"\\s*:\\s*"${iv.requestId}"[\\s\\S]{0,200}?"checksum"\\s*:\\s*"([a-f0-9]{40})"`); + const m2a = ficheResp.html.match(rx2a); + if (m2a) { + iv.ficheTarget = iv.requestId; + iv.ficheChecksum = m2a[1]; + } else { + const rx2b = new RegExp(`"checksum"\\s*:\\s*"([a-f0-9]{40})"[\\s\\S]{0,200}?"id"\\s*:\\s*"${iv.requestId}"`); + const m2b = ficheResp.html.match(rx2b); + if (m2b) { + iv.ficheTarget = iv.requestId; + iv.ficheChecksum = m2b[1]; + } + } + } + } + iv.ficheFetched = true; + + // Rendu incrémental : mettre à jour la ligne dans le DOM immédiatement + // (statut clos → fond vert + ✓, commentaire tech dans le tooltip). + // v4.1.9 : ne touche au DOM que si on est toujours sur la même date + // qui a été demandée initialement (sinon on corromprait la nouvelle vue). + if (!isRefreshAborted(myToken)) { + updateInterventionRow(iv); + } + } catch (err) { + iv.ficheFetched = true; + iv.ficheFetchError = String(err); + console.warn("fetchAndUpdate error:", err); + } +} + +/** + * v4 : Lazy-load du texte d'action détaillé au premier survol d'une intervention. + * + * Le calendar_block nous donne déjà contact/lieu/catégorie via attr1/attr2/attr3 + * (planification initiale), mais pas le TEXTE COMPLET de l'action (Problème/ + * À faire/Matériel/TFS/...) et surtout pas les VRAIES infos à jour : un tech + * peut avoir mis à jour le contact ou le lieu après la planification initiale, + * et ces vraies infos ne sont PAS dans attr1/attr2. + * + * Ce texte vient de planning_xhr_2.php. On le fetch à la demande (premier hover) + * pour ne pas surcharger le serveur au chargement initial. + * + * v4.1.2 : quand les infos arrivent, on MET À JOUR la carte car ces infos + * (venant du texte d'action validé par le tech) sont plus fiables que + * attr1/attr2 (planification initiale parfois erronée). + */ +// v4.3.2 : pré-fetch de tous les xhr2 en parallèle (batch). +// Objectif : avoir les VRAIES infos contact/lieu pour toutes les interventions +// AVANT que l'utilisateur se mette à les survoler. Comme le xhr2 est léger +// (2-5 KB), on peut en faire plusieurs en parallèle sans écrouler EasyVista. +// +// Params : +// techs : liste des techs avec leurs interventions +// myToken : jeton d'annulation (si l'user change de date, on s'arrête) +// forceAll : si true, re-fait le xhr2 même pour les inter déjà xhr2Fetched +// (utilisé par "Tout recharger") +async function prefetchAllXhr2(techs, myToken, forceAll) { + if (!techs) return; + // Lister les iv qui ont besoin d'un xhr2 + const needed = []; + for (const tech of techs) { + for (const iv of tech.interventions || []) { + if (iv.type !== "AL-Intervention") continue; + if (!iv.actionId || iv.ghost) continue; + if (iv.xhr2Fetching) continue; + if (iv.xhr2Fetched && !forceAll) continue; + needed.push(iv); + } + } + if (needed.length === 0) return; + + console.log(`[load] pré-fetch xhr2 batch : ${needed.length} interventoin(s)…`); + const t0 = performance.now(); + + // Si forceAll, reset le flag pour que ensureBulleDescription re-fetch + if (forceAll) { + for (const iv of needed) iv.xhr2Fetched = false; + } + + // Batch en parallèle avec concurrency limitée (6) — assez rapide, pas trop + // aggressif sur EasyVista. + const concurrency = 6; + const queue = [...needed]; + const workers = []; + for (let w = 0; w < concurrency; w++) { + workers.push((async () => { + while (queue.length > 0) { + if (isRefreshAborted(myToken)) return; + const iv = queue.shift(); + try { + await ensureBulleDescription(iv); + } catch (err) { + console.warn("[prefetch xhr2] iv", iv.actionId, err); + } + } + })()); + } + await Promise.all(workers); + console.log(`[load] pré-fetch xhr2 fini en ${Math.round(performance.now() - t0)} ms`); +} + +async function ensureBulleDescription(iv) { + // Déjà chargé : rien à faire + if (iv.xhr2Fetched) return true; + // Fetch déjà en cours (évite les races si l'utilisateur survole plusieurs fois) + if (iv.xhr2Fetching) return false; + // Pas applicable (réservation, absence, ghost, ou pas d'actionId) + if (iv.type !== "AL-Intervention") return false; + if (!iv.actionId || iv.ghost) return false; + + iv.xhr2Fetching = true; + try { + const resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId }); + if (!resp || !resp.ok) return false; + const parsed = parseXhr2Body(resp.body); + if (!parsed) return false; + + if (parsed.description) { + iv.bulleDescription = parsed.description; + const infob = parseActionText(parsed.description); + if (infob) { + iv.infobulle = infob; + } + } + if (parsed.label) iv.label = parsed.label; + iv.xhr2Fetched = true; + + // Mettre à jour la carte : lieu/contact du xhr2 sont les VRAIES infos à + // jour (le tech les a peut-être corrigées après la planification initiale). + updateInterventionRow(iv); + return true; + } catch (err) { + console.warn("[xhr2 lazy] erreur iv", iv.actionId, err); + return false; + } finally { + iv.xhr2Fetching = false; + } +} + +function isClosedStatus(s) { + return !!s && CLOSED_STATUS.some(x => s.includes(x)); +} +function isResolvedStatus(s) { + return !!s && RESOLVED_STATUS.some(x => s.includes(x)); +} +function isCancelledStatus(s) { + return !!s && CANCELLED_STATUS.some(x => s.includes(x)); +} + +// ============================================================================ +// Parsing d'une fiche individuelle (HTML) +// ============================================================================ +// v4 : simplifié. On ne cherche plus dans la fiche que : +// - le statut Clôturé/Résolu (pour le ✓ vert) +// - le commentaire technicien (affiché dans le tooltip) +// - la ref RFC_NUMBER (utilisée seulement en fallback, si le XML n'avait pas) +// Les autres extractions (categoryLine, intervenant, actionDescription) sont +// supprimées car ces infos viennent maintenant du XML attr1/attr2/attr3 ou du +// lazy-load xhr2 au hover. + +/** + * Parse le HTML brut d'une fiche EasyVista (rendu serveur, ~460 Ko, NON hydraté + * par Angular donc ne contient PAS les valeurs d'actions — celles-ci sont + * chargées séparément via l'API timeline). + * + * Rôle : extraire les champs nécessaires : + * - status : STATUS_FR (affichage ✓ et fond vert si clos) + * - rfc : RFC_NUMBER (fallback si pas dans XML) + * - formId : id numérique du form (SD_REQUEST pour S... ou incident) + * - formChecksum : checksum du form (pour appel API timeline) + * - formSenderGuid : v4.1.9 — GUID du form (différent pour incident I... + * vs demande S...). Extrait dynamiquement depuis les + * liens target=FORM_ID&checksum=...&sender={GUID} du + * HTML lui-même. Pour les demandes S → C99ECD05..., pour + * les incidents I → 07ED9C68... (ou autre selon config). + */ +function parseFicheHtml(html) { + const out = { + status: null, + rfc: null, + formId: null, + formChecksum: null, + formSenderGuid: null + }; + + // STATUS_FR (valeur parfois encodée en \u00XX) + let m = html.match(/"dbFieldName"\s*:\s*"STATUS_FR"[^}]*?"value"\s*:\s*"([^"]{2,30})"/); + if (m) out.status = decodeJsonString(m[1]); + + // RFC_NUMBER (fallback au cas où le XML n'aurait pas la ref) + m = html.match(/"dbFieldName"\s*:\s*"RFC_NUMBER"[^}]*?"value"\s*:\s*"([^"]{5,30})"/); + if (m) out.rfc = m[1]; + + // formData.form.{id,checksum} : indispensable pour l'API timeline. + // On matche dans les deux ordres possibles. + m = html.match(/var formData\s*=\s*\{"form":\{[^}]*?"checksum":"([a-f0-9]{40})"[^}]*?"id":"(\d+)"/); + if (m) { + out.formChecksum = m[1]; + out.formId = m[2]; + } else { + m = html.match(/var formData\s*=\s*\{"form":\{[^}]*?"id":"(\d+)"[^}]*?"checksum":"([a-f0-9]{40})"/); + if (m) { + out.formId = m[1]; + out.formChecksum = m[2]; + } + } + + // v4.1.9 : déduire le GUID du form. On cherche dans le HTML un lien qui + // référence notre formId (target=FORM_ID...) avec un sender. C'est le GUID + // du form principal utilisé pour l'API timeline : + // - demande S... → {C99ECD05-3D48-4C62-ABF0-66292053AED6} + // - incident I... → {07ED9C68-6172-48EA-8A58-90912B0A283E} + // v4.1.10 (fix) : regex robuste qui accepte &, &, et parcourt jusqu'à + // 300 chars entre target=ID et sender= (au lieu de stopper au 1er "/'/espace + // ce qui peut échouer sur certains HTML). + if (out.formId) { + const rx = new RegExp( + `target=${out.formId}(?:&(?:amp;)?\\w+=[^&"'\\s<>]*){0,10}?&(?:amp;)?sender=(%7B[A-F0-9\\-]{36}%7D)`, + "i" + ); + const sm = html.match(rx); + if (sm) { + out.formSenderGuid = sm[1]; // garder encodé (déjà prêt pour URL) + } else { + // Fallback : chercher le GUID le plus fréquent associé à notre formId + // dans tout le HTML (tolérant à n'importe quelle séquence entre les 2). + const rxLoose = new RegExp( + `target=${out.formId}[\\s\\S]{0,300}?sender=(%7B[A-F0-9\\-]{36}%7D)`, + "gi" + ); + const counts = new Map(); + let lm; + while ((lm = rxLoose.exec(html)) !== null) { + counts.set(lm[1], (counts.get(lm[1]) || 0) + 1); + } + // Prendre le plus fréquent + let best = null; + let bestCount = 0; + for (const [guid, c] of counts) { + if (c > bestCount) { best = guid; bestCount = c; } + } + if (best) out.formSenderGuid = best; + } + + // v4.1.10 (fix définitif) : si toujours pas trouvé, fallback par défaut + // sur le GUID des demandes S... (le plus courant). Pour les rares + // incidents I... où le HTML brut n'aurait aucun lien target=FORM_ID, le + // timeline ne sera pas chargé mais le reste fonctionne. + if (!out.formSenderGuid && out.rfc) { + if (/^S/i.test(out.rfc)) { + out.formSenderGuid = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D"; + } else if (/^I/i.test(out.rfc)) { + out.formSenderGuid = "%7B07ED9C68-6172-48EA-8A58-90912B0A283E%7D"; + } + } + } + + return out; +} + +/** + * Parse le JSON renvoyé par /api/v1/internal/forms/{GUID}/timeline et en + * extrait le texte de description complet pour UNE action donnée. + * + * Structure du JSON : + * { data: { data: { + * columns: [...13 cols], + * values: [ ← 1 entrée par action dans la fiche + * { rows: [ + * {value:"..."}, // [0..10] statut, groupe, dates, etc. + * {value:"Date : ... Heure : ... Lieu : ..."}, // [11] DESCRIPTION ⭐ + * {value:""}, + * {value:"{\"ACTION_ID\":\"57700033\",...}"} // [13] JSON stringifié + * ] } + * ] }}} + * + * On cherche l'action dont rows[13].ACTION_ID === actionId ; si trouvée, on + * retourne rows[11] nettoyé (br→\n, entités décodées) ; sinon null. + */ +function parseTimelineJsonForAction(jsonText, actionId) { + if (!jsonText || !actionId) return null; + let data; + try { + data = JSON.parse(jsonText); + } catch (e) { + console.warn("[timeline] JSON parse failed:", e); + return null; + } + + const values = data?.data?.data?.values; + if (!Array.isArray(values)) return null; + + const targetId = String(actionId); + + for (const entry of values) { + const rows = entry?.rows; + if (!Array.isArray(rows) || rows.length < 14) continue; + + // rows[13] = JSON stringifié qui contient ACTION_ID + const extraRaw = rows[13]?.value; + if (!extraRaw || typeof extraRaw !== "string") continue; + + let extra; + try { + extra = JSON.parse(extraRaw); + } catch { + continue; + } + + if (String(extra.ACTION_ID) !== targetId) continue; + + // Trouvé : extraire la description (rows[11]) et la nettoyer. + const rawDesc = rows[11]?.value || extra["AM_ACTION.DESCRIPTION"] || ""; + const cleaned = cleanHtmlBlock(rawDesc); + return cleaned || null; + } + + return null; +} + +/** + * Nettoie un bloc HTML pour obtenir du texte brut lisible. + * -
(avec ou sans attributs) → \n + * - entités HTML décodées (  > etc.) + * - tags HTML restants supprimés + * - espaces multiples compactés + */ +function cleanHtmlBlock(html) { + if (!html) return ""; + let s = html; + //
,
,
,
→ \n + s = s.replace(/]*>/gi, "\n"); + // Entités HTML + s = s.replace(/ /g, " ") + .replace(/>/g, ">") + .replace(/</g, "<") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/&/g, "&") + .replace(/\u200b/g, ""); // zero-width space + // Tags HTML restants + s = s.replace(/<[^>]+>/g, ""); + // Espaces compactés, lignes trimmed, lignes vides retirées + s = s.split("\n").map(l => l.trim().replace(/[ \t]+/g, " ")).filter(Boolean).join("\n"); + return s; +} + +function decodeJsonString(s) { + return s + .replace(/\\r/g, "") + .replace(/\\n/g, "\n") + .replace(/\\t/g, "\t") + .replace(/\\\//g, "/") + .replace(/\\"/g, '"') + .replace(/\\\\/g, "\\") + .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => { + try { return String.fromCharCode(parseInt(hex, 16)); } + catch { return _; } + }); +} + +/** + * Parse le texte d'une action au format : + * Date : lundi 20.04 Heure : matin + * Lieu : Ville1/Rue1 1 + * Service : Service1/... + * Contact : Nom1, Prénom1 +41000000001 + * ... + * + * → renvoie un objet { date, heure, lieu, service, contact, etage, bureau, + * probleme, aFaire, tfsAncien, tfsNouveau, materiel, dateProposee, autres } + */ +function parseActionText(text) { + if (!text) return null; + const out = { _raw: text }; + // v4.2 : on track toutes les occurrences de "Contact" / "Personne de contact" + // pour détecter l'anomalie (les 2 présents = situation suspecte). + const contactOccurrences = []; // { kind: "contact"|"personne", value: string } + // Pré-filtrer les lignes "Date proposée par ..." : on NE prend PAS ce champ + // nulle part (ni en infobulle.dateProposee, ni dans autres). + const lines = text.split(/\n+/) + .map(l => l.trim()) + .filter(Boolean) + .filter(l => !/^\s*date\s+propos[ée]e\s+par\b/i.test(l)); + const labelMap = { + "date": "date", + "heure": "heure", + "lieu": "lieu", + "service": "service", + "contact": "contact", + "bénéficiaire": "beneficiaire", + "beneficiaire": "beneficiaire", + "étage": "etage", + "etage": "etage", + "bureau": "bureau", + "problème": "probleme", + "probleme": "probleme", + "a faire": "aFaire", + "à faire": "aFaire", + "matériel": "materiel", + "materiel": "materiel", + "tfs ancien poste": "tfsAncien", + "tfs nouveau poste": "tfsNouveau" + }; + + const autres = []; + for (const line of lines) { + // Si la ligne CONTIENT "Date proposée par ..." à l'intérieur (pas juste au + // début), on coupe cette partie-là avant de parser le reste. + let cleanLine = line.replace(/\bdate\s+propos[ée]e\s+par\s+(?:le\s+|la\s+)?contact\s*[:?]\s*\S+.*$/i, "").trim(); + if (!cleanLine) continue; + + // v4.2 : on détecte aussi "Personne de contact..." (spécifique à la demande + // / sur site / de l'entité quittée / interne / etc.). On la marque comme + // un 2e candidat possible pour le contact affiché. + const rxPersonne = /Personne\s+de\s+contact(?:\s+(?:sur\s+site|sp[ée]cifique[^:]*|de\s+l[''`]?entit[ée][^:]*|interne[^:]*))?\s*:\s*/gi; + let pm; + while ((pm = rxPersonne.exec(cleanLine)) !== null) { + // Valeur = jusqu'au prochain label connu OU fin de ligne + const after = cleanLine.substring(pm.index + pm[0].length); + const stop = after.search(/\b(Date|Heure|Lieu|Service|Contact|B[ée]n[ée]ficiaire|[ÉE]tage|Bureau|Probl[èe]me|A\s*faire|À\s*faire|Mat[ée]riel|TFS|Personne\s+de\s+contact|Num[ée]ro\s+de\s+t[ée]l[ée]phone)\s*:/i); + const val = (stop >= 0 ? after.substring(0, stop) : after).trim() + .replace(/[,;]+$/, "").trim(); + if (val) { + contactOccurrences.push({ kind: "personne", value: val }); + } + } + + // "Date : lundi 20.04 Heure : matin" → split en plusieurs paires + const markers = []; + // v4.2 : on ajoute un lookbehind négatif (?= 2) { + out.contactAnomalie = true; + // On prend quand même le 1er "contact" pur (pas "personne") si possible + const firstReal = contactOccurrences.find(x => x.kind === "contact"); + out.contact = (firstReal || contactOccurrences[0]).value; + } + + if (autres.length) out.autres = autres.join("\n"); + return out; +} + +// ============================================================================ +// Rendu général +// ============================================================================ + +// Compteur de fetches en cours. La flèche tourne tant que ce compteur > 0. +// On le maintient manuellement au lieu d'un booléen pour gérer correctement +// les appels imbriqués (loadForDate + refreshStatuses en parallèle). +let refreshCounter = 0; +// Timer pour effacer le ✓ vert après 5 s +let refreshDoneTimer = null; + +// v4.1.13 : quel bouton doit tourner pendant le refresh en cours. +// Valeurs : "total" (par défaut / chargement auto), "partial", ou "xml_only". +let activeRefreshButton = "total"; + +function setActiveRefreshButton(kind) { + activeRefreshButton = kind || "total"; + // v4.1.20 : si le bouton Arrêter est affiché, le repositionner selon + // le nouveau type de refresh actif. Sinon rien à faire (il prendra sa + // position au prochain showAbortButton(true)). + positionAbortButton(); +} + +// v4.1.20 : place le bouton Arrêter à sa position correcte selon +// activeRefreshButton. Fonction idempotente, sûre à appeler plusieurs fois. +function positionAbortButton() { + const btn = document.getElementById("abort-btn"); + if (!btn) return; + const partialBtn = document.getElementById("refresh-partial-btn"); + const totalBtn = document.getElementById("refresh-btn"); + if (!partialBtn || !totalBtn) return; + if (activeRefreshButton === "partial") { + // Entre Actualiser (partial) et Tout recharger (total) + if (btn.previousElementSibling !== partialBtn) { + totalBtn.parentNode.insertBefore(btn, totalBtn); + } + } else { + // Après Tout recharger + if (totalBtn.nextSibling !== btn) { + totalBtn.parentNode.insertBefore(btn, totalBtn.nextSibling); + } + } +} + +function setRefreshing(on) { + const iconTotal = document.getElementById("refresh-icon"); + const iconPartial = document.getElementById("refresh-partial-icon"); + // Quel icône doit tourner ? Seulement celui correspondant au bouton + // qui a lancé le refresh (ou "total" par défaut). + const targetIcon = (activeRefreshButton === "partial") ? iconPartial : iconTotal; + if (on) { + refreshCounter++; + if (targetIcon) targetIcon.classList.add("spinning"); + clearCheckMark(); + // Afficher "rafraichissement en cours…" si on n'a pas déjà les données + updateCaptureInfoText(); + } else { + refreshCounter = Math.max(0, refreshCounter - 1); + if (refreshCounter === 0) { + // Arrêt : stopper les deux icônes au cas où + if (iconTotal) iconTotal.classList.remove("spinning"); + if (iconPartial) iconPartial.classList.remove("spinning"); + } + updateCaptureInfoText(); + } +} + +// Force le rafraichissement du texte "MAJ HH:MM" ou "rafraichissement en cours…" +// selon refreshCounter. +function updateCaptureInfoText() { + if (state.currentData) { + renderCaptureInfo(state.currentData); + } +} + +/** + * Appelé quand TOUS les fetches (y compris les fetches fiches en + * arrière-plan) sont terminés. Affiche un ✓ vert à côté de l'heure MAJ + * pendant 5 secondes. + */ +function showRefreshDone() { + const check = document.getElementById("refresh-check"); + if (!check) return; + check.classList.remove("hidden"); + check.classList.add("visible"); + if (refreshDoneTimer) clearTimeout(refreshDoneTimer); + refreshDoneTimer = setTimeout(() => { + check.classList.remove("visible"); + setTimeout(() => check.classList.add("hidden"), 300); // après transition + }, 5000); +} + +function clearCheckMark() { + const check = document.getElementById("refresh-check"); + if (check) { + check.classList.remove("visible"); + check.classList.add("hidden"); + } + if (refreshDoneTimer) { + clearTimeout(refreshDoneTimer); + refreshDoneTimer = null; + } +} + +// ─── Barre de progression (v4.1.7) ───────────────────────────────────── +// État global : on affiche la progression du fetch en cours, uniquement si +// c'est le fetch de la page actuellement visible. Si l'utilisateur change +// de date, la barre suit la nouvelle date (son propre état). +function showProgressBar() { + const bar = document.getElementById("progress-bar"); + if (bar) bar.classList.remove("hidden"); +} + +function hideProgressBar() { + const bar = document.getElementById("progress-bar"); + if (bar) bar.classList.add("hidden"); + updateProgressBar(0, 0); +} + +function updateProgressBar(done, total) { + const fill = document.getElementById("progress-bar-fill"); + const label = document.getElementById("progress-bar-label"); + if (!fill || !label) return; + if (total <= 0) { + fill.style.width = "0%"; + label.textContent = ""; + return; + } + const pct = Math.min(100, Math.round((done / total) * 100)); + fill.style.width = pct + "%"; + // v4.1.20 : message différencié selon le type de refresh actif + const prefix = (activeRefreshButton === "partial") ? "Actualisation" : "Rafraîchissement"; + label.textContent = `${prefix}… ${done} / ${total}`; +} + +// Affiche/masque le bouton "Arrêter". N'est montré que pdt un refresh +// manuel (clic utilisateur), pas pendant les chargements normaux ni les +// refresh auto 12h/15h. +function showAbortButton(on) { + const btn = document.getElementById("abort-btn"); + if (!btn) return; + if (on) { + positionAbortButton(); + btn.classList.remove("hidden"); + } else { + btn.classList.add("hidden"); + } +} + +/** + * Toast de feedback quand l'user clique Arrêter. Les fetches en cours peuvent + * encore prendre 1-2 secondes avant de se terminer (on ne peut pas vriament + * annuler un fetch() en cours), mais du point de vue de l'interface tout + * est arrêté : plus de mise à jour, plus de cache, plus rien. + */ +function showAbortToast() { + showToast("Rafraîchissement", "arrêté"); +} + +function renderFromData(data) { + state.currentData = data; + document.getElementById("loading").classList.add("hidden"); + document.getElementById("error-box").classList.add("hidden"); + document.getElementById("session-needed").classList.add("hidden"); + document.getElementById("cards").classList.remove("hidden"); + + // v4.3.0 : détecter les conflits d'horaire entre interventions d'un même + // tech (même heure de début OU chevauchement). + detectOverlaps(data.techs); + + // Calculer les stats + const stats = computeStats(data.techs, data.targetDate); + renderCaptureInfo(data, stats); + renderStats(stats); + renderCards(data); +} + +// v4.3.0 : détection des conflits d'horaire entre interventions d'un même tech. +// Marque iv._hasOverlap = true pour chaque intervention en conflit avec une +// autre (même heure de début OU chevauchement de créneaux). +// Les absences récurrentes, tickets fantômes à retirer, et réservations +// sont ignorés (pas de conflit pertinent pour eux). +function detectOverlaps(techs) { + if (!techs) return; + for (const tech of techs) { + const ivs = (tech.interventions || []).filter(iv => + iv && iv.startTime && iv.endTime && + !iv._disappearRemove && + iv.type !== "AL-Reservation" && + // v4.3.2 : le pompier est une absence "tolérée" qui chevauche par + // nature les heures de travail (garde volontaire) — on l'exclut des + // conflits. En revanche les congés/maladies/formations restent + // détectés car une inter planifiée pdt une absence, c'est un vrai pb. + !iv.isPompier + ); + // Reset flag sur toutes les inters du tech (y compris celles ignorées) + for (const iv of (tech.interventions || [])) { + iv._hasOverlap = false; + } + // Convertir HH:MM en minutes pour comparaison rapide + const toMin = (hhmm) => { + if (!hhmm) return null; + const parts = hhmm.split(":"); + if (parts.length < 2) return null; + const h = parseInt(parts[0], 10); + const m = parseInt(parts[1], 10); + if (isNaN(h) || isNaN(m)) return null; + return h * 60 + m; + }; + // Comparer chaque paire + for (let i = 0; i < ivs.length; i++) { + for (let j = i + 1; j < ivs.length; j++) { + const a = ivs[i], b = ivs[j]; + const aStart = toMin(a.startTime), aEnd = toMin(a.endTime); + const bStart = toMin(b.startTime), bEnd = toMin(b.endTime); + if (aStart === null || aEnd === null || bStart === null || bEnd === null) continue; + // Chevauchement = a commence avant que b finisse ET b commence avant que a finisse. + // Inclut aussi le cas "même heure de début" (aStart === bStart). + if (aStart < bEnd && bStart < aEnd) { + a._hasOverlap = true; + b._hasOverlap = true; + } + } + } + } +} + +function renderCaptureInfo(data, stats) { + const info = document.getElementById("capture-info"); + if (refreshCounter > 0) { + // v4.1.20 : message différencié selon le type de refresh actif + // - partial (Actualiser) → "Actualisation en cours…" + // - total (Tout recharger) → "rafraichissement en cours…" + if (activeRefreshButton === "partial") { + info.textContent = "Actualisation en cours…"; + } else { + info.textContent = "Rafraîchissement en cours…"; + } + info.classList.add("refreshing"); + return; + } + info.classList.remove("refreshing"); + const parts = []; + if (data.captureTime) { + const d = new Date(data.captureTime); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + const today = new Date(); + const isSameDay = d.getFullYear() === today.getFullYear() && + d.getMonth() === today.getMonth() && + d.getDate() === today.getDate(); + // v4.1.20 : préfixe selon le type de refresh qui a généré cette capture + // - lastRefreshKind === "partial" → "Actualisé à HH:MM" + // - lastRefreshKind === "total" → "Synchronisé à HH:MM" + // - data.source === "cache" → "Cache de HH:MM" + let prefix; + if (data.source === "cache") { + prefix = "Cache de "; + } else if (data.lastRefreshKind === "partial") { + prefix = "Actualisé à "; + } else { + prefix = "Synchronisé à "; + } + if (isSameDay) { + parts.push(`${prefix}${hh}:${mm}`); + } else { + const dd = String(d.getDate()).padStart(2, "0"); + const mo = String(d.getMonth() + 1).padStart(2, "0"); + let prefixDate; + if (data.source === "cache") { + prefixDate = "Cache du "; + } else if (data.lastRefreshKind === "partial") { + prefixDate = "Actualisé le "; + } else { + prefixDate = "Synchronisé le "; + } + parts.push(`${prefixDate}${dd}.${mo} ${hh}:${mm}`); + } + } + info.textContent = parts.join(" · "); +} + +function computeStats(techs, targetDate) { + let pompiers = 0, absents = 0; + let totalInterventions = 0, morning = 0, afternoon = 0; + let closed = 0, resolved = 0; + for (const tech of techs) { + const isPompier = tech.interventions.some(iv => iv.isPompier); + const isAbsent = isTechAbsent(tech, targetDate); + if (isPompier) pompiers++; + if (isAbsent) absents++; + const real = tech.interventions.filter(iv => + iv.type !== "AL-Absence" && !iv.isPompier + ); + for (const iv of real) { + totalInterventions++; + const s = timeToMinutes(iv.startTime); + if (s !== null && s < 12 * 60) morning++; + else if (s !== null) afternoon++; + if (isClosedStatus(iv.status)) closed++; + else if (isResolvedStatus(iv.status)) resolved++; + } + } + return { totalTechs: techs.length, pompiers, absents, totalInterventions, morning, afternoon, closed, resolved }; +} + +function renderStats(s) { + const el = document.getElementById("stats"); + el.innerHTML = ` + ${s.totalInterventions} intervention${s.totalInterventions > 1 ? "s" : ""} + (${s.morning} matin · ${s.afternoon} après-midi) + ${(s.closed + s.resolved > 0) ? `·${s.closed + s.resolved} clos` : ""} + · + ${s.totalTechs} techs + · + ${s.pompiers} pompier${s.pompiers > 1 ? "s" : ""} + · + ${s.absents} absent${s.absents > 1 ? "s" : ""} + `; + el.classList.remove("hidden"); +} + +function renderCards(data) { + const container = document.getElementById("cards"); + container.innerHTML = ""; + + // Tri : pompier(s) > actifs alphabétique nom de famille > absents alphabétique + const sorted = [...data.techs].sort((a, b) => compareTechs(a, b, data.targetDate)); + for (const tech of sorted) { + container.appendChild(buildCard(tech, data.targetDate)); + } +} + +function compareTechs(a, b, targetDate) { + const aP = a.interventions.some(iv => iv.isPompier); + const bP = b.interventions.some(iv => iv.isPompier); + if (aP && !bP) return -1; + if (bP && !aP) return 1; + + const aAbs = isTechAbsent(a, targetDate); + const bAbs = isTechAbsent(b, targetDate); + if (aAbs && !bAbs) return 1; + if (bAbs && !aAbs) return -1; + + // Sinon : alphabétique sur le nom de famille + // Les noms sont stockés au format "Nom, Prénom" + const aLast = (a.name || "").split(",")[0].trim(); + const bLast = (b.name || "").split(",")[0].trim(); + return aLast.localeCompare(bLast, "fr"); +} + +function isTechAbsent(tech, isoDate) { + const recurring = RECURRING_ABSENCES[tech.id]; + if (recurring) { + const day = isoToDate(isoDate).getDay(); + if (recurring.includes(day)) return true; + } + if (tech.interventions.length === 0) return false; + return tech.interventions.every(iv => iv.type === "AL-Absence" && !iv.isPompier); +} + +// ============================================================================ +// Construction d'une carte +// ============================================================================ + +// v4.1.20 : détecte si tech = Pillonel Olivier ET jour = vendredi. +// Hardcodé car c'est une absence récurrente connue spécifique à lui. +function isPillonelAbsentFriday(tech, isoDate) { + if (!tech || !tech.name) return false; + // Normaliser le nom (tolère "Pillonel, Olivier", "Pillonel Olivier", etc.) + const name = tech.name.toLowerCase(); + if (!name.includes("pillonel")) return false; + if (!name.includes("olivier")) return false; + // Jour de la semaine : 5 = vendredi (en JS, 0=dim, 1=lun, ..., 5=ven) + const d = isoToDate(isoDate); + return d.getDay() === 5; +} + +function buildCard(tech, isoDate) { + const card = document.createElement("section"); + card.className = "card"; + card.dataset.techId = tech.id; + + const isPompier = tech.interventions.some(iv => iv.isPompier); + const isAbsent = isTechAbsent(tech, isoDate); + if (isPompier) card.classList.add("is-pompier"); + if (isAbsent) card.classList.add("is-absent"); + + const realInterventions = tech.interventions.filter(iv => + iv.type !== "AL-Absence" && !iv.isPompier + ); + const absenceBlocks = tech.interventions.filter(iv => iv.type === "AL-Absence"); + const pompierBlocks = tech.interventions.filter(iv => iv.isPompier); + + const morning = realInterventions.filter(iv => { + const s = timeToMinutes(iv.startTime); + return s !== null && s < 12 * 60; + }).length; + const afternoon = realInterventions.length - morning; + + // --- Header --- + const header = document.createElement("div"); + header.className = "card-header"; + const nameEl = document.createElement("div"); + nameEl.className = "card-tech-name"; + nameEl.textContent = tech.name; + header.appendChild(nameEl); + + if (isPompier || isAbsent) { + const badge = document.createElement("div"); + badge.className = "card-tech-badge"; + if (isPompier) { + badge.classList.add("badge-pompier"); + badge.textContent = "Pompier"; + } else { + badge.classList.add("badge-absent"); + badge.textContent = "Absent"; + } + header.appendChild(badge); + } + card.appendChild(header); + + // --- Body --- + const body = document.createElement("div"); + body.className = "card-body"; + + // Note statut + if (isPompier && pompierBlocks.length) { + const note = document.createElement("div"); + note.className = "card-status-note pompier"; + const pb = pompierBlocks[0]; + if (pb.startDate && pb.endDate && pb.startDate !== pb.endDate) { + note.textContent = `En pompier du ${pb.startDate.substring(0, 5)} au ${pb.endDate.substring(0, 5)}`; + } else { + note.textContent = "En pompier aujourd'hui"; + } + body.appendChild(note); + } else if (isAbsent && absenceBlocks.length) { + const note = document.createElement("div"); + note.className = "card-status-note absent"; + const ab = absenceBlocks[0]; + if (ab.startDate && ab.endDate && ab.startDate !== ab.endDate) { + note.textContent = `Absent du ${ab.startDate.substring(0, 5)} au ${ab.endDate.substring(0, 5)}`; + } else { + note.textContent = "Absent toute la journée"; + } + body.appendChild(note); + } + + // v4.1.20 : cas spécifique Pillonel Olivier, absent tous les vendredis. + // Affichage d'un message explicite au lieu de "Pas d'intervention planifiée". + // v4.2 : prioritaire même si un bloc AL-Absence couvre le vendredi (ce qui + // est le cas normal), pour TOUJOURS afficher "Absent le vendredi". + const isPillonelFriday = isPillonelAbsentFriday(tech, isoDate); + + // Absent sans interv → on stop là (après avoir posé le message Pillonel + // si vendredi). + if (isAbsent && realInterventions.length === 0) { + if (isPillonelFriday) { + const note = document.createElement("div"); + note.className = "tech-absence-recurring"; + note.textContent = "Absent le vendredi"; + body.appendChild(note); + } + card.appendChild(body); + return card; + } + + if (realInterventions.length === 0 && !isPompier) { + if (isPillonelFriday) { + const note = document.createElement("div"); + note.className = "tech-absence-recurring"; + note.textContent = "Absent le vendredi"; + body.appendChild(note); + } else { + const empty = document.createElement("div"); + empty.className = "card-empty"; + empty.textContent = "Pas d'intervention planifiée"; + body.appendChild(empty); + } + card.appendChild(body); + return card; + } + + // Pillonel vendredi avec quand même des interv planifiées ? Rare mais possible. + if (isPillonelFriday && realInterventions.length > 0) { + const note = document.createElement("div"); + note.className = "tech-absence-recurring"; + note.textContent = "Absent le vendredi"; + body.appendChild(note); + } + + // Timeline + body.appendChild(buildTimeline(realInterventions, pompierBlocks, absenceBlocks, card, isPompier, isAbsent)); + + // Stats de carte + if (realInterventions.length > 0) { + const stats = document.createElement("div"); + stats.className = "card-stats"; + stats.innerHTML = ` +
+ ${realInterventions.length} + intervention${realInterventions.length > 1 ? "s" : ""} +
+
+ ${morning} matin + · + ${afternoon} après-midi +
+ `; + body.appendChild(stats); + } + + // Liste interventions + for (const iv of realInterventions) { + body.appendChild(buildInterventionRow(iv, card)); + } + + card.appendChild(body); + return card; +} + +// ============================================================================ +// Timeline +// ============================================================================ + +function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) { + const DAY_START = 8 * 60; + const DAY_END = 18 * 60; + const DAY_LEN = DAY_END - DAY_START; + + const wrap = document.createElement("div"); + wrap.className = "timeline"; + if (isPompier) wrap.classList.add("timeline-pompier"); + + const bar = document.createElement("div"); + bar.className = "timeline-bar"; + + const segments = []; + for (let i = 0; i < realInterventions.length; i++) { + const iv = realInterventions[i]; + const s = timeToMinutes(iv.startTime); + const e = timeToMinutes(iv.endTime); + if (s === null || e === null) continue; + const cs = Math.max(s, DAY_START); + const ce = Math.min(e, DAY_END); + if (ce <= cs) continue; + segments.push({ + kind: "intervention", + colorKey: deriveColorKey(iv), + iv, ivIdx: i, + start: cs, end: ce, + statusClass: getStatusClass(iv) + }); + } + + for (const ab of absenceBlocks || []) { + const s = timeToMinutes(ab.startTime); + const e = timeToMinutes(ab.endTime); + if (s === null || e === null) continue; + const cs = Math.max(s, DAY_START); + const ce = Math.min(e, DAY_END); + if (cs <= DAY_START && ce >= DAY_END) continue; + if (ce <= cs) continue; + segments.push({ kind: "absence", start: cs, end: ce, iv: ab }); + } + + // Calcul des trous (que si pas absent complet) + const occupiedRanges = segments.map(s => [s.start, s.end]).sort((a, b) => a[0] - b[0]); + const merged = []; + for (const [s, e] of occupiedRanges) { + if (merged.length && s <= merged[merged.length - 1][1]) { + merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], e); + } else { + merged.push([s, e]); + } + } + const holes = []; + let cursor = DAY_START; + for (const [s, e] of merged) { + if (s > cursor) holes.push([cursor, s]); + cursor = Math.max(cursor, e); + } + if (cursor < DAY_END) holes.push([cursor, DAY_END]); + + if (!isAbsent) { + for (const [s, e] of holes) { + if (e - s < 15) continue; + const h = document.createElement("div"); + h.className = "timeline-hole"; + h.style.left = ((s - DAY_START) / DAY_LEN) * 100 + "%"; + h.style.width = ((e - s) / DAY_LEN) * 100 + "%"; + h.dataset.startMin = s; + h.dataset.endMin = e; + h.dataset.kind = "hole"; + bindTimelinePopover(h); + bar.appendChild(h); + } + } + + for (const seg of segments) { + const el = document.createElement("div"); + el.className = "timeline-slot kind-" + seg.kind; + if (seg.colorKey) el.classList.add("color-" + seg.colorKey); + if (seg.statusClass) el.classList.add(seg.statusClass); + el.style.left = ((seg.start - DAY_START) / DAY_LEN) * 100 + "%"; + el.style.width = ((seg.end - seg.start) / DAY_LEN) * 100 + "%"; + el.dataset.startMin = seg.start; + el.dataset.endMin = seg.end; + el.dataset.kind = seg.kind; + if (seg.iv) { + el.dataset.title = deriveShortTitle(seg.iv); + if (seg.iv.ref) el.dataset.ref = seg.iv.ref; + } + if (seg.ivIdx !== undefined) { + el.dataset.ivIdx = seg.ivIdx; + el.addEventListener("mouseenter", () => highlightIntervention(cardEl, seg.ivIdx, true)); + el.addEventListener("mouseleave", () => highlightIntervention(cardEl, seg.ivIdx, false)); + } + bindTimelinePopover(el); + bar.appendChild(el); + } + + const noon = document.createElement("div"); + noon.className = "timeline-noon"; + noon.style.left = (((12 * 60) - DAY_START) / DAY_LEN) * 100 + "%"; + bar.appendChild(noon); + + wrap.appendChild(bar); + + const scale = document.createElement("div"); + scale.className = "timeline-scale"; + for (const h of [8, 10, 12, 14, 16, 18]) { + const t = document.createElement("span"); + t.className = "timeline-tick"; + t.style.left = (((h * 60) - DAY_START) / DAY_LEN * 100) + "%"; + t.textContent = h + "h"; + scale.appendChild(t); + } + wrap.appendChild(scale); + + return wrap; +} + +function getStatusClass(iv) { + // v4.2.5 : priorité aux statuts de disparition analysés + if (iv._disappearStatus === "closed") return "status-closed"; + if (iv._disappearStatus === "terminated") return "status-terminated"; + if (iv._disappearStatus === "error") return null; + if (isClosedStatus(iv.status)) return "status-closed"; + if (isResolvedStatus(iv.status)) return "status-resolved"; + return null; +} + +function bindTimelinePopover(el) { + el.addEventListener("mouseenter", (e) => showTimelinePopover(e, el)); + // v4.2.3 : la petite popup timeline SUIT la souris (différent de la grande + // popup des lignes d'intervention qui est ancrée). On n'utilise pas + // moveTooltip() (no-op depuis v4.1.12) mais une fonction dédiée. + el.addEventListener("mousemove", (e) => moveTimelineTooltip(e)); + el.addEventListener("mouseleave", hideTooltip); + + // v4.2.3 : clic / double-clic / Ctrl+clic sur un segment timeline + // - clic simple : ferme la petite popup et ouvre la GRANDE popup + // (ancrée juste en dessous de la timeline, persistante pour permettre + // de sélectionner du texte / copier) + // - double-clic : ouvre la fiche EasyVista dans un nouvel onglet actif + // - Ctrl+clic (Cmd+clic sur Mac) : ouvre dans un onglet en arrière-plan + const kind = el.dataset.kind; + const ivIdxStr = el.dataset.ivIdx; + // Seulement sur les segments avec une interventoin (pas les "hole" libres + // ni certaines absences sans ivIdx) + if (ivIdxStr === undefined) return; + + let singleClickTimer = null; + el.addEventListener("click", (e) => { + // Ctrl / Cmd / molette → ouvrir fiche en arrière-plan + if (e.ctrlKey || e.metaKey || e.button === 1) { + e.preventDefault(); + e.stopPropagation(); + openInterventionFromTimeline(el, { background: true }); + return; + } + // Clic simple (sans Ctrl) : on attend un éventuel double-clic avant + // d'ouvrir la grande popup persistante. + e.stopPropagation(); + if (singleClickTimer) clearTimeout(singleClickTimer); + singleClickTimer = setTimeout(() => { + singleClickTimer = null; + openPersistentTimelinePopup(el); + }, 250); + }); + el.addEventListener("dblclick", (e) => { + // Annuler le clic simple en attente + if (singleClickTimer) { clearTimeout(singleClickTimer); singleClickTimer = null; } + e.preventDefault(); + e.stopPropagation(); + openInterventionFromTimeline(el, { background: false }); + }); +} + +// v4.2.3 : positionne la petite popup timeline à côté du curseur +function moveTimelineTooltip(e) { + const tip = tooltipEl(); + if (!tip || !tip.classList.contains("visible")) return; + // La popup ancrée (grande bulle) ne doit pas être déplacée par la souris + if (bulleState.pinned) return; + // Si la popup affiche une grande bulle d'intervention (classe pinned-like), + // on ne la bouge pas non plus : on la laisse ancrée. + if (tip.dataset.mode === "anchored") return; + const offsetX = 14, offsetY = 16; + let x = e.clientX + offsetX; + let y = e.clientY + offsetY; + const rect = tip.getBoundingClientRect(); + // Ajuster si on sort de la fenêtre + if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offsetX; + if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offsetY; + if (x < 4) x = 4; + if (y < 4) y = 4; + // v4.2.4 : utiliser setTooltipViewportPosition pour bénéficier de la + // détection automatique fixed/abs (et donc de la stabilité au scroll). + setTooltipViewportPosition(x, y); +} + +// v4.2.3 : trouve l'iv correspondant au segment timeline et ouvre sa fiche +function openInterventionFromTimeline(el, opts) { + const ivIdx = el.dataset.ivIdx; + if (ivIdx === undefined) return; + const cardEl = el.closest(".card"); + if (!cardEl) return; + const row = cardEl.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`); + if (!row) return; + const actionId = row.dataset.actionId; + if (!actionId) return; + // recupere l'iv depuis state + const iv = findIvByActionId(actionId); + if (!iv) return; + openInterventionInNewTab(iv, opts || {}); +} + +function findIvByActionId(actionId) { + const data = state.currentData; + if (!data || !data.techs) return null; + for (const tech of data.techs) { + for (const iv of (tech.interventions || [])) { + if (String(iv.actionId) === String(actionId)) return iv; + } + } + return null; +} + +// v4.2.3/4 : ouvre la GRANDE popup au clic sur un segment timeline, ancrée +// juste en dessous du segment. Pas épinglée : se ferme sur clic ailleurs, +// Échap, OU quand la souris quitte la popup elle-même (mouseleave). +function openPersistentTimelinePopup(el) { + const ivIdx = el.dataset.ivIdx; + if (ivIdx === undefined) return; + const cardEl = el.closest(".card"); + if (!cardEl) return; + const row = cardEl.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`); + if (!row) return; + const actionId = row.dataset.actionId; + const iv = findIvByActionId(actionId); + if (!iv) return; + + const tip = tooltipEl(); + if (!tip) return; + + // Nettoyer tout état précédent (ancrage, épinglage, timers) + bulleState.pinned = false; + bulleState.hoveredInBulle = false; + bulleState.hoveredInRow = false; + if (bulleState.hideTimer) { clearTimeout(bulleState.hideTimer); bulleState.hideTimer = null; } + tip.classList.remove("pinned"); + + // Construire la grande bulle + tip.innerHTML = buildTooltipHTML(iv); + tip.classList.remove("hidden"); + tip.classList.add("visible"); + // mode "anchored" : le hover ne doit pas la remplacer par une autre popup + tip.dataset.mode = "anchored"; + state.currentTooltipIv = iv; + + // Position : juste sous le segment timeline. D'abord on reset les coords + // pour que getBoundingClientRect() reflète la vraie taille du nouveau + // contenu. + tip.style.left = "-9999px"; + tip.style.top = "0px"; + // Forcer un reflow pour que tipRect soit à jour avec le nouveau contenu + const tipRect = tip.getBoundingClientRect(); + const r = el.getBoundingClientRect(); + let x = r.left; + let y = r.bottom + 8; + if (x + tipRect.width > window.innerWidth - 8) x = window.innerWidth - tipRect.width - 8; + if (x < 4) x = 4; + if (y + tipRect.height > window.innerHeight - 8) { + y = r.top - tipRect.height - 8; + } + if (y < 4) y = 4; + + // Positionner proprement (avec détection auto fixed vs abs) + setTooltipViewportPosition(x, y); +} + +function showTimelinePopover(e, el) { + const s = parseInt(el.dataset.startMin, 10); + const eMin = parseInt(el.dataset.endMin, 10); + const kind = el.dataset.kind; + const dur = eMin - s; + let html; + if (kind === "hole") { + const h = Math.floor(dur / 60); + const min = dur % 60; + let d; + if (h === 0) d = `${min} min`; + else if (min === 0) d = `${h} h`; + else d = `${h} h ${min} min`; + html = `
+
Libre
${minutesToTime(s)}–${minutesToTime(eMin)}
+
Durée
${d} disponible
+
`; + } else { + const t = el.dataset.title || ""; + const ref = el.dataset.ref || ""; + const k = kind === "absence" ? "Absence" : "Intervention"; + html = `
+
${k}
${minutesToTime(s)}–${minutesToTime(eMin)}
+ ${t ? `
Type
${escapeHtml(t)}
` : ""} + ${ref ? `
Réf
${escapeHtml(ref)}
` : ""} +
`; + } + const tip = tooltipEl(); + // v4.2.3 : si une grande bulle est déjà ancrée (clic timeline), on ne + // la remplace pas par la petite popup hover. + if (tip.dataset.mode === "anchored") return; + // v4.2.4 : annuler tout hideTimer en cours pour éviter que la popup + // précédente, en train d'être masquée, masque AUSSI celle-ci juste après. + // Problème typique quand on passe rapidement d'un segment à un autre. + if (bulleState.hideTimer) { + clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = null; + } + tip.innerHTML = html; + tip.classList.remove("hidden", "pinned"); + tip.classList.add("visible"); + // v4.2.3 : mode "hover" = petite popup qui suit la souris + tip.dataset.mode = "hover"; + moveTimelineTooltip(e); +} + +// ============================================================================ +// Ligne d'interventoin +// ============================================================================ + +function buildInterventionRow(iv, cardEl) { + const row = document.createElement("div"); + row.className = "intervention-v2"; + row.dataset.actionId = iv.actionId; + if (iv.isPompier) row.classList.add("is-pompier-line"); + // v4.3.3 : on ne marque plus les ghosts visuellement (classe is-ghost + // retirée). Les tickets disparus sont soit retirés (_disappearRemove), + // soit affichés en vert (_disappearStatus). Plus de barrage. + // v4.2.5 : indicateur "en cours d'analyse" (ticket disparu, on re-fetch + // la fiche pour décider de le garder en vert ou le retirer). + if (iv._disappearChecking) row.classList.add("_checking"); + + const colorKey = deriveColorKey(iv); + row.classList.add("color-" + colorKey); + + const statusClass = getStatusClass(iv); + if (statusClass) row.classList.add(statusClass); + + const ivIdx = cardEl._rowIdxCounter || 0; + cardEl._rowIdxCounter = ivIdx + 1; + row.dataset.ivIdx = ivIdx; + + if (iv.formLink && !iv.ghost) { + row.classList.add("clickable"); + // v4.1.8 : plus de title au survol (info déjà dans le tooltip en bas) + + // Clic normal : ouvre l'onglet et change de page + // Ctrl/Cmd+Clic : ouvre en arrière-plan (reste sur le planning) + row.addEventListener("click", (e) => { + if (e.target.closest(".intervention-copy")) return; + const background = !!(e.ctrlKey || e.metaKey); + openInterventionInNewTab(iv, { background }); + }); + + // Clic molette (button === 1) : ouvre en arrière-plan + // On utilise 'auxclick' pour les boutons du milieu/droite (standard W3C). + row.addEventListener("auxclick", (e) => { + if (e.button !== 1) return; // que la molette + if (e.target.closest(".intervention-copy")) return; + e.preventDefault(); + openInterventionInNewTab(iv, { background: true }); + }); + + // Empêcher le scroll auto quand on clique la molette sur la ligne + row.addEventListener("mousedown", (e) => { + if (e.button === 1) e.preventDefault(); + }); + } + + // Pastille colorée à gauche (barre verticale, toute la hauteur) + const dot = document.createElement("div"); + dot.className = "intervention-dot"; + row.appendChild(dot); + + // ─── Ligne 1 : Ref centrée (TITRE en gros + gras) ──────────────────────── + const refHeader = document.createElement("div"); + refHeader.className = "iv-ref-header"; + if (iv.type === "AL-Reservation") { + refHeader.textContent = "Réservation"; + refHeader.classList.add("is-reservation-title"); + } else if (iv.ref) { + refHeader.textContent = iv.ref; + } else { + refHeader.textContent = "—"; + refHeader.classList.add("no-ref"); + } + row.appendChild(refHeader); + + // Check ✓ + bouton copier à droite de la ref (pas pour réservation) + if (statusClass && iv.type !== "AL-Reservation") { + const statusEl = document.createElement("div"); + statusEl.className = "iv-status-check"; + // v4.2.5 : ✓✓ double pour clôturé/résolu (statut officiel EasyVista) + // ✓ simple pour "terminé par tech" (commentaire LOGIN: détecté) + if (statusClass === "status-closed" || statusClass === "status-resolved") { + statusEl.textContent = "✓✓"; + statusEl.classList.add("double"); + } else { + statusEl.textContent = "✓"; + } + row.appendChild(statusEl); + } + if (iv.ref && iv.type !== "AL-Reservation") { + const copyBtn = document.createElement("button"); + copyBtn.className = "intervention-copy"; + copyBtn.type = "button"; + copyBtn.title = "Copier la référence"; + copyBtn.innerHTML = "📋"; + copyBtn.addEventListener("click", (e) => { + e.stopPropagation(); + copyRef(iv.ref, copyBtn); + }); + row.appendChild(copyBtn); + } + + // ─── Ligne 2 gauche : heures VERTICALES (début / ↓ / fin) ───────────────── + const timeEl = document.createElement("div"); + timeEl.className = "iv-time-vertical"; + // v4.3.0 : marquer rouge + icône ⚠ si conflit horaire détecté + if (iv._hasOverlap) { + timeEl.classList.add("iv-time-overlap"); + } + if (iv.startTime && iv.endTime) { + const s = document.createElement("div"); + s.className = "iv-time-start"; + s.textContent = iv.startTime; + const sep = document.createElement("div"); + sep.className = "iv-time-arrow"; + sep.textContent = "↓"; + const e = document.createElement("div"); + e.className = "iv-time-end"; + e.textContent = iv.endTime; + timeEl.appendChild(s); + timeEl.appendChild(sep); + timeEl.appendChild(e); + // v4.3.0 : icône d'alerte à côté des heures si conflit + if (iv._hasOverlap) { + const warn = document.createElement("div"); + warn.className = "iv-time-overlap-warn"; + warn.textContent = "⚠"; + warn.title = "Conflit d'horaire avec une autre intervention"; + timeEl.appendChild(warn); + } + } else { + timeEl.textContent = "—"; + } + row.appendChild(timeEl); + + // ─── Ligne 2 droite : lieu / contact+tél / catégorie+signature ─────────── + // Pour une RÉSERVATION : affichage différent (par + sujet) + const rightCol = document.createElement("div"); + rightCol.className = "iv-right"; + + if (iv.type === "AL-Reservation") { + // Bloc "Par Nom, Prénom" (en gras) + if (iv.reservationCreator) { + const parEl = document.createElement("div"); + parEl.className = "iv-reservation-par"; + parEl.textContent = "Par " + iv.reservationCreator; + rightCol.appendChild(parEl); + } + // Sujet (ex: "Ecrans", "Rollout") + if (iv.reservationLabel) { + const sujetEl = document.createElement("div"); + sujetEl.className = "iv-reservation-sujet"; + sujetEl.textContent = "Sujet : " + iv.reservationLabel; + rightCol.appendChild(sujetEl); + } + row.appendChild(rightCol); + + // Tooltip (fixe, ne suit pas la souris — v4.1.12) + row.addEventListener("mouseenter", (e) => { + showTooltip(e, iv, row); + highlightIntervention(cardEl, ivIdx, true); + }); + row.addEventListener("mouseleave", () => { + hideTooltip(); + highlightIntervention(cardEl, ivIdx, false); + }); + return row; + } + + // v4.1.2 : priorité à iv.infobulle (venant du xhr2 = données réelles vérifiées + // par le tech sur place) puis fallback sur iv.bulleContact/iv.bulleLieu + // (venant de attr1/attr2 = planification initiale, parfois incorrecte). + const info = iv.infobulle || {}; + const contactRaw = info.contact || iv.bulleContact || null; + const lieuRaw = info.lieu || iv.bulleLieu || null; + + // Rendu initial de lieu + contacts dans rightCol + renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, info.contactAnomalie); + + // ── Bas : Catégorie (à gauche) + Signature planificateur (à droite) ────── + const bottomEl = document.createElement("div"); + bottomEl.className = "iv-bottom-line"; + + const categoryEl = document.createElement("span"); + categoryEl.className = "iv-category"; + categoryEl.textContent = deriveShortTitle(iv); + bottomEl.appendChild(categoryEl); + + // v4.1.8 : extraire la signature depuis le texte COMPLET (fiche) en + // priorité, sinon depuis le xhr2 tronqué. Le xhr2 tronqué peut couper la + // signature, la fiche a toujours le texte complet. + const signature = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription); + if (signature) { + const sigEl = document.createElement("span"); + sigEl.className = "iv-signature"; + sigEl.textContent = signature; + bottomEl.appendChild(sigEl); + } + + rightCol.appendChild(bottomEl); + row.appendChild(rightCol); + + // Tooltip (fixe, ne suit pas la souris — v4.1.12) + row.addEventListener("mouseenter", (e) => { + showTooltip(e, iv, row); + highlightIntervention(cardEl, ivIdx, true); + }); + row.addEventListener("mouseleave", () => { + hideTooltip(); + highlightIntervention(cardEl, ivIdx, false); + }); + + return row; +} + +// Sender correct pour ouvrir une fiche EasyVista (vu dans les URLs qui marchent) +const FICHE_SENDER = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D"; + +async function openInterventionInNewTab(iv, opts = {}) { + if (!iv.formLink) return; + + // Toast de feedback visuel dès le clic + showToast("Ouverture", iv.ref || iv.actionId); + + // Récupérer la session actuelle pour construire une URL valide + let session = state.session; + if (!session) { + const resp = await sendMessage({ type: "getSession" }); + session = resp && resp.session; + } + if (!session) { + // v4.2.5 : popup modale propre au lieu d'alert natif + showAlertModal({ + title: "Impossible d'ouvrir la fiche", + message: "Votre session EasyVista a expiré. Reconnectez-vous à EasyVista puis réessayez.", + buttons: [ + { label: "Ouvrir EasyVista", variant: "primary", action: () => openEasyVista() }, + { label: "Annuler", variant: "secondary", action: () => {} } + ] + }); + return; + } + + if (!iv.requestId) { + showAlertModal({ + title: "Impossible d'ouvrir la fiche", + message: "L'identifiant de la fiche est manquant. Essayez d'actualiser le planning (bouton Actualiser).", + buttons: [ + { label: "OK", variant: "secondary", action: () => {} } + ] + }); + return; + } + + let target = null; + let checksum = null; + + // v4.1.4 : on fetch TOUJOURS la fiche à la volée au clic pour extraire un + // checksum FRAIS. Ne pas utiliser iv.ficheChecksum du cache : les checksums + // EasyVista peuvent expirer entre le fetch arrière-plan et le clic utilisateur. + // + // Retry automatique en cas d'échec du pattern checksum. + { + console.log("[click] fetch fiche fraîche pour iv", iv.actionId, "requestId=", iv.requestId); + let attempts = 0; + const maxAttempts = 2; + while (attempts < maxAttempts && (!target || !checksum)) { + attempts++; + try { + const ficheResp = await sendMessage({ + type: "fetchFiche", + formLink: iv.formLink + }); + if (!ficheResp.ok) { + if (attempts >= maxAttempts) { + // v4.2.5 : popup modale selon le type d'erreur + if (ficheResp.error === "no_session" || ficheResp.error === "session_expired") { + showAlertModal({ + title: "Session EasyVista expirée", + message: "Votre session a expiré pendant l'ouverture de la fiche. Reconnectez-vous à EasyVista puis réessayez.", + buttons: [ + { label: "Ouvrir EasyVista", variant: "primary", action: () => openEasyVista() }, + { label: "Annuler", variant: "secondary", action: () => {} } + ] + }); + } else if (ficheResp.error === "ev_unreachable") { + showAlertModal({ + title: "EasyVista inaccessible", + message: "EasyVista est inaccessible pour le moment. Réessayez dans quelques instants.", + buttons: [ + { label: "Réessayer", variant: "primary", action: () => openInterventionInNewTab(iv, opts) }, + { label: "Ouvrir EasyVista", variant: "secondary", action: () => openEasyVista() }, + { label: "Annuler", variant: "secondary", action: () => {} } + ] + }); + } else { + showAlertModal({ + title: "Impossible d'ouvrir la fiche", + message: "Une erreur est survenue : " + (ficheResp.error || "inconnue"), + buttons: [ + { label: "OK", variant: "secondary", action: () => {} } + ] + }); + } + return; + } + continue; // retry + } + // Extraire le checksum lié au requestId précis + const rx = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`, 'g'); + const allMatches = [...ficheResp.html.matchAll(rx)]; + console.log(`[click] Trouvé ${allMatches.length} occurrence(s) de target=${iv.requestId}&checksum=... dans HTML de la fiche (taille ${ficheResp.html.length})`); + allMatches.forEach((m, idx) => console.log(` [${idx}] checksum = ${m[1]}`)); + + if (allMatches.length === 0) { + // v4.2.5 : le warning précédent était alarmiste pour rien. + // Tentative 1 peut légitimement échouer (cache stale côté EV). + // On log en info, on retry, et en dernier recours on ouvre quand + // même la fiche (avec un target de fallback) plutôt que de bloquer. + console.info(`[click] tentative ${attempts}/${maxAttempts}: pattern target=${iv.requestId}&checksum=... introuvable dans HTML de la fiche (taille ${ficheResp.html.length})`); + if (attempts >= maxAttempts) { + // Fallback : tenter avec le requestId seul, sans checksum précis. + // Ça ouvre une URL EasyVista valide qui redirige vers la fiche. + console.info(`[click] fallback sans checksum précis pour ${iv.requestId}`); + target = iv.requestId; + checksum = null; + break; + } + await new Promise(r => setTimeout(r, 300)); + continue; + } + // On prend le PREMIER checksum trouvé (comme avant, comportement v4) + target = iv.requestId; + checksum = allMatches[0][1]; + console.log(`[click] checksum retenu: ${checksum}`); + // On stocke aussi en cache pour accélérer le prochain clic (au cas où) + iv.ficheTarget = target; + iv.ficheChecksum = checksum; + } catch (err) { + if (attempts >= maxAttempts) { + // v4.2.5 : popup modale au lieu d'alert + showAlertModal({ + title: "Erreur lors de l'ouverture de la fiche", + message: "Une erreur s'est produite : " + (err && err.message ? err.message : String(err)), + buttons: [ + { label: "Réessayer", variant: "primary", action: () => openInterventionInNewTab(iv, opts) }, + { label: "Annuler", variant: "secondary", action: () => {} } + ] + }); + return; + } + } + } + } + + // v4.1.18 : sender à utiliser dépend du type de fiche : + // - demande S... → {C99ECD05-...} + // - incident I... → {07ED9C68-...} + // On préfère le formSenderGuid extrait du HTML de la fiche si connu, sinon + // fallback sur préfixe de la ref. + let sender = FICHE_SENDER; + if (iv.formSenderGuid) { + sender = iv.formSenderGuid; + } else if (iv.ref && /^I/i.test(iv.ref)) { + sender = "%7B07ED9C68-6172-48EA-8A58-90912B0A283E%7D"; + } + + // Construire l'URL qui fonctionne (format identique à l'URL manuelle qui + // marche dans le navigateur quand on ouvre une fiche depuis l'UI EasyVista). + const internalurltime = Math.floor(Date.now() / 1000); + // v4.2.5 : si on n'a pas pu extraire le checksum précis (fallback après + // retry), on omet le paramètre checksum. EasyVista acceptera l'URL et + // redirigera vers la fiche correspondant au target. + const urlParts = [ + `${session.origin}/index.php`, + `?PHPSESSID=${encodeURIComponent(session.phpsessid)}`, + `&internalurltime=${internalurltime}`, + `&eventName=formEvent`, + `&target=${encodeURIComponent(target)}`, + ]; + if (checksum) { + urlParts.push(`&checksum=${encodeURIComponent(checksum)}`); + } + urlParts.push(`&sender=${sender}`); + const url = urlParts.join(""); + + console.log("[click] ouverture fiche iv=", iv.actionId, "ref=", iv.ref, "target=", target, "bg=", !!opts.background); + // Si background (Ctrl+Clic ou clic molette) : onglet ouvert mais pas actif, + // on reste sur la page du planning. + await chrome.tabs.create({ url, active: !opts.background }); +} + +const TOAST_MAX = 3; +const TOAST_DURATION_MS = 2400; + +/** + * Affiche un toast en bas à droite. S'empile, max 3, animations in/out. + */ +function showToast(label, ref) { + const stack = document.getElementById("toast-stack"); + if (!stack) return; + + // Si on dépasse le max, supprimer le plus ancien (= premier enfant) + while (stack.children.length >= TOAST_MAX) { + const oldest = stack.firstChild; + if (oldest) stack.removeChild(oldest); + } + + const toast = document.createElement("div"); + toast.className = "toast"; + const labelEl = document.createElement("span"); + labelEl.className = "toast-label"; + labelEl.textContent = label; + const refEl = document.createElement("span"); + refEl.className = "toast-ref"; + refEl.textContent = ref || "…"; + toast.appendChild(labelEl); + toast.appendChild(refEl); + + stack.appendChild(toast); + + // Forcer reflow puis animer en entrée + void toast.offsetWidth; + toast.classList.add("visible"); + + // Auto-disparition après TOAST_DURATION_MS + setTimeout(() => { + toast.classList.remove("visible"); + toast.classList.add("leaving"); + setTimeout(() => { + if (toast.parentNode === stack) stack.removeChild(toast); + }, 220); + }, TOAST_DURATION_MS); +} + +/** + * Formate un numéro de téléphone suisse / français. + * 079 123 45 67 (mobile CH) + * 021 123 45 67 (fixe CH) + * +41 79 123 45 67 + * +33 1 23 45 67 89 + * Si le format n'est pas reconnu, renvoie le numéro tel quel (avec les chiffres seuls). + */ +function formatPhone(raw) { + if (!raw) return null; + const digits = String(raw).replace(/[^\d+]/g, ""); + if (!digits) return null; + + // +41 (Suisse international, 9 chiffres après +41) + let m = digits.match(/^\+41(\d{9})$/); + if (m) { + const d = m[1]; + return `+41 ${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`; + } + // v4.2 : 41XXXXXXXXX sans + (format EasyVista qui colle parfois le préfixe) + m = digits.match(/^41(\d{9})$/); + if (m) { + const d = m[1]; + return `+41 ${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`; + } + // +33 (France) + m = digits.match(/^\+33(\d{9})$/); + if (m) { + const d = m[1]; + return `+33 ${d.slice(0, 1)} ${d.slice(1, 3)} ${d.slice(3, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`; + } + // v4.2 : 33XXXXXXXXX sans + + m = digits.match(/^33(\d{9})$/); + if (m) { + const d = m[1]; + return `+33 ${d.slice(0, 1)} ${d.slice(1, 3)} ${d.slice(3, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`; + } + // 0XX XXX XX XX (fixe ou mobile CH, 10 chiffres commençant par 0) + m = digits.match(/^0(\d{9})$/); + if (m) { + const d = m[1]; + return `0${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`; + } + // Numéro court interne (5 chiffres) : 78999, 68999, 88999, etc. + m = digits.match(/^(\d{5})$/); + if (m) { + return m[1]; // tel quel (déjà court et lisible) + } + // Fallback : retour brut + return digits; +} + +/** + * Extrait le numéro de téléphone d'une chaîne contact. + * Accepte les préfixes : +41, +33, 07x, 02x, 03x (CH), 01-09 FR. + * Retourne un objet { name, phone } où phone est déjà formaté. + */ +function extractContactNameAndPhone(raw) { + if (!raw) return { name: null, phone: null }; + const contacts = extractContacts(raw); + if (contacts.length === 0) return { name: null, phone: null }; + // Pour compat avec l'ancien usage qui ne prend qu'1 contact + return contacts[0]; +} + +/** + * Extrait TOUS les contacts d'une chaîne (potentiellement plusieurs séparés + * par "ou", "/", des retours à la ligne, etc.). + * Retourne un tableau [{ name, phone }, { name, phone }, ...] + * Format d'entrée typique : + * "Nom1, Prénom1 +41000000001" + * "Nom1, Prénom1 +41000000001 ou Nom2, Prénom2 +41000000002" + * "Nom1, Prénom1 +41...\nNom2, Prénom2 +41..." + */ +function extractContacts(raw) { + if (!raw) return []; + let s = String(raw).trim(); + // Virer les labels parasites (Nom utilisateur, etc.) qui traînent + s = s.replace(/\b(Nom utilisateur|Utilisateur)\s*:\s*[^\n]+/gi, ""); + + // v4.2.3 : séparer sur plus de délimiteurs pour gérer les cas type + // "Nom1 Prénom1 +41XXXXXXXXX et Nom2 Prénom2 0XXXXXXXXX" + // Délimiteurs acceptés : + // - " ou " / " et " / " and " (mots de liaison) + // - " / " suivi d'une majuscule (nouveau contact) + // - " ; " (point-virgule) + // - saut de ligne + // IMPORTANT : on ne touche PAS aux virgules (car "Nom, Prénom" en contient). + const parts = s.split(/\s+ou\s+|\s+et\s+|\s+and\s+|\s*;\s*|\n+|\s*\/\s*(?=[A-ZÉÈÀÂÎÔÛÇ])/i) + .map(p => p.trim()) + .filter(Boolean); + + const results = []; + for (const part of parts) { + const { name, phone } = splitOneContact(part); + if (name || phone) results.push({ name, phone }); + } + return results; +} + +/** + * Split UN seul bloc "Nom Prénom +41... [autres tels] [commentaires]" en + * { name, phone }. + * + * Stratégie robuste (v4.1.8) : + * - On cherche TOUS les numéros de téléphone (long ou court). + * - Le nom = ce qui précède le PREMIER numéro. + * - Le champ phone concatène les numéros trouvés (séparés par " / "). + * - Ce qui suit les numéros (commentaires "S'annoncer à la réception...", + * "téléphone à l'utilisateur") est JETÉ : ça ne fait pas partie du contact. + * + * Pattern numéro (inchangé, connu pour marcher) : + * Long : +41 / +33 / 0X suivi de 8+ caractères de [chiffres espaces . -] + * Court: 5 chiffres isolés (entre espaces, parenthèses, ou début/fin) + */ +function splitOneContact(raw) { + if (!raw) return { name: null, phone: null }; + // v4.1.20 : regex plus permissives pour tolérer les erreurs humaines : + // - pas d'espace après le numéro (ex: "021555555Textecoller") + // - pas d'espace/parenthèse avant un court numéro + // LONG : +41 / +33 / 0X suivis de chiffres/espaces/points/tirets + // On ne limite plus par séparateur après — on laisse le moteur + // consommer le numéro le plus long possible (greedy) puis on + // s'arrête dès qu'on tombe sur un caractère non numérique. + // v4.2 : on accepte aussi le format "41XXXXXXXXX" sans + devant (fréquent + // quand EasyVista concatène "prefixe+tel" sans espace : Nom, + // Prénom 41XXXXXXXXX → extraire 41XXXXXXXXX puis reformater en + // +41 XX XXX XX XX). On exige exactement 11 chiffres collés pour + // éviter de matcher des codes postaux ou autres nombres. + const rxLong = /(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(?= 9) { + matches.push({ start: mm.index, end: mm.index + mm[1].length, tel: mm[1] }); + } + } + while ((mm = rxShort.exec(raw)) !== null) { + // v4.2.3 : soit le 1er groupe (format avec espaces "7 68 43"), soit le + // 2e groupe (format collé "12345") a matché. + const rawTel = mm[1] || mm[2]; + if (!rawTel) continue; + // On normalise en 5 chiffres sans séparateur + const shortTel = rawTel.replace(/[\s.\-]/g, ""); + if (!/^\d{5}$/.test(shortTel)) continue; + const rawStart = mm.index + mm[0].indexOf(rawTel); + const rawEnd = rawStart + rawTel.length; + const overlaps = matches.some(x => rawStart < x.end && rawEnd > x.start); + if (!overlaps) { + matches.push({ start: rawStart, end: rawEnd, tel: shortTel }); + } + } + matches.sort((a, b) => a.start - b.start); + + let name = raw; + let phone = null; + if (matches.length > 0) { + name = raw.substring(0, matches[0].start).trim(); + const tels = matches.map(x => formatPhone(x.tel)).filter(Boolean); + phone = tels.length > 0 ? tels.join(" / ") : null; + } + + name = cleanContactName(name); + return { name, phone }; +} + +/** + * Nettoie le nom du contact : + * - retire tout ce qui est dans des parenthèses (...) + * - retire les éventuels "Nom utilisateur :" ou libellés + * - retire les virgules en trop en fin + * - v4.1.8 : tronque les commentaires parasites après le nom + * (ex: "Dupont, Jean S'annoncer à la réception" → "Dupont, Jean") + * - Conserve juste "Nom, Prénom" (ou "Nom Prénom" si pas de virgule) + */ +function cleanContactName(raw) { + if (!raw) return null; + let s = String(raw); + // Retirer parenthèses COMPLÈTES et leur contenu : (RH), (support)... + s = s.replace(/\s*\([^)]*\)\s*/g, " "); + // Retirer parenthèses non fermées en fin : "Bento, Joao (" → "Bento, Joao" + s = s.replace(/\s*\([^)]*$/g, " "); + // Retirer parenthèses non ouvertes en début : ")Bento" → "Bento" + s = s.replace(/^[^(]*\)\s*/g, ""); + // Retirer tout caractère parenthèse isolé restant + s = s.replace(/[()]/g, " "); + // Retirer labels type "Nom utilisateur :", "Utilisateur :", "Bénéficiaire :" + s = s.replace(/\b(Nom utilisateur|Utilisateur|B[ée]n[ée]ficiaire)\s*:\s*[^\n,]*/gi, ""); + + // v4.1.20 : virer les commentaires parasites fréquents AVANT la logique + // des 4-mots (ils peuvent apparaître au tout début quand EasyVista n'a + // pas de nom saisi et commence directement par un commentaire). + // On détecte et coupe DÈS que ces expressions apparaissent. + // NOTE: on évite \b avant/après les caractères accentués (à, é) car + // \b est basé sur [a-zA-Z0-9_] et donne de faux négatifs. + const parasitePhrases = [ + // Instructions d'appel (avec "à" ou "a") + /t[ée]l[ée]phone(?:r)?\s*[àa]\s*l[''`]?utilisateur/gi, + /t[ée]l[ée]phone(?:r)?\s*[àa](?:\s|$)/gi, + /t[ée]l[ée]phone(?:r)?\s*[àa]$/gi, + /\bappeler?\s+l[''`]?utilisateur\b/gi, + /\bappeler?\s+(?:le\s+)?b[ée]n[ée]ficiaire\b/gi, + /\bappeler?\s+la\s+personne\b/gi, + /\bappeler?\s+[àa]\s+/gi, + /\brappeler?\s+l[''`]?utilisateur\b/gi, + /\brappeler?\s+(?:le\s+)?b[ée]n[ée]ficiaire\b/gi, + // Instructions de présentation + /s[''`]annoncer?\s+[àa]\s+(?:la\s+r[ée]ception|l[''`]?accueil|.+?)(?=\.|,|$)/gi, + /\bse\s+pr[ée]senter\s+[àa]\s+.+?(?=\.|,|$)/gi, + // Autres + /\bbonjour\b/gi, + /\bmerci\b/gi, + // v4.1.20 : mots isolés qui restent parfois après les nettoyages ci-dessus + /\butilisateur\b/gi, + /\bb[ée]n[ée]ficiaire\b/gi + ]; + for (const rx of parasitePhrases) { + s = s.replace(rx, " "); + } + + // Espaces multiples → un seul + s = s.replace(/\s{2,}/g, " ").trim(); + // Ponctuation en bord + s = s.replace(/^[\s,;:.\-]+|[\s,;:.\-]+$/g, "").trim(); + if (!s) return null; + + // v4.1.8 : tronquer les commentaires parasites qui suivent le nom. + const words = s.split(/\s+/); + const keep = []; + for (let i = 0; i < words.length; i++) { + const w = words[i]; + if (i === 0) { keep.push(w); continue; } + if (/^(de|da|du|van|von|le|la|del|di|der)$/i.test(w)) { keep.push(w); continue; } + if (keep.length >= 2 && /^[a-zéèêàâîôûç]/.test(w)) break; + if (keep.length >= 4) break; + keep.push(w); + } + s = keep.join(" "); + s = s.replace(/[\s,;:.\-]+$/, "").trim(); + + // v4.1.20 : dernier garde-fou : si le résultat final est juste un mot + // parasite (ex: "téléphone" tout seul, "appeler" tout seul), on retourne + // null plutôt qu'afficher un faux nom. + if (/^(t[ée]l[ée]phone|t[ée]l|appeler?|rappeler?|s[''`]?annoncer|bonjour|merci)$/i.test(s)) { + return null; + } + + return s || null; +} + +/** + * Split un lieu du type "Lausanne/Rue Caroline 9 bis" en + * { ville: "Lausanne", adresse: "Rue Caroline 9 bis" } + * Si format inconnu, retourne { ville: null, adresse: raw }. + */ +function splitLieu(raw) { + if (!raw) return { ville: null, adresse: null }; + let s = String(raw).trim(); + // Retirer un / final (avec ou sans espaces) + s = s.replace(/\s*\/\s*$/, "").trim(); + if (!s) return { ville: null, adresse: null }; + const idx = s.indexOf("/"); + let ville, adresse; + if (idx < 0) { + ville = null; + adresse = s; + } else { + ville = s.substring(0, idx).trim(); + adresse = s.substring(idx + 1).trim(); + } + // Capitaliser la 1ère lettre des mots de voie (Rue, Chemin, Route, Avenue, + // Boulevard, Place, Quai, Impasse + abréviations Av., Ch., Rte, Bd) + if (adresse) { + adresse = adresse.replace( + /\b(rue|chemin|route|avenue|boulevard|place|quai|impasse|ruelle|allée|allee|passage|sentier|av\.?|ch\.?|rte\.?|bd\.?)\b/gi, + (match) => { + // Conserver la casse existante si déjà majuscule, sinon capitaliser + if (/^[A-ZÉÈÀÂÎÔÛÇ]/.test(match)) return match; + return match.charAt(0).toUpperCase() + match.slice(1).toLowerCase(); + } + ); + } + return { ville: ville || null, adresse: adresse || null }; +} + +/** + * Extrait la "signature planificateur" de la description d'action. + * Formats acceptés : "ECM 16.04", "JKF 17.04", "AWR 13/04/26", "ECM 16.04.2026". + * Parcourt d'abord les lignes depuis la fin (si la signature est sur sa ligne), + * sinon cherche à la fin de la description entière. + * Retourne null si rien trouvé. + */ +/** + * Normalise une date trouvée dans une signature : + * - "27/03" → "27.03" + * - "27.03" → "27.03" + * - "10/04/26" → "10.04" (on retire l'année) + * - "13/04/2026" → "13.04" + */ +function normalizeSignatureDate(date) { + if (!date) return ""; + // Prendre les 2 premiers blocs de chiffres (JJ et MM) et les joindre avec "." + const parts = String(date).split(/[./]/); + if (parts.length < 2) return date; + const dd = parts[0].padStart(2, "0"); + const mm = parts[1].padStart(2, "0"); + return `${dd}.${mm}`; +} + +function extractPlanifSignature(actionText) { + if (!actionText) return null; + // Formater le texte d'abord pour avoir des lignes séparées + const text = formatActionTextMultiline(String(actionText)).trim(); + + // 1. Dernière ligne non vide : regarder si c'est une signature (avec ou sans date) + const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean); + if (lines.length > 0) { + const last = lines[lines.length - 1]; + + // 1a. Lettres (majuscules OU minuscules) + date + // Ex: "FRD 07/04", "csh 27.03", "AWR 13/04/26", "JKF 17.04" + const mFull = last.match(/^([A-Za-z]{2,4})\s+(\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)$/); + if (mFull) { + return `${mFull[1].toUpperCase()} ${normalizeSignatureDate(mFull[2])}`; + } + + // 1b. Juste les lettres seules (JKF, NDV) sur leur propre ligne + const mSolo = last.match(/^([A-Za-z]{2,4})$/); + if (mSolo) return mSolo[1].toUpperCase(); + } + + // 2. Sinon chercher la dernière signature "lettres + date" collée en fin + let lastMatch = null; + let m; + const rxGlobal = /([A-Za-z]{2,4})\s+(\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)/g; + while ((m = rxGlobal.exec(text)) !== null) { + lastMatch = { sigs: m[1], date: m[2], pos: m.index }; + } + if (lastMatch && lastMatch.pos >= text.length - 100) { + return `${lastMatch.sigs.toUpperCase()} ${normalizeSignatureDate(lastMatch.date)}`; + } + return null; +} + +// v4.1.1 : shortMeta() et buildMetaDom() supprimées (code mort, héritage v1). +// Le rendu actuel utilise renderLieuContactBlocks() + buildInterventionRow(). + +async function copyRef(ref, btn) { + if (!ref) return; + try { + await navigator.clipboard.writeText(ref); + btn.classList.add("copied"); + btn.textContent = "✓"; + setTimeout(() => { btn.classList.remove("copied"); btn.textContent = "📋"; }, 1200); + } catch { + alert("Référence : " + ref); + } +} + +// ─── Rendu incrémental (v3.1) ─────────────────────────────────────────────── +// Met à jour UNE ligne d'interventoin dans le DOM (après qu'un fetch fiche +// ait enrichi l'objet iv avec sa ref, son statut, etc.). Appelée par +// fetchAndUpdateIntervention pour afficher la ref dès qu'elle arrive, sans +// attendre que tous les workers aient fini ni re-rendre toute la vue. +// +// Doit rester en phase avec la structure DOM construite par +// buildInterventionRow (classes iv-ref-header, iv-status-check, +// intervention-copy, intervention-dot, timeline-slot...). +const ALL_COLOR_CLASSES = [ + "color-livraison", "color-installation", "color-recup", + "color-remplacement", "color-incident", "color-rollout", + "color-reservation", "color-autre" +]; + +/** + * (Re)génère les blocs Lieu et Contact(s) dans le conteneur .iv-right. + * Supprime d'abord les anciens blocs (.iv-lieu-block + .iv-contact-line), + * puis insère les nouveaux AVANT le bloc .iv-bottom-line (si présent) pour + * conserver l'ordre d'affichage. Utilisé à la création ET lors de la + * mise à jour après fetch de la fiche. + */ +function renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, contactAnomalie) { + // Supprime les anciens blocs lieu/contact + rightCol.querySelectorAll(".iv-lieu-block, .iv-contact-line").forEach(el => el.remove()); + + const contacts = extractContacts(contactRaw); + const { ville, adresse } = splitLieu(lieuRaw); + + // Point d'insertion : avant .iv-bottom-line (catégorie + signature), sinon à la fin + const anchor = rightCol.querySelector(".iv-bottom-line"); + const insert = (el) => { + if (anchor) rightCol.insertBefore(el, anchor); + else rightCol.appendChild(el); + }; + + // ── Lieu : ville (MAJUSCULES GRAS) puis adresse (italique noir) ────────── + if (ville || adresse) { + const lieuBlock = document.createElement("div"); + lieuBlock.className = "iv-lieu-block"; + if (ville) { + const villeEl = document.createElement("div"); + villeEl.className = "iv-lieu-ville"; + villeEl.textContent = ville.toUpperCase(); + lieuBlock.appendChild(villeEl); + } + if (adresse) { + const addrEl = document.createElement("div"); + addrEl.className = "iv-lieu-adresse"; + addrEl.textContent = adresse; + lieuBlock.appendChild(addrEl); + } + insert(lieuBlock); + } + + // ── Contact(s) + téléphone — un par ligne si plusieurs ────────────────── + for (const c of contacts) { + if (!c.name && !c.phone) continue; + const contactEl = document.createElement("div"); + contactEl.className = "iv-contact-line"; + // v4.2 : si anomalie (les 2 champs Contact + Personne de contact existent + // dans l'action), afficher en rouge pour signaler à l'user de vérifier. + if (contactAnomalie) contactEl.classList.add("iv-contact-anomalie"); + if (c.name) { + const nameSpan = document.createElement("span"); + nameSpan.className = "iv-contact"; + nameSpan.textContent = c.name; + contactEl.appendChild(nameSpan); + } + if (c.phone) { + if (c.name) { + const sep = document.createElement("span"); + sep.className = "iv-sep"; + sep.textContent = " | "; + contactEl.appendChild(sep); + } + const phoneSpan = document.createElement("span"); + phoneSpan.className = "iv-phone"; + phoneSpan.textContent = c.phone; + contactEl.appendChild(phoneSpan); + } + insert(contactEl); + } +} + +function updateInterventionRow(iv) { + // Réservations : pas concerné (pas de fetch fiche pour elles) + if (iv.type === "AL-Reservation") return; + + const row = document.querySelector( + `.intervention-v2[data-action-id="${iv.actionId}"]` + ); + if (!row) return; + + // Classes de statut sur la ligne + const sc = getStatusClass(iv); + row.classList.remove("status-closed", "status-resolved", "status-terminated"); + if (sc) row.classList.add(sc); + + // Classe de couleur sur la ligne (la pastille hérite via CSS) + const colorKey = deriveColorKey(iv); + row.classList.remove(...ALL_COLOR_CLASSES); + row.classList.add("color-" + colorKey); + + // Ref (le titre gros en haut de la ligne) + const refEl = row.querySelector(".iv-ref-header"); + if (refEl) { + if (iv.ref) { + refEl.textContent = iv.ref; + refEl.classList.remove("no-ref"); + } else { + refEl.textContent = "—"; + refEl.classList.add("no-ref"); + } + } + + // Check ✓ : ajouter/retirer/mettre à jour selon statut + let checkEl = row.querySelector(".iv-status-check"); + if (sc) { + // v4.2.5 : ✓✓ pour clos/résolu, ✓ pour terminé tech + const isDouble = (sc === "status-closed" || sc === "status-resolved"); + const desiredText = isDouble ? "✓✓" : "✓"; + if (!checkEl) { + checkEl = document.createElement("div"); + checkEl.className = "iv-status-check"; + // Insérer après la ref (avant le bouton copier s'il existe) + const copy = row.querySelector(".intervention-copy"); + if (copy) row.insertBefore(checkEl, copy); + else if (refEl && refEl.nextSibling) row.insertBefore(checkEl, refEl.nextSibling); + else row.appendChild(checkEl); + } + checkEl.textContent = desiredText; + checkEl.classList.toggle("double", isDouble); + } else if (checkEl) { + checkEl.remove(); + } + + // Bouton 📋 copier : ajouter si on a maintenant une ref et qu'il n'existe pas + let copyBtn = row.querySelector(".intervention-copy"); + if (iv.ref && !copyBtn) { + copyBtn = document.createElement("button"); + copyBtn.className = "intervention-copy"; + copyBtn.type = "button"; + copyBtn.title = "Copier la référence"; + copyBtn.innerHTML = "📋"; + copyBtn.addEventListener("click", (e) => { + e.stopPropagation(); + copyRef(iv.ref, copyBtn); + }); + row.appendChild(copyBtn); + } + + // Catégorie affichée en bas (dépend de la ref pour Incident, etc.) + const catEl = row.querySelector(".iv-category"); + if (catEl) catEl.textContent = deriveShortTitle(iv); + + // v4.1.8 : signature planificateur (XXX JJ.MM). Si le texte fiche (complet) + // est arrivé, il peut maintenant fournir une signature que le xhr2 tronqué + // n'avait pas. On met à jour le span .iv-signature en conséquence. + const bottomEl = row.querySelector(".iv-bottom-line"); + if (bottomEl) { + let sigEl = bottomEl.querySelector(".iv-signature"); + const sig = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription); + if (sig) { + if (!sigEl) { + sigEl = document.createElement("span"); + sigEl.className = "iv-signature"; + bottomEl.appendChild(sigEl); + } + sigEl.textContent = sig; + } else if (sigEl) { + sigEl.remove(); + } + } + + // v4.1.2 : régénérer les blocs lieu/contact depuis les valeurs actuelles. + // Priorité à iv.infobulle (xhr2 lazy, vraies infos) puis attr1/attr2 (planif). + const rightCol = row.querySelector(".iv-right"); + if (rightCol) { + const info = iv.infobulle || {}; + const contactRaw = info.contact || iv.bulleContact || null; + const lieuRaw = info.lieu || iv.bulleLieu || null; + renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, info.contactAnomalie); + } + + // Segment timeline correspondant : même couleur + même classe statut + const card = row.closest(".card"); + if (card && row.dataset.ivIdx !== undefined) { + const slot = card.querySelector( + `.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]` + ); + if (slot) { + slot.classList.remove("status-closed", "status-resolved", "status-terminated", ...ALL_COLOR_CLASSES); + slot.classList.add("color-" + colorKey); + if (sc) slot.classList.add(sc); + // Maj du dataset pour le popover (titre + ref) + slot.dataset.title = deriveShortTitle(iv); + if (iv.ref) slot.dataset.ref = iv.ref; + } + } +} + +// ============================================================================ +// Tooltip +// ============================================================================ + +const tooltipEl = () => document.getElementById("tooltip"); + +// v4.1.10 : état persistant de la bulle +// - pinned : une fois épinglée (double Ctrl), la bulle reste à sa position, +// ne suit plus la souris, et ne se ferme ni au mouseleave ni au +// mouseleave suivant. On peut sélectionner le texte dedans. +// Clic hors bulle (ailleurs que sur une autre intervention) ou +// nouveau double-Ctrl → désépingle. +// - hoveredInBulle : si la souris entre DANS la bulle elle-même, la bulle +// reste visible même si elle n'est pas épinglée. Elle ne +// disparaît que quand la souris sort à la fois de la carte ET +// de la bulle. +let bulleState = { + pinned: false, + hoveredInBulle: false, + hoveredInRow: false, + hideTimer: null +}; + +function showTooltip(e, iv, rowEl) { + // v4.1.15 : si la bulle est épinglée sur une autre iv, on NE REMPLACE PAS + // son contenu (l'user veut garder la fiche épinglée même en survolant + // d'autres cartes). + if (bulleState.pinned && state.currentTooltipIv && state.currentTooltipIv !== iv) { + return; + } + + const el = tooltipEl(); + el.innerHTML = buildTooltipHTML(iv); + el.classList.remove("hidden"); + el.classList.add("visible"); + // Conserver le pinned si on revient sur la même iv + if (bulleState.pinned && state.currentTooltipIv === iv) { + el.classList.add("pinned"); + } else { + el.classList.remove("pinned"); + } + if (bulleState.hideTimer) { + clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = null; + } + bulleState.hoveredInRow = true; + // v4.1.12 : positionner la bulle UNE SEULE FOIS au mouseenter, près de la + // carte (row) et pas du curseur. Elle ne bouge plus pdt le survol. + // v4.1.15 : si pinned, NE PAS repositionner (la bulle doit rester fixe). + if (!bulleState.pinned) { + positionTooltipAnchored(rowEl || (e && e.currentTarget)); + } + + // v4 : lazy-load du texte complet de l'action au premier hover. + // Sans await : on affiche le tooltip IMMÉDIATEMENT avec ce qu'on a (lieu, + // contact, catégorie, ref venant du XML) ; quand le xhr2 arrive (50-200 ms + // plus tard typiquement), on régénère le tooltip s'il est encore visible. + if (iv && iv.type === "AL-Intervention" && !iv.xhr2Fetched && !iv.xhr2Fetching) { + ensureBulleDescription(iv).then(ok => { + // Si ça a marché ET que le tooltip est toujours visible sur CETTE iv, + // on régénère le HTML pour afficher les détails Problème/À faire/Matériel. + if (!ok) return; + const tip = tooltipEl(); + if (!tip.classList.contains("visible")) return; + // Vérifie qu'on affiche toujours la même interventoin (pas un autre hover + // intervenu entretemps) + if (state.currentTooltipIv === iv) { + tip.innerHTML = buildTooltipHTML(iv); + } + }); + } + // Mémoriser quelle iv est actuellement affichée (utilisé pour éviter + // d'écraser un tooltip différent si un autre hover s'est produit entretemps) + state.currentTooltipIv = iv; +} + +function hideTooltip(opts = {}) { + // Si la bulle est épinglée, on ignore (sauf force: true = unpin explicite) + if (bulleState.pinned && !opts.force) return; + bulleState.hoveredInRow = false; + // Petit délai : laisse le temps à la souris d'ENTRER dans la bulle elle-même + // (si l'user veut sélectionner du texte). On annule la fermeture si + // hoveredInBulle passe à true entre-temps. + if (bulleState.hideTimer) clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = setTimeout(() => { + if (bulleState.hoveredInBulle || bulleState.hoveredInRow) return; + if (bulleState.pinned) return; + // v4.2 : si l'utilisateur a une sélection de texte ACTIVE dans la bulle, + // on ne ferme pas (sinon la sélection disparaît avant d'avoir pu copier). + if (!opts.force && hasTextSelectionInTooltip()) return; + const el = tooltipEl(); + el.classList.remove("visible", "pinned"); + el.classList.add("hidden"); + // v4.2.4 : reset du mode d'ancrage et de la détection de position + if (el.dataset) { + delete el.dataset.mode; + } + state.currentTooltipIv = null; + currentTooltipPos = null; + tooltipPositionMode = null; // re-détecter à la prochaine ouverture + }, 120); +} + +// v4.2 : détecte si l'utilisateur a une sélection de texte active dans la bulle. +// Utilisé pour empêcher la fermeture automatique tant qu'on n'a pas fini de +// sélectionner/copier. +function hasTextSelectionInTooltip() { + try { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0) return false; + const tip = tooltipEl(); + if (!tip) return false; + const range = sel.getRangeAt(0); + // La sélection est dans la bulle si au moins un endpoint y est + return tip.contains(range.startContainer) || tip.contains(range.endContainer); + } catch { + return false; + } +} + +function moveTooltip(e) { + // Historique : avant on suivait la souris. Maintenant la bulle est fixe + // (placée une seule fois au mouseenter). Cette fonction est là juste pour + // pas casser les appels existants. +} + +// ============================================================================ +// Positionnement du tooltip +// ============================================================================ +// On positionne avec style.left/top en coords VIEWPORT (comme position:fixed). +// Si un ancêtre casse position:fixed (transform, filter, backdrop-filter ou +// contain), on détecte ça empiriquement au 1er placement via +// getBoundingClientRect — et on bascule en "abs" : mêmes coords mais on +// compense le scroll manuellement pour garder la bulle stable à l'écran. +// ============================================================================ + +// Position stockée : targetLeft / targetTop = coordonnées VIEWPORT désirées +// (où la popup doit apparaître à l'écran, peu importe le scroll). +let currentTooltipPos = null; + +// Mode de positionnement, détecté empiriquement : +// null : pas encore détecté +// "fixed" : position:fixed marche → on laisse le navigateur gérer au scroll +// "abs" : position:fixed cassée → on compense manuellement au scroll +let tooltipPositionMode = null; + +function setTooltipViewportPosition(viewportX, viewportY) { + const el = tooltipEl(); + if (!el) return; + currentTooltipPos = { x: viewportX, y: viewportY }; + + // Appliquer la position en supposant que position:fixed marche + el.style.left = viewportX + "px"; + el.style.top = viewportY + "px"; + + // Détection empirique au 1er positionnement : on compare la position + // réelle du tooltip (getBoundingClientRect) à la position demandée. + // Si ça correspond (à 1px près), position:fixed fonctionne. Sinon + // c'est qu'un ancêtre a cassé le containing block. + if (tooltipPositionMode === null) { + const r = el.getBoundingClientRect(); + const deltaX = Math.abs(r.left - viewportX); + const deltaY = Math.abs(r.top - viewportY); + if (deltaX <= 1 && deltaY <= 1) { + tooltipPositionMode = "fixed"; + } else { + tooltipPositionMode = "abs"; + console.info( + "[tooltip] position:fixed cassée par un ancêtre, passage en mode compensé au scroll. " + + `delta=(${deltaX.toFixed(1)}, ${deltaY.toFixed(1)})` + ); + } + } + + // Si mode "abs" : le top/left qu'on vient de poser est en réalité interprété + // par rapport au containing block (pas le viewport). On doit compenser + // immédiatement pour placer la popup au bon endroit visuellement. + if (tooltipPositionMode === "abs") { + const r = el.getBoundingClientRect(); + const offsetX = viewportX - r.left; // écart à corriger + const offsetY = viewportY - r.top; + // Nouvelle valeur absolute qui produit la position viewport voulue + const absLeft = parseFloat(el.style.left) + offsetX; + const absTop = parseFloat(el.style.top) + offsetY; + el.style.left = absLeft + "px"; + el.style.top = absTop + "px"; + // Mémoriser pour compenser au scroll + el._absBasisLeft = absLeft; + el._absBasisTop = absTop; + el._absBasisScrollX = window.scrollX || window.pageXOffset || 0; + el._absBasisScrollY = window.scrollY || window.pageYOffset || 0; + } +} + +// Listener global scroll : si on est en mode "abs", on compense pour que la +// popup reste visuellement au même endroit pendant le scroll. +function reapplyTooltipPosition() { + if (!currentTooltipPos) return; + const el = tooltipEl(); + if (!el || !el.classList.contains("visible")) return; + if (tooltipPositionMode !== "abs") return; // fixed marche, rien à faire + + // Compenser le scroll : la popup doit rester à currentTooltipPos dans le + // viewport. Pour ça, on ajoute l'écart entre le scroll actuel et le + // scroll au moment de l'ancrage. + const scrollX = window.scrollX || window.pageXOffset || 0; + const scrollY = window.scrollY || window.pageYOffset || 0; + const dx = scrollX - (el._absBasisScrollX || 0); + const dy = scrollY - (el._absBasisScrollY || 0); + el.style.left = ((el._absBasisLeft || 0) + dx) + "px"; + el.style.top = ((el._absBasisTop || 0) + dy) + "px"; +} + +function positionTooltipAnchored(rowEl) { + const el = tooltipEl(); + if (!rowEl || !el) return; + const pad = 14; + const rowRect = rowEl.getBoundingClientRect(); + const tipRect = el.getBoundingClientRect(); + + // Position X : à droite de la ligne par défaut + let x = rowRect.right + pad; + if (x + tipRect.width > window.innerWidth - 8) { + x = rowRect.left - tipRect.width - pad; + } + if (x < 4) x = 4; + + // Position Y : aligné en haut de la ligne + let y = rowRect.top; + if (y + tipRect.height > window.innerHeight - 8) { + y = window.innerHeight - tipRect.height - 8; + } + if (y < 4) y = 4; + + setTooltipViewportPosition(x, y); +} + +// ============================================================================ +// v4.3.0 : système de popups épinglés détachés +// ============================================================================ +// +// Au lieu d'épingler le tooltip unique (qui empêchait d'afficher d'autres +// infos au survol), on clone son contenu en un popup indépendant : +// - Ancré DANS le contenu de la page (position: absolute + coordonnées +// document) → scrolle avec le contenu, pas avec le viewport. +// - Peut coexister avec d'autres popups épinglés (jusqu'à ce qu'il n'y +// ait plus de place disponible). +// - Persiste jusqu'à fermeture explicite (bouton ×, Échap, ou Ctrl×2 si 1 seul). +// +// Le tooltip live (#tooltip) garde son rôle initial : il se ferme au mouseleave. + +const pinnedPopups = []; // [{el, iv, rect}] + +/** + * Ancre la popup au contenu : ajoute le scrollY actuel au top viewport pour + * obtenir une position absolute document, qui scrolle avec le contenu. + */ +function _viewportToDocumentY(y) { + return y + (window.scrollY || window.pageYOffset || 0); +} +function _viewportToDocumentX(x) { + return x + (window.scrollX || window.pageXOffset || 0); +} + +/** + * Teste si un rectangle {left, top, right, bottom} (en coords document) + * chevauche avec un popup déjà épinglé. + */ +function _rectsOverlap(a, b) { + return !(a.right <= b.left || a.left >= b.right || + a.bottom <= b.top || a.top >= b.bottom); +} + +/** + * Cherche une position libre pour un popup de dimensions {w, h} près de la + * ligne source `rowEl`. Essaie dans l'ordre : droite, gauche, dessous, dessus. + * Retourne {x, y} en coordonnées document, ou null si aucune position libre. + */ +function _findFreePopupPosition(rowEl, w, h) { + const pad = 14; + const rowRect = rowEl.getBoundingClientRect(); + const viewportW = window.innerWidth; + const viewportH = window.innerHeight; + + // 4 candidats, en coords viewport + const candidates = [ + // Droite + { x: rowRect.right + pad, y: rowRect.top, name: "droite" }, + // Gauche + { x: rowRect.left - w - pad, y: rowRect.top, name: "gauche" }, + // Dessous + { x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" }, + // Dessus + { x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" } + ]; + + // Pour chaque candidat, clamper dans le viewport (marge 8px) et convertir + // en coord document, puis tester le chevauchement + for (const c of candidates) { + let x = c.x, y = c.y; + // Clamp horizontal dans le viewport + if (x < 4) x = 4; + if (x + w > viewportW - 8) x = viewportW - 8 - w; + // Clamp vertical dans le viewport + if (y < 4) y = 4; + if (y + h > viewportH - 8) y = viewportH - 8 - h; + // 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 }; + if (_rectsOverlap(candRect, rowRectClamped)) continue; + + // Test chevauchement avec les popups déjà épinglés + const docRect = { + left: _viewportToDocumentX(x), + top: _viewportToDocumentY(y), + right: _viewportToDocumentX(x + w), + bottom: _viewportToDocumentY(y + h) + }; + let overlapsOther = false; + for (const p of pinnedPopups) { + if (_rectsOverlap(docRect, p.rect)) { + overlapsOther = true; + break; + } + } + if (!overlapsOther) { + // Position libre trouvée + return { + viewportX: x, viewportY: y, + docX: docRect.left, docY: docRect.top, + rect: docRect + }; + } + } + return null; +} + +/** + * v4.3.0 : épingle la bulle courante en la clonant dans un popup détaché + * ancré au contenu. Le tooltip live redevient disponible. + */ +function pinTooltip() { + if (!state.currentTooltipIv) return; + const srcEl = tooltipEl(); + if (!srcEl) return; + const iv = state.currentTooltipIv; + + // Chercher la ligne source (row iv-v2) + let rowEl = null; + if (iv.actionId) { + rowEl = document.querySelector(`.intervention-v2[data-action-id="${iv.actionId}"]`); + } + if (!rowEl) { + // Fallback : utiliser la position actuelle du tooltip live + rowEl = srcEl; + } + + // Cloner le contenu du tooltip actuel en popup détaché + const popup = document.createElement("div"); + popup.className = "tooltip pinned-popup visible"; + popup.dataset.actionId = iv.actionId || ""; + popup.innerHTML = srcEl.innerHTML; + + // Ajouter un bouton × de fermeture (en plus du 📌) + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.className = "pinned-popup-close"; + closeBtn.innerHTML = "×"; + closeBtn.title = "Désépingler (reste visible tant que la souris est dessus)"; + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + // Désépinglage "mou" : on marque la popup comme non épinglée mais on la + // laisse visible tant que la souris est dessus. Elle disparaît quand la + // souris sort. + _softUnpinPopup(popup); + }); + popup.appendChild(closeBtn); + + // v4.3.3 : barre de drag en haut, pour déplacer la popup à la souris. + // Ancrée en haut à 22px de haut ; le padding-top de la popup est augmenté + // côté CSS pour ne pas que le contenu soit caché derrière. + const dragbar = document.createElement("div"); + dragbar.className = "pinned-popup-dragbar"; + dragbar.title = "Glissez pour déplacer"; + popup.appendChild(dragbar); + _attachPopupDragHandler(popup, dragbar); + + // v4.3.0 : le popup contient un clone du tooltip live, qui inclut le + // bouton 📌. Dans un popup déjà épinglé, ce bouton devient "désépingler". + // On intercepte le clic ici, avant qu'il remonte. + popup.addEventListener("click", (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + if (action === "pin") { + e.stopPropagation(); + e.preventDefault(); + _softUnpinPopup(popup); + } + // Les autres actions (reload, copy-ref, etc.) ne sont pas gérées ici ; + // on pourrait les ajouter plus tard si besoin. + }); + + // Placer en (0,0) temporairement pour mesurer la taille + popup.style.position = "absolute"; + popup.style.left = "-9999px"; + popup.style.top = "-9999px"; + popup.style.visibility = "hidden"; + document.body.appendChild(popup); + + // Mesurer après rendu + const pRect = popup.getBoundingClientRect(); + const w = pRect.width; + const h = pRect.height; + + // Chercher une position libre + const pos = _findFreePopupPosition(rowEl, w, h); + + if (!pos) { + // Pas de place : retirer et afficher un toast + popup.remove(); + showToast("Pas de place", "Fermez une popup épinglée"); + return; + } + + // Appliquer la position (coords document = position: absolute) + popup.style.left = pos.docX + "px"; + popup.style.top = pos.docY + "px"; + popup.style.visibility = "visible"; + + // Enregistrer dans la liste + pinnedPopups.push({ el: popup, iv: iv, rect: pos.rect }); + + // v4.3.0 : libérer le tooltip live (il redevient utilisable pour d'autres survols) + bulleState.pinned = false; + bulleState.hoveredInRow = false; + bulleState.hoveredInBulle = false; + srcEl.classList.remove("visible", "pinned"); + srcEl.classList.add("hidden"); + if (srcEl.dataset) delete srcEl.dataset.mode; + state.currentTooltipIv = null; + currentTooltipPos = null; + tooltipPositionMode = null; + if (bulleState.hideTimer) { + clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = null; + } +} + +/** Ferme un popup épinglé donné. */ +function _closePinnedPopup(el) { + const idx = pinnedPopups.findIndex(p => p.el === el); + if (idx >= 0) pinnedPopups.splice(idx, 1); + el.remove(); +} + +/** + * Désépinglage "mou" : la popup n'est plus considérée épinglée (elle n'est + * plus dans pinnedPopups, donc le comptage pour Ctrl×2 etc. ignore) mais on + * la laisse visible. Elle disparait quand la souris sort. + */ +function _softUnpinPopup(el) { + // Retirer de la liste (pour le comptage Ctrl×2) mais garder le DOM + const idx = pinnedPopups.findIndex(p => p.el === el); + if (idx >= 0) pinnedPopups.splice(idx, 1); + + // v4.3.3 corr : basculer visuellement en tooltip normal (retirer tous les + // attributs visuels du mode épinglé : bordure bleue, dragbar, bouton ×, + // padding-top, etc.). La classe .soft-unpinned fait ça côté CSS. + // On retire .pinned-popup pour que les règles visuelles lourdes + // disparaissent, tout en gardant la popup au même endroit (position + // absolute conservée). + el.classList.remove("pinned-popup"); + el.classList.add("soft-unpinned"); + // Icône 📌 → 📍 pour le clin d'œil (même si elle va bientôt disparaitre) + const pinBtn = el.querySelector('[data-action="pin"]'); + if (pinBtn) pinBtn.textContent = "📍"; + // Supprimer les éléments propres au mode épinglé : barre de drag et × + const dragbar = el.querySelector(".pinned-popup-dragbar"); + if (dragbar) dragbar.remove(); + const closeBtn = el.querySelector(".pinned-popup-close"); + if (closeBtn) closeBtn.remove(); + + // Helper qui joue l'animation de sortie puis supprime le DOM + const animateAndRemove = () => { + el.classList.add("unpinning"); + setTimeout(() => el.remove(), 180); + }; + + if (!el.matches(":hover")) { + animateAndRemove(); + return; + } + // Souris dessus : on ne supprime pas tout de suite. On attend mouseleave + // et à ce moment on joue l'animation de sortie et on supprime. + el.addEventListener("mouseleave", animateAndRemove, { once: true }); +} + +/** Ferme toutes les popups épinglées (appelé par Échap ou changement de date). */ +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()); +} + +/** + * 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; + + // Clamper dans le document (pas sortir trop à gauche/haut) + if (newLeft < 4) newLeft = 4; + if (newTop < 4) newTop = 4; + + popup.style.left = newLeft + "px"; + popup.style.top = newTop + "px"; + }; + + const onMouseUp = () => { + if (!dragging) return; + dragging = false; + popup.classList.remove("dragging"); + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + + // Mettre à jour le rect mémorisé pour la détection de chevauchement + const entry = pinnedPopups.find(p => p.el === popup); + if (entry) { + const l = parseFloat(popup.style.left) || 0; + const t = parseFloat(popup.style.top) || 0; + const w = popup.offsetWidth; + const h = popup.offsetHeight; + entry.rect = { left: l, top: t, right: l + w, bottom: t + h }; + } + }; + + dragbar.addEventListener("mousedown", (e) => { + // Seulement bouton gauche + if (e.button !== 0) return; + e.preventDefault(); + dragging = true; + startMouseX = e.clientX; + startMouseY = e.clientY; + startLeft = parseFloat(popup.style.left) || 0; + startTop = parseFloat(popup.style.top) || 0; + popup.classList.add("dragging"); + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); +} + +// v4.1.14/19 : recharger UNIQUEMENT cette intervention. Fetch DIRECT sans +// passer par isRefreshAborted (pour ne pas être bloqué par un abort global +// ou un refresh précédent). Animation sur le bouton ↻ de la bulle. +async function reloadSingleIntervention(iv, btnEl) { + if (!iv || iv.type === "AL-Reservation") return; + // Empêcher double-clic en cours + if (iv._reloading) return; + iv._reloading = true; + + // v4.1.19 : NE PAS reset les champs AVANT le fetch (sinon si le fetch + // échoue ou est interrompu, on perd les données précédentes). On les + // mettra à jour uniquement si le fetch réussit. + const previousState = { + xhr2Fetched: iv.xhr2Fetched, + ficheFetched: iv.ficheFetched, + ficheActionText: iv.ficheActionText, + ficheFetchError: iv.ficheFetchError, + bulleDescription: iv.bulleDescription, + infobulle: iv.infobulle, + status: iv.status, + label: iv.label, + ficheChecksum: iv.ficheChecksum, + ficheTarget: iv.ficheTarget, + formSenderGuid: iv.formSenderGuid + }; + + // Marquer le bouton ↻ comme en cours (visuel immédiat) + if (btnEl) btnEl.classList.add("spinning"); + // v4.1.19 : toast de feedback en bas à droite + showToast("Rafraîchissement", iv.ref || iv.actionId); + + try { + // ─── xhr2 (rapide) ───────────────────────────────────────────────── + try { + const xhr2Resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId }); + if (xhr2Resp && xhr2Resp.ok) { + const parsed = parseXhr2Body(xhr2Resp.body); + if (parsed) { + if (parsed.description) { + iv.bulleDescription = parsed.description; + const infob = parseActionText(parsed.description); + if (infob) iv.infobulle = infob; + } + if (parsed.label) iv.label = parsed.label; + iv.xhr2Fetched = true; + } + } + } catch (err) { + console.warn("[reloadSingle/xhr2] iv", iv.actionId, err); + } + + // ─── fiche HTML ──────────────────────────────────────────────────── + const ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink }); + if (ficheResp.ok) { + const fiche = parseFicheHtml(ficheResp.html); + iv.status = fiche.status; + if (fiche.rfc && !iv.ref) iv.ref = fiche.rfc; + if (fiche.formSenderGuid) iv.formSenderGuid = fiche.formSenderGuid; + + // ─── timeline API : texte complet ────────────────────────────── + if (fiche.formId && fiche.formChecksum && fiche.formSenderGuid && iv.actionId) { + try { + const tlResp = await sendMessage({ + type: "fetchTimelineApi", + guid: fiche.formSenderGuid, + formId: fiche.formId, + formChecksum: fiche.formChecksum + }); + if (tlResp && tlResp.ok) { + const fullText = parseTimelineJsonForAction(tlResp.body, iv.actionId); + if (fullText) iv.ficheActionText = fullText; + } + } catch (err) { + console.warn("[reloadSingle/timeline] iv", iv.actionId, err); + } + } + + // ─── Extraire checksum pour ouverture ─────────────────────────── + if (iv.requestId && !iv.ficheChecksum) { + const rx1 = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`); + const m1 = ficheResp.html.match(rx1); + if (m1) { + iv.ficheTarget = iv.requestId; + iv.ficheChecksum = m1[1]; + } + } + iv.ficheFetched = true; + iv.ficheFetchError = null; + } else { + iv.ficheFetchError = ficheResp.error || "fetch_failed"; + if (ficheResp.error === "session_expired") { + state.session = null; + showSessionExpiredBanner(); + } + } + + // Mettre à jour la carte (statut clos → ✓ vert, catégorie, etc.) + updateInterventionRow(iv); + + // Si la bulle est toujours ouverte sur cette iv, régénérer son HTML + const tip = tooltipEl(); + if (tip.classList.contains("visible") && state.currentTooltipIv === iv) { + tip.innerHTML = buildTooltipHTML(iv); + } + + // Sauvegarder le cache + try { + const cached = await readCache(state.currentDate); + if (cached && cached.techs) { + for (const tech of cached.techs) { + for (let i = 0; i < (tech.interventions || []).length; i++) { + if (tech.interventions[i].actionId === iv.actionId) { + tech.interventions[i] = iv; + } + } + } + await writeCache(state.currentDate, { techs: cached.techs }); + } + } catch (err) { + console.warn("[reloadSingle/cache]", err); + } + + // v4.1.19 : toast de succès + showToast("Mis à jour", iv.ref || iv.actionId); + } catch (err) { + console.warn("[reloadSingle] erreur iv", iv.actionId, err); + // Restaurer l'état précédent en cas d'erreur globale + Object.assign(iv, previousState); + } finally { + iv._reloading = false; + if (btnEl) btnEl.classList.remove("spinning"); + } +} +function unpinTooltip() { + bulleState.pinned = false; + const el = tooltipEl(); + el.classList.remove("pinned"); + // v4.1.13 : test immédiat si la souris est toujours dans la bulle ou sur + // la ligne. Si ni l'un ni l'autre, on ferme tout de suite (sans timer). + if (!bulleState.hoveredInBulle && !bulleState.hoveredInRow) { + el.classList.remove("visible"); + el.classList.add("hidden"); + if (el.dataset) delete el.dataset.mode; + state.currentTooltipIv = null; + currentTooltipPos = null; + tooltipPositionMode = null; + if (bulleState.hideTimer) { + clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = null; + } + } + // Sinon : la bulle reste visible, et c'est le mouseleave qui la fermera + // normalement quand la souris sortira. +} + +// v4.1.10 : interactions bulle (double-Ctrl pour pin/unpin, hover dans la +// bulle pour persistance, clic hors pour unpin). +function bindTooltipInteractions() { + const el = tooltipEl(); + if (!el) return; + + // v4.1.17 : ré-applique la position au scroll de la page (safety net + // contre un ancêtre qui casserait position:fixed silencieusement). + window.addEventListener("scroll", reapplyTooltipPosition, { passive: true }); + window.addEventListener("resize", () => { + // Au resize, on laisse fermer la bulle (position probablement invalidée) + if (bulleState.pinned) return; + hideTooltip({ force: true }); + }); + + // v4.1.17 : bloquer le scroll de la page quand la souris est DANS la + // bulle. Le scroll interne de la bulle (overflow-y auto) reste OK. + // On utilise "wheel" non-passif pour pouvoir preventDefault. + el.addEventListener("wheel", (e) => { + // Si la bulle a un scroll interne et n'est pas à la limite, laisser + // le scroll naturel se faire. Sinon, bloquer le scroll global. + const canScrollDown = el.scrollTop + el.clientHeight < el.scrollHeight; + const canScrollUp = el.scrollTop > 0; + if ((e.deltaY > 0 && !canScrollDown) || (e.deltaY < 0 && !canScrollUp)) { + e.preventDefault(); + } + // Ne pas laisser le scroll se propager au body + e.stopPropagation(); + }, { passive: false }); + + // Hover sur la bulle elle-même : empêche la fermeture + el.addEventListener("mouseenter", () => { + bulleState.hoveredInBulle = true; + if (bulleState.hideTimer) { + clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = null; + } + }); + el.addEventListener("mouseleave", () => { + bulleState.hoveredInBulle = false; + if (!bulleState.hoveredInRow && !bulleState.pinned) { + hideTooltip(); + } + }); + + // Double-Ctrl : v4.3.0 + // - Si 0 popup épinglé ET un tooltip live visible : épingler + // - Si EXACTEMENT 1 popup épinglé ET souris pas dessus : le fermer + // - Si 2+ popups épinglés : ne fait rien (ambigu, user doit utiliser Échap) + // On détecte 2 keydown Control dans une fenêtre de 400 ms. + let lastCtrlTs = 0; + document.addEventListener("keydown", (e) => { + if (e.key !== "Control") return; + if (e.repeat) return; + const now = performance.now(); + if (now - lastCtrlTs < 400) { + lastCtrlTs = 0; + if (pinnedPopups.length === 0) { + // Aucun popup épinglé : épingler le tooltip live s'il y en a un + if (state.currentTooltipIv) pinTooltip(); + } else if (pinnedPopups.length === 1) { + // 1 popup épinglé : le fermer si la souris n'est pas dessus + const p = pinnedPopups[0]; + if (!p.el.matches(":hover")) { + _closePinnedPopup(p.el); + } + } + // 2+ popups : rien faire (Échap pour tout fermer) + } else { + lastCtrlTs = now; + } + }); + + // v4.1.13 : clic sur le bouton 📌 ou ↻ (bouton d'action de la bulle) + el.addEventListener("click", (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + e.stopPropagation(); + e.preventDefault(); + const action = btn.dataset.action; + if (action === "pin") { + // v4.3.0 : toujours épingler (le tooltip live clone son contenu en popup + // détaché). Pour désépingler, l'user utilise × sur le popup, ou Échap. + if (state.currentTooltipIv) { + pinTooltip(); + } + } else if (action === "reload") { + // v4.1.14 : recharger uniquement l'intervention actuellement affichée + if (state.currentTooltipIv) { + reloadSingleIntervention(state.currentTooltipIv, btn); + } + } else if (action === "copy-ref") { + // v4.1.15 : copier la référence depuis la bulle + const ref = btn.dataset.ref; + if (ref) { + navigator.clipboard.writeText(ref).then(() => { + btn.classList.add("copied"); + const original = btn.textContent; + btn.textContent = "✓"; + setTimeout(() => { + btn.classList.remove("copied"); + btn.textContent = original; + }, 1200); + }).catch(() => {}); + } + } + }); + + // Clic hors bulle : unpin si épinglé. + // Attention : ne pas déclencher sur clic DANS la bulle (elle contient du + // texte sélectionnable), ni sur clic sur une interventoin (qui ouvre la + // fiche — le user n'attend pas que la bulle reste épinglée dans ce cas + // mais le comportement "ouvrir la fiche" reste prioritaire). + document.addEventListener("mousedown", (e) => { + if (!bulleState.pinned) return; + // Clic dans la bulle → on laisse (sélection de texte) + if (el.contains(e.target)) return; + // Dans tous les autres cas (y compris clic sur une autre interventoin), + // on désépingle. Si c'était un clic sur intervention, le handler + // d'ouverture de la fiche s'exécutera ensuite normalement. + unpinTooltip(); + }); +} + +function buildTooltipHTML(iv) { + const i = iv.infobulle || {}; + const rows = []; + + // Cas spécial : réservation (créneau bloqué par un coordinateur) + if (iv.type === "AL-Reservation") { + rows.push(`
Type
Réservation
`); + if (iv.startTime && iv.endTime) { + rows.push(row("Horaire", `${iv.startTime}–${iv.endTime}`)); + } + if (iv.reservationLabel) rows.push(row("Sujet", iv.reservationLabel)); + if (iv.reservationCreator) rows.push(row("Par", iv.reservationCreator)); + return `
${rows.join("")}
`; + } + + // Statut en premier (si connu) + if (iv.status) { + let cls = "other"; + if (isClosedStatus(iv.status)) cls = "closed"; + else if (isResolvedStatus(iv.status)) cls = "resolved"; + else if (/en cours|ex[ée]cution/i.test(iv.status)) cls = "ongoing"; + rows.push(`
Statut
${escapeHtml(iv.status)}
`); + } + + if (iv.startTime && iv.endTime) { + rows.push(row("Horaire", `${iv.startTime}–${iv.endTime}`)); + } + + // ─── Texte d'action : fiche (complet) en priorité, sinon xhr2 (tronqué) ── + // v4.1.8 : un seul bloc "Action" qui s'enrichit automatiquement. Au début, + // le xhr2 tronqué s'affiche ; dès que le fetch timeline est revenu, + // iv.ficheActionText remplace le texte dans le même bloc. + const actionText = iv.ficheActionText || + (iv.bulleDescription ? formatActionTextMultiline(iv.bulleDescription) : null); + if (actionText) { + const htmlAction = escapeHtml(actionText).replace(/\n/g, "
"); + rows.push(`
Action
${htmlAction}
`); + } else { + // Si pas de description (même pas de xhr2), afficher les infos structurées qu'on a + const hasAction = !!(i.date || i.heure || i.lieu || i.contact || i.service || + i.probleme || i.aFaire || i.materiel); + if (i.date || i.heure) { + const dh = [i.date, i.heure].filter(Boolean).join(" · "); + if (dh) rows.push(row("Quand", dh)); + } + const contact = i.contact || iv.bulleContact; + if (contact) rows.push(row("Contact", contact)); + const lieu = i.lieu || iv.bulleLieu; + if (lieu) rows.push(row("Lieu", lieu)); + if (i.service) rows.push(row("Service", i.service)); + if (i.probleme) rows.push(row("Problème", i.probleme)); + if (i.aFaire) rows.push(row("À faire", i.aFaire)); + if (!hasAction && !contact && !lieu) { + if (iv.ficheFetched) { + rows.push(`
Info
Aucun détail pour cette intervention.
`); + } else { + rows.push(`
Info
Chargement des détails…
`); + } + } + } + + // Deadline (si connue et différente) + if (iv.deadline && !i.date) rows.push(row("Deadline", iv.deadline)); + + if (iv.ref) { + rows.push(`
`); + // v4.1.15 : ref avec bouton copier inline + const refSafe = escapeHtml(iv.ref); + rows.push(`
Référence
${refSafe}
`); + } + + if (iv.ghost) { + rows.push(`
`); + rows.push(`
Intervention disparue d'EasyVista (clôturée, déplacée ou annulée)
`); + } else if (iv.formLink) { + rows.push(`
`); + rows.push(`
Cliquer pour ouvrir la fiche
`); + } + + if (rows.length === 0) { + return `
+
+ +
+
📌
+
Info
Aucun détail disponible
`; + } + // v4.1.13/14 : boutons d'action en haut à droite (recharger + épingler) + return `
+
+ +
+
📌
+
${rows.join("")}
`; +} + +/** + * Met en forme un texte d'action EasyVista en ajoutant des retours à la ligne + * avant chaque étiquette connue ("Date :", "Lieu :", "Contact :", etc.). + * Transforme : + * "Date : 20.04 Heure : MatinLieu : Ville1/Rue1 1 bisContact : Nom..." + * En : + * "Date : 20.04 Heure : Matin + * Lieu : Ville1/Rue1 1 bis + * Contact : Nom..." + */ +function formatActionTextMultiline(text) { + if (!text) return ""; + const newlineLabels = [ + "Lieu", "Contact", + "Service", "Étage", "Bureau", + "Nom utilisateur", + "Problème", "A faire", "À faire", + "Matériel", "Materiel", + "Bénéficiaire", "Beneficiaire" + ]; + let result = String(text); + for (const label of newlineLabels) { + const rx = new RegExp(`([^\\n])(${escapeRegex(label)}\\s*:\\s*)`, "g"); + result = result.replace(rx, "$1\n$2"); + } + // Isoler la signature planificateur finale ("ECM 16.04", "csh 27.03", etc.) + // qui se trouve typiquement en fin sans préfixe de label. + // On utilise un look-behind pour ne PAS manger la lettre précédente + // (et donc ne pas couper le "F" de "FRD 07/04"). + result = result.replace(/(?<=[^\n])(\s*)([A-Za-z]{2,4}\s+\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)\s*$/, "\n$2"); + // Nettoyer + result = result.replace(/\n{2,}/g, "\n").trim(); + return result; +} + +function escapeRegex(s) { + return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function row(label, value) { + return `
${escapeHtml(label)}
${escapeHtml(value)}
`; +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function highlightIntervention(cardEl, ivIdx, on) { + const row = cardEl.querySelector(`.intervention[data-iv-idx="${ivIdx}"]`); + const slot = cardEl.querySelector(`.timeline-slot[data-iv-idx="${ivIdx}"]`); + if (row) row.classList.toggle("highlight", on); + if (slot) slot.classList.toggle("highlight", on); +} + +// ============================================================================ +// Helpers temps +// ============================================================================ + +function timeToMinutes(hhmm) { + if (!hhmm) return null; + const m = hhmm.match(/^(\d{1,2}):(\d{2})$/); + if (!m) return null; + return parseInt(m[1], 10) * 60 + parseInt(m[2], 10); +} + +function minutesToTime(mins) { + const h = Math.floor(mins / 60); + const m = mins % 60; + return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); +} + +// ============================================================================ +// Écrans d'erreur +// ============================================================================ + +function showLoading() { + document.getElementById("loading").classList.remove("hidden"); + document.getElementById("error-box").classList.add("hidden"); + document.getElementById("session-needed").classList.add("hidden"); + document.getElementById("stats").classList.add("hidden"); + document.getElementById("cards").innerHTML = ""; +} + +function showError(msg) { + document.getElementById("loading").classList.add("hidden"); + document.getElementById("stats").classList.add("hidden"); + document.getElementById("session-needed").classList.add("hidden"); + const evUnr = document.getElementById("ev-unreachable"); + if (evUnr) evUnr.classList.add("hidden"); + document.getElementById("cards").innerHTML = ""; + const box = document.getElementById("error-box"); + box.textContent = msg; + box.classList.remove("hidden"); +} + +function showSessionNeeded() { + document.getElementById("loading").classList.add("hidden"); + document.getElementById("error-box").classList.add("hidden"); + document.getElementById("stats").classList.add("hidden"); + const evUnr = document.getElementById("ev-unreachable"); + if (evUnr) evUnr.classList.add("hidden"); + document.getElementById("cards").innerHTML = ""; + document.getElementById("session-needed").classList.remove("hidden"); +} + +function hideSessionNeeded() { + document.getElementById("session-needed").classList.add("hidden"); +} + +// v4.2 : écran plein "EasyVista inaccessible" (différent de session expirée). +function showEvUnreachable() { + document.getElementById("loading").classList.add("hidden"); + document.getElementById("error-box").classList.add("hidden"); + document.getElementById("stats").classList.add("hidden"); + document.getElementById("session-needed").classList.add("hidden"); + document.getElementById("cards").innerHTML = ""; + const el = document.getElementById("ev-unreachable"); + if (el) el.classList.remove("hidden"); +} + +function hideEvUnreachable() { + const el = document.getElementById("ev-unreachable"); + if (el) el.classList.add("hidden"); +} + +// v4.1.12 : bannière non bloquante "session expirée". Affichée quand le +// fetch détecte une session morte EN COURS DE ROUTE (pas au démarrage). +// L'utilisateur voit toujours les données déjà chargées, mais est prévenu +// que les mises à jour sont arrêtées. +function showSessionExpiredBanner() { + const b = document.getElementById("session-expired-banner"); + if (b) b.classList.remove("hidden"); + // Masquer la bannière EV si présente (on ne montre qu'une bannière à la fois) + hideEvUnreachableBanner(); +} +function hideSessionExpiredBanner() { + const b = document.getElementById("session-expired-banner"); + if (b) b.classList.add("hidden"); +} + +// v4.2.5 : bannière non bloquante "EasyVista inaccessible" +function showEvUnreachableBanner() { + const b = document.getElementById("ev-unreachable-banner"); + if (b) b.classList.remove("hidden"); + // On masque la bannière session expirée (1 seule bannière à la fois) + hideSessionExpiredBanner(); +} +function hideEvUnreachableBanner() { + const b = document.getElementById("ev-unreachable-banner"); + if (b) b.classList.add("hidden"); +}