diff --git a/manifest.json b/manifest.json index bb207ea..bf08ca5 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "Planification", - "version": "4.2.8", - "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.2.8 : liste de techniciens dans les modals Absence/Douchette entièrement visible sans scroll. Inclut v4.2.7 (URL exacte douchette).", + "version": "4.3.0", + "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.3.0 : (1) conflits horaires entre interventions d'un même tech affichés en rouge + ⚠. (2) Réservations disparues retirées directement (pas de re-fetch inutile). (3) Popups épinglés détachés : plusieurs peuvent coexister, ancrés au contenu (scrollent avec la page), auto-positionnés sans se marcher dessus (toast si pas de place), Échap pour tout fermer, Ctrl×2 pour fermer si un seul épinglé. Inclut v4.2.9.", "permissions": [ "activeTab", "scripting", diff --git a/viewer.css b/viewer.css index 72ad2bd..a253443 100644 --- a/viewer.css +++ b/viewer.css @@ -1751,3 +1751,94 @@ html, body { .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 */ + z-index: 500; /* au-dessus du contenu, sous les modals (10000) */ + 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; +} +@keyframes pinned-popup-in { + from { opacity: 0; transform: scale(0.96); } + to { opacity: 1; transform: scale(1); } +} + +/* Bouton × de fermeture du popup épinglé */ +.pinned-popup-close { + position: absolute; + top: 4px; + 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; +} +.pinned-popup-close:hover { + background: var(--danger-soft, #fbe6e6); + color: var(--danger, #b03030); +} diff --git a/viewer.js b/viewer.js index f2f1e07..c16032b 100644 --- a/viewer.js +++ b/viewer.js @@ -214,6 +214,8 @@ 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(); @@ -458,6 +460,10 @@ function bindTopbar() { 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(); + } } }); @@ -675,6 +681,45 @@ function showAlertModal(opts) { 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 / Mois Année / 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 dateStr = "Avril 26"; // mois/année de release de cette itération + const el = document.createElement("div"); + el.className = "app-footer"; + el.textContent = `QRO / ${dateStr}${version ? " / " + version : ""}`; + document.body.appendChild(el); +} + // ============================================================================ // v4.2.6 : Modals Absence et Douchette // ============================================================================ @@ -1980,6 +2025,17 @@ async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken) * 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", @@ -2933,6 +2989,10 @@ function renderFromData(data) { 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); @@ -2940,6 +3000,51 @@ function renderFromData(data) { 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" + ); + // 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) { @@ -3663,6 +3768,10 @@ function buildInterventionRow(iv, cardEl) { // ─── 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"; @@ -3676,6 +3785,14 @@ function buildInterventionRow(iv, cardEl) { 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 = "—"; } @@ -4799,13 +4916,207 @@ function positionTooltipAnchored(rowEl) { setTooltipViewportPosition(x, y); } -// v4.1.10 : pin/unpin la bulle. Quand pin, on ajoute la classe CSS "pinned" -// qui change le curseur (text) et autorise la sélection. +// ============================================================================ +// 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; - bulleState.pinned = true; - const el = tooltipEl(); - el.classList.add("pinned"); + 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 = "Fermer"; + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + _closePinnedPopup(popup); + }); + popup.appendChild(closeBtn); + + // 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(); +} + +/** Ferme tous les popups épinglés. */ +function closeAllPinnedPopups() { + for (const p of pinnedPopups.slice()) { + p.el.remove(); + } + pinnedPopups.length = 0; } // v4.1.14/19 : recharger UNIQUEMENT cette intervention. Fetch DIRECT sans @@ -5008,22 +5319,29 @@ function bindTooltipInteractions() { } }); - // Double-Ctrl : pin/unpin + // 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; - // Ignorer si la touche est répétée (hold) if (e.repeat) return; const now = performance.now(); if (now - lastCtrlTs < 400) { - // Double-Ctrl détecté lastCtrlTs = 0; - if (bulleState.pinned) { - unpinTooltip(); - } else if (state.currentTooltipIv) { - pinTooltip(); + 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; } @@ -5037,9 +5355,9 @@ function bindTooltipInteractions() { e.preventDefault(); const action = btn.dataset.action; if (action === "pin") { - if (bulleState.pinned) { - unpinTooltip(); - } else if (state.currentTooltipIv) { + // 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") {