// ============================================================================ // viewer.js v3 — vue claire du planning techniciens // ============================================================================ // Différences clés avec v2 : // 1. Fetch direct EasyVista (plus besoin de capturer la page manuellement) // 2. Parsing XML (planning_xhr.php?div=calendar_block) au lieu de HTML // 3. Fetch des fiches individuelles pour détecter les statuts Clôturé/Résolu // 4. Cache persistant 7 jours par date (chrome.storage.local) // 5. Navigation ◀ / date picker / ▶ // 6. Refresh auto 12h / 15h // // Les fetches se font dans le service worker (background.js) pour éviter // les problèmes de CORS : viewer.js envoie des messages, background fait les // requêtes et renvoie les données. // ============================================================================ // ============================================================================ // 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; // Concurrence du fetch en parallèle (fiches + timelines). // Avant v3.1 : 12. Monté à 30 pour afficher les refs plus vite sur les jours // chargés (~34 interventions → 2 vagues au lieu de 3). Si le serveur sature, // redescendre à 20. const FETCH_CONCURRENCY = 30; // ============================================================================ // 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.actionText, 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.actionText, 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 && /^I2\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 && /^I2\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. let currentRefreshToken = 0; let abortedToken = -1; function startNewRefresh() { currentRefreshToken++; return currentRefreshToken; } function abortCurrentRefresh() { abortedToken = currentRefreshToken; } function isRefreshAborted() { return abortedToken === currentRefreshToken; } // ============================================================================ // Boot // ============================================================================ document.addEventListener("DOMContentLoaded", init); async function init() { initTheme(); bindTopbar(); // Initialiser la date = aujourd'hui state.currentDate = todayISO(); document.getElementById("date-picker").value = state.currentDate; // Écouter les messages d'auto-refresh du service worker chrome.runtime.onMessage.addListener((msg) => { if (msg && msg.type === "autoRefresh") { console.log("Auto-refresh 12h/15h déclenché"); refreshPlanning({ keepStatuses: true }); } }); // Charger la session puis le planning await refreshSessionAndLoad(); } async function refreshSessionAndLoad() { const resp = await sendMessage({ type: "getSession" }); if (!resp.ok || !resp.session) { showSessionNeeded(); return; } state.session = resp.session; hideSessionNeeded(); await loadForDate(state.currentDate); } // ============================================================================ // 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); document.getElementById("refresh-btn").addEventListener("click", () => refreshPlanning()); document.getElementById("abort-btn").addEventListener("click", () => { abortCurrentRefresh(); showAbortButton(false); }); document.getElementById("clear-cache-btn").addEventListener("click", onClearCache); document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1)); document.getElementById("nav-next").addEventListener("click", () => navigateDate(+1)); document.getElementById("nav-today").addEventListener("click", () => loadForDate(todayISO())); document.getElementById("date-picker").addEventListener("change", (e) => { if (e.target.value) loadForDate(e.target.value); }); document.getElementById("open-ev-btn").addEventListener("click", openEasyVista); } 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() { if (!confirm(`Vider le cache du ${formatDateDM(state.currentDate)} ?`)) return; await chrome.storage.local.remove(CACHE_PREFIX + state.currentDate); await loadForDate(state.currentDate, { forceRefetch: true }); } // ============================================================================ // Date helpers // ============================================================================ function todayISO() { const d = new Date(); return dateToISO(d); } function dateToISO(d) { const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); return `${yyyy}-${mm}-${dd}`; } function isoToDate(iso) { const [y, m, d] = iso.split("-").map(n => parseInt(n, 10)); return new Date(y, m - 1, d); } function isoToDDMMYYYY(iso) { const [y, m, d] = iso.split("-"); return `${d}/${m}/${y}`; } function formatDateDM(iso) { const [, m, d] = iso.split("-"); return `${d}/${m}`; } function isoToUnixDate(iso) { // Renvoie le timestamp Unix à midi local du jour (pour que le serveur comprenne bien le jour demandé) const d = isoToDate(iso); d.setHours(12, 0, 0, 0); return Math.floor(d.getTime() / 1000); } // ============================================================================ // Messages → background // ============================================================================ function sendMessage(msg) { return new Promise((resolve, reject) => { chrome.runtime.sendMessage(msg, (response) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; } resolve(response || {}); }); }); } // ============================================================================ // Cache (chrome.storage.local) // ============================================================================ async function readCache(isoDate) { const key = CACHE_PREFIX + isoDate; const obj = await chrome.storage.local.get(key); return obj[key] || null; } async function writeCache(isoDate, data) { const key = CACHE_PREFIX + isoDate; await chrome.storage.local.set({ [key]: { ...data, savedAt: Date.now() } }); } // ============================================================================ // Flux principal : charger une date // ============================================================================ async function loadForDate(isoDate, opts = {}) { state.currentDate = isoDate; document.getElementById("date-picker").value = isoDate; if (!state.session) { showSessionNeeded(); return; } // 1. Afficher immédiatement depuis le cache si disponible const cached = await readCache(isoDate); if (cached && !opts.forceRefetch) { renderFromData({ techs: cached.techs, targetDate: isoDate, captureTime: cached.savedAt || null, source: "cache" }); // Si cache présent ET pas de refresh explicite demandé, on s'arrête là. // Pas de fetch XML, pas de fetch xhr2, pas de fetch fiches. // Le cache d'un jour précédent reste affiché jusqu'au prochain refresh manuel. if (!opts.doStatusRefresh) { return; } } else { showLoading(); } // 2. Fetch frais du planning (XML calendar_block — rapide, ~40 ko) const fresh = await fetchPlanningForDate(isoDate); if (!fresh) return; // 3. Fusionner cache + frais const merged = mergeCacheAndFresh(cached, fresh); // 4. Afficher immédiatement avec ce qu'on a renderFromData({ techs: merged.techs, targetDate: isoDate, captureTime: Date.now(), source: "fresh" }); // 5. PHASE BULLES (xhr_2) : fetch planning_xhr_2.php pour chaque intervention const bulleNeeded = []; for (const tech of merged.techs) { for (const iv of tech.interventions) { if (iv.type !== "AL-Intervention") continue; if (iv.infobulle && iv.bulleContact) continue; bulleNeeded.push(iv); } } if (bulleNeeded.length > 0) { console.log(`[load] fetch xhr2 pour ${bulleNeeded.length} interventions…`); await fetchBullesForInterventions(bulleNeeded); renderFromData({ techs: merged.techs, targetDate: isoDate, captureTime: Date.now(), source: "fresh+bulles" }); } // 6. Sauvegarder dans le cache await writeCache(isoDate, { techs: merged.techs }); // 7. Fetch fiches en arrière-plan (pour statut + target/checksum clic) const needFetch = merged.techs.some(tech => tech.interventions.some(iv => iv.type === "AL-Intervention" && !iv.ficheTarget ) ); if (opts.doStatusRefresh || needFetch) { await refreshStatuses(merged.techs, isoDate); } showRefreshDone(); } async function refreshPlanning(opts = {}) { if (!state.session) { await refreshSessionAndLoad(); return; } // Rafraîchissement manuel (clic bouton) : on démarre un nouveau jeton et // on fait apparaître le bouton "Arrêter". Les refresh auto (12h/15h) et // les navigations de date n'ont pas ce bouton (ils ne passent pas ici). startNewRefresh(); showAbortButton(true); try { await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true }); } finally { showAbortButton(false); } } // ============================================================================ // Fetch du planning (via background) // ============================================================================ async function fetchPlanningForDate(isoDate) { setRefreshing(true); try { const unixDate = isoToUnixDate(isoDate); const resp = await sendMessage({ type: "fetchPlanning", session: state.session, unixDate: unixDate }); if (!resp.ok) { if (resp.error === "no_session" || resp.error === "session_expired") { state.session = null; showSessionNeeded(); } else { showError("Erreur de fetch : " + (resp.error || "inconnue")); } 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"); // Extraire la ref S260/I260 du label si présente const refMatch = label.match(/\b([SI]2\d{5}_\d{5})\b/); const ref = refMatch ? refMatch[1] : null; // 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; } } 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, bulleContact: null, bulleLieu: null, bulleDescription: null, infobulle: null, status: null, categoryLine: null, commentaireTech: null, ficheTarget: null, ficheChecksum: null, ficheFetched: false, ficheFetchError: null, 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; } /** * Fetch planning_xhr_2.php pour chaque intervention en parallèle (12 workers) * et renseigne bulleContact / bulleLieu / bulleDescription / infobulle. */ async function fetchBullesForInterventions(interventions) { if (!interventions || interventions.length === 0) return { ok: 0, fail: 0 }; setRefreshing(true); let idx = 0; let ok = 0, fail = 0; async function worker() { while (idx < interventions.length) { const i = idx++; const iv = interventions[i]; try { const resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId }); if (!resp || !resp.ok) { fail++; continue; } const parsed = parseXhr2Body(resp.body); if (!parsed) { fail++; continue; } if (parsed.description) { iv.bulleDescription = parsed.description; const infob = parseActionText(parsed.description); if (infob) { iv.infobulle = infob; if (infob.contact) iv.bulleContact = infob.contact; if (infob.lieu) iv.bulleLieu = infob.lieu; } } if (parsed.label) iv.label = parsed.label; iv.xhr2Fetched = true; ok++; } catch (err) { fail++; console.warn("[xhr2] erreur iv", iv.actionId, err); } } } const workers = []; const nWorkers = Math.min(FETCH_CONCURRENCY, interventions.length); for (let w = 0; w < nWorkers; w++) workers.push(worker()); await Promise.all(workers); console.log(`[xhr2] ${ok} OK, ${fail} échecs sur ${interventions.length}`); setRefreshing(false); return { ok, fail }; } 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 : // - Chaque intervention fresh APPORTE : actionId, type, startTime, endTime, formLink... // - Le cache APPORTE : ref, categoryLine, status, infobulle (contact/lieu/...), // commentaireTech, actionText, ficheFetched // - Pour les CHAMPS ENRICHIS : cache wins (sauf si fresh en a de meilleurs) // - Une intervention en cache mais plus en fresh → marquée "ghost" if (!cached || !cached.techs) { return { techs: fresh.techs }; } // Indexer le cache par actionId const cachedByAction = new Map(); for (const tech of cached.techs) { for (const iv of tech.interventions || []) { cachedByAction.set(iv.actionId, iv); } } const resultTechs = fresh.techs.map(t => ({ ...t, interventions: [] })); const freshActionIds = new Set(); for (const tech of fresh.techs) { const outTech = resultTechs.find(t => t.id === tech.id); for (const iv of tech.interventions) { freshActionIds.add(iv.actionId); const cachedIv = cachedByAction.get(iv.actionId); if (cachedIv) { // On part du cache (qui a les champs enrichis), puis on remplace // les champs "live" depuis le fresh (horaires, type, formLink). const merged = { ...cachedIv, // Champs live venant du fresh (le planning peut avoir bougé) techId: iv.techId || cachedIv.techId, type: iv.type || cachedIv.type, label: iv.label || cachedIv.label, cssClass: iv.cssClass || cachedIv.cssClass, isPompier: iv.isPompier, startDate: iv.startDate || cachedIv.startDate, endDate: iv.endDate || cachedIv.endDate, startTime: iv.startTime || cachedIv.startTime, endTime: iv.endTime || cachedIv.endTime, currentDate: iv.currentDate || cachedIv.currentDate, formLink: iv.formLink || cachedIv.formLink, deadline: iv.deadline || cachedIv.deadline, requestId: iv.requestId || cachedIv.requestId, // Ref : on privilégie celle qu'on a (fresh ou cached) ref: cachedIv.ref || iv.ref, // Bulle (HTML planning) : fresh est plus à jour bulleContact: iv.bulleContact || cachedIv.bulleContact, bulleLieu: iv.bulleLieu || cachedIv.bulleLieu, bulleDescription: iv.bulleDescription || cachedIv.bulleDescription, // ghost : on retire (cette intervention est bien là dans le fresh) ghost: false }; outTech.interventions.push(merged); } else { outTech.interventions.push(iv); } } } // Ajouter les interventions qui sont en cache mais plus en fresh for (const tech of cached.techs) { const outTech = resultTechs.find(t => t.id === tech.id); if (!outTech) continue; for (const iv of tech.interventions || []) { if (!freshActionIds.has(iv.actionId)) { const ghost = { ...iv, ghost: true }; outTech.interventions.push(ghost); } } // Retrier outTech.interventions.sort((a, b) => (a.startTime || "").localeCompare(b.startTime || "") ); } return { techs: resultTechs }; } // ============================================================================ // Fetch des fiches individuelles (pour obtenir le statut et les détails) // ============================================================================ async function refreshStatuses(techs, isoDate) { // 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; // On skip si : // - Déjà clos / résolu ET ficheTarget déjà connu (statut + requestId OK) // - Sinon on garde (pour avoir statut frais OU ficheTarget pour clic) const statusClosed = isClosedStatus(iv.status) || isResolvedStatus(iv.status); if (statusClosed && iv.ficheTarget) continue; toFetch.push(iv); } } if (toFetch.length === 0) return; setRefreshing(true); try { // Fetcher avec concurrence = FETCH_CONCURRENCY (30) // Chaque worker vérifie isRefreshAborted() AVANT de prendre la prochaine // intervention : si l'utilisateur a cliqué "Arrêter", les workers // s'arrêtent proprement dans ~100ms. let idx = 0; async function worker() { while (idx < toFetch.length) { if (isRefreshAborted()) return; const i = idx++; await fetchAndUpdateIntervention(toFetch[i]); } } const workers = []; for (let w = 0; w < FETCH_CONCURRENCY; w++) workers.push(worker()); await Promise.all(workers); // Si annulé : on laisse les refs déjà arrivées s'afficher (le rendu // incrémental les a mises dans le DOM), on skip juste le re-render // final et le nettoyage ghosts/cache. if (isRefreshAborted()) { 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; }); } // Sauvegarder le résultat enrichi dans le cache await writeCache(isoDate, { techs }); // Re-rendre pour afficher les mises à jour (un seul rendu à la fin) renderFromData({ techs, targetDate: isoDate, captureTime: Date.now(), source: "fresh+statuses" }); } finally { setRefreshing(false); } } async function fetchAndUpdateIntervention(iv) { try { // Bail-out coopératif : si l'utilisateur a cliqué sur "Arrêter", // on ne fetch pas cette intervention. if (isRefreshAborted()) { iv.ficheFetched = true; iv.ficheFetchError = "aborted"; return; } // Fetch de la fiche (HTML) pour récupérer statut + commentaire tech + // extraire target/checksum qui servent à : // - l'API timeline (texte validé de l'action, si xhr2 n'avait pas été assez) // - construire une URL d'ouverture qui marche (clic sur intervention) // // 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 (!ficheResp.ok && ficheResp.error !== "session_expired" && !isRefreshAborted()) { await new Promise(r => setTimeout(r, 400)); if (!isRefreshAborted()) { ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink }); } } if (!ficheResp.ok) { iv.ficheFetched = true; iv.ficheFetchError = ficheResp.error || "fetch_failed"; if (ficheResp.error === "session_expired") { state.session = null; } return; } const fiche = parseFicheHtml(ficheResp.html); iv.status = fiche.status; iv.categoryLine = fiche.categoryLine || iv.categoryLine; if (fiche.rfc && !iv.ref) { iv.ref = fiche.rfc; } iv.commentaireTech = fiche.commentaireTech; // Extraire le checksum CORRECT pour ouvrir la fiche : // - Le target de la FICHE = iv.requestId (vient du XML) // - Il faut trouver le checksum qui est accolé à ce target dans le HTML // (pattern : target=REQUEST_ID&checksum=XXX...) if (iv.requestId) { const rx = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`); const ckm = ficheResp.html.match(rx); if (ckm) { iv.ficheTarget = iv.requestId; iv.ficheChecksum = ckm[1]; } } iv.ficheFetched = true; // ─── RENDU INCRÉMENTAL (v3.1) ───────────────────────────────────────── // La ref (RFC_NUMBER) et le statut sont déjà connus : on met à jour la // ligne correspondante DANS LE DOM immédiatement, sans attendre que les // autres workers aient fini. Pas de re-rendu global. updateInterventionRow(iv); // Pour l'API timeline, on utilise le MÊME target + checksum (celui de la fiche) const timelineTarget = iv.ficheTarget; const timelineChecksum = iv.ficheChecksum; // Étape timeline API : on veut le texte COMPLET de l'action. // planning_xhr_2.php tronque souvent à ~300 chars, mais l'API timeline // retourne le texte intégral. On la fetch à chaque fois que possible. const needsTimelineValidation = !iv.actionText; if (needsTimelineValidation && timelineTarget && timelineChecksum) { const tlResp = await sendMessage({ type: "fetchTimeline", target: timelineTarget, checksum: timelineChecksum }); if (tlResp && tlResp.ok) { const actionDetails = parseTimelineJson(tlResp.body, iv.actionId); if (actionDetails && actionDetails.text) { iv.actionText = actionDetails.text; iv.actionDone = actionDetails.doneById; // Le texte de timeline est plus complet que bulleDescription : // on remplace bulleDescription par actionText pour le tooltip. iv.bulleDescription = actionDetails.text; const infob = parseActionText(actionDetails.text); if (infob) { iv.infobulle = infob; if (infob.contact) iv.bulleContact = infob.contact; if (infob.lieu) iv.bulleLieu = infob.lieu; } } } } } catch (err) { iv.ficheFetched = true; iv.ficheFetchError = String(err); console.warn("fetchAndUpdate error:", err); } } 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) // ============================================================================ function parseFicheHtml(html) { const out = { status: null, rfc: null, categoryLine: null, commentaireTech: 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 m = html.match(/"dbFieldName"\s*:\s*"RFC_NUMBER"[^}]*?"value"\s*:\s*"([^"]{5,30})"/); if (m) out.rfc = m[1]; // TITLE_FR contient la catégorie complète m = html.match(/"dbFieldName"\s*:\s*"TITLE_FR"[^}]*?"value"\s*:\s*"([^"]{5,300})"/); if (m) out.categoryLine = decodeJsonString(m[1]); // Commentaire tech à la fin de DESCRIPTION : "

techN: ..." m = html.match(/"dbFieldName"\s*:\s*"DESCRIPTION"[^}]*?"value"\s*:\s*"((?:[^"\\]|\\.)+)"/); if (m) { const desc = decodeJsonString(m[1]); const ctm = desc.match(/
\s*
\s*([a-z][a-z0-9]{2,14})\s*:\s*([^<]{3,500})/i); if (ctm) { out.commentaireTech = ctm[1] + ": " + ctm[2].trim(); } } return out; } 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 de la réponse JSON de /api/v1/.../timeline // Extrait le texte de l'action correspondant à un actionId donné. // ============================================================================ function parseTimelineJson(body, actionId) { let json; try { json = JSON.parse(body); } catch { return null; } const values = json && json.data && json.data.data && json.data.data.values; if (!Array.isArray(values)) return null; // Chaque élément de values a : // - rows: [{value}, {value}, ...] (la ligne du tableau) // - dans la colonne d'index 11 : le texte de l'action (ce qu'on veut) // - dans la colonne d'index 13 : un objet JSON stringifié avec ACTION_ID, AM_DONE_BY_ID, etc. // // L'ordre des colonnes peut varier. On ne se fie pas à des index magiques : // - on cherche la colonne avec ACTION_ID==actionId pour identifier la bonne ligne // - dans cette ligne, on prend la colonne qui ressemble à une description // (contient "
" ou plusieurs ":" typiques de "Date :", "Lieu :", etc.) for (const row of values) { const cells = Array.isArray(row && row.rows) ? row.rows : []; // Chercher la colonne "data" qui est un JSON avec ACTION_ID let meta = null; for (const c of cells) { const v = c && c.value; if (typeof v === "string" && v.startsWith('{"') && v.includes("ACTION_ID")) { try { meta = JSON.parse(v); break; } catch { /* ignore */ } } } if (!meta) continue; if (String(meta.ACTION_ID) !== String(actionId)) continue; // On a trouvé notre action. Chercher la cellule texte (la plus longue contenant
) let best = ""; for (const c of cells) { const v = c && c.value; if (typeof v !== "string") continue; if (v.startsWith('{"')) continue; // c'est un JSON meta, pas le texte if (v.length < 20) continue; if (v.length > best.length) best = v; } // Décoder les entités (
→ \n, &/</>/ , \uXXXX) const text = decodeActionText(best); return { text: text, doneById: meta.AM_DONE_BY_ID || null, actionLabel: meta.NAME || null }; } return null; } function decodeActionText(s) { if (!s) return ""; // \uXXXX échappés en JSON (déjà décodés par JSON.parse normalement, // mais au cas où on reçoit un fragment non parsé) let out = s.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => { try { return String.fromCharCode(parseInt(hex, 16)); } catch { return _; } }); // Tags
→ retour à la ligne out = out.replace(//gi, "\n"); // Autres tags HTML : on les enlève out = out.replace(/<[^>]+>/g, ""); // Entités HTML out = out .replace(/ /g, " ") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'"); return out.trim(); } /** * 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 }; // 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. // Ex: "...Matériel : xxx Date proposée par contact : oui" → on garde la // partie Matériel mais on jette "Date proposée..." 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; // "Date : lundi 20.04 Heure : matin" → split en plusieurs paires const markers = []; const rx = /(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\s+ancien\s+poste|TFS\s+nouveau\s+poste)\s*:\s*/gi; let m; while ((m = rx.exec(cleanLine)) !== null) { markers.push({ label: m[1], valueStart: m.index + m[0].length }); } if (markers.length === 0) { autres.push(cleanLine); continue; } for (let i = 0; i < markers.length; i++) { const mk = markers[i]; let val; if (i + 1 < markers.length) { const nextStart = cleanLine.indexOf(markers[i + 1].label, mk.valueStart); val = cleanLine.substring(mk.valueStart, nextStart).trim(); } else { val = cleanLine.substring(mk.valueStart).trim(); } const keyNorm = mk.label.toLowerCase().replace(/\s+/g, " "); const outKey = labelMap[keyNorm]; if (outKey && val) { out[outKey] = out[outKey] ? out[outKey] + " / " + val : val; } } } 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; function setRefreshing(on) { const icon = document.getElementById("refresh-icon"); if (on) { refreshCounter++; if (icon) icon.classList.add("spinning"); clearCheckMark(); // Afficher "Rafraîchissement en cours…" si on n'a pas déjà les données // (on ne veut pas écraser l'heure du cache si on est juste en train // de re-fetch en arrière-plan) updateCaptureInfoText(); } else { refreshCounter = Math.max(0, refreshCounter - 1); if (refreshCounter === 0 && icon) { icon.classList.remove("spinning"); } updateCaptureInfoText(); } } // Force le rafraîchissement du texte "MAJ HH:MM" ou "Rafraîchissement en cours…" // selon refreshCounter. function updateCaptureInfoText() { if (state.currentData) { renderCaptureInfo(state.currentData); } } /** * Appelé quand TOUS les fetches (y compris les fetches fiches en * arrière-plan) sont terminés. Affiche un ✓ vert à côté de l'heure MAJ * pendant 5 secondes. */ function showRefreshDone() { const check = document.getElementById("refresh-check"); if (!check) return; check.classList.remove("hidden"); check.classList.add("visible"); if (refreshDoneTimer) clearTimeout(refreshDoneTimer); refreshDoneTimer = setTimeout(() => { check.classList.remove("visible"); setTimeout(() => check.classList.add("hidden"), 300); // après transition }, 5000); } function clearCheckMark() { const check = document.getElementById("refresh-check"); if (check) { check.classList.remove("visible"); check.classList.add("hidden"); } if (refreshDoneTimer) { clearTimeout(refreshDoneTimer); refreshDoneTimer = null; } } // Affiche/masque le bouton "Arrêter". N'est montré que pendant un refresh // manuel (clic utilisateur), pas pendant les chargements normaux ni les // refresh auto 12h/15h. function showAbortButton(on) { const btn = document.getElementById("abort-btn"); if (!btn) return; if (on) btn.classList.remove("hidden"); else btn.classList.add("hidden"); } function renderFromData(data) { state.currentData = data; document.getElementById("loading").classList.add("hidden"); document.getElementById("error-box").classList.add("hidden"); document.getElementById("session-needed").classList.add("hidden"); document.getElementById("cards").classList.remove("hidden"); // Calculer les stats const stats = computeStats(data.techs, data.targetDate); renderCaptureInfo(data, stats); renderStats(stats); renderCards(data); } function renderCaptureInfo(data, stats) { const info = document.getElementById("capture-info"); if (refreshCounter > 0) { 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"); // Comparer la date du cache avec aujourd'hui : // - si c'est aujourd'hui → juste l'heure // - sinon → date + heure (format "17.04 14:32") const today = new Date(); const isSameDay = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate(); const prefix = data.source === "cache" ? "Cache de " : "MAJ "; 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"); const prefixDate = data.source === "cache" ? "Cache du " : "MAJ "; 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 // ============================================================================ 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); } // Absent sans interv → on stop là if (isAbsent && realInterventions.length === 0) { card.appendChild(body); return card; } if (realInterventions.length === 0 && !isPompier) { const empty = document.createElement("div"); empty.className = "card-empty"; empty.textContent = "Pas d'intervention planifiée"; body.appendChild(empty); card.appendChild(body); return card; } // 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) { 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)); el.addEventListener("mousemove", moveTooltip); el.addEventListener("mouseleave", hideTooltip); } 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(); tip.innerHTML = html; tip.classList.remove("hidden"); tip.classList.add("visible"); moveTooltip(e); } // ============================================================================ // Ligne d'intervention // ============================================================================ function buildInterventionRow(iv, cardEl) { const row = document.createElement("div"); row.className = "intervention-v2"; row.dataset.actionId = iv.actionId; if (iv.isPompier) row.classList.add("is-pompier-line"); if (iv.ghost) row.classList.add("is-ghost"); const colorKey = deriveColorKey(iv); row.classList.add("color-" + colorKey); const statusClass = getStatusClass(iv); if (statusClass) row.classList.add(statusClass); const ivIdx = cardEl._rowIdxCounter || 0; cardEl._rowIdxCounter = ivIdx + 1; row.dataset.ivIdx = ivIdx; if (iv.formLink && !iv.ghost) { row.classList.add("clickable"); row.title = "Cliquer pour ouvrir la fiche (Ctrl+clic ou clic molette = arrière-plan)"; // Clic normal : ouvre l'onglet et change de page // Ctrl/Cmd+Clic : ouvre en arrière-plan (reste sur le planning) row.addEventListener("click", (e) => { if (e.target.closest(".intervention-copy")) return; const background = !!(e.ctrlKey || e.metaKey); openInterventionInNewTab(iv, { background }); }); // Clic molette (button === 1) : ouvre en arrière-plan // On utilise 'auxclick' pour les boutons du milieu/droite (standard W3C). row.addEventListener("auxclick", (e) => { if (e.button !== 1) return; // que la molette if (e.target.closest(".intervention-copy")) return; e.preventDefault(); openInterventionInNewTab(iv, { background: true }); }); // Empêcher le scroll auto quand on clique la molette sur la ligne row.addEventListener("mousedown", (e) => { if (e.button === 1) e.preventDefault(); }); } // Pastille colorée à gauche (barre verticale, toute la hauteur) const dot = document.createElement("div"); dot.className = "intervention-dot"; row.appendChild(dot); // ─── Ligne 1 : Ref centrée (TITRE en gros + gras) ──────────────────────── const refHeader = document.createElement("div"); refHeader.className = "iv-ref-header"; if (iv.type === "AL-Reservation") { refHeader.textContent = "Réservation"; refHeader.classList.add("is-reservation-title"); } else if (iv.ref) { refHeader.textContent = iv.ref; } else { refHeader.textContent = "—"; refHeader.classList.add("no-ref"); } row.appendChild(refHeader); // Check ✓ + bouton copier à droite de la ref (pas pour réservation) if (statusClass && iv.type !== "AL-Reservation") { const statusEl = document.createElement("div"); statusEl.className = "iv-status-check"; statusEl.textContent = "✓"; row.appendChild(statusEl); } if (iv.ref && iv.type !== "AL-Reservation") { const copyBtn = document.createElement("button"); copyBtn.className = "intervention-copy"; copyBtn.type = "button"; copyBtn.title = "Copier la référence"; copyBtn.innerHTML = "📋"; copyBtn.addEventListener("click", (e) => { e.stopPropagation(); copyRef(iv.ref, copyBtn); }); row.appendChild(copyBtn); } // ─── Ligne 2 gauche : heures VERTICALES (début / ↓ / fin) ───────────────── const timeEl = document.createElement("div"); timeEl.className = "iv-time-vertical"; if (iv.startTime && iv.endTime) { const s = document.createElement("div"); s.className = "iv-time-start"; s.textContent = iv.startTime; const sep = document.createElement("div"); sep.className = "iv-time-arrow"; sep.textContent = "↓"; const e = document.createElement("div"); e.className = "iv-time-end"; e.textContent = iv.endTime; timeEl.appendChild(s); timeEl.appendChild(sep); timeEl.appendChild(e); } else { timeEl.textContent = "—"; } row.appendChild(timeEl); // ─── Ligne 2 droite : lieu / contact+tél / catégorie+signature ─────────── // Pour une RÉSERVATION : affichage différent (par + sujet) const rightCol = document.createElement("div"); rightCol.className = "iv-right"; if (iv.type === "AL-Reservation") { // Bloc "Par Nom, Prénom" (en gras) if (iv.reservationCreator) { const parEl = document.createElement("div"); parEl.className = "iv-reservation-par"; parEl.textContent = "Par " + iv.reservationCreator; rightCol.appendChild(parEl); } // Sujet (ex: "Ecrans", "Rollout") if (iv.reservationLabel) { const sujetEl = document.createElement("div"); sujetEl.className = "iv-reservation-sujet"; sujetEl.textContent = "Sujet : " + iv.reservationLabel; rightCol.appendChild(sujetEl); } row.appendChild(rightCol); // Tooltip row.addEventListener("mouseenter", (e) => { showTooltip(e, iv); highlightIntervention(cardEl, ivIdx, true); }); row.addEventListener("mouseleave", () => { hideTooltip(); highlightIntervention(cardEl, ivIdx, false); }); row.addEventListener("mousemove", moveTooltip); return row; } const i = iv.infobulle || {}; const contactRaw = i.contact || iv.bulleContact || null; const lieuRaw = i.lieu || iv.bulleLieu || null; // Extraire tous les contacts (s'il y en a plusieurs séparés par "ou", etc.) const contacts = extractContacts(contactRaw); // Split le lieu : ville / adresse const { ville, adresse } = splitLieu(lieuRaw); // ── 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); } rightCol.appendChild(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"; 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); } rightCol.appendChild(contactEl); } // ── 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); const signature = extractPlanifSignature(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 (au survol) row.addEventListener("mouseenter", (e) => { showTooltip(e, iv); highlightIntervention(cardEl, ivIdx, true); }); row.addEventListener("mouseleave", () => { hideTooltip(); highlightIntervention(cardEl, ivIdx, false); }); row.addEventListener("mousemove", moveTooltip); return row; } // Sender correct pour ouvrir une fiche EasyVista (vu dans les URLs qui marchent) const FICHE_SENDER = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D"; // ============================================================================ // Toasts de notification // ============================================================================ 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); } async function openInterventionInNewTab(iv, opts = {}) { if (!iv.formLink) return; // Toast de feedback visuel dès le clic showToast("Ouverture", iv.ref || iv.actionId); // Récupérer la session actuelle pour construire une URL valide let session = state.session; if (!session) { const resp = await sendMessage({ type: "getSession" }); session = resp && resp.session; } if (!session) { alert("Pas de session EasyVista active. Ouvre d'abord un onglet EasyVista."); return; } if (!iv.requestId) { alert("Impossible d'ouvrir : identifiant de fiche (request_id) manquant.\n" + "Essaie d'actualiser le planning (bouton Rafraîchir)."); return; } let target = iv.ficheTarget; let checksum = iv.ficheChecksum; // SÉCURITÉ : si ficheTarget n'est pas égal à requestId, c'est qu'il vient // d'une ancienne version (buggée) du cache. On invalide et on re-fetch. if (target && target !== iv.requestId) { console.warn("[click] ficheTarget incohérent :", target, "!=", iv.requestId, "→ re-fetch"); target = null; checksum = null; iv.ficheTarget = null; iv.ficheChecksum = null; } // Si pas encore fetché (ou invalidé), on fetch la fiche à la volée // avec retry automatique en cas d'échec du pattern checksum if (!target || !checksum) { console.log("[click] fetch fiche à la volée pour iv", iv.actionId, "requestId=", iv.requestId); let attempts = 0; const maxAttempts = 2; while (attempts < maxAttempts && (!target || !checksum)) { attempts++; try { const ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink }); if (!ficheResp.ok) { if (attempts >= maxAttempts) { alert("Impossible d'ouvrir la fiche : " + (ficheResp.error || "erreur")); return; } continue; // retry } // Extraire le checksum lié au requestId précis const rx = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`); const m = ficheResp.html.match(rx); if (!m) { console.warn(`[click] tentative ${attempts}: pattern target=${iv.requestId} introuvable dans HTML (taille ${ficheResp.html.length})`); if (attempts >= maxAttempts) { alert("Impossible de trouver le checksum pour cette fiche (après retry)."); return; } // Attendre un peu avant retry await new Promise(r => setTimeout(r, 300)); continue; } target = iv.requestId; checksum = m[1]; iv.ficheTarget = target; iv.ficheChecksum = checksum; } catch (err) { if (attempts >= maxAttempts) { alert("Erreur lors du fetch de la fiche : " + err.message); return; } } } } // Construire l'URL qui fonctionne const internalurltime = Math.floor(Date.now() / 1000); const url = `${session.origin}/index.php` + `?PHPSESSID=${encodeURIComponent(session.phpsessid)}` + `&internalurltime=${internalurltime}` + `&eventName=formEvent` + `&target=${encodeURIComponent(target)}` + `&checksum=${encodeURIComponent(checksum)}` + `&sender=${FICHE_SENDER}`; console.log("[click] ouverture fiche iv=", iv.actionId, "ref=", iv.ref, "target=", target, "bg=", !!opts.background); // Si background (Ctrl+Clic ou clic molette) : onglet ouvert mais pas actif, // on reste sur la page du planning. await chrome.tabs.create({ url, active: !opts.background }); } /** * 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)}`; } // +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)}`; } // 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, ""); // Séparer sur " ou ", " / ", retours à la ligne // Mais attention : "Nom, Prénom" contient une virgule qu'on ne doit pas découper const parts = s.split(/\s+ou\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..." en { name, phone }. */ function splitOneContact(raw) { if (!raw) return { name: null, phone: null }; const rxLong = /(\+41\s?\d[\d\s.\-]{8,}|\+33\s?\d[\d\s.\-]{8,}|0\d[\d\s.\-]{8,})/; const rxShort = /(?:^|\s|\()(\d{5})(?:\s|\)|$)/; let phone = null; let name = raw; let mLong = raw.match(rxLong); if (mLong) { phone = formatPhone(mLong[1]); name = raw.replace(mLong[1], "").trim(); } else { let mShort = raw.match(rxShort); if (mShort) { phone = formatPhone(mShort[1]); name = raw.replace(mShort[0], " ").trim(); } } 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 * - Conserve juste "Nom, Prénom" */ 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, ""); // Espaces multiples → un seul s = s.replace(/\s{2,}/g, " ").trim(); // Ponctuation en bord s = s.replace(/^[\s,;:.\-]+|[\s,;:.\-]+$/g, "").trim(); 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; } function shortMeta(iv) { const i = iv.infobulle || {}; const parts = []; // Contact : priorité aux données VALIDÉES de l'action (infobulle) // sinon on utilise la bulle (attr1 du actions_block) let contact = i.contact || iv.bulleContact || null; if (contact) { // Retirer le numéro de téléphone pour compacter const c = contact.replace(/\s*\+?\d[\d\s.\-]{6,}/, "").trim(); parts.push(c || contact); } // Lieu : priorité aux données VALIDÉES, sinon bulle const lieu = i.lieu || iv.bulleLieu || null; if (lieu) parts.push(lieu); return parts.join(" · ") || "—"; } /** * Construit le bloc avec Lieu, Contact, Téléphone sur 3 lignes séparées. * L'ordre d'affichage : Lieu, puis Contact, puis Téléphone. * Source : priorité action validée (infobulle) > bulle (bulleContact/bulleLieu). */ function buildMetaDom(iv) { const i = iv.infobulle || {}; const container = document.createElement("div"); container.className = "intervention-meta-block"; const contactRaw = i.contact || iv.bulleContact || null; const lieu = i.lieu || iv.bulleLieu || null; // Séparer nom et téléphone du contact // Format observé : "Nom, Prénom +41000000001" ou "Nom, Prénom 000000001" let contactName = contactRaw; let phone = null; if (contactRaw) { const phoneMatch = contactRaw.match(/(\+?\d[\d\s.\-]{6,})/); if (phoneMatch) { phone = phoneMatch[1].trim(); contactName = contactRaw.replace(phoneMatch[0], "").trim(); } } // Ligne 1 : Lieu if (lieu) { const el = document.createElement("div"); el.className = "intervention-meta-line meta-lieu"; el.textContent = lieu; container.appendChild(el); } // Ligne 2 : Contact if (contactName) { const el = document.createElement("div"); el.className = "intervention-meta-line meta-contact"; el.textContent = contactName; container.appendChild(el); } // Ligne 3 : Téléphone (plus discret) if (phone) { const el = document.createElement("div"); el.className = "intervention-meta-line meta-phone"; el.textContent = phone; container.appendChild(el); } // Si aucun info, afficher un petit placeholder if (!lieu && !contactName && !phone) { const el = document.createElement("div"); el.className = "intervention-meta-line meta-empty"; el.textContent = "—"; container.appendChild(el); } return container; } async function copyRef(ref, btn) { if (!ref) return; try { await navigator.clipboard.writeText(ref); btn.classList.add("copied"); btn.textContent = "✓"; setTimeout(() => { btn.classList.remove("copied"); btn.textContent = "📋"; }, 1200); } catch { alert("Référence : " + ref); } } // ─── Rendu incrémental (v3.1) ─────────────────────────────────────────────── // Met à jour UNE ligne d'intervention dans le DOM (après qu'un fetch fiche // ait enrichi l'objet iv avec sa ref, son statut, etc.). Appelée par // fetchAndUpdateIntervention pour afficher la ref dès qu'elle arrive, sans // attendre que tous les workers aient fini ni re-rendre toute la vue. // // Doit rester en phase avec la structure DOM construite par // buildInterventionRow (classes iv-ref-header, iv-status-check, // intervention-copy, intervention-dot, timeline-slot...). const ALL_COLOR_CLASSES = [ "color-livraison", "color-installation", "color-recup", "color-remplacement", "color-incident", "color-rollout", "color-reservation", "color-autre" ]; function updateInterventionRow(iv) { // Réservations : pas concerné (pas de fetch fiche pour elles) if (iv.type === "AL-Reservation") return; const row = document.querySelector( `.intervention-v2[data-action-id="${iv.actionId}"]` ); if (!row) return; // Classes de statut sur la ligne const sc = getStatusClass(iv); row.classList.remove("status-closed", "status-resolved"); if (sc) row.classList.add(sc); // Classe de couleur sur la ligne (la pastille hérite via CSS) const colorKey = deriveColorKey(iv); row.classList.remove(...ALL_COLOR_CLASSES); row.classList.add("color-" + colorKey); // Ref (le titre gros en haut de la ligne) const refEl = row.querySelector(".iv-ref-header"); if (refEl) { if (iv.ref) { refEl.textContent = iv.ref; refEl.classList.remove("no-ref"); } else { refEl.textContent = "—"; refEl.classList.add("no-ref"); } } // Check ✓ : ajouter/retirer selon statut let checkEl = row.querySelector(".iv-status-check"); if (sc && !checkEl) { checkEl = document.createElement("div"); checkEl.className = "iv-status-check"; checkEl.textContent = "✓"; // Insérer après la ref (avant le bouton copier s'il existe) const copy = row.querySelector(".intervention-copy"); if (copy) row.insertBefore(checkEl, copy); else if (refEl && refEl.nextSibling) row.insertBefore(checkEl, refEl.nextSibling); else row.appendChild(checkEl); } else if (!sc && checkEl) { checkEl.remove(); } // Bouton 📋 copier : ajouter si on a maintenant une ref et qu'il n'existe pas let copyBtn = row.querySelector(".intervention-copy"); if (iv.ref && !copyBtn) { copyBtn = document.createElement("button"); copyBtn.className = "intervention-copy"; copyBtn.type = "button"; copyBtn.title = "Copier la référence"; copyBtn.innerHTML = "📋"; copyBtn.addEventListener("click", (e) => { e.stopPropagation(); copyRef(iv.ref, copyBtn); }); row.appendChild(copyBtn); } // Catégorie affichée en bas (dépend de la ref pour Incident, etc.) const catEl = row.querySelector(".iv-category"); if (catEl) catEl.textContent = deriveShortTitle(iv); // Segment timeline correspondant : même couleur + même classe statut const card = row.closest(".card"); if (card && row.dataset.ivIdx !== undefined) { const slot = card.querySelector( `.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]` ); if (slot) { slot.classList.remove("status-closed", "status-resolved", ...ALL_COLOR_CLASSES); slot.classList.add("color-" + colorKey); if (sc) slot.classList.add(sc); // Maj du dataset pour le popover (titre + ref) slot.dataset.title = deriveShortTitle(iv); if (iv.ref) slot.dataset.ref = iv.ref; } } } // ============================================================================ // Tooltip // ============================================================================ const tooltipEl = () => document.getElementById("tooltip"); function showTooltip(e, iv) { const el = tooltipEl(); el.innerHTML = buildTooltipHTML(iv); el.classList.remove("hidden"); el.classList.add("visible"); moveTooltip(e); } function hideTooltip() { const el = tooltipEl(); el.classList.remove("visible"); el.classList.add("hidden"); } function moveTooltip(e) { const el = tooltipEl(); if (el.classList.contains("hidden")) return; const pad = 14; const rect = el.getBoundingClientRect(); let x = e.clientX + pad; let y = e.clientY + pad; if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - pad; if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - pad; el.style.left = Math.max(4, x) + "px"; el.style.top = Math.max(4, y) + "px"; } 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 complet de l'action, formaté avec retours à la ligne ────────── // Le texte brut est comme : "Date : 20.04 Heure : MatinLieu : Ville1/Rue1 1 bisContact : ..." // On ajoute des retours à la ligne AVANT chaque étiquette connue. if (iv.bulleDescription) { const formatted = formatActionTextMultiline(iv.bulleDescription); rows.push(`
Action
${escapeHtml(formatted).replace(/\n/g, "
")}
`); } else { // Si pas de description, 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)); // Commentaire du tech (si présent dans DESCRIPTION de la fiche) if (iv.commentaireTech) { rows.push(`
`); rows.push(`
Commentaire tech
${escapeHtml(iv.commentaireTech)}
`); } if (iv.ref) { rows.push(`
`); rows.push(row("Référence", iv.ref)); } 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
`; } 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"); 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"); document.getElementById("cards").innerHTML = ""; document.getElementById("session-needed").classList.remove("hidden"); } function hideSessionNeeded() { document.getElementById("session-needed").classList.add("hidden"); }