From 0b08ca122b1130a782eaf44e334f377ae2495fbc Mon Sep 17 00:00:00 2001 From: Quentin Rouiller Date: Sun, 19 Apr 2026 09:00:00 +0200 Subject: [PATCH] =?UTF-8?q?Version=204.2.1=20=E2=80=94=20D=C3=A9marrage=20?= =?UTF-8?q?s=C3=A9rie=204.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 254 ++++++++++++++----- manifest.json | 4 +- viewer.css | 252 ++++++++++++++++-- viewer.html | 14 +- viewer.js | 690 +++++++++++++++++++++++++++++++++++++++++++------- 5 files changed, 1039 insertions(+), 175 deletions(-) diff --git a/background.js b/background.js index fa61c7d..11d5929 100644 --- a/background.js +++ b/background.js @@ -7,8 +7,8 @@ // - fetchPlanning : fetch le XML du planning pour une date (1 requête = tout) // - fetchXhr2 : fetch un texte d'action détaillé (utilisé en lazy-load au survol) // - fetchFiche : fetch une fiche individuelle (HTML) pour statut + commentaire tech -// 3. Programmer les alarmes de refresh auto (12h, 15h) -// 4. Nettoyer les vieux caches (>7 jours) +// 3. Nettoyer les vieux caches (>7 jours) +// (v4.2 : l'auto-refresh 12h/15h a été retiré) // // v4 : suppression de fetchTimeline (pu utilisé). Le calendar_block contient // directement ref/contact/lieu/catégorie dans ses attributs attr1/attr2/attr3, @@ -87,12 +87,30 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) { console.log("[bg] fetchPlanningXml →", url.substring(0, 140)); const r = await fetch(url, { credentials: "include" }); console.log("[bg] status =", r.status); - if (!r.ok) throw new Error("HTTP " + r.status); + if (!r.ok) { + // v4.2 : classifier l'erreur HTTP pour que le viewer affiche le bon + // écran (session expirée vs EV inaccessible). + const err = new Error("HTTP " + r.status); + err.kind = classifyHttpStatus(r.status); + err.status = r.status; + throw err; + } const xml = await r.text(); console.log("[bg] taille XML =", xml.length); return xml; } +/** + * v4.2 : classifie un statut HTTP comme "session_expired" ou "ev_unreachable". + * - 401, 403, 404 → session_expired (EV renvoie souvent 404 au lieu de rediriger + * vers la page de login quand PHPSESSID n'est plus valide) + * - 5xx, autres → ev_unreachable (service down, surcharge, etc.) + */ +function classifyHttpStatus(status) { + if (status === 401 || status === 403 || status === 404) return "session_expired"; + return "ev_unreachable"; +} + /** * Fetch planning_xhr_2.php?id=ACTIONID pour UNE intervention. * Retourne ~400 octets au format custom : @@ -101,7 +119,12 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) { async function fetchXhr2(origin, phpsessid, actionId) { const url = `${origin}/planning_xhr_2.php?PHPSESSID=${encodeURIComponent(phpsessid)}&id=${encodeURIComponent(actionId)}`; const r = await fetch(url, { credentials: "include" }); - if (!r.ok) throw new Error("HTTP " + r.status); + if (!r.ok) { + const err = new Error("HTTP " + r.status); + err.kind = classifyHttpStatus(r.status); + err.status = r.status; + throw err; + } return await r.text(); } @@ -109,7 +132,12 @@ async function fetchFicheHtml(origin, phpsessid, formLink) { const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`; console.log("[bg] fetchFicheHtml →", url.substring(0, 120)); const r = await fetch(url, { credentials: "include" }); - if (!r.ok) throw new Error("HTTP " + r.status); + if (!r.ok) { + const err = new Error("HTTP " + r.status); + err.kind = classifyHttpStatus(r.status); + err.status = r.status; + throw err; + } const html = await r.text(); console.log("[bg] fiche status =", r.status, "| taille =", html.length); return html; @@ -136,7 +164,12 @@ async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum) { `&type=todo§ionId=1&navigator=&nbRecord=0` + `&PHPSESSID=${encodeURIComponent(phpsessid)}`; const r = await fetch(url, { credentials: "include" }); - if (!r.ok) throw new Error("HTTP " + r.status); + if (!r.ok) { + const err = new Error("HTTP " + r.status); + err.kind = classifyHttpStatus(r.status); + err.status = r.status; + throw err; + } return await r.text(); } @@ -149,6 +182,93 @@ function looksLikeLoginPage(text) { return /customer_login|my\.policy/i.test((text || "").substring(0, 3000)); } +// ============================================================================ +// v4.2 : récupération de l'utilisateur connecté +// ============================================================================ + +/** + * Essaie de récupérer le nom de l'utilisateur EasyVista connecté en fetchant + * la page d'accueil avec la session active. EasyVista n'exposant pas + * d'endpoint public simple, on cherche des patterns typiques dans le HTML : + * - ...Nom, Prénom... + * - éléments avec data-user-name, data-user-login + * - balises cachées ou variables JS EV.User.name + * - champ "Bienvenue Nom Prénom" + * Retourne { name: "Nom Prénom" | null, login: "..." | null } ou null si + * tout a échoué. + */ +async function fetchCurrentUser(origin, phpsessid) { + const url = `${origin}/index.php?PHPSESSID=${encodeURIComponent(phpsessid)}`; + const resp = await fetch(url, { + method: "GET", + credentials: "include", + headers: { "Accept": "text/html,*/*" } + }); + // v4.2 : cette fonction est lancée en tâche de fond au démarrage. Si la + // session est expirée ou EV inaccessible, on retourne juste null — le + // planning lui-même déclenchera l'écran d'erreur approprié. + if (!resp.ok) return null; + const html = await resp.text(); + if (looksLikeLoginPage(html)) return null; + + // Essais de patterns (du plus spécifique au plus générique) + const patterns = [ + // Attribut data-user-name (si EasyVista l'expose) + /data-user-name\s*=\s*["']([^"']+)["']/i, + /data-username\s*=\s*["']([^"']+)["']/i, + /data-user-fullname\s*=\s*["']([^"']+)["']/i, + // Variable JS typique EasyVista + /EV\.User\.name\s*=\s*["']([^"']+)["']/, + /EV\.User\.fullname\s*=\s*["']([^"']+)["']/, + /userFullName\s*[:=]\s*["']([^"']+)["']/, + /currentUser(?:Name)?\s*[:=]\s*["']([^"']+)["']/, + // Balises cachées ou spans avec classe "user" + /<(?:span|div)[^>]*class=["'][^"']*(?:user[_-]?(?:name|full|display))[^"']*["'][^>]*>([^<]{2,80})<\/(?:span|div)>/i, + // "Bienvenue" / "Welcome" + /(?:Bienvenue|Welcome)[,\s]+(?:M\.?\s+|Mme\s+)?([A-ZÀÁÂÄÇÉÈÊËÍÎÏÓÔÖÚÛÜÑ][\wÀ-ÿ'.\-]+(?:\s*,?\s+[A-ZÀÁÂÄÇÉÈÊËÍÎÏÓÔÖÚÛÜÑ][\wÀ-ÿ'.\-]+){0,3})/, + // Title de la page (souvent "EasyVista - Nom Prénom") + /([^<]*)<\/title>/i + ]; + + let name = null; + for (const rx of patterns) { + const m = html.match(rx); + if (m && m[1]) { + const candidate = m[1].trim() + .replace(/\s+/g, " ") + // Enlever des éléments du <title> type "EasyVista" / "Planning" / etc. + .replace(/^(?:EasyVista|EV|Accueil|Home|Planning|ITSMA)[\s\-|•]+/i, "") + .replace(/[\s\-|•]+(?:EasyVista|EV|ITSMA)$/i, "") + .trim(); + if (candidate && candidate.length >= 3 && candidate.length <= 80 + && /[A-Za-zÀ-ÿ]/.test(candidate) + && !/\b(login|connexion|sign\s*in|easyvista|ITSMA)\b/i.test(candidate)) { + name = candidate; + break; + } + } + } + + // Chercher aussi le login (ID court) — utile comme fallback secondaire + let login = null; + const loginPatterns = [ + /data-user-login\s*=\s*["']([^"']+)["']/i, + /data-login\s*=\s*["']([^"']+)["']/i, + /EV\.User\.login\s*=\s*["']([^"']+)["']/, + /userLogin\s*[:=]\s*["']([^"']+)["']/ + ]; + for (const rx of loginPatterns) { + const m = html.match(rx); + if (m && m[1]) { + login = m[1].trim(); + break; + } + } + + if (!name && !login) return null; + return { name, login }; +} + // ============================================================================ // Messages du viewer // ============================================================================ @@ -168,13 +288,21 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse({ ok: false, error: "no_session" }); return; } - // Fetch XML calendar_block du planning (rapide ~40 ko) - const xml = await fetchPlanningXml(session.origin, session.phpsessid, msg.unixDate); - if (looksLikeLoginPage(xml)) { - sendResponse({ ok: false, error: "session_expired" }); - return; + try { + // Fetch XML calendar_block du planning (rapide ~40 ko) + const xml = await fetchPlanningXml(session.origin, session.phpsessid, msg.unixDate); + if (looksLikeLoginPage(xml)) { + sendResponse({ ok: false, error: "session_expired" }); + return; + } + sendResponse({ ok: true, xml, session }); + } catch (err) { + // v4.2 : classification de l'erreur pour afficher le bon écran + const errorCode = err.kind || ( + /network|fetch|typeerror/i.test(err.message) ? "ev_unreachable" : "ev_unreachable" + ); + sendResponse({ ok: false, error: errorCode, httpStatus: err.status, detail: err.message }); } - sendResponse({ ok: true, xml, session }); return; } @@ -188,7 +316,12 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { const body = await fetchXhr2(session.origin, session.phpsessid, msg.actionId); sendResponse({ ok: true, body }); } catch (err) { - sendResponse({ ok: false, error: String(err) }); + sendResponse({ + ok: false, + error: err.kind || "fetch_failed", + httpStatus: err.status, + detail: err.message || String(err) + }); } return; } @@ -199,12 +332,21 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse({ ok: false, error: "no_session" }); return; } - const html = await fetchFicheHtml(session.origin, session.phpsessid, msg.formLink); - if (looksLikeLoginPage(html)) { - sendResponse({ ok: false, error: "session_expired" }); - return; + try { + const html = await fetchFicheHtml(session.origin, session.phpsessid, msg.formLink); + if (looksLikeLoginPage(html)) { + sendResponse({ ok: false, error: "session_expired" }); + return; + } + sendResponse({ ok: true, html, session }); + } catch (err) { + sendResponse({ + ok: false, + error: err.kind || "fetch_failed", + httpStatus: err.status, + detail: err.message || String(err) + }); } - sendResponse({ ok: true, html, session }); return; } @@ -225,14 +367,32 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } sendResponse({ ok: true, body }); } catch (err) { - sendResponse({ ok: false, error: String(err) }); + sendResponse({ + ok: false, + error: err.kind || "fetch_failed", + httpStatus: err.status, + detail: err.message || String(err) + }); } return; } - if (msg.type === "scheduleAutoRefresh") { - scheduleAutoRefreshAlarms(); - sendResponse({ ok: true }); + if (msg.type === "fetchCurrentUser") { + // v4.2 : essaie d'identifier l'utilisateur EasyVista connecté en + // fetchant la page d'accueil et en cherchant dans le HTML un champ + // contenant son nom. Si on trouve rien, on renvoie { ok: true, + // user: null } pour que l'UI sache qu'on n'a pas pu. + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const user = await fetchCurrentUser(session.origin, session.phpsessid); + sendResponse({ ok: true, user }); + } catch (err) { + sendResponse({ ok: false, error: String(err) }); + } return; } @@ -254,45 +414,21 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { }); // ============================================================================ -// Alarmes : refresh auto 12h / 15h +// v4.2 : les alarmes d'auto-refresh 12h/15h ont été supprimées. Seul le +// nettoyage quotidien des caches > 7 jours reste. +// On supprime aussi activement les anciennes alarmes créées par les +// versions précédentes pour éviter qu'elles restent programmées. // ============================================================================ -function scheduleAutoRefreshAlarms() { - // Calculer le prochain 12h et 15h à partir de maintenant - const now = new Date(); - - function nextAt(hour, minute) { - const d = new Date(); - d.setHours(hour, minute, 0, 0); - if (d <= now) d.setDate(d.getDate() + 1); - return d.getTime(); +async function clearLegacyRefreshAlarms() { + try { + await chrome.alarms.clear("refresh_12h"); + await chrome.alarms.clear("refresh_15h"); + } catch (e) { + console.warn("clearLegacyRefreshAlarms:", e); } - - chrome.alarms.create("refresh_12h", { - when: nextAt(12, 0), - periodInMinutes: 24 * 60 // tous les jours - }); - chrome.alarms.create("refresh_15h", { - when: nextAt(15, 0), - periodInMinutes: 24 * 60 - }); } -chrome.alarms.onAlarm.addListener(async (alarm) => { - if (alarm.name === "refresh_12h" || alarm.name === "refresh_15h") { - // Envoyer un message à tous les viewers ouverts pour qu'ils se rafraîchissent - const viewerUrl = chrome.runtime.getURL("viewer.html"); - const tabs = await chrome.tabs.query({ url: viewerUrl + "*" }); - for (const tab of tabs) { - try { - await chrome.tabs.sendMessage(tab.id, { type: "autoRefresh" }); - } catch { - // Onglet fermé ou pas réactif, on ignore - } - } - } -}); - // ============================================================================ // Nettoyage caches > 7 jours // ============================================================================ @@ -317,13 +453,13 @@ async function cleanupOldCaches(daysToKeep) { return toRemove.length; } -// Au démarrage, programmer les alarmes et nettoyer +// Au démarrage, nettoyer les anciennes alarmes et les anciens caches chrome.runtime.onInstalled.addListener(() => { - scheduleAutoRefreshAlarms(); + clearLegacyRefreshAlarms(); cleanupOldCaches(7).catch(err => console.warn("cleanup:", err)); }); chrome.runtime.onStartup.addListener(() => { - scheduleAutoRefreshAlarms(); + clearLegacyRefreshAlarms(); cleanupOldCaches(7).catch(err => console.warn("cleanup:", err)); }); diff --git a/manifest.json b/manifest.json index 7b97333..e0821c7 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "Planning Techniciens — Vue claire", - "version": "4.1.14", - "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.1.14 : bouton ↻ dans la bulle pour recharger une seule intervention (sans que les boutons topbar tournent), Actualiser tourne quand on arrive sur une date avec cache, signature planif vraiment à droite, progress bar lisible avec halo text-shadow (plus de fond noir).", + "version": "4.2.1", + "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.2.1 : messages d'erreur clairs (session expirée vs EasyVista inaccessible) avec bouton Ouvrir EasyVista et Réessayer, vouvoiement uniformisé. Inclut v4.2.0 : contact + personne de contact sur site avec anomalie rouge, parser téléphone élargi (41XXX sans +), sélection texte dans la bulle sans épingler, utilisateur EV connecté en haut, suppression auto-refresh 12h/15h.", "permissions": [ "activeTab", "scripting", diff --git a/viewer.css b/viewer.css index 41ede86..6e88445 100644 --- a/viewer.css +++ b/viewer.css @@ -231,7 +231,12 @@ html, body { top: 56px; z-index: 9; height: 22px; - background: var(--bg-subtle, rgba(128, 128, 128, 0.08)); + /* v4.1.17 : backdrop-blur sur toute la barre → ce qui défile derrière + est légèrement flouté sur TOUTE la largeur. Pas d'opacité sombre + ajoutée, transparence préservée. */ + background: rgba(128, 128, 128, 0.08); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); border-bottom: 1px solid var(--border, rgba(128, 128, 128, 0.2)); overflow: hidden; } @@ -258,10 +263,8 @@ html, body { color: #fff; pointer-events: none; letter-spacing: 0.3px; - /* v4.1.14 : text-shadow multi-directionnel qui crée un halo sombre autour - du texte. Lisible peu importe ce qui défile derrière (noms, icônes, - fond gris ou barre verte). Aucun fond opaque → la transparence de la - barre est totalement préservée. */ + /* v4.1.14/17 : text-shadow multi-directionnel (halo sombre autour du + texte). Le backdrop-blur est sur toute la barre, plus besoin de pill. */ text-shadow: 0 0 2px rgba(0, 0, 0, 0.95), 0 0 3px rgba(0, 0, 0, 0.85), @@ -269,6 +272,7 @@ html, body { 0 -1px 2px rgba(0, 0, 0, 0.75), 1px 0 2px rgba(0, 0, 0, 0.75), -1px 0 2px rgba(0, 0, 0, 0.75); + z-index: 2; } /* Navigation de date */ @@ -759,9 +763,12 @@ html, body { display: grid; grid-template-columns: 4px 58px 1fr auto; grid-template-rows: auto auto; + /* v4.1.17 : la ligne du bas (right) s'étend maintenant sur les 2 colonnes + droite (right + status) pour que la signature aille vraiment jusqu'au + bord droit. Le ✓ status est positionné en absolute par-dessus. */ grid-template-areas: - "dot time ref copy" - "dot time right status"; + "dot time ref copy" + "dot time right right"; gap: 2px 10px; align-items: start; padding: 10px 12px 12px 8px; @@ -845,12 +852,17 @@ html, body { } .iv-status-check { - grid-area: status; - align-self: center; + /* v4.1.17 : absolute en bas à droite (la grid-area "status" a été + fusionnée avec "right" pour étendre la signature jusqu'au bord). */ + position: absolute; + right: 10px; + bottom: 10px; font-size: 16px; font-weight: 700; color: var(--c-closed); - padding-right: 6px; + pointer-events: none; + /* Au-dessus de la signature, mais discret */ + z-index: 1; } .intervention-v2.status-resolved .iv-status-check { color: var(--c-resolved); } @@ -984,11 +996,13 @@ html, body { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - /* v4.1.14 : flex: 1 pour prendre tout l'espace disponible entre category - et signature — pousse la signature au bord droit. min-width: 0 permet - l'ellipsis sur les longues catégories. */ - flex: 1 1 auto; + /* v4.1.15 : taille naturelle (pas de flex:1 qui étirait le texte et + rendait la signature juste à côté). Sans flex, la catégorie reste à + son contenu + justify-content:space-between pousse la signature à + l'extrême droite du parent. */ min-width: 0; + flex: 0 1 auto; + max-width: calc(100% - 70px); } .iv-signature { color: var(--text-faint); @@ -996,9 +1010,16 @@ html, body { font-family: var(--mono); flex-shrink: 0; letter-spacing: 0.02em; - /* v4.1.14 : collée au bord droit, pas de padding-right parasite */ text-align: right; + /* v4.1.15/17 : margin-left: auto pour collage garanti à droite */ margin-left: auto; + white-space: nowrap; +} +/* v4.1.17 : si statut clos/résolu, le ✓ est à droite en absolute → décaler + la signature pour ne pas se chevaucher */ +.intervention-v2.status-closed .iv-signature, +.intervention-v2.status-resolved .iv-signature { + padding-right: 22px; } /* Réservation (créneau bloqué par un coordinateur) */ @@ -1045,7 +1066,7 @@ html, body { Tooltip ========================================================================== */ .tooltip { - position: fixed; + position: fixed !important; z-index: 100; max-width: 620px; max-height: calc(100vh - 40px); @@ -1061,20 +1082,22 @@ html, body { pointer-events: none; opacity: 0; transition: opacity 0.1s; - /* v4.1.10 : empêcher la sélection par défaut (évite sélection accidentelle - pendant qu'on bouge la souris). Ré-activé quand .pinned. */ - user-select: none; + /* v4.2 : sélection de texte autorisée en permanence. Avant (v4.1.10) on + bloquait par défaut et n'activait qu'en mode épinglé, mais c'était + contre-productif — on veut pouvoir copier un numéro sans pin d'abord. */ + user-select: text; + -webkit-user-select: text; } .tooltip.visible { opacity: 1; /* v4.1.10 : permet à la souris d'entrer dans la bulle pour la garder visible (persistance au hover) et, en mode pinned, pour sélectionner. */ pointer-events: auto; + /* v4.2 : curseur texte par défaut (pour signaler que c'est sélectionnable) */ + cursor: text; } .tooltip.pinned { - /* v4.1.10 : bulle épinglée → curseur texte + sélection active */ - user-select: text; - cursor: text; + /* Bulle épinglée : bordure verte pour indiquer le mode */ border-color: var(--c-accent, #3fb950); box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.15), var(--shadow-hover); } @@ -1130,6 +1153,41 @@ html, body { background: rgba(63, 185, 80, 0.15); } +/* v4.1.15 : référence dans la bulle avec bouton copier inline */ +.tt-ref-cell { + display: inline-flex; + align-items: center; + gap: 8px; +} +.tt-ref-val { + font-family: var(--mono, monospace); +} +.tt-copy-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + width: 26px; + height: 22px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} +.tt-copy-btn:hover { + background: var(--bg-hover); + color: var(--text); + border-color: var(--border-strong); +} +.tt-copy-btn.copied { + background: rgba(63, 185, 80, 0.2); + border-color: #3fb950; + color: #3fb950; +} + .tooltip dl { margin: 0; display: grid; @@ -1245,3 +1303,153 @@ html, body { font-weight: 700; letter-spacing: 0.02em; } + +/* ───────────────────────────────────────────────────────────────────────── + v4.1.20 : Modal central de confirmation (vider cache) + ───────────────────────────────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + /* Flou + assombrissement léger de l'arrière-plan */ + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + animation: modal-fade-in 0.15s ease-out; +} +@keyframes modal-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal-card { + background: var(--bg, #ffffff); + color: var(--text, #111); + border: 1px solid var(--border, rgba(128, 128, 128, 0.25)); + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25), + 0 2px 8px rgba(0, 0, 0, 0.15); + padding: 24px 24px 20px; + width: min(440px, 92vw); + max-height: 90vh; + overflow-y: auto; + animation: modal-card-in 0.18s cubic-bezier(0.16, 1, 0.3, 1); +} +@keyframes modal-card-in { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.modal-title { + margin: 0 0 12px 0; + font-size: 18px; + font-weight: 700; + color: var(--text, #111); +} +.modal-message { + margin: 0 0 20px 0; + font-size: 13px; + line-height: 1.5; + color: var(--text-muted, #555); +} +.modal-actions { + display: flex; + flex-direction: column; + gap: 8px; +} +.modal-actions .btn { + width: 100%; + padding: 10px 14px; + font-size: 13px; + font-weight: 600; + text-align: center; + border-radius: 8px; + cursor: pointer; + transition: background 0.12s, transform 0.06s; + border: 1px solid transparent; +} +.modal-actions .btn:active { transform: translateY(1px); } + +/* Vider le cache du jour : danger modéré (orange) */ +.btn-modal-danger { + background: rgba(234, 128, 38, 0.12); + color: #c85a00; + border-color: rgba(234, 128, 38, 0.3); +} +.btn-modal-danger:hover { + background: rgba(234, 128, 38, 0.22); +} +/* Vider tout le cache : danger fort (rouge) */ +.btn-modal-danger-strong { + background: rgba(220, 60, 60, 0.12); + color: #c03030; + border-color: rgba(220, 60, 60, 0.3); +} +.btn-modal-danger-strong:hover { + background: rgba(220, 60, 60, 0.22); +} +/* Annuler : neutre */ +.btn-modal-cancel { + background: transparent; + color: var(--text-muted, #666); + border-color: var(--border, rgba(128, 128, 128, 0.3)); + margin-top: 4px; +} +.btn-modal-cancel:hover { + background: var(--bg-hover, rgba(128, 128, 128, 0.08)); +} + +/* ───────────────────────────────────────────────────────────────────────── + v4.1.20 : Message d'absence récurrente (Pillonel vendredi) + ───────────────────────────────────────────────────────────────────────── */ +.tech-absence-recurring { + padding: 14px 12px; + text-align: center; + font-size: 13px; + font-style: italic; + color: var(--text-faint, #888); + background: rgba(128, 128, 128, 0.04); + border-top: 1px solid var(--border, rgba(128, 128, 128, 0.15)); + border-bottom: 1px solid var(--border, rgba(128, 128, 128, 0.15)); +} + +/* v4.2 : contact en rouge quand anomalie détectée (Contact + Personne de + contact présents tous les deux dans l'action = situation suspecte). + On signale visuellement pour que l'user aille vérifier dans la fiche. */ +.iv-contact-line.iv-contact-anomalie { + color: #dc3030; +} +.iv-contact-line.iv-contact-anomalie .iv-contact, +.iv-contact-line.iv-contact-anomalie .iv-phone { + color: #dc3030; +} + +/* v4.2 : badge utilisateur EasyVista connecté (en haut à droite de la topbar) */ +.current-user { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + font-size: 12px; + font-weight: 500; + color: var(--text-muted, #666); + background: rgba(128, 128, 128, 0.08); + border: 1px solid var(--border, rgba(128, 128, 128, 0.2)); + border-radius: 999px; + margin-right: 8px; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.current-user::before { + content: "👤"; + font-size: 11px; + opacity: 0.7; +} +.current-user.hidden { + display: none; +} diff --git a/viewer.html b/viewer.html index e29ae22..92c4d02 100644 --- a/viewer.html +++ b/viewer.html @@ -19,6 +19,7 @@ <span id="refresh-check" class="refresh-check hidden" title="Mise à jour terminée">✓</span> </div> <div class="topbar-right"> + <span id="current-user" class="current-user hidden" title="Utilisateur EasyVista connecté"></span> <button id="refresh-partial-btn" class="btn btn-refresh" title="Actualiser : ajoute les nouvelles interventions et retire celles qui ne sont plus dans le planning. Rapide, ne re-télécharge pas les fiches déjà connues."> <svg id="refresh-partial-icon" class="btn-refresh-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8a6 6 0 0 1 10.2-4.24M14 3v3h-3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg> <span class="btn-refresh-label">Actualiser</span> @@ -44,7 +45,7 @@ <span class="session-banner-icon">⚠</span> <span class="session-banner-text"> <strong>Session EasyVista expirée.</strong> - Les mises à jour sont interrompues. Reconnecte-toi à EasyVista, puis clique sur <b>Total</b> pour rafraîchir. + Les mises à jour sont interrompues. Reconnectez-vous à EasyVista, puis cliquez sur <b>Tout recharger</b> pour rafraîchir. </span> <button id="session-banner-reconnect" class="btn btn-primary btn-sm">Ouvrir EasyVista</button> <button id="session-banner-close" class="btn btn-icon" title="Masquer">×</button> @@ -59,11 +60,16 @@ <main id="main"> <div id="error-box" class="error-box hidden"></div> <div id="session-needed" class="session-needed hidden"> - <h2>Connexion à EasyVista requise</h2> - <p>Cette extension doit se connecter à <code>itsma.etat-de-vaud.ch</code> pour fonctionner.</p> - <p>Ouvre EasyVista dans un onglet, connecte-toi, puis <b>reclique sur l'icône de l'extension</b>.</p> + <h2>Session EasyVista expirée</h2> + <p>Reconnectez-vous à EasyVista pour continuer.</p> <button id="open-ev-btn" class="btn btn-primary">Ouvrir EasyVista</button> </div> + <div id="ev-unreachable" class="session-needed hidden"> + <h2>EasyVista est inaccessible pour le moment.</h2> + <p>Réessayez dans quelques instants, ou ouvrez EasyVista directement.</p> + <button id="open-ev-btn-2" class="btn btn-primary">Ouvrir EasyVista</button> + <button id="retry-btn" class="btn btn-subtle">Réessayer</button> + </div> <div id="loading" class="loading">Chargement…</div> <div id="stats" class="stats hidden"></div> <div id="cards" class="cards"></div> diff --git a/viewer.js b/viewer.js index 9080536..0baa068 100644 --- a/viewer.js +++ b/viewer.js @@ -219,13 +219,9 @@ async function init() { 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 }); - } - }); + // v4.2 : l'auto-refresh 12h/15h a été supprimé. Les rafraîchissements sont + // désormais soit manuels (boutons Actualiser / Tout recharger), soit au + // premier chargement si aucun cache n'existe pour la date. // Charger la session puis le planning await refreshSessionAndLoad(); @@ -239,10 +235,38 @@ async function refreshSessionAndLoad() { } state.session = resp.session; hideSessionNeeded(); + hideEvUnreachable(); hideSessionExpiredBanner(); + // v4.2 : en tâche de fond, identifier l'utilisateur EasyVista connecté et + // l'afficher dans la topbar. Ne pas bloquer le chargement du planning + // si ça échoue. + fetchAndShowCurrentUser(); await loadForDate(state.currentDate); } +// v4.2 : fetche l'utilisateur EasyVista connecté (via background.js) et +// l'affiche dans la topbar. En cas d'échec ou si aucun nom n'est trouvé, +// le badge reste caché. +async function fetchAndShowCurrentUser() { + try { + const resp = await sendMessage({ type: "fetchCurrentUser" }); + if (!resp || !resp.ok || !resp.user) return; + const el = document.getElementById("current-user"); + if (!el) return; + const label = resp.user.name || resp.user.login || null; + if (!label) return; + el.textContent = label; + el.title = resp.user.login + ? `Utilisateur EasyVista connecté : ${label} (${resp.user.login})` + : `Utilisateur EasyVista connecté : ${label}`; + el.classList.remove("hidden"); + // Exposer au reste du code pour un usage éventuel plus tard + state.currentUser = resp.user; + } catch (err) { + console.warn("[currentUser] fetch failed:", err); + } +} + // ============================================================================ // Thème clair/sombre // ============================================================================ @@ -315,6 +339,16 @@ function bindTopbar() { document.getElementById("open-ev-btn").addEventListener("click", openEasyVista); + // v4.2 : écran "EasyVista inaccessible" + const openEvBtn2 = document.getElementById("open-ev-btn-2"); + if (openEvBtn2) openEvBtn2.addEventListener("click", openEasyVista); + const retryBtn = document.getElementById("retry-btn"); + if (retryBtn) retryBtn.addEventListener("click", async () => { + hideEvUnreachable(); + document.getElementById("loading").classList.remove("hidden"); + await refreshSessionAndLoad(); + }); + // v4.1.12 : bindings bannière session expirée const reconnectBtn = document.getElementById("session-banner-reconnect"); if (reconnectBtn) reconnectBtn.addEventListener("click", openEasyVista); @@ -342,9 +376,85 @@ function navigateDate(direction) { } 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 }); + // v4.1.20 : modal central avec 2 choix (jour / tout) + annuler + showClearCacheModal(); +} + +// v4.1.20 : modal central de confirmation pour vider le cache. L'arrière-plan +// est flouté, l'utilisateur a deux choix explicites + Annuler. +function showClearCacheModal() { + // Ne pas ouvrir 2x si déjà affiché + if (document.getElementById("clear-cache-modal")) return; + + const dateTxt = formatDateDM(state.currentDate); + + const overlay = document.createElement("div"); + overlay.id = "clear-cache-modal"; + overlay.className = "modal-overlay"; + overlay.innerHTML = ` + <div class="modal-card" role="dialog" aria-labelledby="clear-cache-title"> + <h2 id="clear-cache-title" class="modal-title">Vider le cache</h2> + <p class="modal-message"> + Le cache stocke les données des interventions pour éviter de + re-télécharger à chaque ouverture. Que voulez-vous supprimer ? + </p> + <div class="modal-actions"> + <button type="button" class="btn btn-modal-danger" data-action="clear-day"> + Vider le cache du ${dateTxt} + </button> + <button type="button" class="btn btn-modal-danger-strong" data-action="clear-all"> + Vider tout le cache + </button> + <button type="button" class="btn btn-modal-cancel" data-action="cancel"> + Annuler + </button> + </div> + </div> + `; + document.body.appendChild(overlay); + + const close = () => { + overlay.remove(); + }; + + overlay.addEventListener("click", async (e) => { + const action = e.target.closest("[data-action]")?.dataset.action; + if (!action) { + // Clic sur le fond (pas sur la carte) → fermer + if (e.target === overlay) close(); + return; + } + if (action === "cancel") { + close(); + return; + } + if (action === "clear-day") { + close(); + await chrome.storage.local.remove(CACHE_PREFIX + state.currentDate); + await loadForDate(state.currentDate, { forceRefetch: true }); + return; + } + if (action === "clear-all") { + close(); + // Supprimer toutes les clés CACHE_PREFIX* + const all = await chrome.storage.local.get(null); + const toRemove = Object.keys(all).filter(k => k.startsWith(CACHE_PREFIX)); + if (toRemove.length) { + await chrome.storage.local.remove(toRemove); + } + await loadForDate(state.currentDate, { forceRefetch: true }); + return; + } + }); + + // Échap ferme la modale + const escHandler = (e) => { + if (e.key === "Escape") { + close(); + document.removeEventListener("keydown", escHandler); + } + }; + document.addEventListener("keydown", escHandler); } // ============================================================================ @@ -502,7 +612,8 @@ async function loadForDate(isoDate, opts = {}) { techs: merged.techs, targetDate: isoDate, captureTime: Date.now(), - source: "fresh" + source: "fresh", + lastRefreshKind: activeRefreshButton // v4.1.20 }); console.log(`[load] 1er rendu complet à ${Math.round(performance.now() - t0)} ms`); @@ -589,6 +700,9 @@ async function fetchPlanningForDate(isoDate) { if (resp.error === "no_session" || resp.error === "session_expired") { state.session = null; showSessionNeeded(); + } else if (resp.error === "ev_unreachable") { + // v4.2 : EasyVista inaccessible (500/503/réseau/etc.) + showEvUnreachable(); } else { showError("Erreur de fetch : " + (resp.error || "inconnue")); } @@ -1033,7 +1147,8 @@ async function refreshStatuses(techs, isoDate, opts = {}) { techs, targetDate: isoDate, captureTime: Date.now(), - source: "fresh+statuses" + source: "fresh+statuses", + lastRefreshKind: activeRefreshButton // v4.1.20 }); } finally { setRefreshing(false); @@ -1123,6 +1238,11 @@ async function fetchAndUpdateIntervention(iv, myToken) { if (fiche.rfc && !iv.ref) { iv.ref = fiche.rfc; } + // v4.1.18 : persister le formSenderGuid sur l'iv pour qu'il soit + // disponible au clic pour ouvrir la fiche avec le bon sender (S vs I). + if (fiche.formSenderGuid) { + iv.formSenderGuid = fiche.formSenderGuid; + } // ─── Étape 3 : API timeline → texte complet de l'action ───────────── // Le HTML brut de la fiche ne contient PAS les valeurs d'action (elles @@ -1495,6 +1615,9 @@ function decodeJsonString(s) { function parseActionText(text) { if (!text) return null; const out = { _raw: text }; + // v4.2 : on track toutes les occurrences de "Contact" / "Personne de contact" + // pour détecter l'anomalie (les 2 présents = situation suspecte). + const contactOccurrences = []; // { kind: "contact"|"personne", value: string } // Pré-filtrer les lignes "Date proposée par ..." : on NE prend PAS ce champ // nulle part (ni en infobulle.dateProposee, ni dans autres). const lines = text.split(/\n+/) @@ -1526,14 +1649,31 @@ function parseActionText(text) { 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; + // v4.2 : on détecte aussi "Personne de contact..." (spécifique à la demande + // / sur site / de l'entité quittée / interne / etc.). On la marque comme + // un 2e candidat possible pour le contact affiché. + const rxPersonne = /Personne\s+de\s+contact(?:\s+(?:sur\s+site|sp[ée]cifique[^:]*|de\s+l[''`]?entit[ée][^:]*|interne[^:]*))?\s*:\s*/gi; + let pm; + while ((pm = rxPersonne.exec(cleanLine)) !== null) { + // Valeur = jusqu'au prochain label connu OU fin de ligne + const after = cleanLine.substring(pm.index + pm[0].length); + const stop = after.search(/\b(Date|Heure|Lieu|Service|Contact|B[ée]n[ée]ficiaire|[ÉE]tage|Bureau|Probl[èe]me|A\s*faire|À\s*faire|Mat[ée]riel|TFS|Personne\s+de\s+contact|Num[ée]ro\s+de\s+t[ée]l[ée]phone)\s*:/i); + const val = (stop >= 0 ? after.substring(0, stop) : after).trim() + .replace(/[,;]+$/, "").trim(); + if (val) { + contactOccurrences.push({ kind: "personne", value: val }); + } + } + // "Date : lundi 20.04 Heure : matin" → split en plusieurs paires const markers = []; - 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; + // v4.2 : on ajoute un lookbehind négatif (?<!Personne\s+de\s+) pour ne + // PAS matcher "Contact" à l'intérieur de "Personne de Contact". + // Sans ça on aurait un double match. + const rx = /(?<!Personne\s+de\s+)(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 }); @@ -1554,11 +1694,32 @@ function parseActionText(text) { const keyNorm = mk.label.toLowerCase().replace(/\s+/g, " "); const outKey = labelMap[keyNorm]; if (outKey && val) { - out[outKey] = out[outKey] ? out[outKey] + " / " + val : val; + // v4.2 : on track aussi les "Contact" rencontrés dans contactOccurrences + if (outKey === "contact") { + contactOccurrences.push({ kind: "contact", value: val }); + } else { + out[outKey] = out[outKey] ? out[outKey] + " / " + val : val; + } } } } + // v4.2 : logique de sélection du contact + détection d'anomalie + // - 0 occurrence → rien + // - 1 "contact" → OK + // - 1 "personne" → OK (fallback) + // - ≥ 2 occurrences → anomalie : on garde la 1re mais on marque anomalie + // pour que l'UI affiche en rouge et que le caller sache + // qu'il vaut mieux garder l'ancien contact (xhr2). + if (contactOccurrences.length === 1) { + out.contact = contactOccurrences[0].value; + } else if (contactOccurrences.length >= 2) { + out.contactAnomalie = true; + // On prend quand même le 1er "contact" pur (pas "personne") si possible + const firstReal = contactOccurrences.find(x => x.kind === "contact"); + out.contact = (firstReal || contactOccurrences[0]).value; + } + if (autres.length) out.autres = autres.join("\n"); return out; } @@ -1580,6 +1741,31 @@ let activeRefreshButton = "total"; function setActiveRefreshButton(kind) { activeRefreshButton = kind || "total"; + // v4.1.20 : si le bouton Arrêter est affiché, le repositionner selon + // le nouveau type de refresh actif. Sinon rien à faire (il prendra sa + // position au prochain showAbortButton(true)). + positionAbortButton(); +} + +// v4.1.20 : place le bouton Arrêter à sa position correcte selon +// activeRefreshButton. Fonction idempotente, sûre à appeler plusieurs fois. +function positionAbortButton() { + const btn = document.getElementById("abort-btn"); + if (!btn) return; + const partialBtn = document.getElementById("refresh-partial-btn"); + const totalBtn = document.getElementById("refresh-btn"); + if (!partialBtn || !totalBtn) return; + if (activeRefreshButton === "partial") { + // Entre Actualiser (partial) et Tout recharger (total) + if (btn.previousElementSibling !== partialBtn) { + totalBtn.parentNode.insertBefore(btn, totalBtn); + } + } else { + // Après Tout recharger + if (totalBtn.nextSibling !== btn) { + totalBtn.parentNode.insertBefore(btn, totalBtn.nextSibling); + } + } } function setRefreshing(on) { @@ -1668,7 +1854,9 @@ function updateProgressBar(done, total) { } const pct = Math.min(100, Math.round((done / total) * 100)); fill.style.width = pct + "%"; - label.textContent = `Rafraîchissement… ${done} / ${total}`; + // v4.1.20 : message différencié selon le type de refresh actif + const prefix = (activeRefreshButton === "partial") ? "Actualisation" : "Rafraîchissement"; + label.textContent = `${prefix}… ${done} / ${total}`; } // Affiche/masque le bouton "Arrêter". N'est montré que pendant un refresh @@ -1677,8 +1865,12 @@ function updateProgressBar(done, total) { function showAbortButton(on) { const btn = document.getElementById("abort-btn"); if (!btn) return; - if (on) btn.classList.remove("hidden"); - else btn.classList.add("hidden"); + if (on) { + positionAbortButton(); + btn.classList.remove("hidden"); + } else { + btn.classList.add("hidden"); + } } /** @@ -1708,7 +1900,14 @@ function renderFromData(data) { function renderCaptureInfo(data, stats) { const info = document.getElementById("capture-info"); if (refreshCounter > 0) { - info.textContent = "Rafraîchissement en cours…"; + // v4.1.20 : message différencié selon le type de refresh actif + // - partial (Actualiser) → "Actualisation en cours…" + // - total (Tout recharger) → "Rafraîchissement en cours…" + if (activeRefreshButton === "partial") { + info.textContent = "Actualisation en cours…"; + } else { + info.textContent = "Rafraîchissement en cours…"; + } info.classList.add("refreshing"); return; } @@ -1718,20 +1917,35 @@ function renderCaptureInfo(data, stats) { 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 "; + // v4.1.20 : préfixe selon le type de refresh qui a généré cette capture + // - lastRefreshKind === "partial" → "Actualisé à HH:MM" + // - lastRefreshKind === "total" → "Synchronisé à HH:MM" + // - data.source === "cache" → "Cache de HH:MM" + let prefix; + if (data.source === "cache") { + prefix = "Cache de "; + } else if (data.lastRefreshKind === "partial") { + prefix = "Actualisé à "; + } else { + prefix = "Synchronisé à "; + } if (isSameDay) { parts.push(`${prefix}${hh}:${mm}`); } else { const dd = String(d.getDate()).padStart(2, "0"); const mo = String(d.getMonth() + 1).padStart(2, "0"); - const prefixDate = data.source === "cache" ? "Cache du " : "MAJ "; + let prefixDate; + if (data.source === "cache") { + prefixDate = "Cache du "; + } else if (data.lastRefreshKind === "partial") { + prefixDate = "Actualisé le "; + } else { + prefixDate = "Synchronisé le "; + } parts.push(`${prefixDate}${dd}.${mo} ${hh}:${mm}`); } } @@ -1821,6 +2035,19 @@ function isTechAbsent(tech, isoDate) { // Construction d'une carte // ============================================================================ +// v4.1.20 : détecte si tech = Pillonel Olivier ET jour = vendredi. +// Hardcodé car c'est une absence récurrente connue spécifique à lui. +function isPillonelAbsentFriday(tech, isoDate) { + if (!tech || !tech.name) return false; + // Normaliser le nom (tolère "Pillonel, Olivier", "Pillonel Olivier", etc.) + const name = tech.name.toLowerCase(); + if (!name.includes("pillonel")) return false; + if (!name.includes("olivier")) return false; + // Jour de la semaine : 5 = vendredi (en JS, 0=dim, 1=lun, ..., 5=ven) + const d = isoToDate(isoDate); + return d.getDay() === 5; +} + function buildCard(tech, isoDate) { const card = document.createElement("section"); card.className = "card"; @@ -1892,21 +2119,49 @@ function buildCard(tech, isoDate) { body.appendChild(note); } - // Absent sans interv → on stop là + // v4.1.20 : cas spécifique Pillonel Olivier, absent tous les vendredis. + // Affichage d'un message explicite au lieu de "Pas d'intervention planifiée". + // v4.2 : prioritaire même si un bloc AL-Absence couvre le vendredi (ce qui + // est le cas normal), pour TOUJOURS afficher "Absent le vendredi". + const isPillonelFriday = isPillonelAbsentFriday(tech, isoDate); + + // Absent sans interv → on stop là (après avoir posé le message Pillonel + // si vendredi). if (isAbsent && realInterventions.length === 0) { + if (isPillonelFriday) { + const note = document.createElement("div"); + note.className = "tech-absence-recurring"; + note.textContent = "Absent le vendredi"; + body.appendChild(note); + } card.appendChild(body); return card; } if (realInterventions.length === 0 && !isPompier) { - const empty = document.createElement("div"); - empty.className = "card-empty"; - empty.textContent = "Pas d'intervention planifiée"; - body.appendChild(empty); + if (isPillonelFriday) { + const note = document.createElement("div"); + note.className = "tech-absence-recurring"; + note.textContent = "Absent le vendredi"; + body.appendChild(note); + } else { + const empty = document.createElement("div"); + empty.className = "card-empty"; + empty.textContent = "Pas d'intervention planifiée"; + body.appendChild(empty); + } card.appendChild(body); return card; } + // Pillonel vendredi avec quand même des interv planifiées ? Rare mais possible. + if (isPillonelFriday && realInterventions.length > 0) { + const note = document.createElement("div"); + note.className = "tech-absence-recurring"; + note.textContent = "Absent le vendredi"; + body.appendChild(note); + } + // Timeline body.appendChild(buildTimeline(realInterventions, pompierBlocks, absenceBlocks, card, isPompier, isAbsent)); @@ -2255,7 +2510,7 @@ function buildInterventionRow(iv, cardEl) { const lieuRaw = info.lieu || iv.bulleLieu || null; // Rendu initial de lieu + contacts dans rightCol - renderLieuContactBlocks(rightCol, lieuRaw, contactRaw); + renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, info.contactAnomalie); // ── Bas : Catégorie (à gauche) + Signature planificateur (à droite) ────── const bottomEl = document.createElement("div"); @@ -2377,6 +2632,18 @@ async function openInterventionInNewTab(iv, opts = {}) { } } + // v4.1.18 : sender à utiliser dépend du type de fiche : + // - demande S... → {C99ECD05-...} + // - incident I... → {07ED9C68-...} + // On préfère le formSenderGuid extrait du HTML de la fiche si connu, sinon + // fallback sur préfixe de la ref. + let sender = FICHE_SENDER; + if (iv.formSenderGuid) { + sender = iv.formSenderGuid; + } else if (iv.ref && /^I/i.test(iv.ref)) { + sender = "%7B07ED9C68-6172-48EA-8A58-90912B0A283E%7D"; + } + // Construire l'URL qui fonctionne (format identique à l'URL manuelle qui // marche dans le navigateur quand on ouvre une fiche depuis l'UI EasyVista). const internalurltime = Math.floor(Date.now() / 1000); @@ -2387,7 +2654,7 @@ async function openInterventionInNewTab(iv, opts = {}) { `&eventName=formEvent` + `&target=${encodeURIComponent(target)}` + `&checksum=${encodeURIComponent(checksum)}` + - `&sender=${FICHE_SENDER}`; + `&sender=${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, @@ -2457,12 +2724,24 @@ function formatPhone(raw) { const d = m[1]; return `+41 ${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`; } + // v4.2 : 41XXXXXXXXX sans + (format EasyVista qui colle parfois le préfixe) + m = digits.match(/^41(\d{9})$/); + if (m) { + const d = m[1]; + return `+41 ${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`; + } // +33 (France) m = digits.match(/^\+33(\d{9})$/); if (m) { const d = m[1]; return `+33 ${d.slice(0, 1)} ${d.slice(1, 3)} ${d.slice(3, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`; } + // v4.2 : 33XXXXXXXXX sans + + m = digits.match(/^33(\d{9})$/); + if (m) { + const d = m[1]; + return `+33 ${d.slice(0, 1)} ${d.slice(1, 3)} ${d.slice(3, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`; + } // 0XX XXX XX XX (fixe ou mobile CH, 10 chiffres commençant par 0) m = digits.match(/^0(\d{9})$/); if (m) { @@ -2537,17 +2816,36 @@ function extractContacts(raw) { */ 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,})/g; - const rxShort = /(?:^|[\s(])(\d{5})(?=[\s)]|$)/g; + // v4.1.20 : regex plus permissives pour tolérer les erreurs humaines : + // - pas d'espace après le numéro (ex: "021555555Textecoller") + // - pas d'espace/parenthèse avant un court numéro + // LONG : +41 / +33 / 0X suivis de chiffres/espaces/points/tirets + // On ne limite plus par séparateur après — on laisse le moteur + // consommer le numéro le plus long possible (greedy) puis on + // s'arrête dès qu'on tombe sur un caractère non numérique. + // v4.2 : on accepte aussi le format "41XXXXXXXXX" sans + devant (fréquent + // quand EasyVista concatène "prefixe+tel" sans espace : Nom, + // Prénom 41XXXXXXXXX → extraire 41XXXXXXXXX puis reformater en + // +41 XX XXX XX XX). On exige exactement 11 chiffres collés pour + // éviter de matcher des codes postaux ou autres nombres. + const rxLong = /(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(?<!\d)41\d{9}(?!\d)|(?<!\d)33\d{9}(?!\d))/g; + // SHORT : 5 chiffres isolés. v4.1.20 : on retire l'exigence de séparateur + // après pour tolérer "12345Texte". Avant = début, whitespace ou + // parenthèse (on évite de prendre 5 chiffres au milieu d'un long). + const rxShort = /(?:^|[\s(\/])(\d{5})(?!\d)/g; // Trouver toutes les positions de match pour LONG et SHORT const matches = []; let mm; while ((mm = rxLong.exec(raw)) !== null) { - matches.push({ start: mm.index, end: mm.index + mm[1].length, tel: mm[1] }); + // v4.1.20 : on ne garde que si au moins 8 chiffres pour un long + // (élimine les fausses captures "0 1" ou "01 2") + const digitsOnly = mm[1].replace(/\D/g, ""); + if (digitsOnly.length >= 9) { + matches.push({ start: mm.index, end: mm.index + mm[1].length, tel: mm[1] }); + } } while ((mm = rxShort.exec(raw)) !== null) { - // Ne pas prendre un short qui chevauche un long déjà trouvé const shortTel = mm[1]; const shortStart = mm.index + mm[0].indexOf(shortTel); const shortEnd = shortStart + shortTel.length; @@ -2561,9 +2859,7 @@ function splitOneContact(raw) { let name = raw; let phone = null; if (matches.length > 0) { - // Nom = ce qui précède le 1er numéro name = raw.substring(0, matches[0].start).trim(); - // Tels formatés, joints par " / " const tels = matches.map(x => formatPhone(x.tel)).filter(Boolean); phone = tels.length > 0 ? tels.join(" / ") : null; } @@ -2594,6 +2890,38 @@ function cleanContactName(raw) { s = s.replace(/[()]/g, " "); // Retirer labels type "Nom utilisateur :", "Utilisateur :", "Bénéficiaire :" s = s.replace(/\b(Nom utilisateur|Utilisateur|B[ée]n[ée]ficiaire)\s*:\s*[^\n,]*/gi, ""); + + // v4.1.20 : virer les commentaires parasites fréquents AVANT la logique + // des 4-mots (ils peuvent apparaître au tout début quand EasyVista n'a + // pas de nom saisi et commence directement par un commentaire). + // On détecte et coupe DÈS que ces expressions apparaissent. + // NOTE: on évite \b avant/après les caractères accentués (à, é) car + // \b est basé sur [a-zA-Z0-9_] et donne de faux négatifs. + const parasitePhrases = [ + // Instructions d'appel (avec "à" ou "a") + /t[ée]l[ée]phone(?:r)?\s*[àa]\s*l[''`]?utilisateur/gi, + /t[ée]l[ée]phone(?:r)?\s*[àa](?:\s|$)/gi, + /t[ée]l[ée]phone(?:r)?\s*[àa]$/gi, + /\bappeler?\s+l[''`]?utilisateur\b/gi, + /\bappeler?\s+(?:le\s+)?b[ée]n[ée]ficiaire\b/gi, + /\bappeler?\s+la\s+personne\b/gi, + /\bappeler?\s+[àa]\s+/gi, + /\brappeler?\s+l[''`]?utilisateur\b/gi, + /\brappeler?\s+(?:le\s+)?b[ée]n[ée]ficiaire\b/gi, + // Instructions de présentation + /s[''`]annoncer?\s+[àa]\s+(?:la\s+r[ée]ception|l[''`]?accueil|.+?)(?=\.|,|$)/gi, + /\bse\s+pr[ée]senter\s+[àa]\s+.+?(?=\.|,|$)/gi, + // Autres + /\bbonjour\b/gi, + /\bmerci\b/gi, + // v4.1.20 : mots isolés qui restent parfois après les nettoyages ci-dessus + /\butilisateur\b/gi, + /\bb[ée]n[ée]ficiaire\b/gi + ]; + for (const rx of parasitePhrases) { + s = s.replace(rx, " "); + } + // Espaces multiples → un seul s = s.replace(/\s{2,}/g, " ").trim(); // Ponctuation en bord @@ -2601,31 +2929,26 @@ function cleanContactName(raw) { if (!s) return null; // v4.1.8 : tronquer les commentaires parasites qui suivent le nom. - // On considère qu'un nom de personne ne dépasse pas 4 mots (Nom, Prénom, - // et éventuellement particule "Da Silva" ou second prénom). - // Si après les 4 premiers mots on a encore des mots, ce sont des parasites - // (ex: "Barbosa Oliveira, Bruno S'annoncer à la réception" → on coupe - // après "Bruno"). Le signal de fin de nom : un mot commençant par une - // minuscule (les noms/prénoms commencent par une majuscule). - // On garde quand même le 1er mot (parfois un mot comme "de", "von", - // "van" est lowercase). const words = s.split(/\s+/); const keep = []; for (let i = 0; i < words.length; i++) { const w = words[i]; if (i === 0) { keep.push(w); continue; } - // Particules courantes : de / da / du / van / von / le / la if (/^(de|da|du|van|von|le|la|del|di|der)$/i.test(w)) { keep.push(w); continue; } - // Si on a déjà au moins 2 mots et que ce mot commence par une minuscule, - // c'est un commentaire qui commence → on arrête. if (keep.length >= 2 && /^[a-zéèêàâîôûç]/.test(w)) break; - // Limite dure : 4 mots maximum (Barbosa Oliveira, Bruno Dupont OK) if (keep.length >= 4) break; keep.push(w); } s = keep.join(" "); - // Ponctuation de nouveau en fin au cas où s = s.replace(/[\s,;:.\-]+$/, "").trim(); + + // v4.1.20 : dernier garde-fou : si le résultat final est juste un mot + // parasite (ex: "téléphone" tout seul, "appeler" tout seul), on retourne + // null plutôt qu'afficher un faux nom. + if (/^(t[ée]l[ée]phone|t[ée]l|appeler?|rappeler?|s[''`]?annoncer|bonjour|merci)$/i.test(s)) { + return null; + } + return s || null; } @@ -2760,7 +3083,7 @@ const ALL_COLOR_CLASSES = [ * conserver l'ordre d'affichage. Utilisé à la création ET lors de la * mise à jour après fetch de la fiche. */ -function renderLieuContactBlocks(rightCol, lieuRaw, contactRaw) { +function renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, contactAnomalie) { // Supprime les anciens blocs lieu/contact rightCol.querySelectorAll(".iv-lieu-block, .iv-contact-line").forEach(el => el.remove()); @@ -2798,6 +3121,9 @@ function renderLieuContactBlocks(rightCol, lieuRaw, contactRaw) { if (!c.name && !c.phone) continue; const contactEl = document.createElement("div"); contactEl.className = "iv-contact-line"; + // v4.2 : si anomalie (les 2 champs Contact + Personne de contact existent + // dans l'action), afficher en rouge pour signaler à l'user de vérifier. + if (contactAnomalie) contactEl.classList.add("iv-contact-anomalie"); if (c.name) { const nameSpan = document.createElement("span"); nameSpan.className = "iv-contact"; @@ -2911,7 +3237,7 @@ function updateInterventionRow(iv) { const info = iv.infobulle || {}; const contactRaw = info.contact || iv.bulleContact || null; const lieuRaw = info.lieu || iv.bulleLieu || null; - renderLieuContactBlocks(rightCol, lieuRaw, contactRaw); + renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, info.contactAnomalie); } // Segment timeline correspondant : même couleur + même classe statut @@ -2955,13 +3281,22 @@ let bulleState = { }; function showTooltip(e, iv, rowEl) { + // v4.1.15 : si la bulle est épinglée sur une autre iv, on NE REMPLACE PAS + // son contenu (l'user veut garder la fiche épinglée même en survolant + // d'autres cartes). + if (bulleState.pinned && state.currentTooltipIv && state.currentTooltipIv !== iv) { + return; + } + const el = tooltipEl(); el.innerHTML = buildTooltipHTML(iv); - el.classList.remove("hidden", "pinned"); + el.classList.remove("hidden"); el.classList.add("visible"); - // Reset pin en changeant d'iv - if (bulleState.pinned && state.currentTooltipIv !== iv) { - bulleState.pinned = false; + // Conserver le pinned si on revient sur la même iv + if (bulleState.pinned && state.currentTooltipIv === iv) { + el.classList.add("pinned"); + } else { + el.classList.remove("pinned"); } if (bulleState.hideTimer) { clearTimeout(bulleState.hideTimer); @@ -2970,7 +3305,10 @@ function showTooltip(e, iv, rowEl) { bulleState.hoveredInRow = true; // v4.1.12 : positionner la bulle UNE SEULE FOIS au mouseenter, près de la // carte (row) et pas du curseur. Elle ne bouge plus pendant le survol. - positionTooltipAnchored(rowEl || (e && e.currentTarget)); + // v4.1.15 : si pinned, NE PAS repositionner (la bulle doit rester fixe). + if (!bulleState.pinned) { + positionTooltipAnchored(rowEl || (e && e.currentTarget)); + } // v4 : lazy-load du texte complet de l'action au premier hover. // Sans await : on affiche le tooltip IMMÉDIATEMENT avec ce qu'on a (lieu, @@ -3006,13 +3344,34 @@ function hideTooltip(opts = {}) { bulleState.hideTimer = setTimeout(() => { if (bulleState.hoveredInBulle || bulleState.hoveredInRow) return; if (bulleState.pinned) return; + // v4.2 : si l'utilisateur a une sélection de texte ACTIVE dans la bulle, + // on ne ferme pas (sinon la sélection disparaît avant d'avoir pu copier). + if (!opts.force && hasTextSelectionInTooltip()) return; const el = tooltipEl(); el.classList.remove("visible", "pinned"); el.classList.add("hidden"); state.currentTooltipIv = null; + currentTooltipPos = null; }, 120); } +// v4.2 : détecte si l'utilisateur a une sélection de texte active dans la bulle. +// Utilisé pour empêcher la fermeture automatique tant qu'on n'a pas fini de +// sélectionner/copier. +function hasTextSelectionInTooltip() { + try { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0) return false; + const tip = tooltipEl(); + if (!tip) return false; + const range = sel.getRangeAt(0); + // La sélection est dans la bulle si au moins un endpoint y est + return tip.contains(range.startContainer) || tip.contains(range.endContainer); + } catch { + return false; + } +} + function moveTooltip(e) { // v4.1.12 : la bulle est FIXE (positionnée une fois au mouseenter). Cette // fonction est conservée pour compat mais ne fait plus rien. @@ -3021,18 +3380,21 @@ function moveTooltip(e) { // v4.1.12 : positionnement fixe de la bulle, ancrée par rapport à la ligne // (rowEl). Par défaut à droite de la ligne, avec fallback à gauche si pas // assez de place, et ajustement vertical pour rester dans la fenêtre. +// v4.1.17 : position actuelle de la bulle dans le viewport. On la mémorise +// pour pouvoir la ré-appliquer à chaque scroll (au cas où un ancêtre +// casse position:fixed sans qu'on s'en rende compte). +let currentTooltipPos = null; + function positionTooltipAnchored(rowEl) { const el = tooltipEl(); if (!rowEl || !el) return; const pad = 14; const rowRect = rowEl.getBoundingClientRect(); - // Dimensions de la bulle : rendre visible puis mesurer const tipRect = el.getBoundingClientRect(); // Position X : à droite de la ligne par défaut let x = rowRect.right + pad; if (x + tipRect.width > window.innerWidth - 8) { - // Pas assez de place à droite → à gauche x = rowRect.left - tipRect.width - pad; } if (x < 4) x = 4; @@ -3044,10 +3406,21 @@ function positionTooltipAnchored(rowEl) { } if (y < 4) y = 4; + currentTooltipPos = { x, y }; el.style.left = x + "px"; el.style.top = y + "px"; } +// v4.1.17 : ré-applique la position de la bulle au scroll. Safety net au +// cas où un ancêtre casse position:fixed. Marche peu importe la cause. +function reapplyTooltipPosition() { + if (!currentTooltipPos) return; + const el = tooltipEl(); + if (!el || !el.classList.contains("visible")) return; + el.style.left = currentTooltipPos.x + "px"; + el.style.top = currentTooltipPos.y + "px"; +} + // 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. function pinTooltip() { @@ -3057,56 +3430,136 @@ function pinTooltip() { el.classList.add("pinned"); } -// v4.1.14 : recharger UNIQUEMENT cette intervention. Reset les flags de -// fetch pour forcer la récupération xhr2 + fiche + timeline, puis appeler -// fetchAndUpdateIntervention qui met à jour la carte ET (si la bulle est -// toujours ouverte sur cette iv) son contenu. -// Pendant ce temps, seul le bouton ↻ de la bulle tourne — pas les boutons -// Actualiser / Tout recharger de la topbar. -let singleReloadCounter = 0; +// v4.1.14/19 : recharger UNIQUEMENT cette intervention. Fetch DIRECT sans +// passer par isRefreshAborted (pour ne pas être bloqué par un abort global +// ou un refresh précédent). Animation sur le bouton ↻ de la bulle. async function reloadSingleIntervention(iv, btnEl) { if (!iv || iv.type === "AL-Reservation") return; // Empêcher double-clic en cours if (iv._reloading) return; iv._reloading = true; - // Reset flags pour forcer un refetch complet - iv.xhr2Fetched = false; - iv.ficheFetched = false; - iv.ficheActionText = null; - iv.ficheFetchError = null; + // v4.1.19 : NE PAS reset les champs AVANT le fetch (sinon si le fetch + // échoue ou est interrompu, on perd les données précédentes). On les + // mettra à jour uniquement si le fetch réussit. + const previousState = { + xhr2Fetched: iv.xhr2Fetched, + ficheFetched: iv.ficheFetched, + ficheActionText: iv.ficheActionText, + ficheFetchError: iv.ficheFetchError, + bulleDescription: iv.bulleDescription, + infobulle: iv.infobulle, + status: iv.status, + label: iv.label, + ficheChecksum: iv.ficheChecksum, + ficheTarget: iv.ficheTarget, + formSenderGuid: iv.formSenderGuid + }; - // Marquer le bouton ↻ comme en cours + // Marquer le bouton ↻ comme en cours (visuel immédiat) if (btnEl) btnEl.classList.add("spinning"); - singleReloadCounter++; + // v4.1.19 : toast de feedback en bas à droite + showToast("Rafraîchissement", iv.ref || iv.actionId); try { - // Utiliser le token courant pour que l'abort au changement de date - // stoppe aussi ce reload - await fetchAndUpdateIntervention(iv, currentRefreshToken); + // ─── xhr2 (rapide) ───────────────────────────────────────────────── + try { + const xhr2Resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId }); + if (xhr2Resp && xhr2Resp.ok) { + const parsed = parseXhr2Body(xhr2Resp.body); + if (parsed) { + if (parsed.description) { + iv.bulleDescription = parsed.description; + const infob = parseActionText(parsed.description); + if (infob) iv.infobulle = infob; + } + if (parsed.label) iv.label = parsed.label; + iv.xhr2Fetched = true; + } + } + } catch (err) { + console.warn("[reloadSingle/xhr2] iv", iv.actionId, err); + } + + // ─── fiche HTML ──────────────────────────────────────────────────── + const ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink }); + if (ficheResp.ok) { + const fiche = parseFicheHtml(ficheResp.html); + iv.status = fiche.status; + if (fiche.rfc && !iv.ref) iv.ref = fiche.rfc; + if (fiche.formSenderGuid) iv.formSenderGuid = fiche.formSenderGuid; + + // ─── timeline API : texte complet ────────────────────────────── + if (fiche.formId && fiche.formChecksum && fiche.formSenderGuid && iv.actionId) { + try { + const tlResp = await sendMessage({ + type: "fetchTimelineApi", + guid: fiche.formSenderGuid, + formId: fiche.formId, + formChecksum: fiche.formChecksum + }); + if (tlResp && tlResp.ok) { + const fullText = parseTimelineJsonForAction(tlResp.body, iv.actionId); + if (fullText) iv.ficheActionText = fullText; + } + } catch (err) { + console.warn("[reloadSingle/timeline] iv", iv.actionId, err); + } + } + + // ─── Extraire checksum pour ouverture ─────────────────────────── + if (iv.requestId && !iv.ficheChecksum) { + const rx1 = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`); + const m1 = ficheResp.html.match(rx1); + if (m1) { + iv.ficheTarget = iv.requestId; + iv.ficheChecksum = m1[1]; + } + } + iv.ficheFetched = true; + iv.ficheFetchError = null; + } else { + iv.ficheFetchError = ficheResp.error || "fetch_failed"; + if (ficheResp.error === "session_expired") { + state.session = null; + showSessionExpiredBanner(); + } + } + + // Mettre à jour la carte (statut clos → ✓ vert, catégorie, etc.) + updateInterventionRow(iv); + // Si la bulle est toujours ouverte sur cette iv, régénérer son HTML const tip = tooltipEl(); if (tip.classList.contains("visible") && state.currentTooltipIv === iv) { tip.innerHTML = buildTooltipHTML(iv); } - // Sauvegarder le cache pour cette date - const cached = await readCache(state.currentDate); - if (cached && cached.techs) { - // Remettre l'iv à jour dans le cache - for (const tech of cached.techs) { - for (let i = 0; i < (tech.interventions || []).length; i++) { - if (tech.interventions[i].actionId === iv.actionId) { - tech.interventions[i] = iv; + + // Sauvegarder le cache + try { + const cached = await readCache(state.currentDate); + if (cached && cached.techs) { + for (const tech of cached.techs) { + for (let i = 0; i < (tech.interventions || []).length; i++) { + if (tech.interventions[i].actionId === iv.actionId) { + tech.interventions[i] = iv; + } } } + await writeCache(state.currentDate, { techs: cached.techs }); } - await writeCache(state.currentDate, { techs: cached.techs }); + } catch (err) { + console.warn("[reloadSingle/cache]", err); } + + // v4.1.19 : toast de succès + showToast("Mis à jour", iv.ref || iv.actionId); } catch (err) { console.warn("[reloadSingle] erreur iv", iv.actionId, err); + // Restaurer l'état précédent en cas d'erreur globale + Object.assign(iv, previousState); } finally { iv._reloading = false; - singleReloadCounter = Math.max(0, singleReloadCounter - 1); if (btnEl) btnEl.classList.remove("spinning"); } } @@ -3120,6 +3573,7 @@ function unpinTooltip() { el.classList.remove("visible"); el.classList.add("hidden"); state.currentTooltipIv = null; + currentTooltipPos = null; if (bulleState.hideTimer) { clearTimeout(bulleState.hideTimer); bulleState.hideTimer = null; @@ -3135,6 +3589,30 @@ function bindTooltipInteractions() { const el = tooltipEl(); if (!el) return; + // v4.1.17 : ré-applique la position au scroll de la page (safety net + // contre un ancêtre qui casserait position:fixed silencieusement). + window.addEventListener("scroll", reapplyTooltipPosition, { passive: true }); + window.addEventListener("resize", () => { + // Au resize, on laisse fermer la bulle (position probablement invalidée) + if (bulleState.pinned) return; + hideTooltip({ force: true }); + }); + + // v4.1.17 : bloquer le scroll de la page quand la souris est DANS la + // bulle. Le scroll interne de la bulle (overflow-y auto) reste OK. + // On utilise "wheel" non-passif pour pouvoir preventDefault. + el.addEventListener("wheel", (e) => { + // Si la bulle a un scroll interne et n'est pas à la limite, laisser + // le scroll naturel se faire. Sinon, bloquer le scroll global. + const canScrollDown = el.scrollTop + el.clientHeight < el.scrollHeight; + const canScrollUp = el.scrollTop > 0; + if ((e.deltaY > 0 && !canScrollDown) || (e.deltaY < 0 && !canScrollUp)) { + e.preventDefault(); + } + // Ne pas laisser le scroll se propager au body + e.stopPropagation(); + }, { passive: false }); + // Hover sur la bulle elle-même : empêche la fermeture el.addEventListener("mouseenter", () => { bulleState.hoveredInBulle = true; @@ -3189,6 +3667,20 @@ function bindTooltipInteractions() { if (state.currentTooltipIv) { reloadSingleIntervention(state.currentTooltipIv, btn); } + } else if (action === "copy-ref") { + // v4.1.15 : copier la référence depuis la bulle + const ref = btn.dataset.ref; + if (ref) { + navigator.clipboard.writeText(ref).then(() => { + btn.classList.add("copied"); + const original = btn.textContent; + btn.textContent = "✓"; + setTimeout(() => { + btn.classList.remove("copied"); + btn.textContent = original; + }, 1200); + }).catch(() => {}); + } } }); @@ -3274,7 +3766,9 @@ function buildTooltipHTML(iv) { if (iv.ref) { rows.push(`<hr>`); - rows.push(row("Référence", iv.ref)); + // v4.1.15 : ref avec bouton copier inline + const refSafe = escapeHtml(iv.ref); + rows.push(`<dt>Référence</dt><dd class="tt-ref-cell"><span class="tt-ref-val">${refSafe}</span><button class="tt-copy-btn" data-action="copy-ref" data-ref="${refSafe}" title="Copier la référence">📋</button></dd>`); } if (iv.ghost) { @@ -3394,6 +3888,8 @@ function showError(msg) { document.getElementById("loading").classList.add("hidden"); document.getElementById("stats").classList.add("hidden"); document.getElementById("session-needed").classList.add("hidden"); + const evUnr = document.getElementById("ev-unreachable"); + if (evUnr) evUnr.classList.add("hidden"); document.getElementById("cards").innerHTML = ""; const box = document.getElementById("error-box"); box.textContent = msg; @@ -3404,6 +3900,8 @@ function showSessionNeeded() { document.getElementById("loading").classList.add("hidden"); document.getElementById("error-box").classList.add("hidden"); document.getElementById("stats").classList.add("hidden"); + const evUnr = document.getElementById("ev-unreachable"); + if (evUnr) evUnr.classList.add("hidden"); document.getElementById("cards").innerHTML = ""; document.getElementById("session-needed").classList.remove("hidden"); } @@ -3412,6 +3910,22 @@ function hideSessionNeeded() { document.getElementById("session-needed").classList.add("hidden"); } +// v4.2 : écran plein "EasyVista inaccessible" (différent de session expirée). +function showEvUnreachable() { + document.getElementById("loading").classList.add("hidden"); + document.getElementById("error-box").classList.add("hidden"); + document.getElementById("stats").classList.add("hidden"); + document.getElementById("session-needed").classList.add("hidden"); + document.getElementById("cards").innerHTML = ""; + const el = document.getElementById("ev-unreachable"); + if (el) el.classList.remove("hidden"); +} + +function hideEvUnreachable() { + const el = document.getElementById("ev-unreachable"); + if (el) el.classList.add("hidden"); +} + // v4.1.12 : bannière non bloquante "session expirée". Affichée quand le // fetch détecte une session morte EN COURS DE ROUTE (pas au démarrage). // L'utilisateur voit toujours les données déjà chargées, mais est prévenu