From 8ab62e92d218afa29e2ebf7315b4d1e1278f8fce Mon Sep 17 00:00:00 2001 From: Quentin Rouiller Date: Fri, 17 Apr 2026 09:00:00 +0200 Subject: [PATCH] =?UTF-8?q?Version=203.0.0=20=E2=80=94=20=C3=89volution=20?= =?UTF-8?q?majeure=20du=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 370 +++++- manifest.json | 10 +- viewer.css | 652 ++++++++--- viewer.html | 23 +- viewer.js | 2991 ++++++++++++++++++++++++++++++++++--------------- 5 files changed, 2980 insertions(+), 1066 deletions(-) diff --git a/background.js b/background.js index 3dd483d..7a64086 100644 --- a/background.js +++ b/background.js @@ -1,61 +1,331 @@ // background.js — Service worker (Manifest V3) // -// Au clic sur l'icône : -// 1. Vérifier qu'on est bien sur itsma.vd.ch (sinon message d'erreur) -// 2. Injecter un script dans la page qui récupère le HTML complet -// 3. Stocker dans chrome.storage.local (persistant, sert de "dernière capture") -// 4. Ouvrir viewer.html +// Rôles : +// 1. Au clic sur l'icône : ouvrir le viewer +// 2. Répondre aux messages du viewer : +// - getSession : trouve l'onglet EasyVista ouvert, renvoie {phpsessid, origin} +// - fetchPlanning : fetch le XML du planning pour une date +// - fetchFiche : fetch une fiche individuelle (HTML) +// 3. Programmer les alarmes de refresh auto (12h, 15h) +// 4. Nettoyer les vieux caches (>7 jours) -chrome.action.onClicked.addListener(async (tab) => { - try { - if (!tab.url || !tab.url.startsWith("https://itsma.vd.ch/")) { - await chrome.storage.local.set({ - planningError: - "Cette extension ne fonctionne que sur https://itsma.vd.ch/. " + - "Va d'abord sur la page du planning, puis reclique sur l'icône." - }); - await openViewer(); - return; - } +// Domaines EasyVista reconnus (interne d'abord, externe en fallback) +const EV_ORIGINS = [ + "https://itsma.etat-de-vaud.ch", + "https://itsma.vd.ch" +]; - const results = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: extractPlanningFromPage - }); +// ============================================================================ +// Clic sur l'icône → ouvrir le viewer +// ============================================================================ - const data = results[0]?.result; - if (!data || !data.html) { - await chrome.storage.local.set({ - planningError: - "Impossible de lire le contenu de la page. " + - "Assure-toi d'être sur la page du planning des techniciens." - }); - await openViewer(); - return; - } - - await chrome.storage.local.set({ - planningHtml: data.html, - planningUrl: tab.url, - planningCapturedAt: Date.now(), - planningError: null - }); - - await openViewer(); - } catch (err) { - console.error("Erreur extension:", err); - await chrome.storage.local.set({ - planningError: "Erreur inattendue : " + (err?.message || String(err)) - }); - await openViewer(); +chrome.action.onClicked.addListener(async () => { + const viewerUrl = chrome.runtime.getURL("viewer.html"); + // Si le viewer est déjà ouvert, on focus cet onglet plutôt que d'en ouvrir un autre + const existing = await chrome.tabs.query({ url: viewerUrl + "*" }); + if (existing.length > 0) { + await chrome.tabs.update(existing[0].id, { active: true }); + await chrome.windows.update(existing[0].windowId, { focused: true }); + } else { + await chrome.tabs.create({ url: viewerUrl }); } }); -function extractPlanningFromPage() { - return { html: document.documentElement.outerHTML }; +// ============================================================================ +// Trouver l'onglet EasyVista actif et en extraire le PHPSESSID +// ============================================================================ + +async function findEasyVistaSession() { + // Chercher tous les onglets sur un domaine EasyVista + for (const origin of EV_ORIGINS) { + const tabs = await chrome.tabs.query({ url: origin + "/*" }); + for (const tab of tabs) { + const m = (tab.url || "").match(/[?&]PHPSESSID=([a-zA-Z0-9]+)/); + if (m) { + return { phpsessid: m[1], origin: origin, tabId: tab.id }; + } + } + } + return null; } -async function openViewer() { - const viewerUrl = chrome.runtime.getURL("viewer.html"); - await chrome.tabs.create({ url: viewerUrl }); +// ============================================================================ +// Fetch helpers (s'exécutent dans le contexte du service worker, +// les cookies du domaine sont automatiquement inclus via credentials: include) +// ============================================================================ + +/** + * Fetch du XML retourné par planning_xhr.php?div=calendar_block. + * Contient les interventions de nos 8 techs pour la date donnée (~40 ko). + * + * Ce n'est PAS le HTML de la page Planning — le serveur ne rend pas les données + * dans le HTML, elles arrivent via cet endpoint AJAX. + */ +async function fetchPlanningXml(origin, phpsessid, unixDate) { + const techIds = "76272,83725,66635,92235,90070,40944,72485,86874"; + const groupId = "191"; + const url = + `${origin}/planning_xhr.php` + + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + + `&div=calendar_block` + + `&mode=day` + + `&group_id=${groupId}` + + `&event_name=HelpDesk_PlanningItem` + + `&sql_param=${techIds}` + + `&unix_date=${unixDate}` + + `&start_date_label=Date` + + `&end_date_label=Date` + + `&click_here_label=Ici` + + `&mail_title=mail` + + `&day_start_hour=8` + + `&day_end_hour=19`; + console.log("[bg] fetchPlanningXml →", url.substring(0, 140)); + const r = await fetch(url, { credentials: "include" }); + console.log("[bg] status =", r.status); + if (!r.ok) throw new Error("HTTP " + r.status); + const xml = await r.text(); + console.log("[bg] taille XML =", xml.length); + return xml; } + +/** + * Fetch planning_xhr_2.php?id=ACTIONID pour UNE intervention. + * Retourne ~400 octets au format custom : + * @@DESCRIPTION_S@@...@@DESCRIPTION_E@@@@LABEL_S@@... + */ +async function fetchXhr2(origin, phpsessid, actionId) { + const url = `${origin}/planning_xhr_2.php?PHPSESSID=${encodeURIComponent(phpsessid)}&id=${encodeURIComponent(actionId)}`; + const r = await fetch(url, { credentials: "include" }); + if (!r.ok) throw new Error("HTTP " + r.status); + return await r.text(); +} + +async function fetchFicheHtml(origin, phpsessid, formLink) { + const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`; + console.log("[bg] fetchFicheHtml →", url.substring(0, 120)); + const r = await fetch(url, { credentials: "include" }); + if (!r.ok) throw new Error("HTTP " + r.status); + const html = await r.text(); + console.log("[bg] fiche status =", r.status, "| taille =", html.length); + return html; +} + +// GUID du "sender" du menu/workflow — observé dans les URLs EasyVista du planning. +// Le sender du formLink du XML planning est {9C395E45-...} mais l'API timeline +// utilise le sender de la FICHE parent ({C99ECD05-...}). +const TIMELINE_SENDER = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D"; + +/** + * Fetch l'API timeline JSON pour récupérer le texte des actions d'une fiche. + * Params : + * - target : ID interne de la fiche (pas l'action_id) + * - checksum : checksum frais extrait depuis le HTML de la fiche + * Retour : texte JSON de l'API, ou null en cas d'erreur. + */ +async function fetchTimelineJson(origin, phpsessid, target, checksum) { + const url = + `${origin}/api/v1/internal/forms/${TIMELINE_SENDER}/timeline` + + `?target=${encodeURIComponent(target)}` + + `&checksum=${encodeURIComponent(checksum)}` + + `&type=todo` + + `§ionId=1` + + `&navigator=` + + `&nbRecord=0` + + `&PHPSESSID=${encodeURIComponent(phpsessid)}`; + console.log("[bg] fetchTimelineJson →", url.substring(0, 120)); + const r = await fetch(url, { + credentials: "include", + headers: { + "Accept": "application/json", + "X-Requested-With": "XMLHttpRequest" + } + }); + if (!r.ok) throw new Error("HTTP " + r.status); + const body = await r.text(); + console.log("[bg] timeline status =", r.status, "| taille =", body.length); + return body; +} + +// ============================================================================ +// Détection "session invalide" +// ============================================================================ + +function looksLikeLoginPage(text) { + // La page de login EasyVista contient cette chaîne + return /customer_login|my\.policy/i.test((text || "").substring(0, 3000)); +} + +// ============================================================================ +// Messages du viewer +// ============================================================================ + +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + (async () => { + try { + if (msg.type === "getSession") { + const session = await findEasyVistaSession(); + sendResponse({ ok: true, session }); + return; + } + + if (msg.type === "fetchPlanning") { + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + // 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 }); + return; + } + + if (msg.type === "fetchXhr2") { + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const body = await fetchXhr2(session.origin, session.phpsessid, msg.actionId); + sendResponse({ ok: true, body }); + } catch (err) { + sendResponse({ ok: false, error: String(err) }); + } + return; + } + + if (msg.type === "fetchFiche") { + const session = await findEasyVistaSession(); + if (!session) { + 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; + } + sendResponse({ ok: true, html, session }); + return; + } + + if (msg.type === "fetchTimeline") { + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + const body = await fetchTimelineJson( + session.origin, session.phpsessid, msg.target, msg.checksum + ); + // Si on reçoit du HTML au lieu de JSON, c'est une page d'erreur / login + if (body.trimStart().startsWith("<")) { + sendResponse({ ok: false, error: "not_json" }); + return; + } + sendResponse({ ok: true, body }); + return; + } + + if (msg.type === "scheduleAutoRefresh") { + scheduleAutoRefreshAlarms(); + sendResponse({ ok: true }); + return; + } + + if (msg.type === "cleanupOldCaches") { + const removed = await cleanupOldCaches(msg.daysToKeep || 7); + sendResponse({ ok: true, removed }); + return; + } + + sendResponse({ ok: false, error: "unknown_message" }); + } catch (err) { + console.error("background error:", err); + sendResponse({ ok: false, error: err.message || String(err) }); + } + })(); + + // Retourner true pour garder sendResponse asynchrone + return true; +}); + +// ============================================================================ +// Alarmes : refresh auto 12h / 15h +// ============================================================================ + +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(); + } + + 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 +// ============================================================================ + +async function cleanupOldCaches(daysToKeep) { + const all = await chrome.storage.local.get(null); + const threshold = new Date(); + threshold.setDate(threshold.getDate() - daysToKeep); + const thresholdStr = threshold.toISOString().substring(0, 10); // YYYY-MM-DD + + const toRemove = []; + for (const key of Object.keys(all)) { + // Nos clés de cache sont planning_cache_YYYY-MM-DD + const m = key.match(/^planning_cache_(\d{4}-\d{2}-\d{2})$/); + if (m && m[1] < thresholdStr) { + toRemove.push(key); + } + } + if (toRemove.length > 0) { + await chrome.storage.local.remove(toRemove); + } + return toRemove.length; +} + +// Au démarrage, programmer les alarmes et nettoyer +chrome.runtime.onInstalled.addListener(() => { + scheduleAutoRefreshAlarms(); + cleanupOldCaches(7).catch(err => console.warn("cleanup:", err)); +}); + +chrome.runtime.onStartup.addListener(() => { + scheduleAutoRefreshAlarms(); + cleanupOldCaches(7).catch(err => console.warn("cleanup:", err)); +}); diff --git a/manifest.json b/manifest.json index 2a0c2c4..de29ead 100644 --- a/manifest.json +++ b/manifest.json @@ -1,14 +1,17 @@ { "manifest_version": 3, "name": "Planning Techniciens — Vue claire", - "version": "2.0.1", - "description": "Réaffiche le planning du jour (itsma.vd.ch) avec pompier, absents, tooltips enrichis et thème clair/sombre.", + "version": "3.0.0", + "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch) avec navigation par date, détection automatique des interventions closes et cache 7 jours.", "permissions": [ "activeTab", "scripting", - "storage" + "storage", + "tabs", + "alarms" ], "host_permissions": [ + "https://itsma.etat-de-vaud.ch/*", "https://itsma.vd.ch/*" ], "action": { @@ -30,6 +33,7 @@ "viewer.css" ], "matches": [ + "https://itsma.etat-de-vaud.ch/*", "https://itsma.vd.ch/*" ] } diff --git a/viewer.css b/viewer.css index 3ceda63..c10ae10 100644 --- a/viewer.css +++ b/viewer.css @@ -20,16 +20,30 @@ --ok: #2e7b4a; --ok-soft: #dff0e4; - /* Palette par type d'intervention (clair & lisible) */ - --c-livraison: #2563eb; /* bleu */ + /* Palette par type d'intervention */ + --c-livraison: #2563eb; --c-livraison-soft: #dbeafe; - --c-recup: #16a34a; /* vert */ + --c-recup: #16a34a; --c-recup-soft: #dcfce7; - --c-remplacement: #ea580c; /* orange */ + --c-remplacement: #ea580c; --c-remplacement-soft: #fed7aa; - --c-autre: #6b7280; /* gris */ + --c-incident: #8b5cf6; + --c-incident-soft: #ede9fe; + --c-installation: #2563eb; + --c-installation-soft: #dbeafe; + --c-rollout: #92400e; /* brun */ + --c-rollout-soft: #fde68a; + --c-reservation: #f59e0b; /* jaune/ambre */ + --c-reservation-soft: #fef3c7; + --c-autre: #6b7280; --c-autre-soft: #e5e7eb; + /* Statuts clos */ + --c-closed: #15803d; /* vert foncé = Clôturé */ + --c-closed-soft: #bbf7d0; + --c-resolved: #4ade80; /* vert clair = Résolu */ + --c-resolved-soft: #dcfce7; + --shadow: 0 1px 3px rgba(20, 30, 50, 0.06), 0 1px 2px rgba(20, 30, 50, 0.04); --shadow-hover: 0 2px 8px rgba(20, 30, 50, 0.08); --radius: 8px; @@ -56,16 +70,24 @@ --ok: #78c59a; --ok-soft: #1f3a2b; - /* Palette sombre — tons plus doux mais toujours distincts */ --c-livraison: #60a5fa; --c-livraison-soft: #1e3a5f; --c-recup: #4ade80; --c-recup-soft: #14432a; --c-remplacement: #fb923c; --c-remplacement-soft: #4a2512; + --c-incident: #a78bfa; + --c-incident-soft: #2e1065; + --c-installation: #60a5fa; + --c-installation-soft: #1e3a5f; --c-autre: #9ca3af; --c-autre-soft: #2a2e36; + --c-closed: #22c55e; + --c-closed-soft: #14432a; + --c-resolved: #86efac; + --c-resolved-soft: #0f3320; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.3); --shadow-hover: 0 2px 10px rgba(0, 0, 0, 0.4); } @@ -100,12 +122,16 @@ html, body { background: var(--bg-elevated); border-bottom: 1px solid var(--border); box-shadow: var(--shadow); + gap: 12px; + flex-wrap: wrap; } .topbar-left { display: flex; - align-items: baseline; + align-items: center; gap: 14px; + flex: 1; + min-width: 0; } .topbar h1 { @@ -113,16 +139,72 @@ html, body { font-size: 18px; font-weight: 600; color: var(--text); + white-space: nowrap; } .capture-info { font-size: 12px; color: var(--text-muted); + white-space: nowrap; +} + +.refresh-check { + font-size: 14px; + color: var(--c-recup); /* vert */ + font-weight: 700; + opacity: 0; + transform: scale(0.5); + transition: opacity 0.25s ease, transform 0.25s ease; + pointer-events: none; +} +.refresh-check.visible { + opacity: 1; + transform: scale(1); +} +.refresh-check.hidden { + display: none; } .topbar-right { display: flex; gap: 8px; + flex-shrink: 0; +} + +/* Navigation de date */ +.date-nav { + display: flex; + align-items: center; + gap: 4px; +} + +.btn-nav { + padding: 6px 10px; + font-size: 13px; + min-width: 32px; +} + +.btn-today { + padding: 6px 10px; + font-size: 12px; +} + +.date-input { + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-muted); + color: var(--text); + font-family: inherit; + font-size: 13px; + cursor: pointer; +} +.date-input:hover { + border-color: var(--border-strong); +} +.date-input:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; } .btn { @@ -149,13 +231,45 @@ html, body { transform: translateY(1px); } +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .btn-icon { padding: 6px 10px; font-size: 15px; } +.btn-subtle { + opacity: 0.75; + font-size: 12px; +} +.btn-subtle:hover { + opacity: 1; +} + +.btn-primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} +.btn-primary:hover { + background: var(--accent); + opacity: 0.9; +} + +#refresh-icon.spinning { + display: inline-block; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + /* ========================================================================== - État initial + Écrans d'état ========================================================================== */ .loading { padding: 40px 20px; @@ -175,6 +289,38 @@ html, body { line-height: 1.55; } +.session-needed { + max-width: 500px; + margin: 60px auto; + padding: 28px 32px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + text-align: center; +} +.session-needed h2 { + margin: 0 0 12px 0; + color: var(--text); +} +.session-needed p { + margin: 10px 0; + color: var(--text-muted); +} +.session-needed code { + background: var(--bg-muted); + padding: 2px 6px; + border-radius: 3px; + font-family: var(--mono); + font-size: 12px; +} +.session-needed button { + margin-top: 14px; +} + +/* ========================================================================== + Stats globales + ========================================================================== */ .stats { display: flex; flex-wrap: wrap; @@ -205,7 +351,7 @@ html, body { ========================================================================== */ .cards { display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 14px; padding: 14px 20px 40px 20px; } @@ -262,7 +408,7 @@ html, body { } .card-body { - padding: 8px 0; + padding: 0; flex: 1; } @@ -274,16 +420,12 @@ html, body { text-align: center; } -/* Cartes pompier : liseré rouge discret */ .card.is-pompier { border-left: 3px solid var(--danger); } - -/* Cartes absent : teinte neutre */ .card.is-absent { opacity: 0.85; } - .card.is-absent .card-header { background: var(--bg); } @@ -297,8 +439,6 @@ html, body { border-bottom: 1px solid var(--border); position: relative; } - -/* Fond rouge discret quand la carte est "pompier" */ .timeline-pompier { background: var(--danger-soft); } @@ -312,7 +452,6 @@ html, body { overflow: hidden; } -/* Trous (zones libres) : fond diagonal discret + vert léger au survol */ .timeline-hole { position: absolute; top: 0; @@ -336,7 +475,6 @@ html, body { background: var(--ok-soft); } -/* Blocs occupés : couleurs selon type */ .timeline-slot { position: absolute; top: 0; @@ -346,10 +484,18 @@ html, body { border-right: 1px solid var(--bg-elevated); } -.timeline-slot.color-livraison { background: var(--c-livraison); } -.timeline-slot.color-recup { background: var(--c-recup); } -.timeline-slot.color-remplacement { background: var(--c-remplacement); } -.timeline-slot.color-autre { background: var(--c-autre); } +.timeline-slot.color-livraison { background: var(--c-livraison); } +.timeline-slot.color-installation { background: var(--c-installation); } +.timeline-slot.color-recup { background: var(--c-recup); } +.timeline-slot.color-remplacement { background: var(--c-remplacement); } +.timeline-slot.color-incident { background: var(--c-incident); } +.timeline-slot.color-rollout { background: var(--c-rollout); } +.timeline-slot.color-reservation { background: var(--c-reservation); } +.timeline-slot.color-autre { background: var(--c-autre); } + +/* Statuts clos sur la timeline */ +.timeline-slot.status-closed { background: var(--c-closed); } +.timeline-slot.status-resolved { background: var(--c-resolved); } .timeline-slot.kind-absence { background: repeating-linear-gradient( @@ -368,7 +514,6 @@ html, body { z-index: 2; } -/* Ligne de midi : marqueur vertical discret */ .timeline-noon { position: absolute; top: -2px; @@ -393,7 +538,7 @@ html, body { font-family: var(--mono); } -/* Stats par carte : total en gros, matin/aprem en secondaire */ +/* Stats par carte */ .card-stats { display: flex; align-items: baseline; @@ -432,7 +577,7 @@ html, body { opacity: 0.4; } -/* Note de statut pompier/absent en haut de carte */ +/* Notes de statut */ .card-status-note { padding: 8px 14px; font-size: 12px; @@ -456,14 +601,271 @@ html, body { opacity: 0.7; } -/* Highlight réciproque */ .intervention.highlight { background: var(--bg-hover); } /* ========================================================================== - Interventions (lignes dans la carte) + Interventions — layout v2 (heures verticales) ========================================================================== */ +.intervention-v2 { + display: grid; + grid-template-columns: 4px 58px 1fr auto; + grid-template-rows: auto auto; + grid-template-areas: + "dot time ref copy" + "dot time right status"; + gap: 2px 10px; + align-items: start; + padding: 10px 12px 12px 8px; + border-top: 1px solid var(--border); + cursor: default; + transition: background 0.08s; + position: relative; +} +.intervention-v2:first-child { border-top: none; } +.intervention-v2:hover { background: var(--bg-hover); } + +/* Pastille colorée (barre verticale) */ +.intervention-v2 .intervention-dot { + grid-area: dot; + width: 4px; + height: 100%; + border-radius: 2px; + align-self: stretch; +} +.intervention-v2.color-livraison .intervention-dot { background: var(--c-livraison); } +.intervention-v2.color-installation .intervention-dot { background: var(--c-installation); } +.intervention-v2.color-recup .intervention-dot { background: var(--c-recup); } +.intervention-v2.color-remplacement .intervention-dot { background: var(--c-remplacement); } +.intervention-v2.color-incident .intervention-dot { background: var(--c-incident); } +.intervention-v2.color-rollout .intervention-dot { background: var(--c-rollout); } +.intervention-v2.color-reservation .intervention-dot { background: var(--c-reservation); } +.intervention-v2.color-autre .intervention-dot { background: var(--c-autre); } + +.intervention-v2.clickable { cursor: pointer; } +.intervention-v2.clickable:active { transform: translateY(1px); } + +.intervention-v2.status-closed { + background: var(--c-closed-soft); + box-shadow: inset 4px 0 0 var(--c-closed); +} +.intervention-v2.status-closed:hover { + background: var(--c-closed-soft); + filter: brightness(0.96); +} +.intervention-v2.status-closed .intervention-dot { + background: var(--c-closed); + width: 5px; +} + +.intervention-v2.status-resolved { + background: var(--c-resolved-soft); + box-shadow: inset 4px 0 0 var(--c-resolved); +} +.intervention-v2.status-resolved:hover { + background: var(--c-resolved-soft); + filter: brightness(0.96); +} +.intervention-v2.status-resolved .intervention-dot { + background: var(--c-resolved); + width: 5px; +} + +.intervention-v2.is-ghost { + opacity: 0.5; + text-decoration: line-through; +} + +/* Ligne 1 : REF en titre centré gros gras */ +.iv-ref-header { + grid-area: ref; + font-family: var(--mono); + font-size: 15px; + font-weight: 700; + color: var(--text); + letter-spacing: 0.03em; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 2px 0; +} +.iv-ref-header.no-ref { + font-family: var(--font); + font-weight: 500; + color: var(--text-faint); +} + +.iv-status-check { + grid-area: status; + align-self: center; + font-size: 16px; + font-weight: 700; + color: var(--c-closed); + padding-right: 6px; +} +.intervention-v2.status-resolved .iv-status-check { color: var(--c-resolved); } + +.intervention-copy { + grid-area: copy; + align-self: start; + padding: 2px 6px; + background: transparent; + color: var(--text-faint); + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + opacity: 0; + transition: opacity 0.1s, background 0.1s, color 0.1s; + font-family: inherit; +} +.intervention-v2:hover .intervention-copy { opacity: 1; } +.intervention-copy:hover { + background: var(--bg-muted); + color: var(--text); + border-color: var(--border); +} +.intervention-copy.copied { + color: var(--ok); + background: var(--ok-soft); + opacity: 1; +} + +/* Ligne 2 GAUCHE : heures VERTICALES, centrées par rapport au bloc droit */ +.iv-time-vertical { + grid-area: time; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; /* centrage vertical */ + align-self: center; /* centrage dans la cellule grille */ + gap: 1px; + font-family: var(--mono); + font-size: 12px; + color: var(--text); + height: 100%; +} +.iv-time-start, .iv-time-end { + font-weight: 600; + letter-spacing: 0.02em; +} +.iv-time-arrow { + font-size: 11px; + color: var(--text-muted); + line-height: 1; +} +.intervention-v2.is-pompier-line .iv-time-start, +.intervention-v2.is-pompier-line .iv-time-end { + color: var(--danger); + font-weight: 700; +} + +/* Ligne 2 DROITE : lieu / contact+tél / bas (catégorie + signature) */ +.iv-right { + grid-area: right; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Lieu : ville (MAJ gras) + adresse (italique noir) */ +.iv-lieu-block { + display: flex; + flex-direction: column; + gap: 0; +} +.iv-lieu-ville { + font-size: 13px; + font-weight: 700; + color: var(--text); + letter-spacing: 0.04em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.iv-lieu-adresse { + font-size: 12.5px; + font-style: italic; + font-weight: 400; + color: var(--text); /* noir, pas gris */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Contact + tél — wrap intelligent (pas de coupure de mot) */ +.iv-contact-line { + font-size: 13px; + word-break: normal; + overflow-wrap: break-word; + white-space: normal; + line-height: 1.35; +} +.iv-contact { + font-weight: 600; + color: var(--text); +} +.iv-sep { + color: var(--text-faint); + font-weight: 400; + margin: 0 2px; +} +.iv-phone { + font-family: var(--mono); + color: var(--text-muted); + font-weight: 400; + font-size: 12px; + white-space: nowrap; /* numéro pas coupé */ +} + +/* Bas : catégorie à gauche + signature à droite */ +.iv-bottom-line { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + font-size: 12px; +} +.iv-category { + color: var(--text-muted); + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} +.iv-signature { + color: var(--text-faint); + font-size: 11px; + font-family: var(--mono); + flex-shrink: 0; + letter-spacing: 0.02em; +} + +/* Réservation (créneau bloqué par un coordinateur) */ +.iv-ref-header.is-reservation-title { + color: var(--c-reservation); + font-family: var(--font); + letter-spacing: 0.02em; +} +.iv-reservation-par { + font-size: 13px; + font-weight: 600; + color: var(--text); +} +.iv-reservation-sujet { + font-size: 12.5px; + color: var(--text-muted); + font-style: italic; +} + +/* ────────────────────────────────────────────────────────────────────────── + Anciens styles .intervention (v1) — gardés pour ne pas casser le reste + ────────────────────────────────────────────────────────────────────────── */ .intervention { display: flex; align-items: center; @@ -472,17 +874,10 @@ html, body { border-top: 1px solid var(--border); cursor: default; transition: background 0.08s; + position: relative; } - -.intervention:first-child { - border-top: none; -} - -.intervention:hover { - background: var(--bg-hover); -} - -/* Pastille colorée à gauche, rappel visuel du type */ +.intervention:first-child { border-top: none; } +.intervention:hover { background: var(--bg-hover); } .intervention-dot { flex-shrink: 0; width: 4px; @@ -490,105 +885,16 @@ html, body { margin: 2px 4px 2px 0; border-radius: 2px; } -.intervention.color-livraison .intervention-dot { background: var(--c-livraison); } -.intervention.color-recup .intervention-dot { background: var(--c-recup); } -.intervention.color-remplacement .intervention-dot { background: var(--c-remplacement); } -.intervention.color-autre .intervention-dot { background: var(--c-autre); } - -.intervention-time { - flex-shrink: 0; - font-family: var(--mono); - font-size: 12px; - color: var(--text-muted); - min-width: 86px; -} - -.intervention-content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 1px; -} - -/* La référence S260xxx_xxxxx est mise en avant */ -.intervention-refhdr { - font-family: var(--mono); - font-size: 13px; - font-weight: 600; - color: var(--text); - letter-spacing: 0.02em; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.intervention-refhdr.no-ref { - font-family: var(--font); - font-weight: normal; - color: var(--text-faint); -} - -/* Titre type d'intervention en secondaire */ -.intervention-title { - font-size: 12px; - color: var(--text-muted); - font-weight: 500; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.intervention-meta { - font-size: 11px; - color: var(--text-faint); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.intervention-copy { - flex-shrink: 0; - padding: 4px 8px; - background: transparent; - color: var(--text-faint); - border: 1px solid transparent; - border-radius: 4px; - cursor: pointer; - font-size: 12px; - opacity: 0; - transition: opacity 0.1s, background 0.1s, color 0.1s; - font-family: inherit; -} - -.intervention:hover .intervention-copy { - opacity: 1; -} - -.intervention-copy:hover { - background: var(--bg-muted); - color: var(--text); - border-color: var(--border); -} - -.intervention-copy.copied { - color: var(--ok); - background: var(--ok-soft); - opacity: 1; -} - -/* Intervention de type pompier */ -.intervention.is-pompier-line .intervention-time { - color: var(--danger); - font-weight: 600; -} /* ========================================================================== - Tooltip au survol + Tooltip ========================================================================== */ .tooltip { position: fixed; z-index: 100; - max-width: 420px; + max-width: 620px; + max-height: calc(100vh - 40px); + overflow-y: auto; padding: 12px 14px; background: var(--bg-elevated); color: var(--text); @@ -601,7 +907,6 @@ html, body { opacity: 0; transition: opacity 0.1s; } - .tooltip.visible { opacity: 1; } @@ -628,13 +933,96 @@ html, body { word-break: break-word; } -.tooltip dd.description { +.tooltip dd.description, +.tooltip dd.commentaire { white-space: pre-wrap; } +.tooltip dd.commentaire { + padding: 6px 8px; + background: var(--bg-muted); + border-radius: 4px; + border-left: 2px solid var(--c-closed); + font-style: italic; +} + .tooltip hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; grid-column: 1 / -1; } + +/* Badge de statut dans le tooltip */ +.status-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; +} +.status-pill.closed { + background: var(--c-closed-soft); + color: var(--c-closed); +} +.status-pill.resolved { + background: var(--c-resolved-soft); + color: var(--c-resolved); +} +.status-pill.ongoing { + background: var(--accent-soft); + color: var(--accent); +} +.status-pill.other { + background: var(--bg-muted); + color: var(--text-muted); +} + +/* ========================================================================== + Toasts de notification (ouverture d'intervention) + ========================================================================== */ +.toast-stack { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 200; + display: flex; + flex-direction: column-reverse; /* les nouveaux en bas, les anciens au-dessus */ + gap: 8px; + pointer-events: none; +} +.toast { + min-width: 200px; + max-width: 340px; + padding: 10px 14px; + background: var(--bg-elevated); + color: var(--text); + border: 1px solid var(--border-strong); + border-left: 3px solid var(--c-livraison); + border-radius: 6px; + box-shadow: var(--shadow-hover); + font-size: 13px; + opacity: 0; + transform: translateX(40px); + transition: opacity 0.18s ease-out, transform 0.18s ease-out; + pointer-events: auto; +} +.toast.visible { + opacity: 1; + transform: translateX(0); +} +.toast.leaving { + opacity: 0; + transform: translateX(40px); +} +.toast-label { + color: var(--text-muted); + font-size: 11px; + margin-right: 6px; +} +.toast-ref { + font-family: var(--mono); + font-weight: 700; + letter-spacing: 0.02em; +} diff --git a/viewer.html b/viewer.html index f805e2e..aebdb5a 100644 --- a/viewer.html +++ b/viewer.html @@ -9,11 +9,21 @@

Planning techniciens

+
+ + + + +
+
- + +
Chargement…
@@ -30,6 +46,9 @@ + +
+ diff --git a/viewer.js b/viewer.js index fc7a439..2a2497b 100644 --- a/viewer.js +++ b/viewer.js @@ -1,24 +1,24 @@ // ============================================================================ -// viewer.js — vue claire du planning techniciens (v2) +// viewer.js v3 — vue claire du planning techniciens // ============================================================================ -// Ce fichier fait tourner la page viewer.html. Il : -// 1. Récupère le HTML capturé (via background.js → chrome.storage.local) -// 2. Parse ce HTML pour extraire les techs + événements du jour -// 3. Construit une vue claire (une carte par tech, triées pompier > actifs > absents) -// 4. Gère les interactions : survol (tooltip enrichi), clic copie (ref dans presse-papier), -// bouton rafraîchir, toggle de thème. -// 5. Met en cache le parsing dans sessionStorage pour un rechargement instantané -// (le cache se vide automatiquement à la fermeture du navigateur). +// 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 // -// Pas de fetch de fiches détaillées : v2 affiche uniquement ce qui est dans -// la page du planning. La détection de statut clos viendra en v3. +// 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é. Copié depuis la page du planning. -// Si la composition de l'équipe change, modifier cette map. +// Équipe : ID EasyVista → nom affiché const TEAM = { "76272": "Ciuppa, Mathieu", "83725": "De Almeida Martins, Solange", @@ -30,57 +30,113 @@ const TEAM = { "86874": "Rouiller, Quentin" }; -// Règles fixes d'absence (personnes absentes récurrentes). -// Format : id tech → [liste de jours JS, 0=dim, 1=lun, ..., 5=ven, 6=sam] +// Absences récurrentes (id tech → [jour JS, 0=dim..6=sam]) const RECURRING_ABSENCES = { "40944": [5] // Pillonel absent tous les vendredis }; -// Clés localStorage et sessionStorage -const LS_THEME = "planning_theme"; // "light" | "dark" -const SS_CACHE = "planning_cache_v2"; // dernier parsing JSON +// 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) +const FETCH_CONCURRENCY = 12; // ============================================================================ -// Mapping de catégorie → titre court affiché +// Mapping de catégorie → titre court + couleur // ============================================================================ -// Les interventions EasyVista ont une catégorie longue type : -// "Demande de service/Place de travail/Poste de travail/Z - Options/Remplacement de matériel" -// On extrait un titre court lisible pour l'humain. const CATEGORY_TO_TITLE = [ - // [regex, titre court, clé de couleur CSS] - [/Arriv[ée]e\s+ou\s+mutation/i, "Livraison", "livraison"], - [/Accessoire\s+pour\s+PC/i, "Livraison", "livraison"], + // 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érive un titre court à partir de la catégorie d'une intervention. + * 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) { - const raw = (iv.infobulle && iv.infobulle._raw) || ""; - const catLine = raw.split(/\n+/).map(l => l.trim()) - .find(l => /^(Demande de service|Incident|Changement|Probl[èe]me)\//.test(l)); - if (!catLine) return "Autres"; + 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(catLine)) return title; + if (regex.test(cat)) return title; } return "Autres"; } -/** - * Renvoie la clé de couleur ("livraison", "recup", "remplacement", "autre") - * pour appliquer en classe CSS. - */ function deriveColorKey(iv) { - const raw = (iv.infobulle && iv.infobulle._raw) || ""; - const catLine = raw.split(/\n+/).map(l => l.trim()) - .find(l => /^(Demande de service|Incident|Changement|Probl[èe]me)\//.test(l)); - if (!catLine) return "autre"; + 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(catLine)) return colorKey; + if (regex.test(cat)) return colorKey; } return "autre"; } @@ -89,7 +145,12 @@ function deriveColorKey(iv) { // État global // ============================================================================ -let currentData = null; // { techs, stats, captureTime } +let state = { + session: null, // { phpsessid, origin, tabId } + currentDate: null, // "YYYY-MM-DD" affiché + currentData: null, // résultat parsé (techs, stats, ...) + loading: false +}; // ============================================================================ // Boot @@ -100,44 +161,32 @@ document.addEventListener("DOMContentLoaded", init); async function init() { initTheme(); bindTopbar(); - bindTooltipHandlers(); - // Récupérer le HTML capturé depuis le background - const stored = await chrome.storage.local.get([ - "planningHtml", "planningError", "planningCapturedAt", "planningUrl" - ]); + // Initialiser la date = aujourd'hui + state.currentDate = todayISO(); + document.getElementById("date-picker").value = state.currentDate; - if (stored.planningError) { - showError(stored.planningError); - return; - } - - if (!stored.planningHtml) { - // Essayer le cache de session - const cached = loadFromSessionCache(); - if (cached) { - render(cached); - return; + // É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 }); } - showError( - "Pas de planning à afficher pour le moment. " + - "Va sur la page du planning dans itsma.vd.ch puis clique sur l'icône de l'extension." - ); + }); + + // Charger la session puis le planning + await refreshSessionAndLoad(); +} + +async function refreshSessionAndLoad() { + const resp = await sendMessage({ type: "getSession" }); + if (!resp.ok || !resp.session) { + showSessionNeeded(); return; } - - try { - const parsed = parsePlanning(stored.planningHtml); - parsed.captureTime = stored.planningCapturedAt || Date.now(); - saveToSessionCache(parsed); - render(parsed); - } catch (err) { - console.error("Erreur parsing:", err); - showError( - "Impossible de parser le planning. " + - "Détail technique : " + (err?.message || String(err)) - ); - } + state.session = resp.session; + hideSessionNeeded(); + await loadForDate(state.currentDate); } // ============================================================================ @@ -151,7 +200,6 @@ function initTheme() { } function detectDefaultTheme() { - // Respecter la préférence système if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { return "dark"; } @@ -172,340 +220,874 @@ function toggleTheme() { } // ============================================================================ -// Topbar (rafraîchir + thème) +// Topbar handlers // ============================================================================ function bindTopbar() { document.getElementById("theme-toggle").addEventListener("click", toggleTheme); - document.getElementById("refresh-btn").addEventListener("click", refreshFromPlanning); -} + document.getElementById("refresh-btn").addEventListener("click", () => refreshPlanning()); + document.getElementById("clear-cache-btn").addEventListener("click", onClearCache); -async function refreshFromPlanning() { - // On cherche un onglet itsma.vd.ch ouvert. Sinon on propose d'y aller. - // Le fonctionnement "normal" c'est : l'utilisateur clique sur l'icône depuis - // la page du planning, pas depuis ici. Ici on lui rappelle. - const tabs = await chrome.tabs.query({ url: "https://itsma.vd.ch/*" }); - if (tabs.length === 0) { - alert( - "Ouvre d'abord l'onglet du planning sur itsma.vd.ch, " + - "puis reclique sur l'icône de l'extension (et non sur ce bouton)." - ); - return; - } - // Réactiver l'onglet et demander à l'utilisateur de cliquer sur l'icône - await chrome.tabs.update(tabs[0].id, { active: true }); - alert( - "Le planning a été rouvert. Clique maintenant sur l'icône de l'extension " + - "pour recapturer le planning." - ); -} + document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1)); + document.getElementById("nav-next").addEventListener("click", () => navigateDate(+1)); + document.getElementById("nav-today").addEventListener("click", () => loadForDate(todayISO())); -// ============================================================================ -// Cache sessionStorage -// ============================================================================ - -function saveToSessionCache(data) { - try { - sessionStorage.setItem(SS_CACHE, JSON.stringify(data)); - } catch (e) { - console.warn("Cache session impossible (quota ?) :", e); - } -} - -function loadFromSessionCache() { - try { - const raw = sessionStorage.getItem(SS_CACHE); - if (!raw) return null; - return JSON.parse(raw); - } catch { - return null; - } -} - -// ============================================================================ -// Parsing du HTML du planning -// ============================================================================ - -/** - * Parse le HTML complet de la page EasyVista et retourne une structure : - * { - * techs: [ - * { - * id: "76272", - * name: "Ciuppa, Mathieu", - * interventions: [ - * { - * playerIdx: 0, - * ref: "S260414_00100", - * label: "...", - * type: "intervention" | "absence", - * startTime: "08:00", - * endTime: "12:00", - * startDate: "17/04/2026", - * endDate: "17/04/2026", - * isPompier: boolean, - * infobulle: { - * horaire, ref, type, contact, beneficiaire, lieu, service, - * probleme, aFaire, materiel, deadline, description - * } - * } - * ] - * } - * ], - * targetDate: "17/04/2026", - * stats: { total, pompiers, absents } - * } - */ -function parsePlanning(html) { - // On bosse sur un DOM temporaire pour les sélecteurs, mais aussi sur le HTML - // brut pour attraper les données JS inline (g_arr_player[...]) - const doc = new DOMParser().parseFromString(html, "text/html"); - - // --- 1. Repérer la date cible du planning --- - const targetDate = extractTargetDate(doc, html); - - // --- 2. Extraire les définitions d'événements --- - // Format : g_arr_player[N] = new action_player("event_id", "label", ...); - // Puis ensuite : g_arr_player[N].assign_informations(tech_id, "title", type, ...) - // Puis : g_arr_player[N].assign_date_time_informations(...) - const players = extractPlayers(html); - - // --- 3. Construire la map tech → interventions --- - const techMap = new Map(); - for (const id of Object.keys(TEAM)) { - techMap.set(id, { - id, - name: TEAM[id], - interventions: [] - }); - } - - for (const player of players) { - if (!player.techId || !techMap.has(player.techId)) continue; - // Ne garder que les interventions qui concernent le jour cible - if (!intersectsDate(player, targetDate)) continue; - - techMap.get(player.techId).interventions.push(player); - } - - // --- 4. Tri des interventions de chaque tech par heure --- - for (const tech of techMap.values()) { - tech.interventions.sort((a, b) => { - return (a.startTime || "").localeCompare(b.startTime || ""); - }); - } - - // --- 5. Calculs statistiques et tri final --- - const techs = [...techMap.values()]; - const stats = computeStats(techs, targetDate); - - techs.sort((a, b) => { - const sa = sortKey(a, targetDate); - const sb = sortKey(b, targetDate); - return sa - sb; + document.getElementById("date-picker").addEventListener("change", (e) => { + if (e.target.value) loadForDate(e.target.value); }); - return { targetDate, techs, stats }; + document.getElementById("open-ev-btn").addEventListener("click", openEasyVista); } -function extractTargetDate(doc, html) { - // La date du planning est généralement dans le ou dans un <h1>/<h2> - // à côté du calendrier. On cherche un pattern JJ/MM/AAAA. - const title = doc.title || ""; - let m = title.match(/(\d{2}\/\d{2}\/\d{4})/); - if (m) return m[1]; - - // Chercher dans le HTML brut - m = html.match(/planning[^<]*?(\d{2}\/\d{2}\/\d{4})/i); - if (m) return m[1]; - - // Chercher des patterns assign_date_time_informations pour déduire la date majoritaire - const dates = []; - const re = /assign_date_time_informations\s*\(\s*"(\d{2}\/\d{2}\/\d{4})"/g; - let match; - while ((match = re.exec(html)) !== null) { - dates.push(match[1]); - } - if (dates.length) { - // Prendre la date la plus fréquente - const counts = {}; - dates.forEach(d => counts[d] = (counts[d] || 0) + 1); - return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; - } - - // Fallback : aujourd'hui - return todayAsDDMMYYYY(); +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/" }); } -function todayAsDDMMYYYY() { +// 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(); - const dd = String(d.getDate()).padStart(2, "0"); + return dateToISO(d); +} + +function dateToISO(d) { + const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, "0"); - return `${dd}/${mm}/${d.getFullYear()}`; + 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; + } + // Bouton Rafraîchir manuel : on force le refetch des fiches + await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true }); +} + +// ============================================================================ +// Fetch du planning (via background) +// ============================================================================ + +async function fetchPlanningForDate(isoDate) { + setRefreshing(true); + try { + const unixDate = isoToUnixDate(isoDate); + const resp = await sendMessage({ + type: "fetchPlanning", + session: state.session, + unixDate: unixDate + }); + if (!resp.ok) { + 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 + }; } /** - * Extraction des g_arr_player du HTML brut. - * Structure réelle EasyVista : - * - new action_player("<id_interne_numérique>", "<label qui contient la ref S260XXX>", ...) - * - assign_informations(<tech_id_nombre>, "<title>", "<type>", ...) - * - assign_date_time_informations("dd/mm/yyyy", "h", "m", "s", "dd/mm/yyyy", ...) - * Les infobulles sont dans des <td onmouseover="AffBulle(this, '...')"> - * On les matche ensuite par référence S260XXX (présente dans le label et l'infobulle). + * 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 extractPlayers(html) { - const byIdx = new Map(); +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; +} - const get = idx => { - if (!byIdx.has(idx)) byIdx.set(idx, { playerIdx: idx }); - return byIdx.get(idx); - }; +/** + * 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; - // 1. new action_player : "<id_interne>", "<label contenant la ref>", ... - // Ex: new action_player("57730535", "08:30 S260409_00117 (CM)", ...) - const reNew = /g_arr_player\[(\d+)\]\s*=\s*new\s+action_player\s*\(\s*"([^"]+)"\s*,\s*"((?:[^"\\]|\\.)*)"/g; - let m; - while ((m = reNew.exec(html)) !== null) { - const p = get(parseInt(m[1], 10)); - p.internalId = m[2]; // ID interne EasyVista (numérique) - p.label = unescapeJsString(m[3]); // Label visible ex "08:30 S260409_00117 (CM)" - // Extraire la VRAIE référence S260XXX du label - const refMatch = p.label.match(/\b([SI]2\d{5}_\d{5})\b/); - p.ref = refMatch ? refMatch[1] : null; - } - - // 2. assign_informations : (tech_id, "title", "type", ...) - // ⚠ L'ID tech est un NOMBRE (pas entre guillemets) - const reAssign = /g_arr_player\[(\d+)\]\.assign_informations\s*\(\s*(\d+)\s*,\s*"((?:[^"\\]|\\.)*)"\s*,\s*"([^"]*)"/g; - while ((m = reAssign.exec(html)) !== null) { - const p = get(parseInt(m[1], 10)); - p.techId = m[2]; - p.title = unescapeJsString(m[3]); - p.type = m[4]; - } - - // 3. CSS class (player_holiday = absence, player = intervention) - const reAssignFull = /g_arr_player\[(\d+)\]\.assign_informations\s*\(([^)]*)\)/g; - while ((m = reAssignFull.exec(html)) !== null) { - const p = get(parseInt(m[1], 10)); - const args = m[2]; - if (/"player_holiday"/.test(args)) p.cssClass = "player_holiday"; - else if (/"player"/.test(args)) p.cssClass = "player"; - } - - // 4. assign_date_time_informations - const reDate = /g_arr_player\[(\d+)\]\.assign_date_time_informations\s*\(\s*"(\d{2}\/\d{2}\/\d{4})"\s*,\s*"(\d+)"\s*,\s*"(\d+)"\s*,\s*"\d+"\s*,\s*"(\d{2}\/\d{2}\/\d{4})"\s*,\s*"(\d+)"\s*,\s*"(\d+)"\s*,\s*"\d+"\s*,\s*"(\d{2}:\d{2})"\s*,\s*"(\d{2}:\d{2})"/g; - while ((m = reDate.exec(html)) !== null) { - const p = get(parseInt(m[1], 10)); - p.startDate = m[2]; - p.endDate = m[5]; - p.startTime = m[8]; - p.endTime = m[9]; - } - - // 5. Infobulles : extraites indépendamment et matchées par référence S260XXX. - // Dans EasyVista, chaque <td class="..." onmouseover="AffBulle(this, '...texte...')"> - // contient la ref dans son texte. On collecte toutes les infobulles, puis on - // les attribue au player correspondant via sa ref. - const infobullesByRef = new Map(); - const reBulle = /AffBulle\s*\(\s*this\s*,\s*'((?:[^'\\]|\\.)*)'/g; - while ((m = reBulle.exec(html)) !== null) { - const raw = unescapeJsString(m[1]); - // Chercher la ref S260XXX dans le texte de l'infobulle - const refMatch = raw.match(/\b([SI]2\d{5}_\d{5})\b/); - if (refMatch) { - const ref = refMatch[1]; - // Garder la plus longue version (plus d'infos) si plusieurs pour la même ref - const existing = infobullesByRef.get(ref); - if (!existing || raw.length > existing.length) { - infobullesByRef.set(ref, raw); + 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); } } } - // 6. Attribution des infobulles aux players + parsing + détection pompier - for (const p of byIdx.values()) { - p.isPompier = /pompier/i.test(p.label || "") || /pompier/i.test(p.title || ""); - - // Chercher l'infobulle matchée à la ref du player - if (p.ref && infobullesByRef.has(p.ref)) { - p.infobulleRaw = infobullesByRef.get(p.ref); - } - p.infobulle = parseInfobulle(p.infobulleRaw || ""); - } - - return [...byIdx.values()]; + 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 }; } -/** - * Décode les échappements JS classiques (\" \\ \n etc.) - */ -function unescapeJsString(s) { +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 (12) + let idx = 0; + async function worker() { + while (idx < toFetch.length) { + const i = idx++; + await fetchAndUpdateIntervention(toFetch[i]); + } + } + + const workers = []; + for (let w = 0; w < FETCH_CONCURRENCY; w++) workers.push(worker()); + await Promise.all(workers); + + // 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 { + // 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) + const 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; + + // 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 : "<br><br>techN: ..." + m = html.match(/"dbFieldName"\s*:\s*"DESCRIPTION"[^}]*?"value"\s*:\s*"((?:[^"\\]|\\.)+)"/); + if (m) { + const desc = decodeJsonString(m[1]); + const ctm = desc.match(/<br>\s*<br>\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(/\\"/g, '"') - .replace(/\\'/g, "'") - .replace(/\\n/g, "\n") .replace(/\\r/g, "") + .replace(/\\n/g, "\n") .replace(/\\t/g, "\t") .replace(/\\\//g, "/") - .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 "<br>" 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 <br>) + 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 (<br> → \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 <br> → retour à la ligne + out = out.replace(/<br\s*\/?>/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 infobulle (champs séparés par <BR> ou similaires) - * en un objet structuré. - * - * Exemples de champs rencontrés : - * Date : 16/04 - * Heure : 09:00 + * Parse le texte d'une action au format : + * Date : lundi 20.04 Heure : matin * Lieu : Ville1/Rue1 1 - * Service : DICIRH/DGMR - * Contact : M. Nom1, Prénom1 (+41 21 555 00 00) - * Bénéficiaire : M. Nom1, Prénom1 - * Étage : REZ - * Bureau : 101 - * Problème : remplacement docking - * A faire : changer le docking - * Matériel : docking station - * Deadline : ... + * 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 parseInfobulle(raw) { - if (!raw) return {}; - - // Décoder le double encodage HTML puis le simple. - // On voit dans les vraies données des &lt;BR&gt; (double-encoded). - let txt = raw; - // Passer plusieurs fois pour gérer les encodages multiples - for (let i = 0; i < 3; i++) { - txt = txt - .replace(/&/g, "&") - .replace(/<BR\/?>/gi, "\n") - .replace(/<br\/?>/gi, "\n") - .replace(/<BR\/?>/gi, "\n") - .replace(/<br\/?>/gi, "\n") - .replace(/ /g, " ") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'"); - } - - // Décoder les \uXXXX - txt = decodeUnicodeEscapes(txt); - - const out = { _raw: txt }; - - // Mapping label → clé de sortie - const LABELS = { +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", - "horaire": "horaire", "lieu": "lieu", "service": "service", "contact": "contact", "bénéficiaire": "beneficiaire", "beneficiaire": "beneficiaire", - "demandeur": "demandeur", "étage": "etage", "etage": "etage", "bureau": "bureau", @@ -515,275 +1097,195 @@ function parseInfobulle(raw) { "à faire": "aFaire", "matériel": "materiel", "materiel": "materiel", - "deadline": "deadline", - "date maximum de résolution": "deadline", - "description": "_descBlock", // champ spécial qui contient plein d'autres labels collés - "catégorie": "categorie", - "categorie": "categorie", - "référence": "reference", - "reference": "reference", - "ref": "reference", - "priorité": "priorite", - "priorite": "priorite", - "label": "_skipLabel", - "nom": "_skipNom", - "temps prévu": "tempsPrevu", - "date convenue": "dateConvenue" + "tfs ancien poste": "tfsAncien", + "tfs nouveau poste": "tfsNouveau" }; - // Étape 1 : parser par lignes (séparateur = saut de ligne après décodage) - const lines = txt.split(/\n+/).map(l => l.trim()).filter(Boolean); - const orphanLines = []; - let descBlock = null; - + const autres = []; for (const line of lines) { - const mLabel = line.match(/^([^:]{2,40})\s*:\s*(.*)$/); - if (mLabel) { - const labelNorm = mLabel[1].trim().toLowerCase(); - const key = LABELS[labelNorm]; - const value = mLabel[2].trim(); + // 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; - if (key === "_skipLabel" || key === "_skipNom") continue; - if (key === "_descBlock") { - descBlock = value; - 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(); } - if (key) { - if (out[key]) { - out[key] = out[key] + " / " + value; - } else { - out[key] = value; - } - continue; + const keyNorm = mk.label.toLowerCase().replace(/\s+/g, " "); + const outKey = labelMap[keyNorm]; + if (outKey && val) { + out[outKey] = out[outKey] ? out[outKey] + " / " + val : val; } } - orphanLines.push(line); - } - - // Étape 2 : si on a un "descBlock", il contient des champs collés ensemble. - // "Date : vendredi 17.04 Heure : matinLieu : Ville1/Ch. de Mornex 32Service : Service12..." - // On le re-parse avec une regex qui cherche les étiquettes. - if (descBlock) { - parseDescBlock(descBlock, out); - } - - // Étape 3 : les lignes orphelines (pas d'étiquette) deviennent la description - // de secours, mais si on a déjà bien parsé, on les met à part. - if (orphanLines.length && !out.description) { - // On ignore les lignes trop courtes (ex: "09:00 S260415" c'est du titre) - const meaningful = orphanLines.filter(l => l.length > 20); - if (meaningful.length) { - out.description = meaningful.join("\n"); - } } + if (autres.length) out.autres = autres.join("\n"); return out; } -/** - * Parse un bloc dense type "Date : X Heure : Y Lieu : Z Service : W ..." sans retour ligne. - * On cherche chaque étiquette connue et on coupe entre deux étiquettes. - */ -function parseDescBlock(text, out) { - // Liste des étiquettes attendues, triées par ordre d'apparition typique. - // On construit une regex qui les cherche toutes, puis on extrait les segments. - const labelsForRegex = [ - "Date", "Heure", "Lieu", "Service", "Contact", "Bénéficiaire", "Beneficiaire", - "Étage", "Etage", "Bureau", "Problème", "Probleme", "A faire", "À faire", - "Matériel", "Materiel", "Date proposée par le contact", "Deadline", - "TFS ancien poste", "TFS nouveau poste", "FRD" - ]; +// ============================================================================ +// Rendu général +// ============================================================================ - // Normalisation : clé JSON - const keyMap = { - "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", - "Date proposée par le contact": "dateProposee", - "Deadline": "deadline", - "TFS ancien poste": "tfsAncien", - "TFS nouveau poste": "tfsNouveau", - "FRD": "frd" - }; +// 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; - // Construire le pattern : (Label1|Label2|...)\s*:\s* - // On n'impose PAS d'espace avant — les labels sont souvent collés aux valeurs précédentes - // (ex: "...matinLieu : ...", "...contactProblème : ..."). - // Pour éviter trop de faux matches, on impose que l'étiquette soit suivie d'un ":". - const escapedLabels = labelsForRegex.map(l => - l.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - ); - const labelPattern = new RegExp( - `(${escapedLabels.join("|")})\\s*:\\s*`, - "g" - ); - - // Trouver toutes les positions des étiquettes - const markers = []; - let m; - while ((m = labelPattern.exec(text)) !== null) { - markers.push({ - label: m[1], - matchStart: m.index, - valueStart: m.index + m[0].length - }); +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(); } +} - if (markers.length === 0) { - if (!out.description) out.description = text; +// 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; + } +} + +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; } - - // Pour chaque marqueur, extraire la valeur jusqu'au début du marqueur suivant - for (let i = 0; i < markers.length; i++) { - const mark = markers[i]; - const valueEnd = (i + 1 < markers.length) ? markers[i + 1].matchStart : text.length; - const value = text.substring(mark.valueStart, valueEnd).trim(); - - const key = keyMap[mark.label]; - if (key && value) { - if (out[key]) { - out[key] = out[key] + " / " + value; - } else { - out[key] = value; - } + 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}`); } } -} - -function decodeUnicodeEscapes(s) { - return s.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => { - try { return String.fromCharCode(parseInt(hex, 16)); } - catch { return _; } - }); -} - -/** - * Renvoie true si l'intervention/absence touche le jour cible (startDate ≤ target ≤ endDate). - * Les pompiers et les absences longues couvrent plusieurs jours. - */ -function intersectsDate(player, targetDate) { - if (!player.startDate || !player.endDate) { - // Pas de date : on le garde (prudent, mieux que de le perdre silencieusement) - return true; - } - const t = ddmmyyyyToDateNum(targetDate); - const s = ddmmyyyyToDateNum(player.startDate); - const e = ddmmyyyyToDateNum(player.endDate); - return s <= t && t <= e; -} - -function ddmmyyyyToDateNum(s) { - const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); - if (!m) return 0; - return parseInt(m[3] + m[2] + m[1], 10); // YYYYMMDD + 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++; - - // Compter TOUTES les vraies interventions (même celles des pompiers) const real = tech.interventions.filter(iv => iv.type !== "AL-Absence" && !iv.isPompier ); - totalInterventions += real.length; 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 - }; + return { totalTechs: techs.length, pompiers, absents, totalInterventions, morning, afternoon, closed, resolved }; } -/** - * Clé de tri : pompier d'abord (0), puis actifs triés par nb d'interv décroissant (1..99), - * puis absents à la fin (1000+). - */ -function sortKey(tech, targetDate) { - const isPompier = tech.interventions.some(iv => iv.isPompier); - const isAbsent = isTechAbsent(tech, targetDate); - if (isPompier) return 0; - if (isAbsent) return 1000 + tech.name.localeCompare(tech.name); - // Actifs : plus d'interventions = plus haut - const n = tech.interventions.length; - return 100 - Math.min(n, 50); -} - -function isTechAbsent(tech, targetDate) { - // 1. Règles récurrentes (ex: Pillonel le vendredi) - const recurring = RECURRING_ABSENCES[tech.id]; - if (recurring) { - const targetDay = jsDayOfWeek(targetDate); - if (recurring.includes(targetDay)) return true; - } - - // 2. Si toutes les interventions du tech ce jour-là sont du type AL-Absence - // (et aucune n'est pompier), on considère absent - if (tech.interventions.length === 0) return false; // pas d'info → actif par défaut - const allAbsence = tech.interventions.every(iv => - iv.type === "AL-Absence" && !iv.isPompier - ); - return allAbsence; -} - -function jsDayOfWeek(ddmmyyyy) { - const m = ddmmyyyy.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); - if (!m) return -1; - const d = new Date(parseInt(m[3], 10), parseInt(m[2], 10) - 1, parseInt(m[1], 10)); - return d.getDay(); -} - -// ============================================================================ -// Rendu -// ============================================================================ - -function render(data) { - currentData = data; - document.getElementById("loading").classList.add("hidden"); - document.getElementById("error-box").classList.add("hidden"); - - renderCaptureInfo(data); - renderStats(data); - renderCards(data); -} - -function renderCaptureInfo(data) { - const info = document.getElementById("capture-info"); - const when = data.captureTime ? new Date(data.captureTime) : null; - const parts = [`Planning du ${data.targetDate}`]; - if (when) { - const hh = String(when.getHours()).padStart(2, "0"); - const mm = String(when.getMinutes()).padStart(2, "0"); - parts.push(`capturé à ${hh}:${mm}`); - } - info.textContent = parts.join(" · "); -} - -function renderStats(data) { +function renderStats(s) { const el = document.getElementById("stats"); - const s = data.stats; el.innerHTML = ` <span class="global-stat global-stat-main"><b>${s.totalInterventions}</b> intervention${s.totalInterventions > 1 ? "s" : ""}</span> <span class="global-stat global-stat-sub">(${s.morning} matin · ${s.afternoon} après-midi)</span> + ${(s.closed + s.resolved > 0) ? `<span class="global-stat-sep">·</span><span class="global-stat"><b>${s.closed + s.resolved}</b> clos</span>` : ""} <span class="global-stat-sep">·</span> <span class="global-stat"><b>${s.totalTechs}</b> techs</span> <span class="global-stat-sep">·</span> @@ -798,28 +1300,61 @@ function renderCards(data) { const container = document.getElementById("cards"); container.innerHTML = ""; - for (const tech of data.techs) { + // 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 buildCard(tech, 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, targetDate); + const isAbsent = isTechAbsent(tech, isoDate); if (isPompier) card.classList.add("is-pompier"); if (isAbsent) card.classList.add("is-absent"); - // Séparer les interventions 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); - // Stats matin/après-midi (frontière à 12:00) const morning = realInterventions.filter(iv => { const s = timeToMinutes(iv.startTime); return s !== null && s < 12 * 60; @@ -829,14 +1364,11 @@ function buildCard(tech, targetDate) { // --- 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); - // Badge de statut (pompier/absent uniquement). Les techs actifs n'ont pas de badge - // — leurs stats complètes sont juste en dessous. if (isPompier || isAbsent) { const badge = document.createElement("div"); badge.className = "card-tech-badge"; @@ -855,18 +1387,13 @@ function buildCard(tech, targetDate) { const body = document.createElement("div"); body.className = "card-body"; - // 1. Note contextuelle pompier (avec dates !) / absent + // Note statut if (isPompier && pompierBlocks.length) { const note = document.createElement("div"); note.className = "card-status-note pompier"; - // Afficher la période en dates (14/04 → 18/04) const pb = pompierBlocks[0]; if (pb.startDate && pb.endDate && pb.startDate !== pb.endDate) { - // Format court : jour/mois - const short = d => d ? d.substring(0, 5) : ""; - note.textContent = `En pompier du ${short(pb.startDate)} au ${short(pb.endDate)}`; - } else if (pb.startDate) { - note.textContent = `En pompier le ${pb.startDate.substring(0, 5)}`; + note.textContent = `En pompier du ${pb.startDate.substring(0, 5)} au ${pb.endDate.substring(0, 5)}`; } else { note.textContent = "En pompier aujourd'hui"; } @@ -875,37 +1402,20 @@ function buildCard(tech, targetDate) { const note = document.createElement("div"); note.className = "card-status-note absent"; const ab = absenceBlocks[0]; - // Si période multi-jours, afficher les dates if (ab.startDate && ab.endDate && ab.startDate !== ab.endDate) { - const short = d => d ? d.substring(0, 5) : ""; - note.textContent = `Absent du ${short(ab.startDate)} au ${short(ab.endDate)}`; - } else if (ab.startTime && (ab.startTime === "00:00" || (ab.startTime === "08:00" && ab.endTime === "18:00"))) { - note.textContent = "Absent toute la journée"; - } else if (ab.startTime) { - note.textContent = `Absent ${ab.startTime}–${ab.endTime}`; + note.textContent = `Absent du ${ab.startDate.substring(0, 5)} au ${ab.endDate.substring(0, 5)}`; } else { - note.textContent = "Absent aujourd'hui"; + note.textContent = "Absent toute la journée"; } body.appendChild(note); - } else if (isPompier) { - const note = document.createElement("div"); - note.className = "card-status-note pompier"; - note.textContent = "En pompier aujourd'hui"; - body.appendChild(note); - } else if (isAbsent) { - const note = document.createElement("div"); - note.className = "card-status-note absent"; - note.textContent = "Absent aujourd'hui"; - body.appendChild(note); } - // 2. Si absent SANS intervention : on s'arrête là, pas de timeline ni de stats + // Absent sans interv → on stop là if (isAbsent && realInterventions.length === 0) { card.appendChild(body); return card; } - // 3. Si aucune intervention et pas pompier/absent : message if (realInterventions.length === 0 && !isPompier) { const empty = document.createElement("div"); empty.className = "card-empty"; @@ -915,10 +1425,10 @@ function buildCard(tech, targetDate) { return card; } - // 4. Timeline (pour pompier aussi, avec fond spécial ; pour absent si interventions) + // Timeline body.appendChild(buildTimeline(realInterventions, pompierBlocks, absenceBlocks, card, isPompier, isAbsent)); - // 5. Stats matin/après-midi (total bien mis en avant) + // Stats de carte if (realInterventions.length > 0) { const stats = document.createElement("div"); stats.className = "card-stats"; @@ -936,7 +1446,7 @@ function buildCard(tech, targetDate) { body.appendChild(stats); } - // 6. Liste des interventions + // Liste interventions for (const iv of realInterventions) { body.appendChild(buildInterventionRow(iv, card)); } @@ -945,137 +1455,10 @@ function buildCard(tech, targetDate) { return card; } -function buildInterventionRow(iv, cardEl) { - const row = document.createElement("div"); - row.className = "intervention"; - if (iv.isPompier) row.classList.add("is-pompier-line"); - - // Code couleur par type (même que la timeline) - const colorKey = deriveColorKey(iv); - row.classList.add("color-" + colorKey); - - // Index pour highlight réciproque - const ivIdx = cardEl._rowIdxCounter || 0; - cardEl._rowIdxCounter = ivIdx + 1; - row.dataset.ivIdx = ivIdx; - - // Pastille colorée sur le côté gauche (même couleur que le bloc timeline) - const dot = document.createElement("div"); - dot.className = "intervention-dot"; - row.appendChild(dot); - - // Heure - const timeEl = document.createElement("div"); - timeEl.className = "intervention-time"; - if (iv.startTime && iv.endTime) { - timeEl.textContent = `${iv.startTime}–${iv.endTime}`; - } else if (iv.startTime) { - timeEl.textContent = iv.startTime; - } else { - timeEl.textContent = "—"; - } - row.appendChild(timeEl); - - // Contenu (ref en avant + titre + meta) - const content = document.createElement("div"); - content.className = "intervention-content"; - - // Ligne 1 : la référence S260xxx_xxxxx (en évidence) - const refHeader = document.createElement("div"); - refHeader.className = "intervention-refhdr"; - if (iv.ref) { - refHeader.textContent = iv.ref; - } else { - refHeader.textContent = "—"; - refHeader.classList.add("no-ref"); - } - content.appendChild(refHeader); - - // Ligne 2 : type (livraison / récupération / etc.) en plus discret - const title = document.createElement("div"); - title.className = "intervention-title"; - title.textContent = shortTitle(iv); - content.appendChild(title); - - // Ligne 3 : contact / lieu - const meta = document.createElement("div"); - meta.className = "intervention-meta"; - meta.textContent = shortMeta(iv); - content.appendChild(meta); - - row.appendChild(content); - - // Bouton copier - if (iv.ref) { - 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); - } - - // Tooltip enrichi au survol + highlight réciproque - 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; -} - -function shortTitle(iv) { - // v2.2 : on privilégie la catégorie simplifiée ("Livraison matériel", - // "Récupération", "Remplacement de matériel", "Autres"). - return deriveShortTitle(iv); -} - -function shortMeta(iv) { - const i = iv.infobulle || {}; - const parts = []; - if (i.contact) parts.push(i.contact); - if (i.lieu) parts.push(i.lieu); - return parts.join(" · ") || "—"; -} - -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 (e) { - alert("Référence : " + ref + "\n(La copie automatique n'a pas fonctionné.)"); - } -} - // ============================================================================ -// Frise de temps (pour voir les trous) +// Timeline // ============================================================================ -/** - * Construit une mini-frise de temps horizontale couvrant 08:00 → 18:00. - * - * Les plages occupées (interventions, bloc pompier, absences) sont dessinées - * en couleur ; les zones libres sont détectables au survol (tooltip de durée). - * - * Chaque bloc occupé est lié à son intervention correspondante dans la liste - * via un attribut data-iv-idx (synchronisé avec buildInterventionRow) pour - * permettre le highlight réciproque au hover. - */ function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) { const DAY_START = 8 * 60; const DAY_END = 18 * 60; @@ -1083,56 +1466,41 @@ function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, const wrap = document.createElement("div"); wrap.className = "timeline"; - // Fond spécial quand la journée entière est un contexte pompier/absence if (isPompier) wrap.classList.add("timeline-pompier"); - if (isAbsent) wrap.classList.add("timeline-absent"); const bar = document.createElement("div"); bar.className = "timeline-bar"; - // Construire les segments d'interventions "vraies" 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 clampedS = Math.max(s, DAY_START); - const clampedE = Math.min(e, DAY_END); - if (clampedE <= clampedS) 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), - start: clampedS, - end: clampedE, - iv, - ivIdx: i + iv, ivIdx: i, + start: cs, end: ce, + statusClass: getStatusClass(iv) }); } - // On ajoute les blocs d'absence qui ne sont PAS "toute la journée" (sinon c'est le fond) for (const ab of absenceBlocks || []) { const s = timeToMinutes(ab.startTime); const e = timeToMinutes(ab.endTime); if (s === null || e === null) continue; - // Une absence "complète" (00:00-23:59 ou 08:00-18:00) est gérée en fond de la carte, - // pas comme un segment. Seules les absences partielles sont dessinées. - const clampedS = Math.max(s, DAY_START); - const clampedE = Math.min(e, DAY_END); - const isFullDay = (clampedS <= DAY_START) && (clampedE >= DAY_END); - if (isFullDay) continue; - if (clampedE <= clampedS) continue; - segments.push({ - kind: "absence", - start: clampedS, - end: clampedE, - iv: ab - }); + 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 }); } - // Les pompierBlocks sont toujours "toute la journée" dans notre cas - // → on NE les dessine pas comme segment, c'est le fond rouge de la timeline qui les représente. - // Calculer les trous (pour détecter les créneaux libres) + // 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) { @@ -1150,33 +1518,35 @@ function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, } if (cursor < DAY_END) holes.push([cursor, DAY_END]); - // Dessiner les trous (si la personne n'est pas absente toute la journée) if (!isAbsent) { for (const [s, e] of holes) { if (e - s < 15) continue; - const holeEl = document.createElement("div"); - holeEl.className = "timeline-hole"; - holeEl.style.left = ((s - DAY_START) / DAY_LEN) * 100 + "%"; - holeEl.style.width = ((e - s) / DAY_LEN) * 100 + "%"; - holeEl.dataset.startMin = s; - holeEl.dataset.endMin = e; - holeEl.dataset.kind = "hole"; - bindTimelinePopover(holeEl); - bar.appendChild(holeEl); + 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); } } - // Dessiner les segments occupés 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) { + 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)); @@ -1186,32 +1556,33 @@ function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, bar.appendChild(el); } - // Ligne de midi - const noonMarker = document.createElement("div"); - noonMarker.className = "timeline-noon"; - noonMarker.style.left = (((12 * 60) - DAY_START) / DAY_LEN) * 100 + "%"; - bar.appendChild(noonMarker); + 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); - // Échelle horaire const scale = document.createElement("div"); scale.className = "timeline-scale"; for (const h of [8, 10, 12, 14, 16, 18]) { - const tick = document.createElement("span"); - tick.className = "timeline-tick"; - tick.style.left = (((h * 60) - DAY_START) / DAY_LEN * 100) + "%"; - tick.textContent = h + "h"; - scale.appendChild(tick); + 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; } -/** - * Attache les handlers du popover (heure début/fin ou durée libre). - */ +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); @@ -1222,33 +1593,29 @@ function showTimelinePopover(e, el) { const s = parseInt(el.dataset.startMin, 10); const eMin = parseInt(el.dataset.endMin, 10); const kind = el.dataset.kind; - const durMin = eMin - s; - + const dur = eMin - s; let html; if (kind === "hole") { - // Zone libre : afficher la durée disponible en h/min - const h = Math.floor(durMin / 60); - const min = durMin % 60; - let dur; - if (h === 0) dur = `${min} min`; - else if (min === 0) dur = `${h} h`; - else dur = `${h} h ${min} min`; + 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 = `<dl> <dt>Libre</dt><dd>${minutesToTime(s)}–${minutesToTime(eMin)}</dd> - <dt>Durée</dt><dd>${dur} disponible</dd> + <dt>Durée</dt><dd>${d} disponible</dd> </dl>`; } else { - // Bloc occupé - const titre = el.dataset.title || ""; - const kindLabel = kind === "pompier" ? "Pompier" - : kind === "absence" ? "Absence" - : "Intervention"; + const t = el.dataset.title || ""; + const ref = el.dataset.ref || ""; + const k = kind === "absence" ? "Absence" : "Intervention"; html = `<dl> - <dt>${kindLabel}</dt><dd>${minutesToTime(s)}–${minutesToTime(eMin)}</dd> - ${titre ? `<dt>Type</dt><dd>${escapeHtml(titre)}</dd>` : ""} + <dt>${k}</dt><dd>${minutesToTime(s)}–${minutesToTime(eMin)}</dd> + ${t ? `<dt>Type</dt><dd>${escapeHtml(t)}</dd>` : ""} + ${ref ? `<dt>Réf</dt><dd>${escapeHtml(ref)}</dd>` : ""} </dl>`; } - const tip = tooltipEl(); tip.innerHTML = html; tip.classList.remove("hidden"); @@ -1256,40 +1623,788 @@ function showTimelinePopover(e, el) { 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; + /** - * Highlight d'une intervention : recherche la ligne et le bloc timeline - * correspondants au sein de la même carte et les marque/démarque. + * Affiche un toast en bas à droite. S'empile, max 3, animations in/out. */ -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); +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); } -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); +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 }); } -function minutesToTime(mins) { - const h = Math.floor(mins / 60); - const m = mins % 60; - return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); +/** + * 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); + } +} + +// Met à jour dans le DOM la ligne correspondant à une intervention (après fetch) +function updateInterventionInDom(iv) { + const row = document.querySelector(`.intervention[data-action-id="${iv.actionId}"]`); + if (!row) return; + + // Statut visuel + row.classList.remove("status-closed", "status-resolved"); + const sc = getStatusClass(iv); + if (sc) row.classList.add(sc); + + // Ref (S260xxx) : mise à jour si on l'a trouvée dans la fiche + const refEl = row.querySelector(".intervention-refhdr"); + if (refEl) { + if (iv.ref) { + refEl.textContent = iv.ref; + refEl.classList.remove("no-ref"); + } else { + refEl.textContent = "—"; + refEl.classList.add("no-ref"); + } + } + + // Titre (catégorie) + const title = row.querySelector(".intervention-title"); + if (title) title.textContent = deriveShortTitle(iv); + + // Meta + const meta = row.querySelector(".intervention-meta"); + if (meta) meta.textContent = shortMeta(iv); + + // Check ✓ : ajouter ou retirer + let statusEl = row.querySelector(".intervention-status"); + if (sc && !statusEl) { + statusEl = document.createElement("div"); + statusEl.className = "intervention-status"; + statusEl.textContent = "✓"; + const copy = row.querySelector(".intervention-copy"); + if (copy) row.insertBefore(statusEl, copy); + else row.appendChild(statusEl); + } else if (!sc && statusEl) { + statusEl.remove(); + } + + // Bouton copier : ajouter si on a maintenant une ref + 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); + } + + // Mettre à jour la classe couleur (pour la pastille) + const colorKey = deriveColorKey(iv); + row.classList.remove("color-livraison", "color-recup", "color-remplacement", "color-autre"); + row.classList.add("color-" + colorKey); + + // Mettre à jour le bloc timeline correspondant + const card = row.closest(".card"); + if (card) { + const slot = card.querySelector(`.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]`); + if (slot) { + slot.classList.remove("status-closed", "status-resolved", + "color-livraison", "color-recup", "color-remplacement", "color-autre"); + slot.classList.add("color-" + colorKey); + if (sc) slot.classList.add(sc); + } + } } // ============================================================================ -// Tooltip enrichi +// Tooltip // ============================================================================ const tooltipEl = () => document.getElementById("tooltip"); -function bindTooltipHandlers() { - // rien à faire ici, c'est géré par intervention au cas par cas -} - function showTooltip(e, iv) { const el = tooltipEl(); el.innerHTML = buildTooltipHTML(iv); @@ -1297,13 +2412,11 @@ function showTooltip(e, iv) { 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; @@ -1311,13 +2424,8 @@ function moveTooltip(e) { const rect = el.getBoundingClientRect(); let x = e.clientX + pad; let y = e.clientY + pad; - // Empêcher la sortie à droite / en bas - 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; - } + 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"; } @@ -1326,47 +2434,127 @@ function buildTooltipHTML(iv) { const i = iv.infobulle || {}; const rows = []; - // Heure (déjà dans la carte mais utile) + // Cas spécial : réservation (créneau bloqué par un coordinateur) + if (iv.type === "AL-Reservation") { + rows.push(`<dt>Type</dt><dd><span class="status-pill other" style="background:var(--c-reservation);color:#fff">Réservation</span></dd>`); + 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 `<dl>${rows.join("")}</dl>`; + } + + // 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(`<dt>Statut</dt><dd><span class="status-pill ${cls}">${escapeHtml(iv.status)}</span></dd>`); + } + if (iv.startTime && iv.endTime) { rows.push(row("Horaire", `${iv.startTime}–${iv.endTime}`)); } - if (i.contact) rows.push(row("Contact", i.contact)); - if (i.beneficiaire && i.beneficiaire !== i.contact) { - rows.push(row("Bénéficiaire", i.beneficiaire)); + // ─── 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(`<dt>Action</dt><dd class="description">${escapeHtml(formatted).replace(/\n/g, "<br>")}</dd>`); + } 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(`<dt>Info</dt><dd style="color:var(--text-faint)">Aucun détail pour cette intervention.</dd>`); + } else { + rows.push(`<dt>Info</dt><dd style="color:var(--text-faint)">Chargement des détails…</dd>`); + } + } } - if (i.service) rows.push(row("Service", i.service)); - if (i.lieu) rows.push(row("Lieu", i.lieu)); - const addr = [i.etage, i.bureau].filter(Boolean).join(" · "); - if (addr) rows.push(row("Étage/Bureau", addr)); + // Deadline (si connue et différente) + if (iv.deadline && !i.date) rows.push(row("Deadline", iv.deadline)); - if (i.probleme) rows.push(row("Problème", i.probleme)); - if (i.aFaire) rows.push(row("À faire", i.aFaire)); - if (i.materiel) rows.push(row("Matériel", i.materiel)); - if (i.deadline) rows.push(row("Deadline", i.deadline)); - if (i.categorie) rows.push(row("Catégorie", i.categorie)); - if (i.priorite) rows.push(row("Priorité", i.priorite)); - - // Description résiduelle (ce qui n'a pas été capturé par les labels) - if (i.description && i.description.trim()) { + // Commentaire du tech (si présent dans DESCRIPTION de la fiche) + if (iv.commentaireTech) { rows.push(`<hr>`); - rows.push(`<dt>Description</dt><dd class="description">${escapeHtml(i.description)}</dd>`); + rows.push(`<dt>Commentaire tech</dt><dd class="commentaire">${escapeHtml(iv.commentaireTech)}</dd>`); } - // Ref en bas if (iv.ref) { rows.push(`<hr>`); rows.push(row("Référence", iv.ref)); } + if (iv.ghost) { + rows.push(`<hr>`); + rows.push(`<dt>⚠</dt><dd>Intervention disparue d'EasyVista (clôturée, déplacée ou annulée)</dd>`); + } else if (iv.formLink) { + rows.push(`<hr>`); + rows.push(`<dt></dt><dd style="color:var(--text-faint);font-size:11px">Cliquer pour ouvrir la fiche</dd>`); + } + if (rows.length === 0) { return `<dl><dt>Info</dt><dd>Aucun détail disponible</dd></dl>`; } - return `<dl>${rows.join("")}</dl>`; } +/** + * 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 `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`; } @@ -1380,15 +2568,60 @@ function escapeHtml(s) { .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); +} + // ============================================================================ -// Erreur +// 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"); +}