From 06c0195130dd801059df350140dc50f05bcb2542 Mon Sep 17 00:00:00 2001 From: Quentin Rouiller Date: Fri, 8 May 2026 16:30:47 +0200 Subject: [PATCH] =?UTF-8?q?v2026.5.45=20=E2=80=94=20Dock=20lat=C3=A9ral=20?= =?UTF-8?q?drag&drop,=20fix=20verdicts=20ghost,=20multi-onglets=20EZV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refonte de l'expérience drag&drop avec dock latéral à droite pour parquer des interventions entre les jours. Fix critique du parser de fiche EV qui marquait à tort des interventions terminées comme annulées (les dates d'action sont détectées par scan regex au lieu d'indices fixes [8]/[9]). Décorrélation des « Logs verbeux » et de la case « Garder les disparitions » dans Paramètres → Diagnostics. Issues résolues : #3 #4 #5 #6 #7 #8. Closes #3 #4 #5 #6 #7 #8 --- CHANGELOG.md | 49 + README.md | 19 +- firefox-updates.json | 5 + src/background.js | 362 ++++- src/manifest.json | 5 +- src/viewer.css | 912 ++++++++++- src/viewer.html | 2 +- src/viewer.js | 3602 ++++++++++++++++++++++++++++++++++++++---- 8 files changed, 4560 insertions(+), 396 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd44776..c2082cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,55 @@ --- +## v2026.5.45 — Dock latéral drag&drop, fix verdicts ghost, multi-onglets EZV résolu + +> Refonte de l'expérience drag&drop avec dock latéral pour parquer des interventions entre les jours, fix critique du parser de fiches qui marquait à tort des interventions terminées comme annulées, résolution des 6 issues ouvertes (multi-onglets EZV, absences récurrentes, popups épinglés, pompier absent), et nombreux ajustements d'ergonomie. + +### Issues résolues + +- **#3** — Coches « Absences récurrentes » : merge propre avec l'état stocké au lieu d'écrasement, les coches sont retenues lors d'un changement de groupe ou d'une réouverture des paramètres. +- **#4** — Perte de session EZV multi-onglets : permission optionnelle `cookies` + listener `cookieChanged` côté background, toggle dans Paramètres → Diagnostics. La session reste valable même après reconnexion et fermeture d'un onglet EZV. +- **#5** — Bouton de copie de référence dans une popup épinglée : handler `copy-ref` ajouté. +- **#6** — Popup épinglée au premier plan : clic sur une popup recalcule le z-index pour la passer au-dessus des autres. +- **#7** — Notification « +2 min » fantôme : reset des flags d'alerte slide au retour de la prolongation. +- **#8** — Compteur pompier : un pompier absent toute la journée est exclu du compteur. + +### Dock latéral drag&drop + +Le dock à droite permet de mettre des interventions de côté pendant qu'on navigue entre les jours, puis de les redéposer plus tard. + +- Apparition graduelle pendant un drag : peep-min (sans contenu), peep (avec cartes), expanded (au survol ou au bord droit). +- **Délai 500 ms** pour expand/collapse pour éviter le flicker quand le curseur effleure le bord du dock. +- Card du dock : référence + durée prévue (`1h`, `1h30`, `45min`) avec barre verticale 4 px sur la gauche dans la couleur de la catégorie. Fond transparent. +- Bouton de retrait `×` : **appui long 2 s** avec animation `conic-gradient`. Un clic simple ne fait rien. +- Drag depuis le dock : retrait différé à l'activation effective du drag (5 px). Ghost flottant sans heure à gauche. Drop sur tech → modal de confirmation. Drop hors zone / Échap / sur le même slot d'origine → restauration dans le dock. +- Plus de scrollbar horizontale en bas du dock, plus de clic-through dans la zone autour du bouton « Tout annuler ». + +### Verdicts ghost — fix critique + +Une intervention terminée par le tech mais dont la fiche était passée en statut « Redirigé » / « Finalisation » / « Exécution » était à tort marquée comme « annulée » et retirée du planning, parce que le parser de fiche cherchait les dates d'action aux indices `[8]`/`[9]` du tableau `rows` alors que le layout EV récent les place en `[6]`/`[7]` (et la description en `[9]` au lieu de `[11]`). + +- **Détection robuste** : scan des valeurs pour le pattern `DD/MM/YYYY HH:MM:SS`, garde des 2 dernières occurrences comme (création, fin). Survit aux variations de layout EV. +- **Décorrélation logs / KEEP-forcé** : nouvelle case dédiée « Garder les disparitions » dans Paramètres → Diagnostics, indépendante des « Logs verbeux ». Mode prod par défaut → verdict `REMOVE` appliqué (statut Annulé/Supprimé, ou statut clos sans commentaire du tech, ou action sans commentaire `login:`). + +### Drag&drop bloqué pour les interventions non-déplaçables + +`_canRescheduleIv` refuse maintenant explicitement le drag pour : + +- iv en verdict `terminated-pending` (gris « fait »). +- iv en verdict `terminated-clos` (vert ✓✓). +- iv dont le statut EV est dans `CLOSED_STATUS` ou `RESOLVED_STATUS`. +- iv en cours d'analyse de disparition (`_disappearChecking`). + +### Popups dépinglés + +- Auto-fermeture du popup dépinglé quand la souris quitte sa zone et celle des cards liées de la même iv (300 ms de grâce). N'interfère pas avec un drag de planning en cours. + +### Tooltip / contact + +- Le contact n'inclut plus les labels « fiche » (`Étage`, `Bureau`, `Service`, `Matériel`, `Problème`, `TFS`, `Date`, `Heure`, `Lieu`, `Bénéficiaire`, `Nom utilisateur`) qui se collent parfois à la valeur du contact à cause de séparateurs perdus dans la source EV. Le tooltip insère un saut de ligne avant chaque label collé pour la lisibilité. + + ## v2026.5.44 — Refonte topbar, personnalisation Apparence, onboarding équipe, refresh séquentiel > Refonte visuelle de la topbar (vue classique + horizontale), nouveau panneau diff --git a/README.md b/README.md index 44c68a3..a4bb35d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Extension Chrome / Firefox pour visualiser de manière claire et rapide le plann - **Auteur** : Quentin Rouiller (QRO), Technicien DGNSI — Canton de Vaud - **Public cible** : coordinateurs DGNSI qui pilotent dans EasyVista (`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) le planning de l'équipe technicienne - **Démarrage projet** : jeudi 16 avril 2026 -- **Version actuelle** : [`v2026.5.44`](https://gitea.netaplaid.ch/FroSteel/Planification/releases/tag/v2026.5.44) (latest) +- **Version actuelle** : [`v2026.5.45`](https://gitea.netaplaid.ch/FroSteel/Planification/releases/tag/v2026.5.45) (latest) - **Contact** : voir [page wiki Contact](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Contact) ou [ouvrir une issue](https://gitea.netaplaid.ch/FroSteel/Planification/issues/new) - **Manifest** : V3 (Chrome/Edge/Firefox 140+) - **Format** : `.zip` (Chromium) + `.xpi` signé Mozilla (Firefox) @@ -90,7 +90,7 @@ L'extension a connu **3 systèmes de versionning successifs** : |---|---|---| | 16-17 avril 2026 | Versions de base | `1.0.0`, `2.0.0`, `3.0.0` | | 18-20 avril 2026 | SemVer classique | `4.1.3`, `4.2.8`, `5.0.12` | -| 21 avril 2026 → maintenant | **`ANNÉE.MAJEURE.PATCH`** | `2026.5.16` → `2026.5.44` | +| 21 avril 2026 → maintenant | **`ANNÉE.MAJEURE.PATCH`** | `2026.5.16` → `2026.5.45` | ### Format actuel : `ANNÉE.MAJEURE.PATCH` @@ -113,7 +113,20 @@ Le numéro de **majeure** n'est **pas** un mois et **pas** un chiffre lié au ca ## Versions notables -### `v2026.5.44` (latest, 1 mai 2026) — Refonte topbar, personnalisation Apparence, onboarding équipe, fix #1 +### `v2026.5.45` (latest, 8 mai 2026) — Dock latéral drag&drop, fix verdicts ghost, multi-onglets EZV résolu +- **Dock latéral drag&drop** : nouvelle zone à droite pour parquer des interventions et les redéposer plus tard, avec apparition graduelle (peep-min/peep/expanded), délai 500 ms pour expand/collapse anti-flicker, card compacte (réf + durée + barre catégorie 4 px à gauche), bouton de retrait par appui long 2 s. +- **Verdicts ghost** : fix critique du parser de fiche (les dates d’action ne sont plus cherchées aux indices fixes \`[8]\`/\`[9]\` mais détectées par scan regex pattern \`DD/MM/YYYY HH:MM:SS\`). Décorrélation des « Logs verbeux » et « Garder les disparitions » dans Paramètres → Diagnostics. +- **Drag&drop bloqué** sur les interventions « Fait » (gris), « Clos » (vert ✓✓), statut Clôturé/Résolu/Terminé, et pendant l’analyse de disparition d’un ghost. +- **Issue #3** : coches « Absences récurrentes » retenues (merge propre au lieu d’écrasement) lors d’un changement de groupe. +- **Issue #4** : multi-onglets EZV — permission optionnelle \`cookies\` + listener \`cookieChanged\`, la session reste valable après reconnexion + fermeture d’un onglet EZV. +- **Issue #5** : copie de référence dans une popup épinglée fonctionne. +- **Issue #6** : popup épinglée passe au premier plan au clic. +- **Issue #7** : plus de notification « +2 min » fantôme après prolongation. +- **Issue #8** : pompier absent toute la journée n’est plus compté. +- **Auto-fermeture** des popups dépinglés quand la souris quitte leur zone et celle des cards liées. +- **Tooltip / contact nettoyé** : les labels « fiche » (Étage/Bureau/Service/etc.) collés à la valeur du contact sont retirés. + +### `v2026.5.44` (1 mai 2026) — Refonte topbar, personnalisation Apparence, onboarding équipe, fix #1 - **Topbar refondue (vue classique)** : sélecteur de date du planning ancré au centre absolu (ne se décale plus quand le bouton « Arrêter » apparaît), bouton « Aujourd'hui » en toutes lettres, horloge contextuelle dans un cadre encadré. - **Personnalisation Apparence** : couleur de la barre du haut (12 presets + picker custom + champ hex), contraste de texte calculé automatiquement par luminance ; police de l'application avec 28 choix (Arial, Helvetica, Verdana, Tahoma, Trebuchet, Calibri, Segoe UI, Times New Roman, Georgia, Cambria, Garamond, Palatino, Courier, Consolas, Comic Sans, Impact, …) appliquée à toute la page. - **Vue horizontale** : bloc Aujourd'hui + horloge dans le même cadre que la classique, barre verte verticale à droite des mini-cards clos/résolu, sidebar adopte la couleur de topbar custom de manière cohérente. diff --git a/firefox-updates.json b/firefox-updates.json index 2782486..e11f7bc 100644 --- a/firefox-updates.json +++ b/firefox-updates.json @@ -2,6 +2,11 @@ "addons": { "planification-dgnsi@netaplaid.ch": { "updates": [ + { + "version": "2026.5.45", + "update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.45/planification-v2026.5.45-firefox.xpi", + "update_hash": "sha256:42ac47aeda23912e80051fc941ba45a0dec74ec4a6c509a25270c27b73dddead" + }, { "version": "2026.5.44", "update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.44/planification-v2026.5.44-firefox.xpi", diff --git a/src/background.js b/src/background.js index c8ae706..cb26eb3 100644 --- a/src/background.js +++ b/src/background.js @@ -188,6 +188,48 @@ async function getDayBounds() { // Clic sur l'icône → ouvrir le viewer // ============================================================================ +// ============================================================================ +// badge "!" clignotant sur l'icône de l'extension quand la session +// EasyVista va expirer. Permet à l'utilisateur de voir l'alerte même si +// l'onglet planning n'est pas au premier plan. +// ============================================================================ + +let _expirationBlinkInterval = null; +let _expirationBlinkTimeout = null; + +function _setExpirationBadge(active, durationMs) { + // Stop l'état précédent + if (_expirationBlinkInterval) { + clearInterval(_expirationBlinkInterval); + _expirationBlinkInterval = null; + } + if (_expirationBlinkTimeout) { + clearTimeout(_expirationBlinkTimeout); + _expirationBlinkTimeout = null; + } + try { chrome.action.setBadgeText({ text: "" }); } catch (e) {} + + if (!active) return; + + // Couleur de fond rouge pour le "!" + try { chrome.action.setBadgeBackgroundColor({ color: "#d6443d" }); } catch (e) {} + let on = true; + const tick = () => { + try { + chrome.action.setBadgeText({ text: on ? "!" : "" }); + } catch (e) {} + on = !on; + }; + tick(); + _expirationBlinkInterval = setInterval(tick, 700); + + if (durationMs && durationMs > 0) { + _expirationBlinkTimeout = setTimeout(() => { + _setExpirationBadge(false); + }, durationMs); + } +} + 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 @@ -207,24 +249,146 @@ chrome.action.onClicked.addListener(async () => { /** * Trouve l'onglet EasyVista ouvert et récupère phpsessid + origin. * + * v2026.5.45 (issue #4) : si la permission optionnelle "cookies" est + * accordée, on lit le PHPSESSID directement depuis le cookie HttpOnly du + * domaine EZV — toujours à jour, immune au PHPSESSID périmé qu'on trouve + * dans l'URL des onglets historiques après un relog. Sinon, fallback sur + * l'ancienne logique URL (compat sans la permission). + * * @author Quentin Rouiller */ -async function findEasyVistaSession() { - // v2026.5.41 : les origines EV viennent de admin_config (éditables dans - // Paramètres → EasyVista), avec fallback sur DEFAULT_EV_ORIGINS. +async function _hasCookiesPermission() { + return new Promise(resolve => { + try { + chrome.permissions.contains({ permissions: ["cookies"] }, granted => { + resolve(!!granted); + }); + } catch (e) { + resolve(false); + } + }); +} + +async function _readPhpsessidFromCookie(origin) { + if (!chrome.cookies) return null; + return new Promise(resolve => { + try { + chrome.cookies.get({ url: origin, name: "PHPSESSID" }, c => { + if (!c || !c.value) { resolve(null); return; } + resolve({ + value: c.value, + expirationDate: c.expirationDate || null + }); + }); + } catch (e) { + resolve(null); + } + }); +} + +// ─── findEasyVistaSession ─────────────────────────────────────────────────── +// +// Stratégie unifiée : pour chaque origine EV configurée, on cherche le +// PHPSESSID dans cet ordre : +// 1. cookie HttpOnly (autoritatif, donne aussi expirationDate) +// 2. fallback URL d'un onglet ouvert (?PHPSESSID=…) +// On retourne la première origine qui a un onglet ouvert ET un PHPSESSID. +// +// Anti-rafale : la fonction est appelée par ~16 endroits dans le service +// worker, parfois en parallèle. On cache le résultat 200 ms pour éviter +// le bombardement de chrome.cookies.get + chrome.tabs.query, et de logs +// dupliqués. Le cache est invalidé immédiatement quand un événement +// cookies.onChanged tombe (relog, expiration). +// +// Logs : une seule ligne `LOG.warn` par CHANGEMENT d'état (transition +// cookie↔url, nouveau PHPSESSID, ou perte de session). Pas de log +// répétitif par appel — utiliser `LOG.info` pour les détails internes. + +let _sessionCache = null; +let _sessionCacheTs = 0; +// Cache désactivé temporairement (TTL=0) — soupçon que la durée de +// fenêtre stale puisse causer une réponse "page de login" (~8 Ko) lors +// d'un fetch fiche. À remettre à 200 après confirmation que ce n'était +// pas le souci. +const _SESSION_CACHE_TTL_MS = 0; +let _lastSessionSignature = null; + +function _invalidateSessionCache() { + _sessionCache = null; + _sessionCacheTs = 0; +} + +async function _findEasyVistaSessionRaw() { const origins = await getEvOrigins(); + const hasCookies = await _hasCookiesPermission(); + for (const origin of 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 }; + if (!tabs.length) continue; + + let phpsessid = null; + let expirationDate = null; + let source = null; + + if (hasCookies) { + const c = await _readPhpsessidFromCookie(origin); + if (c && c.value) { + phpsessid = c.value; + expirationDate = c.expirationDate; + source = "cookie"; } } + + if (!phpsessid) { + for (const tab of tabs) { + const m = (tab.url || "").match(/[?&]PHPSESSID=([a-zA-Z0-9]+)/); + if (m) { + phpsessid = m[1]; + source = "url"; + break; + } + } + } + + if (phpsessid) { + return { phpsessid, origin, tabId: tabs[0].id, source, expirationDate }; + } } return null; } +async function findEasyVistaSession() { + const now = Date.now(); + if (_sessionCache !== null && now - _sessionCacheTs < _SESSION_CACHE_TTL_MS) { + return _sessionCache; + } + if (_sessionCache === null && _sessionCacheTs > 0 && now - _sessionCacheTs < _SESSION_CACHE_TTL_MS) { + // Cache négatif (pas de session trouvée à l'instant) — même TTL. + return null; + } + + const result = await _findEasyVistaSessionRaw(); + _sessionCache = result; + _sessionCacheTs = now; + + // Log uniquement si l'état change (source, origine, ou PHPSESSID). + const sig = result + ? `${result.source}|${result.origin}|${result.phpsessid.slice(0, 8)}` + : "none"; + if (sig !== _lastSessionSignature) { + if (result) { + LOG.warn("session", + `🔑 PHPSESSID via ${result.source.toUpperCase()} (${result.phpsessid.slice(0, 8)}…) sur ${result.origin}`, + { source: result.source, origin: result.origin, + expirationDate: result.expirationDate || null }); + } else { + LOG.warn("session", "❌ Aucun PHPSESSID disponible (pas d'onglet EZV ouvert ou cookie absent)"); + } + _lastSessionSignature = sig; + } + return result; +} + // ============================================================================ // Fetch helpers (s'exécutent dans le contexte du service worker, // les cookies du domaine sont automatiquement inclus via credentials: include) @@ -1263,6 +1427,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return; } + // badge "!" clignotant sur l'icône de l'extension. + // { active: true, durationMs?: number } + if (msg.type === "setExpirationBadge") { + _setExpirationBadge(!!msg.active, msg.durationMs || 0); + sendResponse({ ok: true }); + return; + } + if (msg.type === "fetchPlanning") { const session = await findEasyVistaSession(); if (!session) { @@ -1336,6 +1508,88 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { // probe rapide de session — fetch un endpoint léger pour vérifier // que PHPSESSID est toujours valide. Renvoie ok=false/error=session_expired // si la session est morte. + // (feature reschedule) : déplace une intervention vers un autre + // tech / nouvelle date / nouvel horaire de début. Durée préservée + // automatiquement par EZV (l'API ne change que start_date+hour+minute). + // Args : actionId, employeeId, date (DD/MM/YYYY), hour (0-23), minute (0-59). + if (msg.type === "rescheduleAction") { + const session = await findEasyVistaSession(); + if (!session) { sendResponse({ ok: false, error: "no_session" }); return; } + try { + const url = `${session.origin}/planning_updator_xhr.php` + + `?PHPSESSID=${encodeURIComponent(session.phpsessid)}` + + `&function_name=Planning_schedule_action_Employee` + + `&action_id=${encodeURIComponent(msg.actionId)}` + + `&employee_id=${encodeURIComponent(msg.employeeId)}` + + `&date=${encodeURIComponent(msg.date)}` + + `&hour=${encodeURIComponent(msg.hour)}` + + `&minute=${encodeURIComponent(msg.minute)}` + + `&multi_day_mode_act=0`; + LOG.warn("reschedule", + `📅 reschedule actionId=${msg.actionId} → tech=${msg.employeeId} ${msg.date} ${msg.hour}:${String(msg.minute).padStart(2,"0")}`); + const r = await evFetch(url, session.origin); + if (!r.ok) { + sendResponse({ ok: false, error: classifyHttpStatus(r.status), httpStatus: r.status }); + return; + } + const txt = await r.text(); + if (looksLikeLoginPage(txt)) { + sendResponse({ ok: false, error: "session_expired" }); + return; + } + sendResponse({ ok: true, response: txt }); + } catch (err) { + LOG.warn("reschedule", "rescheduleAction err", { err: err && err.message }); + sendResponse({ ok: false, error: err.kind || "fetch_failed", detail: err.message }); + } + return; + } + + // (feature reschedule) : modifie la durée (heure début/fin) d'une + // action sans changer de tech. POST application/x-www-form-urlencoded + // sur planning_updator_xhr.php avec function_name=fc_save_inspector. + // Args : actionId, suffix (ex "act__nb_0_date_"), + // startDate, endDate (DD/MM/YYYY), startTime, endTime (HH:MM). + if (msg.type === "updateActionTimes") { + const session = await findEasyVistaSession(); + if (!session) { sendResponse({ ok: false, error: "no_session" }); return; } + try { + const params = new URLSearchParams(); + params.set(`start_date_${msg.suffix}`, msg.startDate); + params.set(`start_time_${msg.suffix}`, msg.startTime); + params.set(`end_date_${msg.suffix}`, msg.endDate); + params.set(`end_time_${msg.suffix}`, msg.endTime); + params.set("action_id", msg.actionId); + params.set("suffix_act", msg.suffix); + params.set(`act_absence_${msg.suffix}`, ""); + params.set("function_name", "fc_save_inspector"); + const body = params.toString(); + const url = `${session.origin}/planning_updator_xhr.php` + + `?PHPSESSID=${encodeURIComponent(session.phpsessid)}`; + LOG.warn("reschedule", + `⏱ updateActionTimes actionId=${msg.actionId} ${msg.startDate} ${msg.startTime}→${msg.endTime}`); + const r = await evFetch(url, session.origin, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body + }); + if (!r.ok) { + sendResponse({ ok: false, error: classifyHttpStatus(r.status), httpStatus: r.status }); + return; + } + const txt = await r.text(); + if (looksLikeLoginPage(txt)) { + sendResponse({ ok: false, error: "session_expired" }); + return; + } + sendResponse({ ok: true, response: txt }); + } catch (err) { + LOG.warn("reschedule", "updateActionTimes err", { err: err && err.message }); + sendResponse({ ok: false, error: err.kind || "fetch_failed", detail: err.message }); + } + return; + } + if (msg.type === "checkSession") { const session = await findEasyVistaSession(); if (!session) { @@ -1655,10 +1909,33 @@ async function _getCacheDays() { } // Au démarrage, nettoyer les anciennes alarmes et les anciens caches -chrome.runtime.onInstalled.addListener(async () => { +// v2026.5.45 (issue #3) : on stocke aussi un marqueur d'install/update +// avec previousVersion. Au prochain ouverture du viewer, on affiche un +// toast préventif si la version précédente était < 2026.5.44 (où le bug +// structurel d'écrasement des absences récurrentes existait encore). +chrome.runtime.onInstalled.addListener(async (details) => { clearLegacyRefreshAlarms(); const days = await _getCacheDays(); cleanupOldCaches(days).catch(err => LOG.warn("cleanup", "échec onInstalled", { err: err && err.message })); + try { + const reason = details && details.reason; + const previousVersion = (details && details.previousVersion) || null; + if (reason === "install" || reason === "update") { + const currentVersion = (chrome.runtime.getManifest && chrome.runtime.getManifest().version) || null; + await chrome.storage.local.set({ + _lastInstallEvent: { + reason, + previousVersion, + currentVersion, + ts: Date.now(), + notified: false + } + }); + LOG.info("install", "marqueur posé", { reason, previousVersion, currentVersion }); + } + } catch (e) { + LOG.warn("install", "écriture _lastInstallEvent échouée", { err: e && e.message }); + } }); chrome.runtime.onStartup.addListener(async () => { @@ -1666,3 +1943,72 @@ chrome.runtime.onStartup.addListener(async () => { const days = await _getCacheDays(); cleanupOldCaches(days).catch(err => LOG.warn("cleanup", "échec onStartup", { err: err && err.message })); }); + +// v2026.5.45 (issue #4 — bonus) : listener sur les changements de cookie +// EZV. Quand la permission `cookies` est accordée, on s'abonne pour détecter +// EN TEMPS RÉEL les changements de PHPSESSID (relog dans un autre onglet, +// expiration côté serveur, révocation…). On envoie un message broadcast aux +// viewers ouverts pour qu'ils recalibrent leur compteur de session. +function _attachCookiesListener() { + if (!chrome.cookies || !chrome.cookies.onChanged) return; + if (_attachCookiesListener._attached) return; + _attachCookiesListener._attached = true; + LOG.warn("cookies", "🍪 listener PHPSESSID activé (permission cookies accordée)"); + chrome.cookies.onChanged.addListener((info) => { + try { + if (!info || !info.cookie) return; + const c = info.cookie; + if (c.name !== "PHPSESSID") return; + const dom = (c.domain || "").replace(/^\./, ""); + if (!/itsma\.(etat-de-vaud|vd)\.ch$/.test(dom)) return; + const phpsessid8 = c.value ? c.value.slice(0, 8) + "…" : null; + // événements importants → LOG.warn (toujours visibles). + // Détail (cause, expirationDate, …) → LOG.info (debug only). + if (info.removed) { + LOG.warn("cookies", `🚫 PHPSESSID supprimé sur ${dom} (cause=${info.cause}) → session morte`); + } else { + LOG.warn("cookies", `🍪 PHPSESSID ${phpsessid8} sur ${dom} (cause=${info.cause})`); + } + // Invalide immédiatement le cache findEasyVistaSession() pour que + // le prochain appel reflète le nouveau cookie sans attendre la TTL. + if (typeof _invalidateSessionCache === "function") _invalidateSessionCache(); + LOG.info("cookies", " détail onChanged", + { domain: dom, removed: !!info.removed, cause: info.cause, + phpsessid8, expirationDate: c.expirationDate || null, + exp: c.expirationDate ? new Date(c.expirationDate * 1000).toISOString() : null }); + try { + chrome.runtime.sendMessage({ + type: "cookieChanged", + domain: dom, + removed: !!info.removed, + cause: info.cause, + phpsessid: c.value || null, + expirationDate: c.expirationDate || null + }).catch(() => { /* viewers fermés → pas grave */ }); + } catch (e) { /* idem */ } + } catch (e) { + LOG.warn("cookies", "listener err", { err: e && e.message }); + } + }); +} + +// Au boot du service worker, on log l'état de la permission (toujours visible) +// et on attache le listener si déjà accordée. +(async () => { + try { + const has = await _hasCookiesPermission(); + LOG.warn("cookies", `🔧 permission cookies au boot : ${has ? "ACCORDÉE → lecture cookie active" : "non accordée → fallback URL"}`); + if (has) _attachCookiesListener(); + } catch (e) { /* silent */ } +})(); +chrome.permissions.onAdded && chrome.permissions.onAdded.addListener((p) => { + if (p && p.permissions && p.permissions.includes("cookies")) { + LOG.warn("cookies", "✅ permission cookies ACCORDÉE par l'utilisateur"); + _attachCookiesListener(); + } +}); +chrome.permissions.onRemoved && chrome.permissions.onRemoved.addListener((p) => { + if (p && p.permissions && p.permissions.includes("cookies")) { + LOG.warn("cookies", "🛑 permission cookies RETIRÉE → fallback URL réactivé"); + } +}); diff --git a/src/manifest.json b/src/manifest.json index 1fc7f48..b50e6c3 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planification", - "version": "2026.5.44", + "version": "2026.5.45", "description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin Rouiller — DGNSI, Canton de Vaud.", "permissions": [ "activeTab", @@ -10,6 +10,9 @@ "tabs", "alarms" ], + "optional_permissions": [ + "cookies" + ], "host_permissions": [ "https://itsma.etat-de-vaud.ch/*", "https://itsma.vd.ch/*" diff --git a/src/viewer.css b/src/viewer.css index e873963..fe29af8 100644 --- a/src/viewer.css +++ b/src/viewer.css @@ -33,7 +33,7 @@ /* Palette par type d'intervention */ --c-livraison: #2563eb; --c-livraison-soft: #dbeafe; - /* R12e : récupération en TEAL (turquoise) — distinct des verts de statut. */ + /* : récupération en TEAL (turquoise) — distinct des verts de statut. */ --c-recup: #14b8a6; --c-recup-soft: #ccfbf1; --c-remplacement: #ea580c; @@ -50,7 +50,7 @@ --c-autre-soft: #e5e7eb; /* Statuts clos - R12e : + - terminated reprend l'ANCIEN vert de récupération (#16a34a) — vert pur - closed bascule sur un vert plus FONCÉ/bleuté pour bien le distinguer visuellement de terminated. Les deux restent verts, mais opposés @@ -90,7 +90,7 @@ --c-livraison: #60a5fa; --c-livraison-soft: #1e3a5f; - /* R12e : récupération en teal (dark mode). */ + /* : récupération en teal (dark mode). */ --c-recup: #2dd4bf; --c-recup-soft: #134e4a; --c-remplacement: #fb923c; @@ -102,7 +102,7 @@ --c-autre: #9ca3af; --c-autre-soft: #2a2e36; - /* R12e dark : closed = vert sapin clair, resolved = lime, terminated = vert pur. */ + /* closed = vert sapin clair, resolved = lime, terminated = vert pur. */ --c-closed: #34d399; --c-closed-soft: #064e3b; --c-resolved: #a3e635; @@ -126,6 +126,10 @@ html, body { font-family: var(--font); font-size: 12px; line-height: 1.5; + /* v2026.5.45 — coupe la scrollbar horizontale qui apparaissait à + cause du dock translaté en dehors de la viewport (transform sur + position:fixed contribue malgré tout à l'overflow scrollable). */ + overflow-x: hidden; } .hidden { display: none !important; } @@ -334,6 +338,40 @@ html.view-classic .topbar-left .date-nav { pointer-events: auto; } +/* : bascule "compact" déclenchée DYNAMIQUEMENT par le JS dès qu'un + chevauchement est détecté (pas un seuil de viewport fixe). En compact : + - topbar en colonne, sections empilées + - date-nav désancrée du absolute, sur sa propre ligne, centrée + - boutons droite en wrap centré */ +html.view-classic.topbar-compact .topbar { + flex-direction: column; + align-items: stretch; + gap: 10px; +} +html.view-classic.topbar-compact .topbar-left { + flex-wrap: wrap; + justify-content: center; + row-gap: 8px; + width: 100%; +} +html.view-classic.topbar-compact .topbar-left .date-nav { + position: static !important; + transform: none !important; + flex: 1 0 100%; + justify-content: center; + margin: 0 !important; + order: 99; +} +html.view-classic.topbar-compact .topbar-right { + flex-wrap: wrap; + justify-content: center; + row-gap: 6px; + width: 100%; +} +html.view-classic.topbar-compact #app-session { + align-self: center; +} + /* encadré qui regroupe le bouton "Aujourd'hui" + l'horloge actuelle. */ .today-block { display: inline-flex; @@ -395,6 +433,8 @@ html.view-classic .topbar-left .date-nav { z-index: 8; display: flex; align-items: center; + flex-wrap: wrap; /* : permet au sous-bandeau cookies-prompt + de descendre sur sa propre ligne */ gap: 12px; padding: 12px 18px; /* v4.2.5 : rouge plus vif + bord plus épais pour visibilité max */ @@ -408,6 +448,129 @@ html.view-classic .topbar-left .date-nav { /* petite animation d'apparition pour attirer l'œil */ animation: session-banner-in 0.22s ease-out; } + +/* : sous-bandeau "Multi-onglets EasyVista" — version centrée et + propre. Encart blanc semi-translucide qui se détache du rouge de la + bannière, contenu centré horizontalement, titre + texte court + + bouton, et un lien dismiss discret en dessous. */ +.session-banner-cookies-prompt { + flex: 1 0 100%; + margin-top: 10px; + display: flex; + justify-content: center; +} +.session-banner-cookies-inner { + width: 100%; + max-width: 520px; + padding: 14px 18px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 10px; + backdrop-filter: blur(2px); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 8px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); +} +.session-banner-cookies-title { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.3px; +} +.session-banner-cookies-explain { + font-size: 12px; + font-weight: 400; + line-height: 1.45; + color: rgba(255, 255, 255, 0.92); + max-width: 460px; + display: flex; + flex-direction: column; + gap: 2px; +} +.session-banner-cookies-explain-sub { + font-size: 11px; + opacity: 0.85; +} +.session-banner-cookies-actions { + margin-top: 4px; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} +.session-banner-cookies-btn { + background: #fff !important; + color: #c93030 !important; + font-weight: 700 !important; + font-size: 13px !important; + padding: 9px 28px !important; + border-radius: 999px !important; + border: none !important; + cursor: pointer; + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.30); +} +.session-banner-cookies-btn:hover { + transform: translateY(-1px); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.38); + background: #f9f9f9 !important; +} +.session-banner-cookies-btn:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; +} +.session-banner-cookies-dismiss { + background: transparent !important; + color: rgba(255, 255, 255, 0.88) !important; + font-size: 11px !important; + border: none !important; + padding: 2px 6px !important; + cursor: pointer; + text-decoration: underline; + opacity: 0.85; +} +.session-banner-cookies-dismiss:hover { opacity: 1; } + +/* variante du sous-bandeau cookies pour les contextes à + fond clair (écran plein "session nécessaire" ET modals d'alerte + "Impossible d'ouvrir la fiche") — encart gris léger, texte normal, + bouton accent, lien dismiss discret. */ +.session-needed .session-banner-cookies-prompt, +.modal-card .session-banner-cookies-prompt { + margin-top: 18px; + flex: none; +} +.session-needed .session-banner-cookies-inner, +.modal-card .session-banner-cookies-inner { + background: var(--bg-muted, rgba(0, 0, 0, 0.04)); + border: 1px solid var(--border, rgba(0, 0, 0, 0.12)); + color: var(--text); + backdrop-filter: none; +} +.session-needed .session-banner-cookies-title, +.modal-card .session-banner-cookies-title { + color: var(--text); +} +.session-needed .session-banner-cookies-explain, +.modal-card .session-banner-cookies-explain { + color: var(--text-muted); +} +.session-needed .session-banner-cookies-btn, +.modal-card .session-banner-cookies-btn { + background: var(--accent, #0f4f8b) !important; + color: #fff !important; +} +.session-needed .session-banner-cookies-btn:hover, +.modal-card .session-banner-cookies-btn:hover { + background: var(--accent-strong, #0a3d6f) !important; +} +.session-needed .session-banner-cookies-dismiss, +.modal-card .session-banner-cookies-dismiss { + color: var(--text-faint, #6b7280) !important; +} @keyframes session-banner-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } @@ -1111,7 +1274,10 @@ html.view-classic .error-box.error-box-centered { ); } .timeline-hole:hover { - background: var(--ok-soft); + background: var(--accent-soft); + outline: 3px solid var(--accent, #0f4f8b); + outline-offset: -3px; + z-index: 6; } .timeline-slot { @@ -1132,7 +1298,7 @@ html.view-classic .error-box.error-box-centered { .timeline-slot.color-reservation { background: var(--c-reservation); } .timeline-slot.color-autre { background: var(--c-autre); } -/* R12c : statuts clos/résolu/terminé sur la timeline → on GARDE la couleur +/* : statuts clos/résolu/terminé sur la timeline → on GARDE la couleur de catégorie en fond et on ajoute un bandeau vert en BAS de la slot pour signaler que c'est terminé. La couleur de la catégorie reste lisible. */ .timeline-slot.status-closed { box-shadow: inset 0 -4px 0 var(--c-closed); } @@ -1385,7 +1551,7 @@ html.view-horizontal .timeline-noon { .intervention-v2.clickable { cursor: pointer; } .intervention-v2.clickable:active { transform: translateY(1px); } -/* R12d : on GARDE la couleur de catégorie visible (.intervention-dot reste +/* : on GARDE la couleur de catégorie visible (.intervention-dot reste en couleur de catégorie + fond neutre). Le statut clos/résolu/terminé est signalé par un bandeau vert sur la DROITE de la carte (inset shadow) + le ✓✓ ou ✓ existant. */ @@ -1737,23 +1903,26 @@ html.view-horizontal .iv-mini-card.status-resolved .iv-mini-status-check { /* ========================================================================== Tooltip ========================================================================== */ +/* v2026.5.45 : on aligne les dimensions du tooltip live sur celles du + pinned-popup (padding-top 28px qui réserve la place de la dragbar, border + 2px). Ainsi, passer de live → épinglé → désépinglé ne change RIEN à la + taille de la boîte — seule la couleur de la bordure varie pour signaler + l'état. */ .tooltip { position: fixed !important; - /* v4.2.4 : forcer un stacking context propre et l'isolation pour que le - tooltip ne soit pas affecté par un éventuel filter/transform/contain - sur un ancêtre (qui casserait position:fixed). `contain: layout` et - `will-change: transform` garantissent aussi que le navigateur traite - ce tooltip indépendamment. */ isolation: isolate; contain: layout; z-index: 100; max-width: 620px; max-height: calc(100vh - 40px); overflow-y: auto; - padding: 12px 14px; + /* : padding-top 28px d'office pour réserver la place de la dragbar. */ + padding: 28px 14px 12px 14px; background: var(--bg-elevated); color: var(--text); - border: 1px solid var(--border-strong); + /* : border 2px d'office (transparent en mode live), pour ne pas + redimensionner la boîte au passage en épinglé. */ + border: 2px solid transparent; border-radius: 8px; box-shadow: var(--shadow-hover); font-size: 13px; @@ -1764,6 +1933,10 @@ html.view-horizontal .iv-mini-card.status-resolved .iv-mini-status-check { user-select: text; -webkit-user-select: text; } +.tooltip.visible:not(.pinned-popup):not(.soft-unpinned) { + /* : en mode live (hover), bordure discrète mais bien 2px de large. */ + border-color: var(--border-strong); +} .tooltip.visible { opacity: 1; /* v4.1.10 : permet à la souris d'entrer dans la bulle pour la garder @@ -2410,54 +2583,42 @@ body.modal-open { Ancrés au contenu (position:absolute coord document) → scrollent avec la page. Persistent jusqu'à fermeture explicite. ───────────────────────────────────────────────────────────────────────── */ +/* v2026.5.45 : pinned-popup hérite la même taille que le tooltip live. + Seule la couleur du border change. Pas d'animation scale (saut visuel), + juste un fade opacity court. */ .tooltip.pinned-popup { - position: absolute !important; /* override le fixed du .tooltip */ - /* v4.3.3 corr : les popups épinglées doivent passer DERRIÈRE la topbar - quand on scrolle (topbar sticky z-index 10). Donc on met 5 : au-dessus - du contenu normal, mais sous la topbar / bannières / modals. */ + position: absolute !important; z-index: 5 !important; opacity: 1 !important; pointer-events: auto !important; - /* Bordure plus visible pour distinguer du tooltip live */ - border: 2px solid var(--accent, #0f4f8b); + border-color: var(--accent, #0f4f8b) !important; box-shadow: 0 8px 24px rgba(0,0,0,0.18); - /* Pas de contain: layout (hérité) car ça limite le rendu ; on laisse */ - animation: pinned-popup-in 0.15s ease-out; - /* Le padding-top est augmenté pour accueillir la barre de drag. */ - padding-top: 28px !important; -} -@keyframes pinned-popup-in { - from { opacity: 0; transform: scale(0.96); } - to { opacity: 1; transform: scale(1); } + /* : resize natif depuis le coin bas-droite (drag pour redimensionner). */ + resize: both; + overflow: auto; + min-width: 280px; + min-height: 120px; } -/* v4.3.3 : animation de sortie (symétrique à l'apparition) quand on - désépingle. Appliquée par la classe .unpinning. */ +/* : animation d'unpin réduite à un fade opacity (pas de scale). */ .tooltip.pinned-popup.unpinning, .tooltip.soft-unpinned.unpinning { animation: pinned-popup-out 0.18s ease-in forwards !important; } @keyframes pinned-popup-out { - from { opacity: 1; transform: scale(1); } - to { opacity: 0; transform: scale(0.94); } + from { opacity: 1; } + to { opacity: 0; } } -/* v4.3.3 corr : quand une popup est désépinglée "mou", elle perd son look - "épinglé" et redevient un tooltip normal visuellement, tout en gardant - sa position absolute (pour ne pas sauter). */ +/* v2026.5.45 : soft-unpinned reprend exactement les mêmes dimensions que + les autres états — seule la couleur de bordure change (gris au lieu d'accent). */ .tooltip.soft-unpinned { position: absolute !important; z-index: 5 !important; opacity: 1 !important; pointer-events: auto !important; - /* v2026.5.43 : on conserve les MÊMES dimensions que .pinned-popup - (padding-top 28px, border 2px) pour que la popup ne bouge ni ne change - de taille au softUnpin. La dragbar est juste retirée (l'espace - reste, c'est le tradeoff pour préserver position + taille). - Bordure plus discrète (variable --border-strong au lieu de --accent). */ - border: 2px solid var(--border-strong) !important; + border-color: var(--border-strong) !important; box-shadow: var(--shadow-hover) !important; - padding-top: 28px !important; animation: none !important; } @@ -2854,11 +3015,13 @@ header.topbar::before { v5.0.9 : Compteur de session EasyVista (topbar) ========================================================================== */ +/* v2026.5.45 (issue #7) : compteur session repositionné SOUS la pastille + des initiales (top-gauche), à environ 8px sous la topbar. Avant : centré au + milieu de la topbar (`top: 50%; left: calc(50% + 60px)`), peu intuitif. */ .app-session { - position: absolute; - top: 50%; - left: calc(50% + 60px); /* à droite de l'horloge (~60px de décalage) */ - transform: translateY(-50%); + position: fixed; + top: calc(var(--topbar-height, 56px) + 8px); + left: 16px; display: flex; align-items: center; gap: 8px; @@ -2870,6 +3033,7 @@ header.topbar::before { z-index: 9; background: rgba(0, 0, 0, 0.05); transition: background 0.3s, color 0.3s; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); } .app-session.hidden { display: none; @@ -3089,8 +3253,10 @@ header.topbar::before { v2026.5.17 : topbar des popups épinglés (3 boutons : _ ▭ 📍) ========================================================================== */ .pinned-popup { - /* Laisser un peu de place en haut pour la topbar */ - padding-top: 30px !important; + /* v2026.5.45 : padding-top harmonisé à 28px avec .tooltip pour qu'il + n'y ait aucun saut de boîte entre live / pinned / soft-unpinned. La + topbar du popup épinglé se place en absolute sur cet espace. */ + padding-top: 28px !important; } /* v2026.5.18 : masquer le conteneur d'actions d'origine (↻ reload + 📌 pin) dans les popups épinglés — leur place est reprise par notre .pinned-popup-topbar */ @@ -4483,6 +4649,42 @@ html.view-horizontal body > header.topbar { display: none !important; } +/* : la session-banner (sticky avec top: var(--topbar-height, 56px)) + laissait une "barre invisible" de 56 px au-dessus en vue horizontale + parce que la topbar est en display:none mais --topbar-height conserve + sa dernière valeur. En vue horizontale on force top: 0 pour qu'elle + soit collée au sommet. */ +html.view-horizontal .session-banner { + top: 0 !important; +} + +/* rendu absence + réservation dans les mini-cards horizontales — + juste le mot ("Congé" / "Réservation") centré. Les infos détaillées + restent dans le tooltip au survol. */ +.iv-mini-card-absence .iv-mini-time-vertical, +.iv-mini-card-reservation .iv-mini-time-vertical { display: none; } +.iv-mini-card-absence .iv-mini-card-text, +.iv-mini-card-reservation .iv-mini-card-text { + align-items: center; + justify-content: center; + text-align: center; + flex: 1 1 auto; +} +.iv-mini-absence-label, +.iv-mini-reservation-label { + font-weight: 600; + font-size: 13px; + text-transform: none !important; /* garantir l'accent sur "Congé" */ +} +.iv-mini-absence-label { color: var(--text-muted, #98a2b3); } + +/* : seul le texte "Réservation" est en jaune ambré (comme la vue + classique) — pas de fond coloré, on garde la barre couleur à gauche + qui est déjà jaune via .color-reservation .iv-mini-card-bar. */ +.iv-mini-reservation-label { + color: var(--c-reservation, #f59e0b) !important; +} + /* 2. Sidebar : structure verticale avec section fixe en haut (user+titre+date) et section "boutons" en bas poussée via margin-top: auto. v2026.5.39 r8 : max-height retiré (était en conflit avec min-height @@ -4780,6 +4982,109 @@ html.view-horizontal .day-period-sep { display: none !important; } +/* : séparateur "vide" (la période n'a aucune iv) — on garde le + même design que le séparateur normal d'une période avec iv, pour + l'homogénéité. Aucun override visuel. */ + +/* Option "Afficher les pauses" — quand inactive, on cache les + .gap-placeholder SAUF .gap-placeholder-full (le bloc Matin/Aprèm + complètement vide reste toujours visible, peu importe l'option). + La détection bidirectionnelle hover du timeline-hole reste + fonctionnelle (le hole existe toujours dans le DOM). */ +html:not(.show-iv-gaps) .gap-placeholder:not(.gap-placeholder-full) { + display: none; +} + +/* : "carte vide" entre 2 iv qui ne s'enchainent pas — même rendu + que le placeholder Matin/Aprèm vide. */ +.gap-placeholder { + display: flex; + align-items: center; + justify-content: center; + min-height: 44px; + margin: 6px 0; + font-size: 16px; + font-weight: 700; + color: var(--text-faint); + border: 1px dashed var(--border); + border-radius: 6px; + background: transparent; + cursor: help; + user-select: none; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} +.gap-placeholder.gap-placeholder-mini { + /* : flex défini inline par JS — flex-grow proportionnel, + flex-shrink 10 (compresse 10× plus que l'iv en cas de manque + de place), basis 30 px. min-width 0 : peut disparaître visuelle- + ment quand l'espace manque, pour laisser la place aux iv. */ + flex: 1 10 30px; + min-width: 0; + min-height: 0; + align-self: stretch; + margin: 0; + font-size: 14px; + overflow: hidden; +} +/* : variante "full" — quand le bloc Matin/Aprèm est complètement + vide en vue horizontale, le gap prend TOUTE la largeur du bloc + (au lieu des 32 px fixes), comme l'ancien .iv-mini-block-empty. */ +.gap-placeholder.gap-placeholder-mini.gap-placeholder-full { + flex: 1 1 auto; + min-width: 60px; +} + +/* : en vue horizontale, on cache le grand .gap-placeholder (vue + classique) qui apparaissait en bas de la card-body — les rows + .intervention-v2 sont déjà cachées, le rect entre rows traînait + en plus en bas. Seuls les .gap-placeholder-mini (entre les + mini-cards horizontales) restent visibles. */ +html.view-horizontal .gap-placeholder:not(.gap-placeholder-mini) { + display: none !important; +} + +/* Surbrillance individuelle (CSS pur, toujours active) : + - hover direct du rectangle → le rectangle s'éclaire + - hover direct du timeline-hole → le hole s'éclaire + PLUS l'effet symétrique via la classe .gap-hover-active appliquée par + JS sur l'élément lié. Du coup les 2 côtés se highlight ensemble même + si le bind JS échoue à trouver le partenaire. */ +.gap-placeholder:hover, +.gap-placeholder.gap-hover-active { + background: var(--accent-soft); + border-color: var(--accent, #0f4f8b); + border-style: solid; + color: var(--accent, #0f4f8b); +} +.timeline-hole.gap-hover-active { + background: var(--accent-soft); + outline: 3px solid var(--accent, #0f4f8b); + outline-offset: -3px; + z-index: 6; +} + +/* : placeholder "—" sous le séparateur d'une période vide, dans + la vue classique. Reprend le même design que .iv-mini-block-empty + en vue horizontale : encadré dashed, "—" centré, hauteur fixe. */ +.day-period-empty-placeholder { + display: flex; + align-items: center; + justify-content: center; + min-height: 44px; + margin: 6px 0; + font-size: 16px; + font-weight: 700; + color: var(--text-faint); + border: 1px dashed var(--border); + border-radius: 6px; + background: transparent; + user-select: none; + pointer-events: none; +} +html.view-horizontal .day-period-empty-placeholder { + display: none !important; +} + /* ========================================================================== v2026.5.39 : Admin — section Apparence (rows label/control + select + groupe boutons zoom). @@ -5199,9 +5504,10 @@ html.view-horizontal .pinned-popups-dock { } } -/* v2026.5.40 r9 : 2 blocs MATIN / APRÈS-MIDI côte à côte. Chaque bloc a - son label en haut + ses mini-cards en dessous. Séparation visuelle - entre les 2 blocs via un gap plus large + bordure gauche sur l'après-midi. */ +/* v2026.5.45 : 2 blocs MATIN / APRÈS-MIDI toujours présents (même vides), + séparés par une vraie barre verticale dédiée (.iv-mini-sep). Garantit la + symétrie d'une carte tech à l'autre — l'œil retrouve toujours les 2 demi- + journées au même endroit horizontal. */ .iv-mini-block { flex: 1 1 0; display: flex; @@ -5209,10 +5515,35 @@ html.view-horizontal .pinned-popups-dock { min-width: 0; gap: 2px; } -.iv-mini-block.period-afternoon { - border-left: 2px solid var(--border-strong); - padding-left: 8px; - margin-left: 8px; +.iv-mini-sep { + flex: 0 0 12px; + align-self: stretch; + position: relative; + margin: 0 6px; + /* Trait vertical centré dans la zone réservée. */ + background: + linear-gradient(to bottom, + transparent 0, + transparent 14%, + var(--border-strong) 14%, + var(--border-strong) 86%, + transparent 86%, + transparent 100%) no-repeat center / 2px 100%; +} +.iv-mini-sep::before { + /* Petit "•" centré pour signaler la coupure 12h. */ + content: "12h"; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: calc(9px * var(--text-scale)); + font-weight: 700; + color: var(--text-muted); + background: var(--bg-elevated); + padding: 1px 2px; + border-radius: 3px; + letter-spacing: 0.4px; } .iv-mini-block-label { font-size: calc(10px * var(--text-scale)); @@ -5231,7 +5562,30 @@ html.view-horizontal .pinned-popups-dock { min-width: 0; } .iv-mini-block-cards .iv-mini-card { + /* : flex inline (proportionnel à la durée + basis 100 px). + min-width 80 px : seuil ABSOLU sous lequel l'iv ne descend pas, + même si le bloc est très étroit — garantit que la ref / l'heure + restent lisibles. Quand l'espace manque, c'est les gaps qui + compressent en premier (flex-shrink 10 vs 1). */ + flex: 1 1 100px; + min-width: 80px; +} +/* : placeholder pour un bloc vide — garde la place réservée et + indique visuellement qu'il n'y a rien sur cette demi-journée. */ +.iv-mini-block-empty { flex: 1 1 0; + display: flex; + align-items: center; + justify-content: center; + min-height: 36px; + font-size: calc(14px * var(--text-scale)); + font-weight: 700; + color: var(--text-faint); + border: 1px dashed var(--border); + border-radius: 6px; + background: transparent; + user-select: none; + pointer-events: none; } /* v2026.5.40 r18 : ordre fixe des boutons en vue classique topbar-right. @@ -5347,3 +5701,451 @@ html.view-classic .topbar-right #theme-toggle { order: 6; } color: var(--text-faint) !important; font-weight: 700; } + +/* ========================================================================== + — Feature reschedule (modifier horaire / déplacer iv) + - Bloc heure éditable : curseur grab + petit hover halo + - Ghost qui suit la souris pendant le drag + - Preview rectangle vert sur la timeline du tech survolé + - Modal d'édition + confirmation (réutilise les styles modal existants) + ========================================================================== */ +.iv-time-vertical.editable-time, +.iv-mini-time-vertical.editable-time { + cursor: grab; + position: relative; + user-select: none; + border-radius: 6px; + transition: background 0.1s, box-shadow 0.1s; +} +.iv-time-vertical.editable-time:hover, +.iv-mini-time-vertical.editable-time:hover { + background: rgba(63, 185, 80, 0.10); + box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.45); +} +html.reschedule-dragging .iv-time-vertical.editable-time, +html.reschedule-dragging .iv-mini-time-vertical.editable-time { + cursor: grabbing; +} +html.reschedule-dragging, +html.reschedule-dragging * { + cursor: grabbing !important; + user-select: none !important; +} + +/* Ghost flottant attaché au curseur pendant le drag */ +.reschedule-ghost { + position: fixed; + z-index: 9999; + pointer-events: none; + background: var(--bg-elevated); + border: 2px solid var(--ok, #2e7b4a); + border-radius: 8px; + padding: 6px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35); + font-size: 12px; + line-height: 1.3; + min-width: 140px; + max-width: 360px; + opacity: 0.95; + transition: opacity 0.1s; + transform: rotate(-1.5deg); +} + +/* : clone visuel de la card source intégré dans le ghost flottant */ +.reschedule-ghost .reschedule-ghost-visual { + pointer-events: none !important; + margin: 0 !important; + cursor: grabbing !important; + filter: none !important; + /* on annule les hover-effects résiduels du clone (.intervention-v2:hover) */ + background: var(--bg-card, var(--bg-elevated)) !important; +} +.reschedule-ghost .reschedule-ghost-visual::before, +.reschedule-ghost .reschedule-ghost-visual::after { display: none !important; } + +.reschedule-ghost .rg-meta { + margin-top: 6px; + padding: 4px 6px 2px; + border-top: 1px dashed var(--border-faint, rgba(255,255,255,0.12)); +} +.reschedule-ghost.on-target { + border-color: var(--ok, #2e7b4a); + background: var(--ok-soft, #dff0e4); +} +.reschedule-ghost:not(.on-target):not(.on-blocked) { + border-color: var(--text-faint, #50596a); + opacity: 0.7; +} +/* : tech cible absent toute la journée → drop refusé visuellement */ +.reschedule-ghost.on-blocked { + border-color: var(--err, #d6443d) !important; + background: rgba(214, 68, 61, 0.12) !important; + opacity: 0.85; +} +.reschedule-ghost.on-blocked::before { + content: "🚫 absent"; + position: absolute; + top: -10px; + right: 8px; + background: var(--err, #d6443d); + color: #fff; + font-size: 10px; + font-weight: 700; + padding: 1px 8px; + border-radius: 10px; + letter-spacing: 0.3px; + pointer-events: none; +} +.reschedule-ghost .rg-title { + font-weight: 700; + color: var(--text); + font-family: var(--mono, monospace); +} +.reschedule-ghost .rg-times { + margin-top: 2px; + color: var(--text-muted); +} +.reschedule-ghost .rg-times .rg-dur { + color: var(--text-faint); + font-size: 11px; +} +.reschedule-ghost .rg-tech { + margin-top: 2px; + color: var(--text-muted); + font-size: 11px; + font-style: italic; +} + +/* : drop-ghost = clone visuel grisé de la carte (row classique ou + mini-card horizontale) inséré dans la card-body / bloc Matin-Aprèm + du tech cible à la position chronologique correspondant aux heures + projetées. C'est ce que voit le user pour repérer où sa carte se + loge — un vrai fac-similé grisé de la carte. */ +.reschedule-drop-ghost { + opacity: 0.55 !important; + filter: grayscale(0.5); + border: 2px dashed var(--ok, #2e7b4a) !important; + pointer-events: none !important; + background: var(--bg-card, var(--bg-elevated)) !important; + transition: margin 0.12s ease; +} +.reschedule-drop-ghost.reschedule-drop-ghost-mini { + /* Mini-card horizontale — mêmes codes mais on évite le filter qui + écraserait les couleurs de catégorie de la mini-card. */ + filter: none; + opacity: 0.55 !important; +} + +/* Petite barre verte sur la timeline-bar pour repérer la position + horaire précise (en plus du clone in-card). Volontairement minimaliste + (pas de label, pas de pulsation) pour ne pas concurrencer le clone. */ +.reschedule-timeline-mark { + position: absolute; + top: 0; + bottom: 0; + background: rgba(46, 123, 74, 0.55); + border-left: 2px solid var(--ok, #2e7b4a); + border-right: 2px solid var(--ok, #2e7b4a); + pointer-events: none; + z-index: 5; + box-sizing: border-box; +} + +/* Modal d'édition reschedule — réutilise modal-card mais avec un style + plus compact pour le formulaire à 4 champs. */ +.modal-card.reschedule-modal { + min-width: 380px; + max-width: 460px; +} + +/* Pendant le drag, on supprime tous les hover handlers concurrents : + tooltip live, popovers timeline, hover des mini-cards et iv-rows. + Comme ça elementFromPoint trouve toujours la timeline-bar / card pour + un drop précis, et aucune popup parasite ne s'ouvre. */ +html.reschedule-dragging .timeline-slot, +html.reschedule-dragging .iv-mini-card, +html.reschedule-dragging .intervention-v2, +html.reschedule-dragging .timeline-popover, +html.reschedule-dragging .tooltip, +html.reschedule-dragging .pinned-popup { + pointer-events: none !important; +} +html.reschedule-dragging .tooltip:not(.pinned-popup):not(.soft-unpinned) { + display: none !important; +} + +/* Texte projeté du ghost : vert vif quand on est sur une cible valide. */ +.reschedule-ghost .rg-times { + font-family: var(--mono, monospace); + font-size: 13px; + font-weight: 700; + color: var(--text-muted); + margin-top: 4px; +} +.reschedule-ghost.on-target .rg-times { + color: var(--ok, #2e7b4a); + font-size: 14px; +} +.reschedule-ghost .rg-tech.rg-tech-changed { + color: var(--accent, #0f4f8b); + font-weight: 700; + font-style: normal; +} +.reschedule-ghost .rg-tech.rg-tech-changed::before { + content: "→ "; +} + +/* : la source est retirée du flux pendant le drag (display:none) — + la place est immédiatement reprise par le clone à destination inséré + au même endroit dès l'activation du drag, donc la card ne rétrécit + pas visuellement. */ +.reschedule-source-hidden { + display: none !important; +} + +/* ============================================================================ + Dock latéral des interventions mises de côté + ─────────────────────────────────────────────────────────────────────────── + Conteneur = simple carré flouté à droite (backdrop-filter), pas de chrome. + Cartes = exactement la même structure que le ghost de drag (.reschedule- + ghost) : clone .intervention-v2 / .iv-mini-card avec heure à gauche, ref + et meta à droite. L'utilisateur retrouve sous le dock ce qu'il voyait + sous son curseur pendant le drag. + ──────────────────────────────────────────────────────────────────────────── */ + +.iv-dock { + position: fixed; + top: 80px; + right: 8px; + bottom: 12px; + width: 200px; + z-index: 9000; + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + pointer-events: none; + background: rgba(255, 255, 255, 0.10); + -webkit-backdrop-filter: blur(14px) saturate(120%); + backdrop-filter: blur(14px) saturate(120%); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 12px; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18); + transition: transform 140ms ease-out, opacity 140ms ease-out; +} +html.dark .iv-dock, +html[data-theme="dark"] .iv-dock { + background: rgba(20, 25, 35, 0.32); + border-color: rgba(255, 255, 255, 0.10); +} +body:has(.pinned-popups-dock.visible) .iv-dock { + bottom: 64px; +} +.iv-dock.iv-dock--hidden { + opacity: 0; + transform: translateX(110%); + pointer-events: none; +} +/* Drag commencé, dock vide : juste un petit onglet pour signaler la cible + sans rien masquer du planning (~32 px visibles sur 260 px). */ +.iv-dock.iv-dock--peep-min { + transform: translateX(88%); +} +/* Cartes dockées (drag ou pas) : intermédiaire, le numéro DS reste lisible + à droite. v2026.5.45 — décalé un peu plus à droite (50% au lieu de + 30%) pour libérer du planning. */ +.iv-dock.iv-dock--peep { + /* v2026.5.45 — léger décalage supplémentaire vers la gauche (8 px de + plus visibles que 50%) pour libérer un peu plus le numéro DS. */ + transform: translateX(calc(50% - 8px)); +} +.iv-dock.iv-dock--expanded { + transform: translateX(0); +} +.iv-dock-list { + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + /* v2026.5.45 — sans overflow-x explicite, "overflow-y: auto" force aussi + l'axe X en auto (spec CSS), d'où la scrollbar horizontale qui apparaissait + en bas du dock dès qu'une carte dépassait d'1 px. On la supprime. */ + overflow-x: hidden; + flex: 1; + pointer-events: auto; +} +/* Dock vide (peep-min pendant un drag, ou sans rien) : on ne capte aucun + clic, sinon le carré flouté bloque les mousedown sur les cartes du + planning qui sont sous le dock — ce qui cassait le drag&drop standard. */ +.iv-dock-list:empty { + pointer-events: none; +} +.iv-dock.iv-dock--peep-min, +.iv-dock.iv-dock--peep-min .iv-dock-list { + pointer-events: none; +} +/* v2026.5.45 — quand le dock est visible avec du contenu (peep ou + expanded), il capte les événements souris : empêche les clics et le hover + de traverser jusqu'au planning derrière (ex : zone autour du bouton + « Tout annuler » qui sélectionnait l'iv en dessous). */ +.iv-dock.iv-dock--peep, +.iv-dock.iv-dock--expanded { + pointer-events: auto; +} + +/* Carte du dock = juste ref + date sur fond teinté de la couleur de la + catégorie. Drag handle = la carte entière. Pas d'heure, pas de barre, + pas de chrome. */ +.iv-dock-card.reschedule-ghost { + /* v2026.5.45 — repart de zéro : aucun fond, aucune bordure sur la + carte. La couleur de catégorie sera ajoutée UNIQUEMENT par les règles + .iv-dock-card.color-* ci-dessous, qui posent un border-left de 4 px et + RIEN d'autre. */ + position: relative !important; + z-index: auto !important; + transform: rotate(-1.5deg) !important; + transition: transform 140ms ease, box-shadow 120ms ease; + pointer-events: auto; + cursor: grab; + opacity: 1; + margin: 0; + min-width: 0; + max-width: none; + padding: 0 !important; + display: block; + font-family: var(--mono, monospace); + background: transparent; + border: none; + /* v2026.5.45 — overflow visible pour que le bouton × (positionné en + -8px/-8px) ne soit pas tronqué par les bords de la card. */ + overflow: visible; + user-select: none; +} +.iv-dock-card.reschedule-ghost:hover { + transform: rotate(0deg) scale(1.02) !important; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.30); + z-index: 1 !important; +} +.iv-dock-card.reschedule-ghost:active { + cursor: grabbing; +} + +/* v2026.5.45 — couleur du type d'intervention sur la GAUCHE seulement. + Aucun fond, aucune autre bordure. Juste un trait vertical de 4 px. */ +.iv-dock-card.color-livraison { border-left: 4px solid var(--c-livraison); } +.iv-dock-card.color-installation { border-left: 4px solid var(--c-installation); } +.iv-dock-card.color-recup { border-left: 4px solid var(--c-recup); } +.iv-dock-card.color-remplacement { border-left: 4px solid var(--c-remplacement); } +.iv-dock-card.color-incident { border-left: 4px solid var(--c-incident); } +.iv-dock-card.color-rollout { border-left: 4px solid var(--c-rollout); } +.iv-dock-card.color-reservation { border-left: 4px solid var(--c-reservation); } +.iv-dock-card.color-autre { border-left: 4px solid var(--c-autre); } + +/* Bloc info : ref + date, texte collé aux bords (juste 2 px latéraux + pour éviter de coller pile à la bordure de la carte). */ +.iv-dock-card-info { + display: flex; + flex-direction: column; + justify-content: center; + padding: 0; +} +.iv-dock-card-ref { + font-weight: 700; + font-size: 13px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; + padding: 0 2px; +} +.iv-dock-card-meta { + font-size: 10px; + color: var(--text-muted, #6c7280); + font-style: italic; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.1; + padding: 0 2px; +} + +.iv-dock-card-fallback { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + font-size: 12px; +} +.iv-dock-card-fallback .iv-dock-card-time { + font-weight: 700; + font-family: var(--mono, monospace); +} + +.iv-dock-card-remove { + /* v2026.5.45 — repositionné DANS la card (top:2px / right:2px) au lieu + de déborder en -8/-8 : le parent .iv-dock-list a overflow-x: hidden + pour supprimer la scrollbar horizontale, donc tout ce qui sort à droite + est tronqué. Le bouton tient en 22 px dans le coin supérieur droit. */ + position: absolute; + top: 2px; + right: 2px; + width: 22px; + height: 22px; + border: none; + background: var(--err, #d6443d); + color: #fff; + border-radius: 999px; + cursor: pointer; + display: none; + font-size: 14px; + line-height: 1; + font-weight: 700; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + z-index: 2; +} +.iv-dock-card:hover .iv-dock-card-remove { + display: block; +} +/* v2026.5.45 — pendant l'appui long sur ×, on signale visuellement la + progression : un anneau conic-gradient se remplit en 2 s. À la fin, le + handler JS retire l'iv. Si l'utilisateur lâche avant, la classe est + retirée → l'anim s'annule, rien n'est supprimé. */ +.iv-dock-card-remove.holding { + background: + conic-gradient(rgba(255,255,255,0.55) var(--hold, 0%), transparent 0) + content-box, + var(--err, #d6443d); + animation: iv-dock-remove-hold 2000ms linear forwards; +} +@property --hold { + syntax: ''; + initial-value: 0%; + inherits: false; +} +@keyframes iv-dock-remove-hold { + from { --hold: 0%; } + to { --hold: 100%; } +} + +.iv-dock-clear-all { + pointer-events: auto; + align-self: flex-end; + border: 1px solid var(--border, #d4d8e0); + background: var(--bg-elevated, #fff); + color: var(--text-muted, #6c7280); + font-size: 10px; + letter-spacing: 0.4px; + padding: 3px 10px; + border-radius: 999px; + cursor: pointer; + display: none; + text-transform: uppercase; +} +.iv-dock-clear-all:hover { + color: var(--err, #d6443d); + border-color: var(--err, #d6443d); +} +.iv-dock--expanded .iv-dock-clear-all { + display: inline-block; +} diff --git a/src/viewer.html b/src/viewer.html index 80f7b08..cb0d689 100644 --- a/src/viewer.html +++ b/src/viewer.html @@ -28,7 +28,7 @@ type="button" aria-label="Utilisateur connecté" title="Utilisateur — cliquer pour accéder aux paramètres">?

Planification

-
diff --git a/src/viewer.js b/src/viewer.js index 66452cb..cbd394a 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -119,10 +119,18 @@ const LOG = (() => { // Global error handler : attrape les exceptions qui passent à travers tous les // try/catch. L'user voit un toast, on logue tout en console. window.addEventListener("error", (event) => { + // Bruit bénin de Chrome lors d'un redimensionnement rapide de fenêtre : + // un callback de ResizeObserver déclenche un layout qui programme un nouveau + // resize au tick suivant. Le navigateur gère la situation tout seul (les + // notifications restantes seront livrées au tick suivant) — il n'y a rien + // à corriger côté JS, mais Chrome signale quand même l'event en boucle. + // On filtre pour éviter de spammer les logs et le toast utilisateur. + const msg = (event.message || "") + ""; + if (msg.includes("ResizeObserver loop")) return; + LOG.exception("global", "uncaught error", event.error || new Error(event.message || "unknown"), { filename: event.filename, lineno: event.lineno, colno: event.colno }); - // Toast non-bloquant — on suppose que showToast est dispo (init en haut) try { if (typeof showToast === "function") { showToast("Erreur inattendue", "Voir la console (F12) pour le détail"); @@ -145,66 +153,8 @@ window.addEventListener("unhandledrejection", (event) => { LOG.info("boot", "viewer.js chargé", { version: LOG.version() }); -// v2026.5.45 — Refonte topbar + personnalisation Apparence + onboarding équipe. -// -// Topbar (vue classique) : -// - Sélecteur de date du planning au CENTRE absolu, ne bouge plus quand le -// bouton "Arrêter" apparaît à droite pendant un rafraîchissement. -// - Bouton "Aujourd'hui" en toutes lettres + horloge contextuelle discrète. -// -// Personnalisation (Paramètres → Apparence) : -// - Couleur de la topbar : 12 presets cliquables + picker custom + champ hex. -// La couleur du texte s'adapte automatiquement (clair/foncé) selon la -// luminance choisie pour rester lisible. -// - Police de l'application : 28 choix (Arial, Helvetica, Verdana, Tahoma, -// Trebuchet, Calibri, Segoe UI, Times New Roman, Georgia, Cambria, -// Garamond, Palatino, Courier, Consolas, Comic Sans, Impact, …) appliquée -// à TOUTE la page (topbar, cards, popups, panel admin). -// -// Vue horizontale : -// - Bloc Aujourd'hui + horloge + date sélectionnée empilés en sidebar dans -// le même cadre, comme la vue classique. -// - Barre verte verticale à droite des mini-cards quand le ticket est -// officiellement clôturé / résolu (✓✓), avec décalage du ✓✓ pour ne pas -// chevaucher la barre. -// - Quand l'utilisateur a choisi une couleur de topbar, la sidebar prend -// aussi la même couleur, avec icônes / boutons / séparateurs / theme-toggle -// translucides cohérents (color-mix). -// -// Stats globales : -// - Nouveau compteur "X faits / Y clos" entre "(matin · après-midi)" et -// "tech. dispo". En vue classique, "//" remplace "·" entre clos et dispo. -// En sidebar horizontale, c'est une vraie barre horizontale de séparation. -// -// Onboarding équipe : -// - Quand aucun technicien n'est sélectionné (1ʳᵉ install ou cfg vide), -// une carte centrée propre s'affiche avec icône, titre, description et -// bouton "Ouvrir paramètres" qui pointe directement sur la section Équipe -// du panel admin. Centrée verticalement et horizontalement dans la zone -// disponible (entre la sidebar et le bord droit en horizontal). -// -// Bugfix divers : -// - Absences récurrentes affichées au switch de groupe puis retour (n'étaient -// plus cochées dans la table même si la donnée existait toujours). -// - Barre de progression / bannière session expirée : suivent la hauteur -// dynamique de la topbar via --topbar-height (ne se cachent plus dessous -// en scrollant). -// - Flèche " ↗" retirée des références cliquables dans le tooltip. LOG.warn("planification", - `🩺 Planification v${LOG.version()} — Mode ${LOG.isDebug() ? "DIAGNOSTIC" : "PROD"}.`); - -// on n'analyse que les ghosts du tech Quentin pour se concentrer. -// Les ghosts des autres techs sont loggés comme "skipped" et restent visibles. -const DIAGNOSTIC_TECH_FILTER_ID = "86874"; // Rouiller, Quentin - -// v2026.5.40 r22 : pattern simple et fiable pour fermer le tooltip -// - mouseleave row → timer 500ms (démarré dans hideTooltip) -// - mouseenter popup → clearTimeout (annule la fermeture en cours) -// - mouseleave popup → hideTooltip (démarre un nouveau timer 500ms) -// - mouseenter row → showTooltip (annule la fermeture aussi) -// Pas de flags hoveredInBulle/hoveredInRow, pas de watchdog mousemove, -// pas de elementFromPoint : juste des events natifs qui sont fiables. -// Source : pattern recommandé dans Tippy.js, jQuery PowerTip, MDN Popover API. + `🩺 Planification v${LOG.version()} — Mode ${LOG.isDebug() ? "DEBUG" : "PROD"}.`); // ============================================================================ // Configuration @@ -222,12 +172,80 @@ const DIAGNOSTIC_TECH_FILTER_ID = "86874"; // Rouiller, Quentin let TEAM = {}; let RECURRING_ABSENCES = {}; +// idx commun dérivé des bornes "startMin-endMin", posé sur le +// .gap-placeholder (rect carte-body / mini-cards) ET sur le +// .timeline-hole correspondant. Pas de matching fragile — si les bornes +// sont identiques, les 2 partagent le même attribut. highlightGap +// toggle .gap-hover-active sur tous les [data-gap-idx="N"] dans la +// card, exactement comme highlightIntervention pour [data-iv-idx="N"]. +function highlightGap(cardEl, gapIdx, on) { + if (!cardEl || gapIdx == null) return; + cardEl.querySelectorAll(`[data-gap-idx="${gapIdx}"]`).forEach(el => { + el.classList.toggle("gap-hover-active", on); + }); +} + +function _bindGapPlaceholder(phEl, cardEl) { + if (!cardEl || !phEl) return; + const startMin = Number(phEl.dataset.startMin); + const endMin = Number(phEl.dataset.endMin); + if (!Number.isFinite(startMin) || !Number.isFinite(endMin)) return; + const idx = startMin + "-" + endMin; + phEl.dataset.gapIdx = idx; + phEl.addEventListener("mouseenter", () => highlightGap(cardEl, idx, true)); + phEl.addEventListener("mouseleave", () => highlightGap(cardEl, idx, false)); +} + +// Détection post-rendu d'une mini-card iv tronquée → masque les +// gap-placeholder-mini de la MÊME ligne pour libérer la place. Le +// flex CSS ne suffit pas car le navigateur ne sait pas que le +// contenu de l'iv déborde son flex-basis (overflow:hidden masque le +// débordement). On le détecte explicitement via scrollWidth > clientWidth. +function _trimMiniGapsForFit() { + document.querySelectorAll(".iv-mini-block-cards").forEach(blockCards => { + // Reset : remettre tous les gaps non-full visibles d'abord + blockCards.querySelectorAll(".gap-placeholder-mini:not(.gap-placeholder-full)").forEach(g => { + g.style.removeProperty("display"); + }); + // Détection : au moins une iv tronque-t-elle son contenu ? + let needsTrim = false; + blockCards.querySelectorAll(".iv-mini-card").forEach(iv => { + const txt = iv.querySelector(".iv-mini-card-text"); + if (txt && txt.scrollWidth > txt.clientWidth + 1) needsTrim = true; + const tCol = iv.querySelector(".iv-mini-time-vertical"); + if (tCol && tCol.scrollWidth > tCol.clientWidth + 1) needsTrim = true; + }); + if (needsTrim) { + blockCards.querySelectorAll(".gap-placeholder-mini:not(.gap-placeholder-full)").forEach(g => { + g.style.display = "none"; + }); + } + }); +} + +// Re-check au resize de la fenêtre (la largeur dispo change → certaines +// iv passent ou non en troncature). +let _trimRO = null; +function _attachMiniGapsResizeWatcher() { + if (typeof ResizeObserver === "undefined") return; + if (_trimRO) return; + let scheduled = false; + _trimRO = new ResizeObserver(() => { + if (scheduled) return; + scheduled = true; + requestAnimationFrame(() => { scheduled = false; _trimMiniGapsForFit(); }); + }); + _trimRO.observe(document.body); +} + async function _initTeamFromConfig() { try { const cfg = await loadAdminConfig(); TEAM = cfg.team || {}; RECURRING_ABSENCES = cfg.recurringAbsences || {}; - // R12v : recharger les statuts personnalisables depuis la config. + // applique l'option "Afficher les pauses" au boot. + document.documentElement.classList.toggle("show-iv-gaps", !!cfg.showInterventionGaps); + // recharger les statuts personnalisables depuis la config. if (Array.isArray(cfg.closedStatus) && cfg.closedStatus.length > 0) { CLOSED_STATUS = cfg.closedStatus.slice(); } @@ -237,12 +255,13 @@ async function _initTeamFromConfig() { if (Array.isArray(cfg.cancelledStatus) && cfg.cancelledStatus.length > 0) { CANCELLED_STATUS = cfg.cancelledStatus.slice(); } + KEEP_DIAG_GHOSTS = !!cfg.keepDisappearedGhosts; } catch (e) { console.warn("[boot] _initTeamFromConfig err", e); } } -// R12v : statuts EasyVista éditables depuis admin_config (Paramètres → EV). +// statuts EasyVista éditables depuis admin_config (Paramètres → EV). // Les valeurs par défaut servent de filet de sécurité au 1er install et // pour les versions où la config n'aurait pas encore ces clés. // Clôturé et Terminé par défaut (les 2 statuts qui signifient @@ -255,6 +274,10 @@ const DEFAULT_CANCELLED_STATUS = ["Annulé", "Supprimé"]; let CLOSED_STATUS = [...DEFAULT_CLOSED_STATUS]; let RESOLVED_STATUS = [...DEFAULT_RESOLVED_STATUS]; let CANCELLED_STATUS = [...DEFAULT_CANCELLED_STATUS]; +// v2026.5.45 — flag diagnostic, lu depuis admin_config au boot et mis à +// jour quand on toggle la case "Garder les disparitions". Quand true : on +// ne retire JAMAIS les ghosts (KEEP forcé), peu importe le verdict. +let KEEP_DIAG_GHOSTS = false; // Clés de stockage const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD @@ -368,6 +391,10 @@ let state = { reconnecting: false, // v5.0.9 : true si la session est expirée (bannière rouge affichée) sessionExpired: false, + // Cookie PHPSESSID présent mais fetchUser retourne null (page login SSO + // intermédiaire). On garde le polling actif tant que la vraie session + // (avec user identifié) n'est pas arrivée. + sessionPending: false, // v5.0.9 : true si on a déjà fait le ping de confirmation < 5 min sessionPingDone: false, // v5.0.10 : dernière origine EV connue comme fonctionnelle (itsma.vd.ch @@ -391,6 +418,37 @@ const SESSION_CRITICAL_THRESHOLD_MS = 2 * 60 * 1000; // 2 min → rouge + modal // dans ce délai, on bascule en état "Reconnexion échouée" avec choix du réseau. const RECONNECT_TIMEOUT_MS = 90 * 1000; // 90 sec +// Tracking des iv déplacées vers une autre date. +// EasyVista a un cache serveur qui peut continuer à retourner l'iv pour la +// date d'origine pendant quelques secondes après le drag&drop. Pour éviter +// que l'iv ré-apparaisse en boucle sur l'ancien jour pendant ce délai, on +// la marque ici et on la filtre du fresh tant que la fenêtre est active. +// Map +const _recentlyMovedAway = new Map(); +const _RECENT_MOVE_TTL_MS = 60 * 1000; + +function _registerRecentMove(actionId, fromDate, toDate) { + if (!actionId || !fromDate || !toDate || fromDate === toDate) return; + _recentlyMovedAway.set(String(actionId), { fromDate, toDate, ts: Date.now() }); +} + +function _filterRecentlyMovedAway(techs, currentDate) { + if (!techs || !_recentlyMovedAway.size) return techs; + const now = Date.now(); + for (const [k, e] of _recentlyMovedAway) { + if (now - e.ts > _RECENT_MOVE_TTL_MS) _recentlyMovedAway.delete(k); + else if (e.toDate === currentDate) _recentlyMovedAway.delete(k); + } + if (!_recentlyMovedAway.size) return techs; + for (const tech of techs) { + tech.interventions = (tech.interventions || []).filter(iv => { + const e = _recentlyMovedAway.get(String(iv.actionId)); + return !(e && e.fromDate === currentDate); + }); + } + return techs; +} + // ─── Annulation coopérative d'un refresh manuel (v3.1) ────────────────────── // Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent // isRefreshAborted() avant chaque fetch : si le jeton a changé ou si @@ -468,6 +526,15 @@ async function init() { _initTopbarStyleFromConfig(); // exposer la hauteur réelle de la topbar (sticky) en variable CSS. _initTopbarHeightVar(); + // surveille les redim. → re-trim les gaps mini si une iv tronque + _attachMiniGapsResizeWatcher(); + // v2026.5.45 (issue #3) : si on vient de faire une mise à jour depuis + // une version qui avait le bug structurel d'écrasement des absences + // récurrentes, prévenir l'utilisateur via toast. Une seule fois. + _checkInstallEventAndNotify(); + // v2026.5.45 (issue #4) : écouter les changements de cookie EZV + // broadcastés par background.js (si la permission "cookies" est accordée). + _attachCookieChangeListener(); // v2026.5.39 : lire les heures de la journée depuis admin_config (8-18 défaut). await _initDayBoundsFromConfig(); // v2026.5.41 : charger l'équipe et les absences récurrentes depuis admin_config. @@ -483,6 +550,7 @@ async function init() { _applyViewMode(); // v2026.5.32 : appliquer la vue sauvegardée initSessionTimer(); // v5.0.9 : compteur de session EV (tick 1s) initDateCustomPicker(); // v2026.5.17 : faux input date avec jour + _ivDockInit(); // dock latéral d'iv mises de côté // Initialiser la date = aujourd'hui state.currentDate = todayISO(); @@ -624,8 +692,16 @@ async function fetchAndShowCurrentUser() { // On retire aussi "hidden" au cas où (compat ancienne version) badge.classList.remove("hidden"); state.currentUser = resp.user; + const wasPending = state.sessionPending; + state.sessionPending = false; // session définitive → polling peut s'arrêter success = true; _currentUserRetryCount = 0; // reset compteur au succès + // Si on était en attente d'auth définitive (page login SSO), le + // planning n'a pas encore été chargé avec la vraie session — on + // relance maintenant qu'on a un user valide. + if (wasPending && typeof loadForDate === "function") { + loadForDate(state.currentDate, { forceRefetch: true }).catch(() => {}); + } } } } catch (err) { @@ -639,6 +715,13 @@ async function fetchAndShowCurrentUser() { // et on schedule un retry. console.warn(`[currentUser] échec (raison: ${errorReason}) — badge reste en état "?"`); + // user_null = page de login SSO intermédiaire → on a un cookie PHPSESSID + // mais pas la vraie session définitive. On flag pour que le polling 2s + // continue à surveiller le PHPSESSID (qui changera quand l'auth termine). + if (errorReason === "user_null") { + state.sessionPending = true; + } + // Défense : s'assurer que le badge est bien en état inconnu (au cas où // une mise à jour partielle a eu lieu puis échoué). badge.textContent = "?"; @@ -1194,6 +1277,57 @@ function showClearCacheModal() { * @param {Array<{label:string, variant:"primary"|"secondary"|"danger", action:(()=>void|Promise)}>} opts.buttons * Boutons (en bas du modal). Le 1er = focus par défaut. */ +// ============================================================================ +// notification dans le titre de l'onglet (tab title flip) +// ============================================================================ +// Quand la session EasyVista va expirer, on alterne le titre de l'onglet +// entre "Planification" et "❗ Planification" (toggle 700 ms) — visible +// dans la barre des onglets du navigateur même quand le tab n'est pas +// au premier plan, comme Gmail/Slack qui ajoutent "(3) " devant. +let _expirationTitleInterval = null; +let _expirationTitleTimeout = null; +let _expirationTitleOriginal = null; + +function _setExpirationTitleNotice(active, durationMs) { + if (_expirationTitleInterval) { + clearInterval(_expirationTitleInterval); + _expirationTitleInterval = null; + } + if (_expirationTitleTimeout) { + clearTimeout(_expirationTitleTimeout); + _expirationTitleTimeout = null; + } + // Restaure l'original si on en avait sauvegardé un. + if (!active) { + if (_expirationTitleOriginal != null) { + try { document.title = _expirationTitleOriginal; } catch (e) {} + _expirationTitleOriginal = null; + } + return; + } + _expirationTitleOriginal = document.title || "Planification"; + let on = true; + const tick = () => { + try { + // remplacement complet du titre par un message d'alerte — + // le mot change drastiquement entre les deux états, l'œil capte + // beaucoup mieux le mouvement dans la barre d'onglets. + document.title = on + ? "🚨 SESSION EXPIRE 🚨" + : _expirationTitleOriginal; + } catch (e) {} + on = !on; + }; + tick(); + _expirationTitleInterval = setInterval(tick, 700); + + if (durationMs && durationMs > 0) { + _expirationTitleTimeout = setTimeout(() => { + _setExpirationTitleNotice(false); + }, durationMs); + } +} + function showAlertModal(opts) { // Si un alert modal est déjà affiché, l'enlever d'abord const existing = document.getElementById("alert-modal"); @@ -1244,6 +1378,14 @@ function showAlertModal(opts) { }); card.appendChild(actions); + // opt-in pour injecter le sous-bandeau "Activer multi-onglets" + // dans la modal — utilisé par les modals d'erreur fiche EZV pour que + // l'utilisateur puisse activer la lecture du cookie sans naviguer + // vers les paramètres. + if (opts.injectCookiesPrompt && typeof _injectCookiesPromptInBanner === "function") { + try { _injectCookiesPromptInBanner(card); } catch (e) {} + } + overlay.appendChild(card); document.body.appendChild(overlay); @@ -1715,54 +1857,71 @@ function updateNowLine() { // Surveillance du timeout de session EasyVista // ============================================================================ +/** + * Vérifie si une nouvelle session EV est apparue (cookie PHPSESSID différent). + * Cas couverts : + * 1. Reconnexion volontaire (clic « Me reconnecter ») : state.reconnecting = true + * 2. Reconnexion externe (user rouvre EV / SSO refait silencieusement) : + * state.sessionExpired = true, mais l'user n'a jamais cliqué sur le bouton + * + * Sans le cas 2, l'user devait recharger l'extension pour que le viewer voie + * qu'il était à nouveau connecté (il restait bloqué sur la bannière « Session + * expirée » tant qu'il ne cliquait pas). + */ +async function _checkForRestoredSession() { + if (!state.reconnecting && !state.sessionExpired && !state.sessionPending) return; + try { + const resp = await sendMessage({ type: "getSession" }); + if (!resp || !resp.ok || !resp.session || !resp.session.phpsessid) return; + const oldPhpsessid = state.session ? state.session.phpsessid : null; + if (resp.session.phpsessid === oldPhpsessid) return; + + console.log("[session] nouvelle session détectée :", resp.session.phpsessid); + if (state.reconnectTimeoutId) { + clearTimeout(state.reconnectTimeoutId); + state.reconnectTimeoutId = null; + } + state.session = resp.session; + if (resp.session.origin) state.lastKnownOrigin = resp.session.origin; + state.reconnecting = false; + state.sessionExpired = false; + hideReconnectingBanner(); + hideSessionExpiredBanner(); + hideReconnectFailedBanner(); + markSessionActivity(); + showToast("Reconnecté", "Session EasyVista renouvelée"); + _maybeRetryFetchUser("session_reconnected"); + await loadForDate(state.currentDate); + } catch (e) { + // Silencieux : on réessayera au prochain tick / au prochain focus. + } +} + /** * Initialise le tick du compteur de session (toutes les secondes). * Pas de requête réseau : décompte purement local depuis state.sessionExpireAt. - * En parallèle, un polling 2s actif uniquement en reconnexion, pour détecter - * dès que l'user s'est reconnecté dans l'onglet EasyVista ouvert. + * En parallèle, un polling 2s actif dès qu'on est en reconnexion OU en session + * expirée, pour détecter automatiquement le retour de session, peu importe + * comment l'user s'est reconnecté (bouton bandeau, ouverture manuelle EV, ou + * SSO/IAM silencieux depuis l'extérieur). */ function initSessionTimer() { setInterval(() => { updateSessionIndicator(); }, 1000); - // Polling actif UNIQUEMENT pendant une reconnexion pour détecter le nouveau - // PHPSESSID dès qu'il apparaît dans un onglet EV. Rien d'envoyé au serveur - // en dehors de ça. - setInterval(async () => { - if (!state.reconnecting) return; - try { - const resp = await sendMessage({ type: "getSession" }); - if (resp && resp.ok && resp.session && resp.session.phpsessid) { - const oldPhpsessid = state.session ? state.session.phpsessid : null; - if (resp.session.phpsessid !== oldPhpsessid) { - console.log("[session] nouvelle session détectée après reconnexion :", resp.session.phpsessid); - // v5.0.11 : annuler le timeout de reconnexion puisque ça a marché - if (state.reconnectTimeoutId) { - clearTimeout(state.reconnectTimeoutId); - state.reconnectTimeoutId = null; - } - state.session = resp.session; - if (resp.session.origin) state.lastKnownOrigin = resp.session.origin; - state.reconnecting = false; - state.sessionExpired = false; - hideReconnectingBanner(); - hideSessionExpiredBanner(); - hideReconnectFailedBanner(); - markSessionActivity(); - showToast("Reconnecté", "Session EasyVista renouvelée"); - // v2026.5.34 : relancer fetchUser tout de suite (au lieu d'attendre - // le retry de 60s) — la session vient d'être renouvelée, c'est le - // meilleur moment pour récupérer le user. - _maybeRetryFetchUser("session_reconnected"); - // Recharger le planning à la date courante sans perdre la position - await loadForDate(state.currentDate); - } - } - } catch (e) { - // Silencieux, on réessayera au prochain tick - } - }, 2000); + setInterval(_checkForRestoredSession, 2000); + + // Au retour de visibilité (l'user revient sur l'onglet du viewer) ou au + // focus de la fenêtre, check immédiat — sans attendre le tick de 2s. Évite + // de regarder une bannière « expirée » périmée pendant 2s alors qu'on est + // déjà reconnecté. + document.addEventListener("visibilitychange", () => { + if (!document.hidden) _checkForRestoredSession(); + }); + window.addEventListener("focus", () => { + _checkForRestoredSession(); + }); } /** @@ -1905,6 +2064,13 @@ function _handleSessionSlideAlerts(remainingMs) { function _showSessionSlideAlert({ urgent }) { // Retirer l'ancienne si elle existe _hideSessionSlideAlert(); + // démarre la notification clignotante du titre d'onglet en même + // temps que l'alerte slide. Restée active tant que l'alerte est + // visible — arrêtée par _hideSessionSlideAlert (prolongation, "Plus + // tard", ou expiration finale). + if (typeof _setExpirationTitleNotice === "function") { + try { _setExpirationTitleNotice(true); } catch (e) {} + } const el = document.createElement("div"); el.id = "session-slide-alert"; @@ -1965,6 +2131,12 @@ function _showSessionSlideAlert({ urgent }) { } function _hideSessionSlideAlert() { + // arrête toujours la notification titre, même si l'alerte + // visuelle n'est plus dans le DOM (cas où on hide après un retour + // en focus, etc.). + if (typeof _setExpirationTitleNotice === "function") { + try { _setExpirationTitleNotice(false); } catch (e) {} + } const el = document.getElementById("session-slide-alert"); if (!el) return; el.classList.remove("visible"); @@ -2006,6 +2178,15 @@ function showSessionCriticalModal() { state.sessionExpireAt = Date.now() + resp.remainingMs; state.sessionPingDone = false; state._criticalModalShown = false; + // v2026.5.45 (issue #7) : reset des flags slide-alert + // ET fermeture du popup top-gauche s'il était visible. + // Sans ça, le « ⚠ Session expire dans 2 minutes » qui s'affiche + // en arrière-plan du modal restait visible après prolongation. + state._slideAlert5minShown = false; + state._slideAlert2minShown = false; + if (typeof _hideSessionSlideAlert === "function") { + _hideSessionSlideAlert(); + } showToast("Session prolongée", "30 minutes de plus"); updateSessionIndicator(); } @@ -2105,7 +2286,12 @@ function getDefaultAdminConfig() { cancelledStatus: [...DEFAULT_CANCELLED_STATUS], dayStart: 8, dayEnd: 18, - cacheDays: 7 + cacheDays: 7, + // v2026.5.45 — diagnostic disparitions : si true, on NE retire JAMAIS + // les ghosts (verdict CANCELLED/REMOVE → on log seulement). Sert à valider + // les verdicts cas par cas avant de basculer en prod. Décorrélé de + // LOG.isDebug() (qui ne contrôle plus que la verbosité console). + keepDisappearedGhosts: false }; } @@ -2493,13 +2679,30 @@ function renderAdminSectionTeam(container, cfg, saveFn) { saveBtn.style.marginTop = "20px"; saveBtn.style.marginLeft = "10px"; saveBtn.addEventListener("click", () => { - // Reconstruire cfg.team et cfg.recurringAbsences à partir de rows - const newTeam = {}; - const newRecAbs = {}; + // v2026.5.45 (issue #3 — cause structurelle) : on MERGE avec l'état + // existant au lieu de tout remplacer. Sans ça, sauvegarder pendant + // qu'on est sur un autre groupe que celui où les absences récurrentes + // ont été configurées WIPE silencieusement les absences des techs qui + // ne sont pas dans rows (ex: techs d'un autre groupe). + // Logique : on ne touche QUE les entrées des techs présents dans la + // vue (rows) — leur état (inclus/exclu, jours) est mis à jour. Les + // techs absents de la vue gardent leurs entrées intactes. + const newTeam = { ...(cfg.team || {}) }; + const newRecAbs = { ...(cfg.recurringAbsences || {}) }; for (const r of rows) { - if (!r.included || !r.id) continue; - newTeam[r.id] = r.name || ("? (" + r.id + ")"); - if (r.days && r.days.length > 0) newRecAbs[r.id] = r.days.slice(); + if (!r.id) continue; + if (r.included) { + newTeam[r.id] = r.name || ("? (" + r.id + ")"); + if (r.days && r.days.length > 0) { + newRecAbs[r.id] = r.days.slice(); + } else { + delete newRecAbs[r.id]; + } + } else { + // Tech décoché → on retire son entrée de team ET de recurringAbsences + delete newTeam[r.id]; + delete newRecAbs[r.id]; + } } cfg.team = newTeam; cfg.recurringAbsences = newRecAbs; @@ -2721,7 +2924,7 @@ function renderAdminSectionEV(container, cfg, saveFn) { container.appendChild(btnWrap); - // ─── R12w : sous-section "Statuts EasyVista" — UI liste ─────────────── + // ─── : sous-section "Statuts EasyVista" — UI liste ─────────────── // Une seule entrée par statut suffit (ex: "Clôturé") : la détection est // automatiquement insensible à la casse et aux accents. L'user peut // ajouter, modifier ou supprimer des entrées avec les boutons. @@ -2836,7 +3039,7 @@ function renderAdminSectionEV(container, cfg, saveFn) { (arr) => { cfg.closedStatus = arr; } ); - // R12z : entrées "Résolu" et "Annulé/Supprimé" retirées de l'UI. + // entrées "Résolu" et "Annulé/Supprimé" retirées de l'UI. // - Résolu : fusionné dans la liste Clôturé/Résolu (un seul statut // "finalisé" couvre les deux côté admin). // - Annulé/Supprimé : la décision est basée sur le comportement du tech @@ -2880,7 +3083,7 @@ function renderAdminSectionAppearance(container, cfg, saveFn) { themeRow.querySelector(".admin-row-control").appendChild(themeSelect); container.appendChild(themeRow); - // R12p : la durée du cache a été déplacée dans Diagnostics → section Cache, + // la durée du cache a été déplacée dans Diagnostics → section Cache, // à côté des boutons Export/Import (regroupement logique). // ---- Champ Taille du texte (3e) — slider horizontal ---- @@ -3183,6 +3386,30 @@ function renderAdminSectionAppearance(container, cfg, saveFn) { fontWrap.appendChild(fontPreview); fontRow.querySelector(".admin-row-control").appendChild(fontWrap); container.appendChild(fontRow); + + // option "Afficher les pauses" — fait apparaître le rectangle + // dashed entre 2 iv qui ne s'enchainent pas, sur les 2 vues. Le hover + // bidirectionnel rectangle ↔ timeline-hole est ensuite synchronisé. + const gapsRow = _makeAdminRow( + "Afficher les pauses", + "Affiche un rectangle entre 2 interventions consécutives qui ne s'enchainent pas. Au survol du rectangle ou de la zone vide dans la timeline, les 2 se mettent en surbrillance ensemble." + ); + const gapsLabel = document.createElement("label"); + gapsLabel.style.cssText = "display:flex; align-items:center; gap:8px; cursor:pointer;"; + const gapsCb = document.createElement("input"); + gapsCb.type = "checkbox"; + gapsCb.checked = !!cfg.showInterventionGaps; + const gapsTxt = document.createElement("span"); + gapsTxt.textContent = "Activé"; + gapsLabel.appendChild(gapsCb); + gapsLabel.appendChild(gapsTxt); + gapsCb.addEventListener("change", async () => { + cfg.showInterventionGaps = gapsCb.checked; + document.documentElement.classList.toggle("show-iv-gaps", gapsCb.checked); + await saveAdminConfig(cfg); + }); + gapsRow.querySelector(".admin-row-control").appendChild(gapsLabel); + container.appendChild(gapsRow); } // Helper pour créer une ligne label/desc + zone contrôle (utilisée par @@ -3367,6 +3594,106 @@ function _applyAppFont(val) { } } +// v2026.5.45 (issue #3) : lit le marqueur _lastInstallEvent posé par +// background.js dans chrome.runtime.onInstalled. Si la version précédente +// était < 2026.5.45 ET que ce n'est pas une fresh install, affiche un toast +// préventif invitant à vérifier les absences récurrentes (susceptibles +// d'avoir été corrompues par le bug d'écrasement de l'ancienne version). +// Marque ensuite l'event comme `notified: true` pour ne pas re-afficher. +// v2026.5.45 (issue #4) : reçoit les broadcasts cookieChanged émis +// par background.js. Quand le PHPSESSID change ou expire côté navigateur, +// on recale le compteur local de session sans avoir besoin d'attendre un +// fetch raté. Si le cookie est supprimé (removed) → on bascule en état +// "session expirée". Si une expirationDate fraîche arrive → on l'utilise +// pour aligner le compteur à la valeur EXACTE plutôt qu'à une estimation. +function _attachCookieChangeListener() { + if (!chrome.runtime || !chrome.runtime.onMessage) return; + if (_attachCookieChangeListener._attached) return; + _attachCookieChangeListener._attached = true; + chrome.runtime.onMessage.addListener((msg) => { + if (!msg || msg.type !== "cookieChanged") return; + try { + const phpsessid8 = msg.phpsessid ? msg.phpsessid.slice(0, 8) + "…" : null; + LOG.info("session", "broadcast cookieChanged reçu", + { domain: msg.domain, removed: !!msg.removed, cause: msg.cause, + phpsessid8, expirationDate: msg.expirationDate || null }); + if (msg.removed) { + if (!state.sessionExpired) { + LOG.warn("session", `🚫 cookie PHPSESSID supprimé (${msg.domain}) → bascule en session expirée`); + if (typeof handleSessionExpired === "function") handleSessionExpired(); + } + return; + } + if (msg.phpsessid) { + if (state.sessionExpired) { + LOG.warn("session", `🔄 nouveau PHPSESSID ${phpsessid8} via cookie → reprise auto (relog détecté)`); + state.sessionExpired = false; + if (typeof hideSessionExpiredBanner === "function") hideSessionExpiredBanner(); + } else { + LOG.warn("session", `🍪 PHPSESSID rafraîchi en cookie (${phpsessid8})`); + } + if (msg.expirationDate && typeof msg.expirationDate === "number") { + state.sessionExpireAt = Math.round(msg.expirationDate * 1000); + state.sessionPingDone = false; + state._criticalModalShown = false; + state._slideAlert5minShown = false; + state._slideAlert2minShown = false; + LOG.info("session", "compteur recalibré sur expirationDate cookie", + { remainingSec: Math.round((state.sessionExpireAt - Date.now()) / 1000) }); + if (typeof updateSessionIndicator === "function") updateSessionIndicator(); + } + } + } catch (e) { + LOG.warn("session", "cookieChanged handler err", { err: e && e.message }); + } + }); + LOG.info("session", "listener cookieChanged attaché côté viewer"); +} + +async function _checkInstallEventAndNotify() { + try { + const stored = await chrome.storage.local.get("_lastInstallEvent"); + const ev = stored && stored._lastInstallEvent; + if (!ev || ev.notified) return; + const isUpdate = ev.reason === "update"; + const prev = ev.previousVersion || ""; + // Toast uniquement pour les mises à jour depuis < 2026.5.45. + // Le bug d'écrasement existait dans toutes les versions antérieures. + const isAffected = isUpdate && _versionIsBefore(prev, "2026.5.45"); + if (isAffected) { + // Délai pour laisser le viewer s'initialiser proprement avant le toast. + setTimeout(() => { + showToast( + `Mise à jour vers v${ev.currentVersion || LOG.version()}`, + "Vérifiez vos absences récurrentes dans Paramètres → Équipe — un bug de l'ancienne version pouvait les effacer." + ); + }, 2000); + } + // Marquer comme notifié dans tous les cas (pas de re-toast au prochain boot). + ev.notified = true; + await chrome.storage.local.set({ _lastInstallEvent: ev }); + } catch (e) { + LOG.warn("install", "_checkInstallEventAndNotify err", { err: e && e.message }); + } +} + +// Comparaison lexicographique des numéros sémantiques `YYYY.M.PATCH`. Retourne +// true si `a < b`. Retourne false si `a` est invalide ou >= b. +function _versionIsBefore(a, b) { + if (!a) return false; + const parse = s => String(s).split(".").map(n => parseInt(n, 10)).map(n => isNaN(n) ? 0 : n); + const A = parse(a); + const B = parse(b); + const len = Math.max(A.length, B.length); + for (let i = 0; i < len; i++) { + const ai = A[i] || 0; + const bi = B[i] || 0; + if (ai < bi) return true; + if (ai > bi) return false; + } + return false; // égales +} + async function _initTopbarStyleFromConfig() { try { const cfg = await loadAdminConfig(); @@ -3392,18 +3719,78 @@ function _initTopbarHeightVar() { if (h > 0) { document.documentElement.style.setProperty("--topbar-height", h + "px"); } + // on en profite pour vérifier dynamiquement si la topbar + // déborde en mode "row" — si oui, on bascule en compact (column). + _ensureTopbarFitsOrCompact(); }; updateVar(); try { if (_topbarRO) _topbarRO.disconnect(); _topbarRO = new ResizeObserver(updateVar); _topbarRO.observe(topbar); + _topbarRO.observe(document.body); // pour réagir au redim. fenêtre } catch (e) { - // Fallback : pas de ResizeObserver → re-mesure au resize fenêtre. window.addEventListener("resize", updateVar); } } +// détection dynamique de chevauchement dans la topbar (vue classique). +// pour pouvoir REVENIR en mode row depuis le compact, on doit mesurer +// la largeur des sections en mode row (= classe topbar-compact retirée). +// Sinon en compact .date-nav prend 100 % et leftWidth devient énorme → on +// reste bloqué en compact. +// +// Approche : on retire temporairement la classe avant la mesure (un flag +// évite la boucle infinie via ResizeObserver), on mesure, on réapplique +// si nécessaire. Le flash potentiel d'1 frame est invisible à l'œil. +let _measuringTopbar = false; +function _ensureTopbarFitsOrCompact() { + if (_measuringTopbar) return; + if (!document.documentElement.classList.contains("view-classic")) return; + const tb = document.querySelector("header.topbar"); + if (!tb) return; + const leftEl = tb.querySelector(".topbar-left"); + const rightEl = tb.querySelector(".topbar-right"); + if (!leftEl || !rightEl) return; + + const wasCompact = document.documentElement.classList.contains("topbar-compact"); + if (wasCompact) { + _measuringTopbar = true; + document.documentElement.classList.remove("topbar-compact"); + // Force un reflow synchrone pour que les widths reflètent le mode row. + void tb.offsetWidth; + } + + const measure = (el, gap) => { + let sum = 0; let visibleCount = 0; + for (const c of el.children) { + if (!(c instanceof HTMLElement)) continue; + if (c.classList.contains("hidden")) continue; + const rect = c.getBoundingClientRect(); + if (rect.width === 0) continue; + sum += rect.width; + visibleCount++; + } + if (visibleCount > 1) sum += (visibleCount - 1) * gap; + return sum; + }; + + const leftWidth = measure(leftEl, 14); + const rightWidth = measure(rightEl, 8); + const sessEl = document.getElementById("app-session"); + const sessWidth = (sessEl && !sessEl.classList.contains("hidden")) + ? sessEl.getBoundingClientRect().width + 12 : 0; + + const padding = 40; + const sectionGap = 12; + const totalNeeded = leftWidth + rightWidth + sessWidth + padding + sectionGap; + const available = window.innerWidth; + + const compactNeeded = totalNeeded > available - 4; + document.documentElement.classList.toggle("topbar-compact", compactNeeded); + _measuringTopbar = false; +} + // v2026.5.39 : génère un texte décrivant l'emplacement physique du cache // chrome.storage.local selon le navigateur + l'OS détecté. Sert de tooltip // (title=) sur le champ "Durée du cache" dans le panel admin. @@ -3531,7 +3918,7 @@ function _watchOsThemeChanges() { else if (mq.addListener) mq.addListener(handler); // fallback Safari ancien } -// R12p : export du cache planning au format JSON (toutes les clés +// export du cache planning au format JSON (toutes les clés // planning_cache_*). Logge chaque étape (succès / échec) pour diagnostic. async function exportPlanningCache() { LOG.info("cache-export", "début export du cache planning"); @@ -3571,7 +3958,7 @@ async function exportPlanningCache() { } } -// R12p : import du cache planning. Si pas de cache local → import direct, +// import du cache planning. Si pas de cache local → import direct, // sinon modal "Tout écraser" / "Fusionner" / "Annuler". Logs verbeux. async function importPlanningCache() { LOG.info("cache-import", "ouverture du sélecteur de fichier"); @@ -3680,7 +4067,7 @@ async function importPlanningCache() { input.click(); } -// R12p : export / import de admin_config (paramètres). Pas de modal — +// export / import de admin_config (paramètres). Pas de modal — // import = écrasement direct. Logs verbeux. async function exportAdminConfig() { LOG.info("config-export", "début export des paramètres"); @@ -3772,7 +4159,7 @@ function renderAdminSectionDiagnostics(container, cfg, saveFn) { `; container.appendChild(info); - // R12p : on regroupe tout ce qui touche au cache dans une sous-section + // on regroupe tout ce qui touche au cache dans une sous-section // dédiée (durée + export + import) pour que ce soit logique visuellement. const SUB_TITLE_STYLE = "margin:24px 0 8px 0; font-size:14px; font-weight:600; color:var(--text); border-bottom:1px solid var(--border); padding-bottom:4px;"; const SUB_HINT_STYLE = "font-size:12px; color:var(--text-faint); margin:0 0 10px 0;"; @@ -3858,6 +4245,48 @@ function renderAdminSectionDiagnostics(container, cfg, saveFn) { cfgBtnRow.appendChild(importCfgBtn); container.appendChild(cfgBtnRow); + // v2026.5.45 (issue #3) : bouton dédié pour réinitialiser UNIQUEMENT + // les absences récurrentes (utile pour récupérer après corruption silen- + // cieuse causée par un ancien bug). Plus ciblé que le reset complet. + const recAbsRow = document.createElement("div"); + recAbsRow.style.cssText = "margin-top:12px;"; + const recAbsHint = document.createElement("p"); + recAbsHint.textContent = "Si vos absences récurrentes ont été effacées par un ancien bug et que la config est dans un état incohérent, vous pouvez tout remettre à zéro et reconfigurer depuis Paramètres → Équipe."; + recAbsHint.style.cssText = SUB_HINT_STYLE; + recAbsRow.appendChild(recAbsHint); + const resetRecAbsBtn = document.createElement("button"); + resetRecAbsBtn.type = "button"; + resetRecAbsBtn.className = "btn"; + resetRecAbsBtn.textContent = "🗓 Réinitialiser les absences récurrentes"; + resetRecAbsBtn.addEventListener("click", () => { + const count = Object.keys(cfg.recurringAbsences || {}).length; + showAlertModal({ + title: "Réinitialiser les absences récurrentes ?", + message: count > 0 + ? `Cela supprimera les ${count} absence(s) récurrente(s) actuellement configurée(s). L'équipe et les autres paramètres ne sont pas touchés. Continuer ?` + : "Aucune absence récurrente n'est actuellement configurée. Le bouton videra quand même la clé pour repartir d'un état propre.", + buttons: [ + { label: "Annuler", variant: "secondary", action: () => {} }, + { + label: "Réinitialiser", + variant: "danger", + action: async () => { + cfg.recurringAbsences = {}; + await saveAdminConfig(cfg); + await _initTeamFromConfig(); + showToast("Absences récurrentes", "réinitialisées"); + // Re-render du panel admin pour refléter l'état vide. + const overlay = document.getElementById("admin-panel"); + if (overlay) overlay.remove(); + if (typeof showAdminPanel === "function") showAdminPanel("team"); + } + } + ] + }); + }); + recAbsRow.appendChild(resetRecAbsBtn); + container.appendChild(recAbsRow); + // ─── Sous-section : Système ────────────────────────────────────────── const sysTitle = document.createElement("div"); sysTitle.textContent = "🛠 Système"; @@ -3883,6 +4312,97 @@ function renderAdminSectionDiagnostics(container, cfg, saveFn) { }); container.appendChild(debugRow); + // v2026.5.45 — Toggle "Garder les disparitions" (mode diagnostic ghosts) + // Décorrélé de "Logs verbeux". Quand coché : aucun ghost n'est retiré, on + // log juste les verdicts. Quand décoché (défaut) : verdict REMOVE = retrait + // effectif de l'iv du planning. + const keepDiagRow = document.createElement("label"); + keepDiagRow.className = "admin-keep-diag-row"; + keepDiagRow.style.cssText = "display:flex; align-items:center; gap:10px; padding:10px; background:var(--bg-muted); border-radius:6px; cursor:pointer; margin-top:10px;"; + const keepDiagCheckbox = document.createElement("input"); + keepDiagCheckbox.type = "checkbox"; + keepDiagCheckbox.checked = !!KEEP_DIAG_GHOSTS; + const keepDiagText = document.createElement("div"); + keepDiagText.style.cssText = "flex:1;"; + keepDiagText.innerHTML = `Garder les disparitions (diagnostic ghosts)
Coché : aucune intervention disparue n'est retirée — verdict CANCELLED/REMOVE log seulement, l'iv reste affichée. Décoché (défaut) : verdict appliqué, les iv marquées REMOVE sortent du planning.
`; + keepDiagRow.appendChild(keepDiagCheckbox); + keepDiagRow.appendChild(keepDiagText); + keepDiagCheckbox.addEventListener("change", async () => { + KEEP_DIAG_GHOSTS = keepDiagCheckbox.checked; + const cfg2 = await loadAdminConfig(); + cfg2.keepDisappearedGhosts = KEEP_DIAG_GHOSTS; + await saveAdminConfig(cfg2); + LOG.warn("admin", `keepDisappearedGhosts = ${KEEP_DIAG_GHOSTS}`); + showToast("Diagnostic ghosts", KEEP_DIAG_GHOSTS ? "ACTIVÉ — aucun retrait, verdicts loggés" : "désactivé — verdict REMOVE appliqué"); + }); + container.appendChild(keepDiagRow); + + // v2026.5.45 (issue #4) : toggle pour autoriser la lecture du cookie + // PHPSESSID (HttpOnly) → fix les pertes de session multi-onglets après + // reconnexion. Permission "cookies" optionnelle, demandée au runtime via + // chrome.permissions.request. Si refusée, fallback sur l'ancien comportement + // (lecture URL). + const cookieRow = document.createElement("label"); + cookieRow.className = "admin-cookie-row"; + cookieRow.style.cssText = "display:flex; align-items:center; gap:10px; padding:10px; background:var(--bg-muted); border-radius:6px; cursor:pointer; margin-top:10px;"; + const cookieCheckbox = document.createElement("input"); + cookieCheckbox.type = "checkbox"; + cookieCheckbox.disabled = true; // activé après check de l'état initial + const cookieText = document.createElement("div"); + cookieText.style.cssText = "flex:1;"; + cookieText.innerHTML = `Gérer les sessions multi-onglets EZV
Permet à l'extension de lire le cookie session pour rester connectée même si plusieurs onglets EZV historiques sont ouverts. Sans ça, après une reconnexion, l'extension peut se baser sur un PHPSESSID périmé.
`; + cookieRow.appendChild(cookieCheckbox); + cookieRow.appendChild(cookieText); + // Init : check si permission déjà accordée + (async () => { + try { + const has = await new Promise(r => chrome.permissions.contains({ permissions: ["cookies"] }, r)); + cookieCheckbox.checked = !!has; + cookieCheckbox.disabled = false; + } catch (e) { + cookieCheckbox.disabled = true; + LOG.warn("permissions", "contains err", { err: e && e.message }); + } + })(); + cookieCheckbox.addEventListener("change", async () => { + cookieCheckbox.disabled = true; + try { + if (cookieCheckbox.checked) { + LOG.warn("permissions", "📥 demande de permission cookies (toggle Diagnostics)"); + const granted = await new Promise(r => chrome.permissions.request({ permissions: ["cookies"] }, r)); + if (granted) { + LOG.warn("permissions", "✅ permission cookies ACCORDÉE → multi-onglets EZV actif"); + showToast("Multi-onglets activé", "L'extension lit désormais le cookie session."); + cfg.cookiesPromptDismissed = false; + await saveAdminConfig(cfg); + // retirer toutes les copies du sous-bandeau ouvertes + // (bannière, écran plein, modal) — l'invitation est résolue. + if (typeof _removeAllCookiesPrompts === "function") _removeAllCookiesPrompts(); + } else { + LOG.warn("permissions", "❌ permission cookies REFUSÉE par l'utilisateur"); + cookieCheckbox.checked = false; + showToast("Refusé", "L'extension reste sur l'ancien comportement."); + } + } else { + LOG.warn("permissions", "🛑 retrait de la permission cookies (toggle Diagnostics)"); + const removed = await new Promise(r => chrome.permissions.remove({ permissions: ["cookies"] }, r)); + if (removed) { + LOG.warn("permissions", "✅ permission cookies retirée → fallback URL"); + showToast("Multi-onglets désactivé", "Retour au comportement standard (URL)."); + } else { + LOG.warn("permissions", "⚠ retrait de la permission a échoué"); + cookieCheckbox.checked = true; + } + } + } catch (err) { + LOG.warn("permissions", "toggle err", { err: err && err.message }); + showToast("Erreur", "Impossible de modifier la permission"); + } finally { + cookieCheckbox.disabled = false; + } + }); + container.appendChild(cookieRow); + // Bouton reset const resetBtn = document.createElement("button"); resetBtn.type = "button"; @@ -4640,6 +5160,14 @@ async function loadForDate(isoDate, opts = {}) { // 3. Fusionner cache + frais const merged = mergeCacheAndFresh(cached, fresh); + // Si une iv vient juste d'être déplacée vers une autre date et que + // EasyVista la retourne encore par cache serveur pour la date d'origine, + // on la masque pendant la fenêtre TTL — sinon elle ré-apparaitrait après + // chaque drop. + _filterRecentlyMovedAway(merged.techs, isoDate); + // Iv parquées dans le dock latéral : invisibles tant qu'elles y sont. + _ivDockFilterTechs(merged.techs); + // v4.2.5 : AVANT de retirer les ghosts, on lance une analyse de chaque // ghost pour déterminer si c'est : // - un ticket TERMINÉ par le tech (→ garder en vert ✓ simple) @@ -4717,7 +5245,7 @@ async function loadForDate(isoDate, opts = {}) { // Évite d'attendre le retry de 60s quand on vient juste de se reconnecter. _maybeRetryFetchUser("after_load_success"); - // R12b : retour à la logique v42 en 2 phases distinctes — + // retour à la logique v42 en 2 phases distinctes — // Phase A : prefetchAllXhr2 EN PARALLÈLE (concurrency=6, rapide) // → toutes les cartes ont leurs infos courtes contact/lieu // en bloc dès le 1er chargement. @@ -5194,7 +5722,21 @@ function mergeCacheAndFresh(cached, fresh) { infobulle: cachedIv.infobulle || iv.infobulle, xhr2Fetched: cachedIv.xhr2Fetched || iv.xhr2Fetched, // ghost : on retire (cette intervention est bien là dans le fresh) - ghost: false + ghost: false, + // L'iv est de retour dans le fresh = elle est active dans le + // planning. On reset les flags d'analyse de disparition hérités + // du cache (terminated-pending/clos/cancelled) pour ne pas + // bloquer le drag&drop ni la modal d'édition. Sans ça, une iv + // qui était "fait" hier puis ré-attribuée aujourd'hui restait + // marquée terminated-pending → _canRescheduleIv retourne false + // → impossible de la déplacer. + _disappearStatus: undefined, + _disappearChecking: false, + _disappearRemove: false, + _diagnosticVerdict: undefined, + _diagnosticTechComment: undefined, + _diagnosticActionInfo: undefined, + _diagnosticOfficiallyClosed: undefined, }; outTech.interventions.push(merged); } else { @@ -5294,8 +5836,7 @@ function mergeCacheAndFresh(cached, fresh) { formLink: iv.formLink ? iv.formLink.substring(0, 120) + "…" : null }); // les réservations/absences disparues du planning EV sont - // retirées immédiatement (pas de "ghost" pour elles) — comportement - // v5.0.1 d'origine, demandé par Quentin. + // retirées immédiatement (pas de "ghost" pour elles). if (iv.type === "AL-Reservation" || iv.type === "AL-Absence") { continue; // on ne rajoute pas → disparait du planning } @@ -5320,8 +5861,8 @@ function mergeCacheAndFresh(cached, fresh) { LOG.info("disparition", "merge: ✅ aucune disparition (toutes les iv du cache sont dans le fresh)"); } else { LOG.info("disparition", - `merge: 📋 RÉCAP — ${ghostsTotal} GHOST(s) détecté(s), aucun retiré (mode diagnostic v2026.5.44)`, - { totalCache: cacheCount, totalFresh: freshCount, totalGhosts: ghostsTotal }); + `merge: 📋 RÉCAP — ${ghostsTotal} GHOST(s) détecté(s), ${KEEP_DIAG_GHOSTS ? "aucun retiré (case diagnostic ON)" : "verdict appliqué (case diagnostic OFF)"}`, + { totalCache: cacheCount, totalFresh: freshCount, totalGhosts: ghostsTotal, diagFlag: KEEP_DIAG_GHOSTS }); } return { techs: resultTechs }; @@ -5368,21 +5909,29 @@ const RX_LOGIN_COMMENTAIRE = /(?:^|\n|)\s*([a-z0-9_]{3,12})\s*:\s+(\S[ /** * Extrait toutes les actions d'une fiche en parsant les blocs "rows" du HTML. - * Chaque action a 14 values : - * [2] = Intervenant (ex: "Nom, Prénom" ou "EZV_WS_REST_USER") - * [4] = Type d'action (ex: "AL-Intervention", "Ajout d'informations") - * [8] = Date de création (JJ/MM/AAAA HH:MM:SS) - * [9] = Date de fin - * [11] = Description HTML (contient le texte de l'action + commentaire tech) + * Une action a 14 values. Indices stables : + * [2] = Intervenant (ex: "Nom, Prénom" ou "EZV_WS_REST_USER") + * [4] = Type d'action (ex: "AL-Intervention", "Ajout d'informations") + * [13] = JSON stringifié contenant ACTION_ID, AM_DONE_BY_ID, … * - * Retourne : [ { intervenant, type, dateCreation, dateFin, description }, ... ] + * v2026.5.45 — la position des dates et de la description varie selon le + * layout EV (anciennement [8]/[9]/[11], aujourd'hui [6]/[7]/[9] sur les + * fiches AL-Intervention récentes). On les détecte par scan plutôt que par + * indices fixes : + * - dates création/fin = les 2 DERNIÈRES valeurs matchant + * "DD/MM/YYYY HH:MM:SS" (ignore la colonne d'affichage en [1] qui + * réplique parfois la création). + * - description = 2 colonnes après la date de fin (cellule intercalée + * vide). + * + * Retourne : [ { intervenant, type, dateCreation, dateFin, description, ... }, ... ] */ function parseAllActionsFromFicheHtml(html) { if (!html) return []; // Décoder : dans le HTML, les JSON imbriqués ont \u0022 pour " et \/ pour / const decoded = html .replace(/\\\//g, '/'); - // R12u : on NE pré-décode PAS " ici — les " littéraux à l'intérieur + // on NE pré-décode PAS " ici — les " littéraux à l'intérieur // d'une valeur sont encodés " (Unicode escape) dans le JSON et le // parser doit les voir comme du texte (6 chars), pas comme des " réels. // Les délimiteurs JSON (autour de "value", "rows", etc.) sont déjà des " @@ -5427,14 +5976,40 @@ function parseAllActionsFromFicheHtml(html) { if (mAid) actionId = mAid[1]; if (mDid && mDid[1]) amDoneById = mDid[1]; } + // v2026.5.45 — détection robuste des dates et de la description. + // On scanne values pour trouver les valeurs qui matchent + // "DD/MM/YYYY HH:MM:SS" et on garde les 2 DERNIÈRES = (création, fin). + // La description est 2 colonnes après la fin (cellule intermédiaire + // toujours vide). Survit aux variations de layout EV (avant: [8]/[9]/[11], + // aujourd'hui: [6]/[7]/[9] pour les fiches AL-Intervention). + const RX_ACTION_DATE = /^\d{1,2}\/\d{1,2}\/\d{4}\s+\d{1,2}:\d{2}:\d{2}$/; + const dateIdxs = []; + for (let k = 0; k < values.length; k++) { + if (RX_ACTION_DATE.test(values[k] || "")) dateIdxs.push(k); + } + let dateCreation = "", dateFin = "", descIdx = -1; + if (dateIdxs.length >= 2) { + const iCreate = dateIdxs[dateIdxs.length - 2]; + const iFin = dateIdxs[dateIdxs.length - 1]; + dateCreation = values[iCreate] || ""; + dateFin = values[iFin] || ""; + descIdx = iFin + 2; + } else if (dateIdxs.length === 1) { + dateCreation = values[dateIdxs[0]] || ""; + dateFin = dateCreation; + descIdx = dateIdxs[0] + 2; + } + const description = (descIdx >= 0 && descIdx < values.length) + ? (values[descIdx] || "") : ""; + actions.push({ - actionId: actionId, // ID unique pour matching exact - amDoneById: amDoneById, // ID du tech ayant fait l'action + actionId, // ID unique pour matching exact + amDoneById, // ID du tech ayant fait l'action intervenant: decodeUnicodeEscapes(values[2] || ""), type: decodeUnicodeEscapes(values[4] || ""), - dateCreation: values[8] || "", - dateFin: values[9] || "", - description: values[11] || "" + dateCreation, + dateFin, + description }); } return actions; @@ -5451,7 +6026,7 @@ function extractValuesFromRowBlock(block) { const mIdx = block.indexOf('"value":"', i); if (mIdx < 0) break; const start = mIdx + '"value":"'.length; - // R12u : on lit jusqu'au prochain " non échappé. + // on lit jusqu'au prochain " non échappé. // Les escapes \X (\\, \") avancent de 2. // Les " littéraux internes sont encodés " dans le block (parce // qu'on n'a PAS pré-décodé) → 6 chars qui ne déclenchent pas le break. @@ -5502,46 +6077,108 @@ function hasClosedAlInterventionInHtml(html) { } /** - * Vérifie si le texte d'une action contient un commentaire tech au format - * `LOGIN: commentaire`. Nettoie d'abord le HTML de la description. + * Vérifie si le texte d'une action contient un commentaire tech. */ function hasTechCommentInDescription(description) { return !!extractTechCommentFromDescription(description); } /** - * v2026.5.44 retourne le commentaire tech complet ou null. + * Préfixes des lignes structurelles à ignorer dans la description d'une + * action EV avant de chercher le commentaire tech. Insensible à la casse + * et aux accents. Le `...` final permet n'importe quel suffixe avant ` :` + * (ex: `Date prévue :`, `TFS-IT :`). + */ +const _TECH_COMMENT_EXCLUDED_PREFIXES = [ + "service", "contact", "bénéficiaire", "beneficiaire", + "étage", "etage", "bureau", "problème", "probleme", + "à faire", "a faire", "matériel", "materiel", +]; +const _TECH_COMMENT_EXCLUDED_WILDCARDS = ["tfs", "date"]; +// Ligne meta = (préfixe exact OU wildcard suivi de n'importe quoi) puis ` :` +const _RX_TECH_META_LINE = new RegExp( + "^\\s*(?:" + + _TECH_COMMENT_EXCLUDED_PREFIXES + .map(p => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|") + + "|(?:" + _TECH_COMMENT_EXCLUDED_WILDCARDS.join("|") + ")[^:]*?" + + ")\\s*:", + "i" +); +// Signature en queue de bloc : 3 lettres maj/min suivies optionnellement +// d'une date (24.04 / 23/04/26 / 28.04.2026). La date peut être absente. +const _RX_TECH_SIGNATURE = /^\s*([A-Za-zÀ-ÿ]{3})(?:\s+(\d{1,2}[.\/]\d{1,2}(?:[.\/]\d{2,4})?))?\s*$/; + +/** + * Retourne le commentaire tech d'une action EV ou null. * - * Le commentaire tech apparaît APRÈS une signature du coordinateur du type - * "ECM 24.04" / "AWR 23/04/26" / "KAA 28.04.2026" — soit 2-5 lettres - * majuscules suivies d'une date courte, puis un retour à la ligne, puis - * le commentaire libre saisi par le tech. + * Algorithme : + * 1. Nettoyage HTML → texte plat, découpage par lignes. + * 2. Filtrage des lignes meta (Service :, Contact :, Bénéficiaire :, + * Étage :, Bureau :, Problème :, À Faire :, TFS… :, Matériel :, + * Date… :) — insensible casse et accents. + * 3. Identification de la dernière ligne "signature" du gros bloc + * (3 lettres + date optionnelle). + * 4. Le commentaire = la 1re ligne non-vide qui suit la signature, + * séparée par exactement UN saut de ligne (la fin du bloc descriptif). + * + * Fallback : pattern legacy `login: texte` si aucune signature détectée. */ function extractTechCommentFromDescription(description) { if (!description) return null; - const txt = decodeUnicodeEscapes(description) - .replace(//gi, '\n') - .replace(/<\/?p[^>]*>/gi, '\n') - .replace(/<[^>]+>/g, '') - .replace(/ /g, ' ') - .replace(/&/g, '&'); - // R12q : on essaie 2 patterns successifs. - // 1. SIGNATURE puis commentaire : "XXX JJ.MM\nTEXTE…" - // (le tech a tapé sa signature ECM/AWR/etc puis ligne nouvelle + texte). - // 2. LOGIN: TEXTE — fallback : "vyjuva: ok", "awr: a faire", etc. - // (parfois le tech écrit juste son login sans signature au-dessus). - const rxSig = /\b([A-Z]{2,5})\s+(\d{1,2}[.\/]\d{1,2}(?:[.\/]\d{2,4})?)\s*\n+([\s\S]+)/; - const m = txt.match(rxSig); - if (m) { - const commentaire = m[3].trim(); - if (commentaire && commentaire.length >= 3) { - return { signature: `${m[1]} ${m[2]}`, commentaire, full: commentaire }; + const raw = decodeUnicodeEscapes(description) + .replace(//gi, "\n") + .replace(/<\/?p[^>]*>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&"); + + const lines = raw.split(/\r?\n/); + + // Index de la dernière ligne signature dans le flux brut (en ignorant + // les lignes meta pour ne pas casser sur un pattern accidentel dans + // un libellé Service: / Contact:). + let lastSigIdx = -1; + for (let i = 0; i < lines.length; i++) { + const ln = lines[i]; + if (_RX_TECH_META_LINE.test(ln)) continue; + if (_RX_TECH_SIGNATURE.test(ln)) { + lastSigIdx = i; } } - // Fallback : pattern login: texte - const m2 = txt.match(RX_LOGIN_COMMENTAIRE); + if (lastSigIdx >= 0) { + const sigLine = lines[lastSigIdx].trim(); + // Le commentaire = première ligne non vide après la signature. + for (let i = lastSigIdx + 1; i < lines.length; i++) { + const next = lines[i]; + if (next == null) continue; + const trimmed = next.trim(); + if (!trimmed) continue; + // Ligne meta après signature = pas le commentaire (cas rare où une + // section Date :/Matériel : suivrait la signature). + if (_RX_TECH_META_LINE.test(next)) continue; + if (trimmed.length < 3) continue; + return { signature: sigLine, commentaire: trimmed, full: trimmed }; + } + } + + // Fallback 1 : pattern legacy plus permissif — signature 2-5 lettres + // majuscules + date n'importe où dans le texte (pas forcément seule sur + // sa ligne), suivie d'un \n et d'un commentaire. Récupère les fiches où + // la signature est collée à du texte avant. + const rxLegacy = /\b([A-Z]{2,5})\s+(\d{1,2}[.\/]\d{1,2}(?:[.\/]\d{2,4})?)\s*\n+([\s\S]+)/; + const m1 = raw.match(rxLegacy); + if (m1) { + const commentaire = (m1[3] || "").trim(); + if (commentaire && commentaire.length >= 3) { + return { signature: `${m1[1]} ${m1[2]}`, commentaire, full: commentaire }; + } + } + + // Fallback 2 : pattern legacy "login: texte" + const m2 = raw.match(RX_LOGIN_COMMENTAIRE); if (m2) { const commentaire = (m2[2] || "").trim(); if (commentaire && commentaire.length >= 3) { @@ -5601,7 +6238,7 @@ function normalizeName(s) { * Ignore les actions système (EZV_WS_REST_USER, vide). */ function actionBelongsToTech(action, techName, techId) { - // R12s : on accepte 2 voies d'attribution : + // on accepte 2 voies d'attribution : // 1. action.amDoneById === techId (le tech a réalisé/modifié l'action, // même si elle a été créée par RPH ou un autre intervenant) // 2. intervenant matche le nom du tech (ancien comportement) @@ -5684,7 +6321,9 @@ async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken, type: iv.type, verdict: iv._diagnosticVerdict, decisionNormale: iv._diagnosticDecisionNormal, - decisionAppliquee: "KEEP (forcé par mode diagnostic v2026.5.44)", + decisionAppliquee: KEEP_DIAG_GHOSTS + ? "KEEP (forcé par case diagnostic « Garder les disparitions »)" + : iv._diagnosticDecisionNormal, raisons: iv._diagnosticReasoning }); } @@ -5692,13 +6331,13 @@ async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken, } LOG.warn("disparition", `📊 SYNTHÈSE FINALE — ${summary.length} ghost(s) analysé(s)`, summary); - // filtre selon le mode. - // - Mode DIAGNOSTIC (debug ON) : on garde tout, on log juste ce qu'on - // aurait retiré pour permettre au coordinateur de comprendre les - // verdicts cas par cas avant de basculer en prod. - // - Mode PROD (debug OFF) : on retire vraiment les iv marquées - // _disappearRemove=true depuis tech.interventions. - const isDiag = (typeof LOG !== "undefined") && LOG.isDebug && LOG.isDebug(); + // v2026.5.45 — filtre selon le flag diagnostic admin (case dédiée + // "Garder les disparitions" dans Paramètres → Diagnostics) : + // - flag ON : on garde tout, on log juste ce qu'on aurait retiré + // (mise au point des verdicts). + // - flag OFF (défaut) : on applique la décision normale → REMOVE pour + // les iv marquées _disappearRemove=true. + const isDiag = !!KEEP_DIAG_GHOSTS; if (isDiag) { for (const tech of techs) { for (const iv of tech.interventions) { @@ -5756,10 +6395,10 @@ async function analyzeOneDisappearedIv(tech, iv) { iv._diagnosticVerdict = status; iv._diagnosticDecisionNormal = decisionNormal; for (const r of rs) reason(r); - // mode diagnostic = actif UNIQUEMENT quand le flag debug log - // est activé (Paramètres → Diagnostics → Logs verbeux). Sinon mode - // prod : on applique la décision normale (REMOVE → l'iv sera retirée). - const isDiag = (typeof LOG !== "undefined") && LOG.isDebug && LOG.isDebug(); + // v2026.5.45 — mode diagnostic = case "Garder les disparitions" + // (Paramètres → Diagnostics). Décorrélé de LOG.isDebug. Quand OFF : + // on applique la décision normale (REMOVE → retire l'iv). + const isDiag = !!KEEP_DIAG_GHOSTS; const shouldRemove = !!decisionNormal && /\bREMOVE\b/i.test(decisionNormal); LOG.warn("disparition", `🔎 VERDICT ${tag} → ${status.toUpperCase()} | code normal : ${decisionNormal} | mode actuel : ${isDiag ? "DIAGNOSTIC (KEEP forcé)" : (shouldRemove ? "PROD → REMOVE" : "PROD → KEEP")}`, @@ -5875,9 +6514,11 @@ async function analyzeOneDisappearedIv(tech, iv) { reason(`Étape 2 — statut global = ${JSON.stringify(ficheStatus)}, officiellementClos=${isOfficiallyClosed}`); // si le statut global correspond aux libellés "Clôturé/Résolu" de - // la config admin → on AFFIRME terminated-clos d'office. On extrait quand - // même le commentaire tech (pour le tooltip) si on en trouve un, mais le - // verdict est figé : ✓✓ vert. Plus de risque de tomber en cancelled/moved. + // la config admin → ticket fermé côté EV. On regarde si le tech a + // laissé un commentaire : + // - oui → `terminated-clos` (KEEP en ✓✓ vert), commentaire affiché. + // - non → REMOVE : ticket clos sans trace du tech = il n'a rien à + // voir avec ce planning, on le retire. if (isOfficiallyClosed) { const allActions = parseAllActionsFromFicheHtml(html); const techName = tech.name || tech.label || ""; @@ -5885,39 +6526,39 @@ async function analyzeOneDisappearedIv(tech, iv) { const techActionWithComment = techActions .map(a => ({ a, c: extractTechCommentFromDescription(a.description) })) .filter(x => x.c); - if (techActionWithComment.length > 0) { - const best = techActionWithComment[0]; - iv._diagnosticTechComment = best.c.full; - iv._diagnosticActionInfo = { - intervenant: best.a.intervenant, - dateCreation: best.a.dateCreation, - dateFin: best.a.dateFin, - type: best.a.type - }; - // /o : décoder + convertir
/

→ \n et stripper HTML. - // retirer aussi la ligne du commentaire tech ("vyjuva: ok", - // etc.) de la description — elle est déjà affichée dans le bloc - // dédié "Commentaire" plus bas. Évite la duplication dans le popup. - if (best.a.description) { - const decoded = decodeUnicodeEscapes(best.a.description) - .replace(//gi, "\n") - .replace(/<\/?p[^>]*>/gi, "\n") - .replace(/<[^>]+>/g, "") - .replace(/ /g, " ") - .replace(/&/g, "&"); - // strip strictement la ligne "login: …" (lowercase + chiffres - // uniquement, sans flag /i). Avant : le /i matchait aussi "Lieu : " - // ou "Service : " et tronquait toute la description après. - const cleaned = decoded - .replace(/\n+\s*[a-z0-9_]{3,12}\s*:\s+[\s\S]*$/, "") - .replace(/\s+$/, ""); - iv.bulleDescription = cleaned; - iv.ficheActionText = cleaned; - } - } iv._diagnosticOfficiallyClosed = true; + if (techActionWithComment.length === 0) { + return verdict("cancelled-closed-no-comment", "REMOVE", + `Statut "${ficheStatus}" = clos officiel MAIS aucune action du tech avec commentaire → ticket fermé sans trace du tech, on retire`); + } + const best = techActionWithComment[0]; + iv._diagnosticTechComment = best.c.full; + iv._diagnosticActionInfo = { + intervenant: best.a.intervenant, + dateCreation: best.a.dateCreation, + dateFin: best.a.dateFin, + type: best.a.type, + }; + // Décoder + nettoyer la description pour le tooltip, en retirant la + // ligne du commentaire tech (déjà affichée séparément). Évite la + // duplication dans le popup. + if (best.a.description) { + const decoded = decodeUnicodeEscapes(best.a.description) + .replace(//gi, "\n") + .replace(/<\/?p[^>]*>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&"); + // Strip strictement la ligne "login: …" (lowercase + chiffres + // uniquement, sans flag /i pour ne pas matcher "Lieu : "/"Service :"). + const cleaned = decoded + .replace(/\n+\s*[a-z0-9_]{3,12}\s*:\s+[\s\S]*$/, "") + .replace(/\s+$/, ""); + iv.bulleDescription = cleaned; + iv.ficheActionText = cleaned; + } return verdict("terminated-clos", "KEEP (vert ✓✓)", - `Statut "${ficheStatus}" matche la config Clôturé/Résolu → terminée officielle (${techActionWithComment.length > 0 ? "commentaire tech récupéré" : "pas de commentaire tech"})`); + `Statut "${ficheStatus}" = clos + commentaire tech récupéré → terminée officielle`); } // ─── Étape 3 : parser TOUTES les actions de la fiche ──────────────────── @@ -5941,7 +6582,7 @@ async function analyzeOneDisappearedIv(tech, iv) { const ghostDateShort = ghostDate ? ghostDate.substring(0, 10) : null; reason(`Étape 4 — recherche actions du tech '${techName}' avec date ≈ ${ghostDateShort || "?"}`); - // R12s : on passe aussi tech.id pour matcher via amDoneById quand + // on passe aussi tech.id pour matcher via amDoneById quand // l'action a été créée par RPH/un autre intervenant mais réalisée par le tech. const techActions = actions.filter(a => actionBelongsToTech(a, techName, tech.id)); reason(`Étape 4 — ${techActions.length} action(s) au tech (sur ${actions.length} totales)`); @@ -6007,7 +6648,7 @@ async function analyzeOneDisappearedIv(tech, iv) { } if (sameDay.length > 0) { - // R12r : on log les descriptions COMPLÈTES des actions sameDay qui n'ont + // on log les descriptions COMPLÈTES des actions sameDay qui n'ont // pas matché pour qu'on puisse adapter le pattern d'extraction. const descDump = sameDay.map((a, i) => ({ idx: i, @@ -6174,19 +6815,27 @@ async function refreshStatuses(techs, isoDate, opts = {}) { } // Résoudre le sort des ghosts - // v2026.5.44 (mode diagnostic) : on NE retire plus rien automatiquement. - // Avant : on retirait silencieusement les ghosts dont le statut était dans - // CANCELLED_STATUS (Annulé/Supprimé). Maintenant on log ce qu'on aurait - // retiré pour pouvoir comprendre. + // v2026.5.45 — gated par la case diagnostic "Garder les disparitions" : + // - case ON : on log seulement ce qu'on aurait retiré (mise au point). + // - case OFF (défaut, prod) : on retire les ghosts dont le statut EV + // est dans CANCELLED_STATUS (Annulé/Supprimé). for (const tech of techs) { - for (const iv of tech.interventions) { - if (!iv.ghost) continue; - if (CANCELLED_STATUS.includes(iv.status)) { + const before = tech.interventions.length; + tech.interventions = tech.interventions.filter(iv => { + if (!iv.ghost) return true; + if (!CANCELLED_STATUS.includes(iv.status)) return true; + if (KEEP_DIAG_GHOSTS) { LOG.info("disparition", - `🛡️ KEEP forcé (refreshStatuses) — ref=${iv.ref || "?"} statusEV='${iv.status}'`, + `🛡️ KEEP forcé (refreshStatuses, case diag ON) — ref=${iv.ref || "?"} statusEV='${iv.status}'`, { ref: iv.ref, actionId: iv.actionId, requestId: iv.requestId, status: iv.status }); + return true; } - } + LOG.warn("disparition", + `🗑 RETIRÉ (refreshStatuses) — ref=${iv.ref || "?"} statusEV='${iv.status}' \u2208 CANCELLED_STATUS`, + { ref: iv.ref, actionId: iv.actionId, requestId: iv.requestId, status: iv.status }); + return false; + }); + void before; } // Sauvegarde finale du cache @@ -6230,7 +6879,7 @@ async function processInterventionsSequentially(techs, isoDate, opts = {}) { const sortedTechs = [...techs].sort((a, b) => compareTechs(a, b, isoDate)); - // R12b : on construit UNIQUEMENT la file des fiches + ghosts (séquentiel, + // on construit UNIQUEMENT la file des fiches + ghosts (séquentiel, // lent). Le xhr2 (rapide, parallèle) est lancé séparément AVANT, par // prefetchAllXhr2 — comme en v42. Voir loadForDate. const ficheQueue = []; @@ -6426,12 +7075,36 @@ async function fetchAndUpdateIntervention(iv, myToken, opts = {}) { return; } - // réponse OK mais TRONQUÉE (< 20 Ko après 3 retries) → ⚠ sur la - // carte + probe de session. Si session morte → bannière + abort tout. + // réponse OK mais TRONQUÉE (< 20 Ko). Avant de probe session, on + // tente un fallback automatique sur la fiche REQUEST (parente) via + // basicAutoComplete + redirectHeader. EZV sert souvent une fiche action + // minimaliste (~8 Ko) pour les iv clôturées, alors que la fiche request + // contient la description complète et le commentaire de résolution. + if (ficheResp.truncated) { + const triedRequestFallback = formLinkToUse !== iv.formLink; + if (!triedRequestFallback && iv.ref) { + console.info(`[seq] iv ${iv.actionId} (ref=${iv.ref}) fiche action tronquée (${ficheResp.size} o) → fallback fiche request via ref…`); + const fresh = await getFreshFicheFormLinkForRef(iv.ref); + if (fresh && fresh !== formLinkToUse) { + formLinkToUse = fresh; + iv.formLink = fresh; + ficheResp = await sendMessage({ type: "fetchFiche", formLink: formLinkToUse }); + if (isRefreshAborted(myToken)) return; + if (ficheResp && ficheResp.ok && !ficheResp.truncated) { + console.info(`[seq] ✓ fallback fiche request OK (${ficheResp.size} o)`); + // tombe naturellement dans le parsing en dessous + } else { + console.warn(`[seq] fallback fiche request encore tronqué (${ficheResp && ficheResp.size} o)`); + } + } + } + } + + // Toujours tronquée après le fallback ? On marque ⚠ + probe session. if (ficheResp.truncated) { iv._fetchWarning = true; iv.ficheFetchError = "truncated"; - console.warn(`[seq] iv ${iv.actionId} (ref=${iv.ref}) fiche tronquée (${ficheResp.size} octets) → probe session…`); + console.warn(`[seq] iv ${iv.actionId} (ref=${iv.ref}) fiche tronquée définitive (${ficheResp.size} octets) → probe session…`); try { const probe = await sendMessage({ type: "checkSession" }); if (!probe || !probe.ok) { @@ -6444,8 +7117,6 @@ async function fetchAndUpdateIntervention(iv, myToken, opts = {}) { } catch (e) { console.warn("[seq] probe session échouée :", e); } - // Session OK mais fiche tronquée → on garde le ⚠ visible, on continue - // (l'iv suivante peut très bien marcher). iv.ficheFetched = true; return; } @@ -6576,7 +7247,7 @@ async function fetchAndUpdateIntervention(iv, myToken, opts = {}) { // (utilisé par "Tout recharger") async function prefetchAllXhr2(techs, myToken, forceAll) { if (!techs) return; - // R12b : retour au comportement v42 — PARALLÈLE (concurrency=6) pour avoir + // retour au comportement v42 — PARALLÈLE (concurrency=6) pour avoir // les infos courtes (contact/lieu) sur TOUTES les cartes en bloc dès le // 1er chargement. Chaque xhr2 fait ~400 octets, donc même 30 iv en // parallèle reste léger pour EV. @@ -6657,7 +7328,7 @@ async function ensureBulleDescription(iv) { } } -// R12x : normalise + stem un libellé statut pour comparaison TRÈS souple. +// normalise + stem un libellé statut pour comparaison TRÈS souple. // - lowercase // - suppression des accents // - suppression des terminaisons verbales courantes (er, ée, ées, és, @@ -7234,6 +7905,13 @@ function renderFromData(data) { renderCaptureInfo(data, stats); renderStats(stats); renderCards(data); + // Détection post-rendu : si une mini-card iv déborde son contenu, + // on masque les gap-placeholder-mini de la même ligne pour + // libérer la place. Appelé via rAF pour avoir les dimensions + // après layout flex. + if (typeof _trimMiniGapsForFit === "function") { + requestAnimationFrame(() => _trimMiniGapsForFit()); + } LOG.info("render", "renderFromData OK", { ms: Math.round(performance.now() - _tStart) }); } catch (err) { LOG.exception("render", "renderFromData a planté", @@ -7355,7 +8033,12 @@ function computeStats(techs, targetDate) { for (const tech of techs) { const isPompier = tech.interventions.some(iv => iv.isPompier); const isAbsent = isTechAbsent(tech, targetDate); - if (isPompier) pompiers++; + // v2026.5.45 (issue #8) : un pompier absent TOUTE la journée ne doit + // pas compter comme pompier disponible. Avant : `if (isPompier) pompiers++` + // → le compteur "1 pompier" restait à 1 même si la personne était en + // congé/maladie sur la journée entière. Maintenant on le compte uniquement + // s'il n'est pas absent toute la journée. + if (isPompier && !isAbsent) pompiers++; if (isAbsent) absents++; // v2026.5.39 : tech disponible = pas absent/malade ET pas réservé toute @@ -7862,8 +8545,23 @@ function buildCard(tech, isoDate) { // une largeur proportionnelle à la durée (flex-grow), un min-width pour // garantir la lisibilité, et des trous représentés par des espaces vides // (préserve l'alignement visuel avec la timeline du dessus). - if (realInterventions.length > 0) { - body.appendChild(_buildMiniCardsRow(realInterventions, card)); + // on inclut aussi les absences partielles (congés d'une heure ou + // demi-journée) ET les réservations dans les mini-cards horizontales, + // pour que le coordinateur les voie sur la vue. + const partialAbsences = absenceBlocks.filter(iv => { + if (iv.isPompier) return false; + const s = timeToMinutes(iv.startTime); + const e = timeToMinutes(iv.endTime); + if (s == null || e == null) return false; // multi-jours sans horaires = full + const DAY_LEN_MIN = 10 * 60; + const cs = Math.max(s, 8 * 60); + const ce = Math.min(e, 18 * 60); + const covered = Math.max(0, ce - cs); + return covered < 0.9 * DAY_LEN_MIN; + }); + const miniCardsList = realInterventions.concat(partialAbsences); + if (miniCardsList.length > 0) { + body.appendChild(_buildMiniCardsRow(miniCardsList, card)); } // Stats de carte @@ -7889,49 +8587,92 @@ function buildCard(tech, isoDate) { // un séparateur dès qu'on bascule de "matin" (<12h) à "après-midi" (>=12h). // Si une période est vide, son séparateur n'est pas affiché. // (Caché en vue horizontale via CSS, vu que les rows sont masquées.) - let _lastPeriod = null; - for (const iv of realInterventions) { - const sMin = timeToMinutes(iv.startTime); - const period = (sMin !== null && sMin < 12 * 60) ? "morning" : "afternoon"; - if (period !== _lastPeriod) { - const sep = document.createElement("div"); - sep.className = "day-period-sep day-period-" + period; - sep.innerHTML = `${period === "morning" ? "Matin" : "Après-midi"}`; - body.appendChild(sep); - _lastPeriod = period; + // refonte — on rend Matin et Après-midi en 2 blocs séparés. + // Chaque période est TOUJOURS rendue (séparateur + iv + gaps), même + // si elle est totalement vide → un gap-placeholder couvre alors toute + // la période (à taille standard, pas de stretch en vue classique). + const MID = 12 * 60; + const _periodBounds = (p) => p === "morning" + ? { start: DAY_START, end: MID } + : { start: MID, end: DAY_END }; + const _addGap = (sMin, eMin) => { + if (sMin == null || eMin == null || eMin <= sMin) return; + const ph = document.createElement("div"); + ph.className = "gap-placeholder"; + ph.dataset.startMin = String(sMin); + ph.dataset.endMin = String(eMin); + ph.dataset.kind = "hole"; + ph.textContent = "—"; + body.appendChild(ph); + _bindGapPlaceholder(ph, card); + if (typeof bindTimelinePopover === "function") bindTimelinePopover(ph); + }; + const _renderPeriod = (periodKey, ivs) => { + const sep = document.createElement("div"); + sep.className = "day-period-sep day-period-" + periodKey; + sep.innerHTML = `${periodKey === "morning" ? "Matin" : "Après-midi"}`; + body.appendChild(sep); + const pb = _periodBounds(periodKey); + if (ivs.length === 0) { + // Période complètement vide → gap-placeholder-full (visible + // même quand l'option "Afficher les pauses" est désactivée). + const ph = document.createElement("div"); + ph.className = "gap-placeholder gap-placeholder-full"; + ph.dataset.startMin = String(pb.start); + ph.dataset.endMin = String(pb.end); + ph.dataset.kind = "hole"; + ph.textContent = "—"; + body.appendChild(ph); + _bindGapPlaceholder(ph, card); + if (typeof bindTimelinePopover === "function") bindTimelinePopover(ph); + return; } - body.appendChild(buildInterventionRow(iv, card)); - } + let _prev = null; + for (let i = 0; i < ivs.length; i++) { + const iv = ivs[i]; + const sMin = timeToMinutes(iv.startTime); + if (i === 0 && sMin !== null && sMin > pb.start) { + _addGap(pb.start, sMin); + } else if (_prev !== null && sMin !== null && sMin > _prev) { + _addGap(_prev, sMin); + } + body.appendChild(buildInterventionRow(iv, card)); + const eMin = timeToMinutes(iv.endTime); + if (eMin !== null) _prev = eMin; + } + if (_prev !== null && _prev < pb.end) _addGap(_prev, pb.end); + }; - // v5.0.15 : afficher aussi les absences partielles (demi-journée) comme - // des rows, avec le même style que les réservations mais en gris foncé. - // Les absences qui couvrent toute la journée sont déjà traitées plus haut - // (carte "Absent toute la journée") et ne doivent pas être dupliquées ici. + // on inclut aussi les absences partielles (1 h, demi-journée) dans + // la séquence triée par heure pour qu'elles s'affichent au bon endroit + // chronologiquement et que les gaps les contournent. + const allItems = realInterventions.slice(); if (!isAbsent) { - const partialAbsences = absenceBlocks.filter(ab => { - if (ab.isPompier) return false; + for (const ab of absenceBlocks) { + if (ab.isPompier) continue; const s = timeToMinutes(ab.startTime); const e = timeToMinutes(ab.endTime); - if (s === null || e === null) return false; - return !(s <= DAY_START && e >= DAY_END); - }); - // Trier par heure de début - partialAbsences.sort((a, b) => (a.startTime || "").localeCompare(b.startTime || "")); - // v2026.5.39 : on continue la logique de séparation matin/après-midi. - // Si une absence partielle change de période, on insère un séparateur. - for (const ab of partialAbsences) { - const sMin = timeToMinutes(ab.startTime); - const period = (sMin !== null && sMin < 12 * 60) ? "morning" : "afternoon"; - if (period !== _lastPeriod) { - const sep = document.createElement("div"); - sep.className = "day-period-sep day-period-" + period; - sep.innerHTML = `${period === "morning" ? "Matin" : "Après-midi"}`; - body.appendChild(sep); - _lastPeriod = period; - } - body.appendChild(buildInterventionRow(ab, card)); + if (s === null || e === null) continue; + if (s <= DAY_START && e >= DAY_END) continue; // full-day déjà géré plus haut + allItems.push(ab); } } + allItems.sort((a, b) => (a.startTime || "").localeCompare(b.startTime || "")); + + const morningItems = []; + const afternoonItems = []; + for (const it of allItems) { + const sMin = timeToMinutes(it.startTime); + if (sMin !== null && sMin < MID) morningItems.push(it); + else afternoonItems.push(it); + } + _renderPeriod("morning", morningItems); + _renderPeriod("afternoon", afternoonItems); + + // les absences partielles sont désormais incluses dans allItems + // ci-dessus (rendues par _renderPeriod en chronologie). Pas de bloc + // séparé à la fin. _renderPeriod crée toujours les 2 séparateurs + // (Matin + Après-midi) même si la période est vide. // le séparateur per-card "X faites" introduit en a été retiré // — l'information est désormais dans la barre stats globale au format @@ -7991,10 +8732,12 @@ function _buildMiniCardsRow(realInterventions, cardEl) { .filter(o => o.s !== null && o.e !== null && o.e > o.s) .sort((a, b) => a.s - b.s); - // v2026.5.40 r9 : structure en 2 BLOCS (Matin / Après-midi) avec un label - // au-dessus de chaque bloc. Plus lisible que la pill au milieu. + // v2026.5.45 : on crée TOUJOURS les 2 blocs (Matin + Après-midi) même + // si l'un est vide, séparés par un élément visuel dédié (.iv-mini-sep). + // Garantit que les coordinateurs voient toujours les 2 demi-journées, + // alignées de la même façon d'une carte tech à l'autre. const blocks = { - morning: null, // créé à la demande + morning: null, afternoon: null }; function _ensureBlock(period) { @@ -8009,10 +8752,18 @@ function _buildMiniCardsRow(realInterventions, cardEl) { cards.className = "iv-mini-block-cards"; block.appendChild(cards); blocks[period] = { root: block, cards }; - row.appendChild(block); return blocks[period]; } - for (const { iv, s } of sorted) { + // on construit l'ordre dès maintenant : matin | séparateur | aprèm. + const morningBlock = _ensureBlock("morning"); + const sepEl = document.createElement("div"); + sepEl.className = "iv-mini-sep"; + sepEl.setAttribute("aria-hidden", "true"); + const afternoonBlock = _ensureBlock("afternoon"); + row.appendChild(morningBlock.root); + row.appendChild(sepEl); + row.appendChild(afternoonBlock.root); + for (const { iv, s, e } of sorted) { const period = (s !== null && s < 12 * 60) ? "morning" : "afternoon"; _ensureBlock(period); const colorKey = deriveColorKey(iv) || "autre"; @@ -8025,6 +8776,9 @@ function _buildMiniCardsRow(realInterventions, cardEl) { if (iv._hasOverlap) card.classList.add("intervention-conflict-overlap"); card.dataset.ivIdx = String(ivIdx); if (iv.ref) card.dataset.ref = iv.ref; + // actionId sur la mini-card pour permettre au reschedule de + // localiser la mini-card source / cible en vue horizontale. + if (iv.actionId) card.dataset.actionId = String(iv.actionId); const lieu = splitLieu(iv.bulleLieu); const ville = (lieu && lieu.ville) ? lieu.ville.toUpperCase() : ""; @@ -8054,30 +8808,58 @@ function _buildMiniCardsRow(realInterventions, cardEl) { tEnd.className = "iv-mini-time-end"; tEnd.textContent = endTime; timeCol.appendChild(tEnd); + // (feature reschedule) : attach handlers sur le bloc heure + // pour clic (modal édition) + drag (déplacement). + if (typeof _attachRescheduleHandlers === "function") { + _attachRescheduleHandlers(timeCol, iv); + } card.appendChild(timeCol); } - // Bloc texte : ref + ville + adresse (3 lignes) + // rendu différencié selon le type d'iv + // - AL-Absence (= congé partiel) → "Congé" (ou catégorie d'absence) + // centré, pas d'autres infos + // - AL-Reservation → reservationLabel (ex "Écrans") + creator si dispo + // - AL-Intervention → ref + ville + adresse (rendu standard) const txt = document.createElement("div"); txt.className = "iv-mini-card-text"; - const lineRef = document.createElement("div"); - lineRef.className = "iv-mini-line iv-mini-ref"; - lineRef.textContent = ref; - txt.appendChild(lineRef); + if (iv.type === "AL-Absence") { + card.classList.add("iv-mini-card-absence"); + const lbl = document.createElement("div"); + lbl.className = "iv-mini-line iv-mini-absence-label"; + // libellé unique "Absence" pour toutes les catégories. + // Détails (maladie/congé/...) restent dans le tooltip au survol. + lbl.textContent = "Absence"; + txt.appendChild(lbl); + } else if (iv.type === "AL-Reservation") { + card.classList.add("iv-mini-card-reservation"); + const lbl = document.createElement("div"); + lbl.className = "iv-mini-line iv-mini-reservation-label"; + lbl.textContent = "Réservation"; + txt.appendChild(lbl); + } else { + // Fallback : si la ref n'a pas été extraite du XML (rare), on + // affiche au moins l'actionId pour que la mini-card ne soit pas + // visuellement vide. + const lineRef = document.createElement("div"); + lineRef.className = "iv-mini-line iv-mini-ref"; + lineRef.textContent = ref || (iv.actionId ? "#" + iv.actionId : "(sans réf)"); + txt.appendChild(lineRef); - if (ville) { - const lineVille = document.createElement("div"); - lineVille.className = "iv-mini-line iv-mini-ville"; - lineVille.textContent = ville; - txt.appendChild(lineVille); - } + if (ville) { + const lineVille = document.createElement("div"); + lineVille.className = "iv-mini-line iv-mini-ville"; + lineVille.textContent = ville; + txt.appendChild(lineVille); + } - if (adresse) { - const lineAdr = document.createElement("div"); - lineAdr.className = "iv-mini-line iv-mini-adresse"; - lineAdr.textContent = adresse; - txt.appendChild(lineAdr); + if (adresse) { + const lineAdr = document.createElement("div"); + lineAdr.className = "iv-mini-line iv-mini-adresse"; + lineAdr.textContent = adresse; + txt.appendChild(lineAdr); + } } card.appendChild(txt); @@ -8146,9 +8928,81 @@ function _buildMiniCardsRow(realInterventions, cardEl) { }); } - // v2026.5.40 r9 : on ajoute la card dans le bloc Matin ou Après-midi - // (period déjà calculé en haut de la boucle) + // priorité MODÉRÉE aux iv : équilibre entre lisibilité iv et + // visibilité des gaps proportionnelle à leur durée. + // - iv : flex-grow = durée × 3 (boost ×3 pour favoriser les iv, + // tout en gardant un long gap visible), shrink 0, basis 100 px. + // Entre 2 iv elles restent proportionnelles à leur durée. + // - gap : flex-grow = durée (proportionnel → un long gap est plus + // large qu'un court), shrink 100 (compresse en priorité + // quand espace serré), basis 30 px. + const blkInfo = blocks[period]; + if (blkInfo) { + const _addMiniGap = (sMin, eMin) => { + if (sMin == null || eMin == null || eMin <= sMin) return; + const dur = eMin - sMin; + const ph = document.createElement("div"); + ph.className = "gap-placeholder gap-placeholder-mini"; + ph.dataset.startMin = String(sMin); + ph.dataset.endMin = String(eMin); + ph.dataset.kind = "hole"; + ph.style.flex = dur + " 100 30px"; + ph.textContent = "—"; + blkInfo.cards.appendChild(ph); + if (cardEl) _bindGapPlaceholder(ph, cardEl); + if (typeof bindTimelinePopover === "function") bindTimelinePopover(ph); + }; + if (blkInfo._lastEndMin == null) { + const pStart = period === "morning" ? DAY_START : 12 * 60; + if (s !== null && s > pStart) _addMiniGap(pStart, s); + } else if (s !== null && s > blkInfo._lastEndMin) { + _addMiniGap(blkInfo._lastEndMin, s); + } + } + const ivDuration = (e - s); + if (ivDuration > 0) card.style.flex = (ivDuration * 3) + " 0 100px"; blocks[period].cards.appendChild(card); + const cardEndMin = timeToMinutes(iv.endTime); + if (cardEndMin !== null) blocks[period]._lastEndMin = cardEndMin; + } + + // à la fin de chaque bloc, gap [endLastMini, fin_période] si + // la dernière mini-card ne finit pas pile à la fin de la période. + // Si le bloc n'a aucune mini-card (totalement vide), on couvre TOUTE + // la période avec un seul gap-placeholder — remplace l'ancien + // .iv-mini-block-empty. + for (const period of ["morning", "afternoon"]) { + const blk = blocks[period]; + if (!blk) continue; + const pStart = period === "morning" ? DAY_START : 12 * 60; + const pEnd = period === "morning" ? 12 * 60 : DAY_END; + if (blk.cards.children.length === 0) { + const ph = document.createElement("div"); + // .gap-placeholder-full → CSS étire à toute la largeur du + // bloc en horizontal (uniquement quand bloc complètement vide). + ph.className = "gap-placeholder gap-placeholder-mini gap-placeholder-full"; + ph.dataset.startMin = String(pStart); + ph.dataset.endMin = String(pEnd); + ph.dataset.kind = "hole"; + ph.textContent = "—"; + blk.cards.appendChild(ph); + if (cardEl) _bindGapPlaceholder(ph, cardEl); + if (typeof bindTimelinePopover === "function") bindTimelinePopover(ph); + } else if (blk._lastEndMin != null && blk._lastEndMin < pEnd) { + // Gap "après dernière mini-card" du bloc (ex: dernière iv finit + // à 11h30 → gap [11h30, 12h00] visible en fin de matinée). + const dur = pEnd - blk._lastEndMin; + const ph = document.createElement("div"); + ph.className = "gap-placeholder gap-placeholder-mini"; + ph.dataset.startMin = String(blk._lastEndMin); + ph.dataset.endMin = String(pEnd); + ph.dataset.kind = "hole"; + ph.style.flex = dur + " 100 30px"; + ph.textContent = "—"; + blk.cards.appendChild(ph); + if (cardEl) _bindGapPlaceholder(ph, cardEl); + if (typeof bindTimelinePopover === "function") bindTimelinePopover(ph); + } } return row; @@ -8202,13 +9056,26 @@ function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, merged.push([s, e]); } } + // on découpe TOUJOURS les holes à 12h00 pour qu'ils correspondent + // exactement aux gap-placeholder par demi-journée (sinon un hole qui + // chevauche midi se retrouve avec un data-gap-idx différent du gap + // côté carte → pas de surbrillance bidirectionnelle). + const _MID_TIMELINE = 12 * 60; + const _pushHole = (arr, s, e) => { + if (s < _MID_TIMELINE && e > _MID_TIMELINE) { + arr.push([s, _MID_TIMELINE]); + arr.push([_MID_TIMELINE, e]); + } else { + arr.push([s, e]); + } + }; const holes = []; let cursor = DAY_START; for (const [s, e] of merged) { - if (s > cursor) holes.push([cursor, s]); + if (s > cursor) _pushHole(holes, cursor, s); cursor = Math.max(cursor, e); } - if (cursor < DAY_END) holes.push([cursor, DAY_END]); + if (cursor < DAY_END) _pushHole(holes, cursor, DAY_END); if (!isAbsent) { for (const [s, e] of holes) { @@ -8220,6 +9087,14 @@ function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, h.dataset.startMin = s; h.dataset.endMin = e; h.dataset.kind = "hole"; + // idx dérivé des bornes — identique au gap-placeholder qui + // a les mêmes bornes. Permet le highlight bidirectionnel même + // si _bindGapPlaceholder ne retrouve pas le hole (les listeners + // sont attachés ici au hole, indépendamment). + const gapIdx = s + "-" + e; + h.dataset.gapIdx = gapIdx; + h.addEventListener("mouseenter", () => highlightGap(cardEl, gapIdx, true)); + h.addEventListener("mouseleave", () => highlightGap(cardEl, gapIdx, false)); bindTimelinePopover(h); bar.appendChild(h); } @@ -8628,8 +9503,1649 @@ function _showTimelinePopoverImpl(e, el) { } // ============================================================================ -// Ligne d'interventoin +// Feature reschedule (modifier horaire / déplacer iv) — viewer side // ============================================================================ +// +// L'utilisateur peut, depuis le bloc heure (à gauche) d'une intervention : +// - cliquer pour ouvrir un modal d'édition (date, heure début, heure fin, tech) +// - drag pour déplacer la card sur le planning, avec preview visuel sur la +// timeline du tech survolé. Mouseup → modal de confirmation, puis appel +// à l'API EZV (Planning_schedule_action_Employee + éventuellement +// fc_save_inspector si la durée a changé). +// +// Désactivé pour : non-AL-Intervention, et iv déjà clos / résolu / fait / +// suspendu (rien à modifier sur un ticket fini). + +function _canRescheduleIv(iv) { + // Conditions strictement nécessaires : il faut une vraie intervention + // avec un actionId et des horaires pour pouvoir construire la requête + // de reschedule API. + if (!iv) return false; + if (iv.type !== "AL-Intervention") return false; + if (!iv.actionId) return false; + if (!iv.startTime || !iv.endTime) return false; + // v2026.5.45 — bloque le drag d'une iv FAIT (terminated-pending, gris + // « fait ») ou CLOSE (terminated-clos vert ✓✓ / statut EV Clôturé / + // Résolu / Terminé). On ne bloque PAS "Suspendu" (jaune ✓) qui reste + // déplaçable, ni "cancelled" (déjà retirée du planning de toute façon). + // Les iv revenues dans le fresh ont leurs flags _diagnosticVerdict reset + // côté merge (cf. ~L5733), donc pas de blocage sur du stale. + const verdict = iv._diagnosticVerdict; + if (verdict === "terminated-pending" || verdict === "terminated-clos") return false; + if (typeof isClosedStatus === "function" && isClosedStatus(iv.status)) return false; + if (typeof isResolvedStatus === "function" && isResolvedStatus(iv.status)) return false; + // v2026.5.45 — bloque aussi tant que l'analyse de disparition est en + // cours (analyzeOneDisappearedIv pose iv._disappearChecking=true au start + // et le repasse à false à la fin via verdict() ou le catch). On évite que + // l'utilisateur tente de déplacer une iv dont l'état réel n'est pas encore + // déterminé — sinon on pourrait drop sur un ticket qui sera retiré juste + // après par le verdict (cancelled). + if (iv._disappearChecking === true) return false; + return true; +} + +// Helpers de format / conversion +function _isoToDDMMYYYY(iso) { + // "2026-05-04" → "04/05/2026" + if (!iso || typeof iso !== "string") return ""; + const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/); + return m ? `${m[3]}/${m[2]}/${m[1]}` : ""; +} +function _isoToDDMMYYYYNoSep(iso) { + // "2026-05-04" → "04052026" + if (!iso) return ""; + const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/); + return m ? `${m[3]}${m[2]}${m[1]}` : ""; +} +function _ddmmyyyyToIso(ddmmyyyy) { + // "04/05/2026" → "2026-05-04" + if (!ddmmyyyy) return ""; + const m = String(ddmmyyyy).match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + return m ? `${m[3]}-${m[2]}-${m[1]}` : ""; +} +function _hhmmToMin(hhmm) { + if (!hhmm) return null; + const m = String(hhmm).match(/^(\d{1,2}):(\d{2})$/); + if (!m) return null; + return parseInt(m[1], 10) * 60 + parseInt(m[2], 10); +} +function _minToHHMM(min) { + if (typeof min !== "number" || isNaN(min)) return ""; + const h = Math.floor(min / 60); + const m = min % 60; + return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); +} + +// Snap à 30 min (granularité EZV par défaut). +function _snap30(min) { + return Math.round(min / 30) * 30; +} + +// Trouve le tech destinataire à partir d'un élément DOM (pour le drag). +// Retourne {techId, techName, cardEl} ou null. +function _findTechTargetFromElement(el) { + if (!el || !el.closest) return null; + const card = el.closest(".card"); + if (!card) return null; + const techId = card.dataset.techId; + if (!techId) return null; + // Récupère le nom depuis l'objet état + let techName = ""; + if (state.currentData && state.currentData.techs) { + const t = state.currentData.techs.find(t => String(t.id) === String(techId)); + if (t) techName = t.name || techId; + } + return { techId, techName, cardEl: card }; +} + +// Convertit la position X du curseur sur la timeline d'un tech en heure +// (snappée à 30 min). Retourne {hour, minute, totalMin}. +// on accepte aussi clientX hors plage de la timeline-bar (le user +// peut être sur la card-body, à gauche/droite des heures) — on clampe +// xRatio à [0, 1] au lieu de retourner null. Permet de droper depuis +// n'importe où dans la card, pas seulement sur la timeline. +// check si le tech cible est absent toute la journée — refuse le +// drop dans ce cas (pas d'iv possible). +function _isTargetTechAbsentAllDay(techId) { + if (!state.currentData || !state.currentData.techs) return false; + const tech = state.currentData.techs.find(t => String(t.id) === String(techId)); + if (!tech) return false; + try { + return typeof isTechAbsent === "function" + ? !!isTechAbsent(tech, state.currentDate || "") + : false; + } catch (e) { return false; } +} + +// retire à la fois le clone in-card et la barre timeline. +function _clearReschedulePlaceholders() { + if (_rescheduleDrag.placeholder) { + try { _rescheduleDrag.placeholder.el.remove(); } catch (e) {} + _rescheduleDrag.placeholder = null; + } + if (_rescheduleDrag.timelineMark) { + try { _rescheduleDrag.timelineMark.el.remove(); } catch (e) {} + _rescheduleDrag.timelineMark = null; + } +} + +function _cursorToTimeOnTimeline(timelineBarEl, clientX) { + if (!timelineBarEl) return null; + const r = timelineBarEl.getBoundingClientRect(); + if (r.width <= 0) return null; + let xRatio = (clientX - r.left) / r.width; + if (xRatio < 0) xRatio = 0; + else if (xRatio > 1) xRatio = 1; + const dayLen = (typeof DAY_LEN === "number" && DAY_LEN > 0) ? DAY_LEN : 600; + const dayStart = (typeof DAY_START === "number") ? DAY_START : 480; + let totalMin = dayStart + xRatio * dayLen; + totalMin = _snap30(totalMin); + if (totalMin < dayStart) totalMin = dayStart; + if (totalMin > dayStart + dayLen) totalMin = dayStart + dayLen; + return { hour: Math.floor(totalMin / 60), minute: totalMin % 60, totalMin }; +} + +// calcule l'heure projetée quand le curseur est dans la card-body +// (pas sur la timeline). Logique simple : "au-dessus / en-dessous" des iv +// existantes — l'heure se cale pour s'enchaîner avec la voisine sans +// chevauchement. Pour la précision exacte, le user repasse sur la +// timeline. +// +// - Vue classique : l'ordre est vertical (clientY) +// - Vue horizontale : l'ordre est horizontal (clientX) dans le bloc +// Matin/Après-midi survolé +function _cardCursorToProjectedStart(targetCard, ev, durationMin, isHorizontal) { + if (!targetCard) return null; + + // Scope = card-body en classique, bloc Matin/Aprèm en horizontal + let scope = targetCard; + let blockPeriod = null; + if (isHorizontal) { + // les blocs Matin/Aprèm sont côte à côte horizontalement → + // discrimination par clientX, pas clientY (avant on prenait toujours + // le 1er bloc, donc impossible de cibler l'aprèm). + const blocks = targetCard.querySelectorAll(".iv-mini-block"); + for (const b of blocks) { + const r = b.getBoundingClientRect(); + if (ev.clientX >= r.left && ev.clientX <= r.right + && ev.clientY >= r.top && ev.clientY <= r.bottom) { + scope = b; + if (b.classList.contains("period-morning")) blockPeriod = "morning"; + else if (b.classList.contains("period-afternoon")) blockPeriod = "afternoon"; + break; + } + } + } + + const childSel = isHorizontal ? ".iv-mini-card" : ".intervention-v2"; + const techId = targetCard.dataset.techId; + const tech = (state.currentData && state.currentData.techs) + ? state.currentData.techs.find(t => String(t.id) === String(techId)) + : null; + + const items = []; + if (tech) { + const children = Array.from(scope.querySelectorAll(childSel)).filter(c => + !c.classList.contains("reschedule-source-hidden") && + !c.classList.contains("reschedule-drop-ghost")); + for (const child of children) { + const aid = child.dataset.actionId; + if (!aid) continue; + const iv = (tech.interventions || []).find(it => String(it.actionId) === String(aid)); + if (!iv || !iv.startTime || !iv.endTime) continue; + const sMin = _hhmmToMin(iv.startTime); + const eMin = _hhmmToMin(iv.endTime); + if (sMin == null || eMin == null) continue; + const r = child.getBoundingClientRect(); + items.push({ + startMin: sMin, + endMin: eMin, + top: isHorizontal ? r.left : r.top, + bottom: isHorizontal ? r.right : r.bottom + }); + } + } + items.sort((a, b) => a.startMin - b.startMin); + + const dayStart = (typeof DAY_START === "number") ? DAY_START : 480; + const dayLen = (typeof DAY_LEN === "number" && DAY_LEN > 0) ? DAY_LEN : 600; + const dayEnd = dayStart + dayLen; + + if (items.length === 0) { + // si on est sur un bloc horizontal vide → heure d'aprèm/matin + // (13h00 / 8h00) pour permettre le placement même quand le bloc est + // vide. En vue classique, on retombe sur l'heure d'origine. + if (blockPeriod === "afternoon") return 13 * 60; + if (blockPeriod === "morning") return Math.max(dayStart, 8 * 60); + const orig = _hhmmToMin(_rescheduleDrag.iv.startTime); + return orig != null ? orig : dayStart; + } + + const cursor = isHorizontal ? ev.clientX : ev.clientY; + + // logique fondée sur les bords, pas les centres : + // • cursor < top du 1er → avant le 1er + // • cursor dans le gap entre items[i] et items[i+1] → après items[i] + // • cursor sur items[i] → moitié haute = avant items[i] (= après i-1), + // moitié basse = après items[i] + // • cursor > bottom du dernier → après le dernier + // Beaucoup plus permissif pour insérer entre 2 iv collées : il suffit + // de viser n'importe quelle des moitiés concernées (haut de la 2e ou + // bas de la 1re). + // (vue classique) : zones virtuelles Matin/Aprèm — si toutes les iv + // visibles sont au matin et le curseur est sous la dernière → on cible + // l'aprèm vide (13h00). Symétriquement matin si toutes en aprèm + curseur + // au-dessus de la 1re. Évite d'avoir à ajouter une ligne séparatrice au + // rendu de la card pour exposer la zone aprèm. + const last = items[items.length - 1]; + const allMorning = !isHorizontal && items.every(it => it.startMin < 12 * 60); + const allAfternoon = !isHorizontal && items.every(it => it.startMin >= 12 * 60); + + if (cursor < items[0].top) { + if (allAfternoon) return Math.max(dayStart, 8 * 60); + return Math.max(dayStart, items[0].startMin - durationMin); + } + for (let i = 0; i < items.length; i++) { + const it = items[i]; + if (cursor < it.top) { + return items[i - 1].endMin; + } + if (cursor <= it.bottom) { + const mid = (it.top + it.bottom) / 2; + if (cursor < mid) { + if (i === 0) return Math.max(dayStart, it.startMin - durationMin); + return items[i - 1].endMin; + } + return it.endMin; + } + } + if (allMorning && cursor > last.bottom + 4) return 13 * 60; + return Math.min(dayEnd - durationMin, last.endMin); +} + +// Liste des techs visibles dans le planning courant — pour le select de la +// modal d'édition. Inclut le tech de l'iv même si pas dans la liste actuelle. +function _getRescheduleTechOptions(currentIv) { + const out = []; + if (state.currentData && state.currentData.techs) { + for (const t of state.currentData.techs) { + out.push({ id: String(t.id), name: t.name || String(t.id) }); + } + } + // Si le tech actuel n'est pas dans la liste (rare mais possible) → l'ajouter + if (currentIv && currentIv.techId && !out.some(o => o.id === String(currentIv.techId))) { + out.unshift({ id: String(currentIv.techId), name: currentIv.techName || String(currentIv.techId) }); + } + return out; +} + +// ─── Modal d'édition (clic court sur le bloc heure) ───────────────────────── + +function _showRescheduleEditModal(iv) { + const existing = document.getElementById("reschedule-edit-modal"); + if (existing) existing.remove(); + + const overlay = document.createElement("div"); + overlay.id = "reschedule-edit-modal"; + overlay.className = "modal-overlay"; + const card = document.createElement("div"); + card.className = "modal-card reschedule-modal"; + + const h = document.createElement("h2"); + h.className = "modal-title"; + h.textContent = "Modifier l'horaire / déplacer"; + card.appendChild(h); + + const sub = document.createElement("p"); + sub.className = "modal-message"; + sub.style.cssText = "font-size:12px; color:var(--text-muted); margin:0 0 14px 0;"; + sub.textContent = `${iv.ref || "(sans ref)"} — ${iv.techName || ""}`; + card.appendChild(sub); + + // Form layout + const form = document.createElement("div"); + form.style.cssText = "display:flex; flex-direction:column; gap:10px; margin-bottom:18px;"; + + // Date + const dateRow = document.createElement("label"); + dateRow.style.cssText = "display:flex; align-items:center; gap:10px;"; + const dateLabel = document.createElement("span"); + dateLabel.textContent = "Date"; + dateLabel.style.cssText = "min-width:90px; font-size:13px; color:var(--text-muted);"; + const dateInput = document.createElement("input"); + dateInput.type = "date"; + dateInput.className = "admin-input"; + dateInput.value = state.currentDate || ""; + dateRow.appendChild(dateLabel); + dateRow.appendChild(dateInput); + form.appendChild(dateRow); + + // "Heure début" en 2 selects (heure 8-18 + minute 00/15/30/45) + // pour que les minutes soient toujours logiques. + champ "Durée" en + // slider 30 min → 4h par tranche de 15 min. endTime se calcule à + // partir de startTime + durée. + const ivStartMin = _hhmmToMin(iv.startTime) || (8 * 60); + const ivEndMin = _hhmmToMin(iv.endTime) || (ivStartMin + 60); + const ivDurationMin = Math.max(15, ivEndMin - ivStartMin); + + const startRow = document.createElement("label"); + startRow.style.cssText = "display:flex; align-items:center; gap:10px;"; + const startLabel = document.createElement("span"); + startLabel.textContent = "Heure début"; + startLabel.style.cssText = "min-width:90px; font-size:13px; color:var(--text-muted);"; + + const hourSelect = document.createElement("select"); + hourSelect.className = "admin-select"; + hourSelect.style.cssText = "min-width:64px;"; + for (let h = 8; h <= 18; h++) { + const o = document.createElement("option"); + o.value = String(h); + o.textContent = String(h).padStart(2, "0") + " h"; + if (h === Math.floor(ivStartMin / 60)) o.selected = true; + hourSelect.appendChild(o); + } + + const minSelect = document.createElement("select"); + minSelect.className = "admin-select"; + minSelect.style.cssText = "min-width:64px;"; + for (const m of [0, 15, 30, 45]) { + const o = document.createElement("option"); + o.value = String(m); + o.textContent = String(m).padStart(2, "0"); + if (m === ivStartMin % 60) o.selected = true; + minSelect.appendChild(o); + } + + const sep = document.createElement("span"); + sep.textContent = ":"; + sep.style.cssText = "color:var(--text-muted); font-weight:700;"; + + startRow.appendChild(startLabel); + startRow.appendChild(hourSelect); + startRow.appendChild(sep); + startRow.appendChild(minSelect); + form.appendChild(startRow); + + // Durée — slider 15 min → 4h (240 min), step 15 min + const durRow = document.createElement("label"); + durRow.style.cssText = "display:flex; align-items:center; gap:10px;"; + const durLabel = document.createElement("span"); + durLabel.textContent = "Durée"; + durLabel.style.cssText = "min-width:90px; font-size:13px; color:var(--text-muted);"; + const durSlider = document.createElement("input"); + durSlider.type = "range"; + durSlider.min = "15"; + durSlider.max = "240"; + durSlider.step = "15"; + durSlider.value = String(Math.min(240, Math.max(15, ivDurationMin))); + durSlider.style.cssText = "flex:1 1 auto;"; + const durDisplay = document.createElement("span"); + durDisplay.style.cssText = "min-width:64px; font-family:var(--mono,monospace); font-weight:700;"; + function _fmtDur(min) { + const h = Math.floor(min / 60); + const m = min % 60; + if (h === 0) return `${m} min`; + if (m === 0) return `${h} h`; + return `${h} h ${String(m).padStart(2, "0")}`; + } + durDisplay.textContent = _fmtDur(parseInt(durSlider.value, 10)); + durSlider.addEventListener("input", () => { + durDisplay.textContent = _fmtDur(parseInt(durSlider.value, 10)); + }); + durRow.appendChild(durLabel); + durRow.appendChild(durSlider); + durRow.appendChild(durDisplay); + form.appendChild(durRow); + + // Tech + const techRow = document.createElement("label"); + techRow.style.cssText = "display:flex; align-items:center; gap:10px;"; + const techLabel = document.createElement("span"); + techLabel.textContent = "Technicien"; + techLabel.style.cssText = "min-width:90px; font-size:13px; color:var(--text-muted);"; + const techSelect = document.createElement("select"); + techSelect.className = "admin-select"; + for (const opt of _getRescheduleTechOptions(iv)) { + const o = document.createElement("option"); + o.value = opt.id; + o.textContent = opt.name; + if (String(iv.techId) === opt.id) o.selected = true; + techSelect.appendChild(o); + } + techRow.appendChild(techLabel); + techRow.appendChild(techSelect); + form.appendChild(techRow); + + card.appendChild(form); + + // Boutons + const actions = document.createElement("div"); + actions.className = "modal-actions"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "btn"; + cancelBtn.textContent = "Annuler"; + cancelBtn.addEventListener("click", () => overlay.remove()); + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.className = "btn btn-primary"; + saveBtn.textContent = "Enregistrer"; + saveBtn.addEventListener("click", async () => { + const newDateIso = dateInput.value; + const sH = parseInt(hourSelect.value, 10); + const sM = parseInt(minSelect.value, 10); + const dur = parseInt(durSlider.value, 10); + const sMin = sH * 60 + sM; + const eMin = sMin + dur; + const newStart = `${String(sH).padStart(2, "0")}:${String(sM).padStart(2, "0")}`; + const newEnd = `${String(Math.floor(eMin / 60)).padStart(2, "0")}:${String(eMin % 60).padStart(2, "0")}`; + const newTechId = techSelect.value; + if (!newDateIso || isNaN(sMin) || isNaN(dur) || dur <= 0) { + showToast("Champs invalides", "Vérifie date / heure / durée"); + return; + } + if (eMin > 24 * 60) { + showToast("Durée invalide", "L'heure de fin dépasse minuit"); + return; + } + saveBtn.disabled = true; + saveBtn.textContent = "…"; + // on ferme la modal 1 seconde après le clic — le user voit le + // feedback "…" puis la modal disparaît, sans rester figée pendant + // toute la durée de l'API call + refresh. + setTimeout(() => { try { overlay.remove(); } catch (e) {} }, 1000); + try { + await _applyReschedule(iv, { + newDateIso, + newStartTime: newStart, + newEndTime: newEnd, + newTechId + }); + } catch (err) { + LOG.warn("reschedule", "edit modal apply err", { err: err && err.message }); + showToast("Échec", err.message || "Impossible d'appliquer la modification"); + } + }); + actions.appendChild(cancelBtn); + actions.appendChild(saveBtn); + card.appendChild(actions); + + overlay.appendChild(card); + document.body.appendChild(overlay); + + // Échap ferme + const escHandler = (e) => { + if (e.key === "Escape") { + overlay.remove(); + document.removeEventListener("keydown", escHandler); + } + }; + document.addEventListener("keydown", escHandler); +} + +// ─── Drag tracking + ghost flottant + drop-ghost à destination ────────────── + +const _rescheduleDrag = { + active: false, + iv: null, + startX: 0, startY: 0, + durationMin: 0, + ghost: null, // ghost flottant qui suit le curseur (clone visuel) + placeholder: null, // clone in-card grisé à la position chrono cible + timelineMark: null, // petite barre verte sur la timeline-bar cible + hiddenSourceEls: null, + lastTarget: null // { techId, techName, cardEl, startMin, endMin } +}; + +function _attachRescheduleHandlers(timeEl, iv) { + if (!_canRescheduleIv(iv)) return; + timeEl.classList.add("editable-time"); + timeEl.setAttribute("title", "Cliquer pour modifier — glisser pour déplacer"); + + // Empêcher le tooltip live de s'afficher quand la souris est sur ce bloc. + timeEl.addEventListener("mouseenter", (e) => { + e.stopPropagation(); + if (typeof hideTooltip === "function") { + try { hideTooltip({ force: true }); } catch (err) {} + } + }); + // quand la souris quitte la zone heure pour revenir dans le + // reste de la carte (zone texte, etc.), le mouseenter de la card + // parente ne se redéclenche pas (on n'est jamais ressorti). Du coup + // le tooltip force-hidé ci-dessus ne se ré-affichait plus tant qu'on + // ne sortait pas complètement de la card. On le re-déclenche + // manuellement ici. + timeEl.addEventListener("mouseleave", (e) => { + const container = timeEl.closest(".intervention-v2, .iv-mini-card"); + if (!container) return; + if (e.relatedTarget && container.contains(e.relatedTarget)) { + if (typeof showTooltip === "function") { + try { showTooltip(e, iv, container); } catch (err) {} + } + } + }); + + // mousedown → setup, on attend le mousemove pour basculer en drag réel. + timeEl.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; // bouton gauche uniquement + e.preventDefault(); + e.stopPropagation(); + _startReschedulePotentialDrag(iv, e); + }); + + // Click direct (sans drag) → modal d'édition. + timeEl.addEventListener("click", (e) => { + e.stopPropagation(); + e.preventDefault(); + if (_rescheduleDrag.justDragged) { + _rescheduleDrag.justDragged = false; + return; + } + _showRescheduleEditModal(iv); + }); +} + +function _startReschedulePotentialDrag(iv, ev) { + const sMin = _hhmmToMin(iv.startTime); + const eMin = _hhmmToMin(iv.endTime); + if (sMin == null || eMin == null) return; + _rescheduleDrag.active = false; + _rescheduleDrag.justDragged = false; + _rescheduleDrag.iv = iv; + _rescheduleDrag.startX = ev.clientX; + _rescheduleDrag.startY = ev.clientY; + _rescheduleDrag.durationMin = Math.max(30, eMin - sMin); + + const onMove = (e) => { + if (!_rescheduleDrag.iv) return; + const dx = e.clientX - _rescheduleDrag.startX; + const dy = e.clientY - _rescheduleDrag.startY; + if (!_rescheduleDrag.active) { + // Seuil de déclenchement du drag : 5 px + if (Math.hypot(dx, dy) < 5) return; + _activateRescheduleDrag(e); + } + _updateRescheduleGhost(e); + _ivDockOnDragMove(e); + }; + const detach = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.removeEventListener("keydown", onKey, true); + }; + const onUp = (e) => { + detach(); + if (_rescheduleDrag.active) { + _finalizeRescheduleDrag(e); + _rescheduleDrag.justDragged = true; + } + _cleanupRescheduleDrag(); + }; + // Échap annule le drag — pas de POST EZV, pas de modal de + // confirmation, on remet l'iv source à sa place visuellement (cleanup + // restaure les éléments masqués). + const onKey = (e) => { + if (e.key !== "Escape") return; + e.preventDefault(); + e.stopPropagation(); + detach(); + if (_rescheduleDrag.active) { + LOG.info("reschedule", "drag cancelled (Escape)", + { actionId: _rescheduleDrag.iv && _rescheduleDrag.iv.actionId }); + _rescheduleDrag.justDragged = true; // évite que le mouseup résiduel re-trigger un click + } + // Si on draguait depuis le dock, on remet l'iv dedans. + if (_ivDockPendingRestore) { + _ivDock.items.set(String(_ivDockPendingRestore.actionId), _ivDockPendingRestore); + _ivDockPendingRestore = null; + _ivDockRender(); + _ivDockUpdateVisibility(); + } + _cleanupRescheduleDrag(); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.addEventListener("keydown", onKey, true); +} + +function _activateRescheduleDrag(ev) { + _rescheduleDrag.active = true; + document.documentElement.classList.add("reschedule-dragging"); + // v2026.5.45 — drag effectivement démarré (5 px franchi) : si l'iv + // vient du dock, c'est MAINTENANT qu'on la retire (mémorisée dans + // _ivDockPendingRestore pour les chemins d'annulation : Échap / drop hors + // zone / refus EV → restauration dans le dock). + const _ivDrag = _rescheduleDrag.iv; + if (_ivDrag && _ivDrag._fromDock && _ivDrag._dockSnap) { + const _aid = String(_ivDrag._dockSnap.actionId); + if (_ivDock.items.has(_aid)) { + _ivDock.items.delete(_aid); + _ivDockPendingRestore = _ivDrag._dockSnap; + _ivDockRender(); + } + } + // Dès qu'un drag commence, le dock latéral apparaît (peep s'il a déjà + // des cartes, dropzone si vide). + if (typeof _ivDockUpdateVisibility === "function") _ivDockUpdateVisibility(); + + const iv = _rescheduleDrag.iv; + const aid = String(iv.actionId); + + // le ghost flottant intègre un CLONE VISUEL de la carte source + // (row .intervention-v2 en vue classique, mini-card .iv-mini-card en + // horizontale) — ainsi le user voit littéralement "sa carte" suivre + // le curseur et pas juste un encadré texte. Le clone est figé à la + // taille de la source au moment du drag pour rester reconnaissable. + const isHorizontal = document.documentElement.classList.contains("view-horizontal"); + const srcSel = isHorizontal + ? `.iv-mini-card[data-action-id="${aid}"]` + : `.intervention-v2[data-action-id="${aid}"]`; + let srcEl = document.querySelector(srcSel); + let measuredWidth = 0; + // Iv venant du dock : pas de DOM source dans le planning courant. On + // reconstruit le visuel à partir du HTML capturé au dock-add → la carte + // reprend sa forme standard pendant le drag. + if (!srcEl && iv._dockHtml) { + const tmp = document.createElement("div"); + tmp.innerHTML = iv._dockHtml; + srcEl = tmp.firstElementChild; + } + if (srcEl && srcEl.isConnected) { + measuredWidth = srcEl.getBoundingClientRect().width; + } + + const ghost = document.createElement("div"); + ghost.className = "reschedule-ghost"; + if (isHorizontal) ghost.classList.add("reschedule-ghost-horizontal"); + + if (srcEl) { + const visual = srcEl.cloneNode(true); + visual.classList.add("reschedule-ghost-visual"); + visual.classList.remove("reschedule-source-hidden"); + visual.removeAttribute("data-iv-idx"); + visual.removeAttribute("data-action-id"); + visual.removeAttribute("data-click-bound"); + if (measuredWidth > 0) visual.style.width = Math.round(measuredWidth) + "px"; + // v2026.5.45 — drag DEPUIS le dock : l'heure d'origine n'a plus de + // sens (l'iv attend d'être reposée à un nouveau créneau). On retire le + // bloc heure à gauche (.iv-time-vertical en classique, .iv-mini-time-vertical + // en horizontale). Référence, adresse, contact, problème, etc. restent + // visibles pour identifier la carte. La projection horaire pendant le + // drag s'affiche dans le rg-meta du ghost flottant. + if (iv._fromDock) { + visual.querySelectorAll(".iv-time-vertical, .iv-mini-time-vertical").forEach(el => el.remove()); + } + ghost.appendChild(visual); + } + + const meta = document.createElement("div"); + meta.className = "rg-meta"; + meta.innerHTML = + `

${escapeHtml(iv.startTime)} → ${escapeHtml(iv.endTime)}
` + + `
durée ${_rescheduleDrag.durationMin} min
`; + ghost.appendChild(meta); + + document.body.appendChild(ghost); + _rescheduleDrag.ghost = ghost; + _positionRescheduleGhost(ev); + + // ordre important pour ne pas rétrécir la card d'origine — + // 1. d'abord on insère le clone à destination à la position d'origine + // (mêmes heures, même tech), pour qu'il prenne déjà la place, + // 2. puis on hide la source en display:none. La place perdue par la + // source est immédiatement reprise par le clone (même tick DOM). + const srcCard = (() => { + const aid = String(iv.actionId); + const srcEl = document.querySelector( + `.intervention-v2[data-action-id="${aid}"], .iv-mini-card[data-action-id="${aid}"]`); + return srcEl ? srcEl.closest(".card") : null; + })(); + if (srcCard) { + const sStart = _hhmmToMin(iv.startTime); + const sEnd = _hhmmToMin(iv.endTime); + if (sStart != null && sEnd != null) { + _updateReschedulePlaceholder(srcCard, sStart, sEnd); + } + } + _hideRescheduleSource(iv); + + LOG.info("reschedule", "drag started", + { actionId: iv.actionId, durationMin: _rescheduleDrag.durationMin }); +} + +function _hideRescheduleSource(iv) { + const aid = String(iv.actionId); + _rescheduleDrag.hiddenSourceEls = []; + + // Rows .intervention-v2 + mini-cards .iv-mini-card portant l'actionId source. + document.querySelectorAll( + `.intervention-v2[data-action-id="${aid}"], ` + + `.iv-mini-card[data-action-id="${aid}"]` + ).forEach(el => { + el.classList.add("reschedule-source-hidden"); + _rescheduleDrag.hiddenSourceEls.push(el); + }); + + // Segments timeline (classique + horizontale) : pas de data-action-id + // direct, on les retrouve via la row .intervention-v2 ayant le même + // ivIdx dans la même card. + document.querySelectorAll(`.intervention-v2[data-action-id="${aid}"]`).forEach(row => { + const card = row.closest(".card"); + const ivIdx = row.dataset.ivIdx; + if (!card || ivIdx == null) return; + card.querySelectorAll(`.timeline-slot[data-iv-idx="${ivIdx}"]`).forEach(slot => { + slot.classList.add("reschedule-source-hidden"); + _rescheduleDrag.hiddenSourceEls.push(slot); + }); + }); +} + +function _restoreRescheduleSource() { + if (_rescheduleDrag.hiddenSourceEls) { + for (const el of _rescheduleDrag.hiddenSourceEls) { + el.classList.remove("reschedule-source-hidden"); + } + } + _rescheduleDrag.hiddenSourceEls = null; +} + +function _positionRescheduleGhost(ev) { + const g = _rescheduleDrag.ghost; + if (!g) return; + g.style.left = (ev.clientX + 14) + "px"; + g.style.top = (ev.clientY + 14) + "px"; +} + +function _updateRescheduleGhost(ev) { + _positionRescheduleGhost(ev); + + const g = _rescheduleDrag.ghost; + if (!g) return; + + // v2026.5.45 — si le curseur est dans le rect visible du dock latéral, + // on coupe net : pas de placeholder ni de marqueur timeline sur le tech qui + // se trouve "en dessous" visuellement. Le drop sur le dock reste géré par + // _ivDockHitTest dans le mouseup. getBoundingClientRect tient compte du + // transform (peep-min / peep / expanded) donc on a le rect réel à l'écran. + const dockEl = document.getElementById("iv-dock"); + if (dockEl && !dockEl.classList.contains("iv-dock--hidden")) { + const dr = dockEl.getBoundingClientRect(); + if (dr.width > 0 && dr.height > 0 + && ev.clientX >= dr.left && ev.clientX <= dr.right + && ev.clientY >= dr.top && ev.clientY <= dr.bottom) { + _rescheduleDrag.lastTarget = null; + g.classList.remove("on-target", "on-blocked"); + _clearReschedulePlaceholders(); + _updateRescheduleGhostText(null); + return; + } + } + + // On masque temporairement le ghost pour que elementFromPoint trouve + // l'élément dessous (sinon il pointe sur le ghost lui-même). + g.style.pointerEvents = "none"; + const under = document.elementFromPoint(ev.clientX, ev.clientY); + g.style.pointerEvents = ""; + + const techTarget = _findTechTargetFromElement(under); + if (!techTarget) { + _rescheduleDrag.lastTarget = null; + g.classList.remove("on-target", "on-blocked"); // reset rouge dehors + _clearReschedulePlaceholders(); + _updateRescheduleGhostText(null); + return; + } + + // refuser le drop si le tech cible est absent toute la journée. + // badge "🚫 absent" affiché UNIQUEMENT tant qu'on est dans la zone + // de cette card (retiré dès qu'on sort, géré par le branch !techTarget). + if (_isTargetTechAbsentAllDay(techTarget.techId)) { + _rescheduleDrag.lastTarget = null; + g.classList.remove("on-target"); + g.classList.add("on-blocked"); + _clearReschedulePlaceholders(); + _updateRescheduleGhostText(null); + return; + } else { + g.classList.remove("on-blocked"); + } + + const bar = techTarget.cardEl.querySelector(".timeline-bar"); + + // 2 modes — sur la timeline-bar = heure PRÉCISE (snap 30 min selon X) ; + // ailleurs dans la card = ordre simple "au-dessus / en-dessous" des iv + // existantes (heure auto-calée pour s'enchaîner sans chevauchement). + const onTimeline = !!(under && under.closest && under.closest(".timeline-bar")); + const isHorizontal = document.documentElement.classList.contains("view-horizontal"); + + let startMin; + if (onTimeline && bar) { + const t = _cursorToTimeOnTimeline(bar, ev.clientX); + if (!t) { + g.classList.remove("on-target"); + _rescheduleDrag.lastTarget = null; + _updateRescheduleGhostText(null); + return; + } + startMin = t.totalMin; + } else { + startMin = _cardCursorToProjectedStart( + techTarget.cardEl, ev, _rescheduleDrag.durationMin, isHorizontal); + if (startMin == null) { + g.classList.remove("on-target"); + _rescheduleDrag.lastTarget = null; + _updateRescheduleGhostText(null); + return; + } + } + + g.classList.add("on-target"); + + const endMin = startMin + _rescheduleDrag.durationMin; + + // ghost de placement = overlay sur la timeline-bar du tech cible. + // Visible que le curseur soit sur la timeline ou sur la card-body — on + // détermine la card via elementFromPoint, et l'overlay reste collé sur + // la timeline-bar de cette card jusqu'à ce qu'on change de tech. + _updateReschedulePlaceholder(techTarget.cardEl, startMin, endMin); + + // Texte du ghost mis à jour avec NOUVEAU créneau + tech cible. + _updateRescheduleGhostText({ + startMin, endMin, + techName: techTarget.techName, + techChanged: String(_rescheduleDrag.iv.techId) !== String(techTarget.techId) + }); + + _rescheduleDrag.lastTarget = { + techId: techTarget.techId, + techName: techTarget.techName, + cardEl: techTarget.cardEl, + hour: Math.floor(startMin / 60), + minute: startMin % 60, + totalMin: startMin, + startMin, endMin + }; +} + +// Met à jour le texte du ghost pour refléter le créneau projeté (vert) ou +// l'heure d'origine (gris si pas de cible valide). +function _updateRescheduleGhostText(projection) { + const g = _rescheduleDrag.ghost; + if (!g) return; + const tEl = g.querySelector(".rg-times"); + const techEl = g.querySelector(".rg-tech"); + const iv = _rescheduleDrag.iv; + if (!projection) { + if (tEl) { + tEl.textContent = `${iv.startTime} → ${iv.endTime}`; + tEl.removeAttribute("data-projected"); + } + if (techEl) techEl.textContent = iv.techName || ""; + return; + } + if (tEl) { + tEl.textContent = `${_minToHHMM(projection.startMin)} → ${_minToHHMM(projection.endMin)}`; + tEl.setAttribute("data-projected", "1"); + } + if (techEl) { + techEl.textContent = projection.techName || ""; + if (projection.techChanged) techEl.classList.add("rg-tech-changed"); + else techEl.classList.remove("rg-tech-changed"); + } +} + +// Retour visuel à destination = (1) clone visuel grisé de la carte +// inséré dans la card-body / bloc Matin-Après-midi du tech cible à la +// bonne position chronologique + (2) petit rectangle vert sur la timeline +// pour repérer la position horaire précise. Plus de overlay timeline +// "complet". Le clone donne le retour visuel "voici la carte qui se +// loge ici", la barre verte donne la précision horaire. +function _updateReschedulePlaceholder(targetCard, newStartMin, newEndMin) { + if (!targetCard) { + if (_rescheduleDrag.placeholder) { + try { _rescheduleDrag.placeholder.el.remove(); } catch (e) {} + _rescheduleDrag.placeholder = null; + } + if (_rescheduleDrag.timelineMark) { + try { _rescheduleDrag.timelineMark.el.remove(); } catch (e) {} + _rescheduleDrag.timelineMark = null; + } + return; + } + + // ─── 1. Marqueur sur la timeline-bar du tech cible ──────────────────────── + const bar = targetCard.querySelector(".timeline-bar"); + if (bar) { + const dayLen = (typeof DAY_LEN === "number" && DAY_LEN > 0) ? DAY_LEN : 600; + const dayStart = (typeof DAY_START === "number") ? DAY_START : 480; + const leftPct = ((newStartMin - dayStart) / dayLen) * 100; + const widthPct = ((Math.min(newEndMin, dayStart + dayLen) - newStartMin) / dayLen) * 100; + + let mark; + if (_rescheduleDrag.timelineMark && _rescheduleDrag.timelineMark.parentBar === bar) { + mark = _rescheduleDrag.timelineMark.el; + } else { + if (_rescheduleDrag.timelineMark) { + try { _rescheduleDrag.timelineMark.el.remove(); } catch (e) {} + } + mark = document.createElement("div"); + mark.className = "reschedule-timeline-mark"; + bar.appendChild(mark); + _rescheduleDrag.timelineMark = { el: mark, parentBar: bar }; + } + mark.style.left = leftPct + "%"; + mark.style.width = Math.max(1.5, widthPct) + "%"; + } + + // ─── 2. Clone visuel grisé de la carte dans la card-body / bloc ─────────── + const isHorizontal = document.documentElement.classList.contains("view-horizontal"); + const iv = _rescheduleDrag.iv; + const aid = String(iv.actionId); + + let container; + if (isHorizontal) { + const period = newStartMin < 12 * 60 ? "morning" : "afternoon"; + container = targetCard.querySelector(`.iv-mini-block.period-${period} .iv-mini-block-cards`); + } else { + container = targetCard.querySelector(".card-body"); + } + if (!container) return; + + const wantedKind = isHorizontal ? "horizontal" : "classic"; + + let ph; + if (_rescheduleDrag.placeholder + && _rescheduleDrag.placeholder.kind === wantedKind + && _rescheduleDrag.placeholder.container === container) { + ph = _rescheduleDrag.placeholder.el; + } else { + if (_rescheduleDrag.placeholder) { + try { _rescheduleDrag.placeholder.el.remove(); } catch (e) {} + } + const srcSel = isHorizontal + ? `.iv-mini-card[data-action-id="${aid}"]` + : `.intervention-v2[data-action-id="${aid}"]`; + let srcEl = document.querySelector(srcSel); + // v2026.5.45 — drag DEPUIS le dock : l'iv a été retirée du planning, + // pas de srcEl trouvable. On reconstruit depuis iv._dockHtml (HTML capturé + // au moment de l'ajout au dock) pour pouvoir afficher le ghost cloné dans + // la card-body / bloc du tech survolé, exactement comme un drag normal. + if (!srcEl && iv._dockHtml) { + const tmp = document.createElement("div"); + tmp.innerHTML = iv._dockHtml; + srcEl = tmp.firstElementChild; + } + if (!srcEl) return; + ph = srcEl.cloneNode(true); + // Le clone hérite des classes de la source, dont reschedule-source-hidden + // (display:none) — on la retire impérativement, et on retire data-action-id + // pour qu'il ne soit pas re-capté par les sélecteurs de la source. + ph.classList.remove("reschedule-source-hidden"); + ph.classList.add("reschedule-drop-ghost"); + if (isHorizontal) ph.classList.add("reschedule-drop-ghost-mini"); + ph.removeAttribute("data-action-id"); + ph.removeAttribute("data-iv-idx"); + ph.removeAttribute("data-click-bound"); + _rescheduleDrag.placeholder = { el: ph, container, kind: wantedKind }; + } + + // Met à jour les heures dans le clone pour refléter le créneau projeté. + const startSel = isHorizontal ? ".iv-mini-time-start" : ".iv-time-start"; + const endSel = isHorizontal ? ".iv-mini-time-end" : ".iv-time-end"; + const sEl = ph.querySelector(startSel); + const eEl = ph.querySelector(endSel); + if (sEl) sEl.textContent = _minToHHMM(newStartMin); + if (eEl) eEl.textContent = _minToHHMM(newEndMin); + + // Insertion chronologique — avant la 1re iv dont startTime >= newStartMin + // (>= au lieu de > : permet d'insérer entre 2 iv collées où la 2e + // commence pile à l'heure de fin de la 1re). On filtre aussi la source + // masquée pour qu'elle ne fausse pas l'ordre. + const childSel = isHorizontal ? ".iv-mini-card" : ".intervention-v2"; + const children = Array.from(container.querySelectorAll(childSel)).filter(c => + !c.classList.contains("reschedule-source-hidden")); + const startMinByActionId = new Map(); + if (state.currentData && state.currentData.techs) { + for (const tech of state.currentData.techs) { + for (const it of tech.interventions || []) { + if (it.actionId && it.startTime) { + const m = _hhmmToMin(it.startTime); + if (m != null) startMinByActionId.set(String(it.actionId), m); + } + } + } + } + let insertBefore = null; + for (const c of children) { + if (c === ph) continue; + const childAid = c.dataset.actionId; + const s = childAid ? startMinByActionId.get(String(childAid)) : null; + if (s != null && s >= newStartMin) { insertBefore = c; break; } + } + if (ph.parentNode !== container || ph.nextSibling !== insertBefore) { + if (insertBefore) container.insertBefore(ph, insertBefore); + else container.appendChild(ph); + } + + // Vue horizontale : cache l'éventuel placeholder "—" du bloc vide pour + // que le clone occupe seul la place. + if (isHorizontal) { + const empty = container.parentElement.querySelector(".iv-mini-block-empty"); + if (empty) empty.style.display = "none"; + } +} + +function _finalizeRescheduleDrag(ev) { + const iv = _rescheduleDrag.iv; + + // Drop dans le dock latéral ? + if (iv && _ivDockHitTest(ev.clientX, ev.clientY)) { + if (iv._fromDock) { + // déjà dans le dock — on ré-injecte le snapshot original + if (_ivDockPendingRestore) { + _ivDock.items.set(String(_ivDockPendingRestore.actionId), _ivDockPendingRestore); + _ivDockPendingRestore = null; + _ivDockRender(); + _ivDockUpdateVisibility(); + } + } else { + // Capture du DOM source AVANT le re-render (qui le ferait disparaître). + const aid = String(iv.actionId); + const srcEl = document.querySelector( + `.intervention-v2[data-action-id="${aid}"], .iv-mini-card[data-action-id="${aid}"]` + ); + const added = _ivDockAddItem(iv, srcEl); + if (added && state.currentData && Array.isArray(state.currentData.techs)) { + const num = iv.actionId; + for (const t of state.currentData.techs) { + t.interventions = (t.interventions || []).filter(x => x.actionId !== num); + } + try { renderFromData(state.currentData); } catch (e) { /* render fail tolérée */ } + } + } + return; + } + + const tgt = _rescheduleDrag.lastTarget; + if (!iv || !tgt) { + LOG.info("reschedule", "drag canceled (no valid target)"); + // Si l'iv venait du dock et n'a pas de cible valide → la remettre + if (iv && iv._fromDock && _ivDockPendingRestore) { + _ivDock.items.set(String(_ivDockPendingRestore.actionId), _ivDockPendingRestore); + _ivDockPendingRestore = null; + _ivDockRender(); + _ivDockUpdateVisibility(); + } + return; + } + // Date courante (drag = même jour) + const isoDate = state.currentDate || ""; + const newStart = _minToHHMM(tgt.startMin); + const newEnd = _minToHHMM(tgt.endMin); + // Confirmation modal minimaliste + _showRescheduleConfirmModal(iv, { + newDateIso: isoDate, + newStartTime: newStart, + newEndTime: newEnd, + newTechId: tgt.techId, + newTechName: tgt.techName + }); +} + +function _cleanupRescheduleDrag() { + if (_rescheduleDrag.ghost) { + try { _rescheduleDrag.ghost.remove(); } catch (e) {} + } + if (_rescheduleDrag.placeholder) { + try { _rescheduleDrag.placeholder.el.remove(); } catch (e) {} + } + if (_rescheduleDrag.timelineMark) { + try { _rescheduleDrag.timelineMark.el.remove(); } catch (e) {} + } + // Sécurité : retire tout orphelin restant + document.querySelectorAll( + ".reschedule-drop-ghost, .reschedule-timeline-mark, .reschedule-preview" + ).forEach(p => { try { p.remove(); } catch (e) {} }); + _restoreRescheduleSource(); + // Restaure les placeholders "—" des blocs vides en horizontale qu'on + // avait masqués pour laisser la place au clone. + document.querySelectorAll(".iv-mini-block-empty").forEach(e => { + e.style.display = ""; + }); + document.documentElement.classList.remove("reschedule-dragging"); + _rescheduleDrag.active = false; + _rescheduleDrag.iv = null; + _rescheduleDrag.ghost = null; + _rescheduleDrag.placeholder = null; + _rescheduleDrag.timelineMark = null; + _rescheduleDrag.lastTarget = null; + // Cleanup ne TOUCHE PAS _ivDockPendingRestore : le modal de confirmation + // peut être encore ouvert (cleanup est synchrone, modal async). C'est le + // modal (ou le handler Escape) qui décide du sort du pending. + _ivDockSetExpanded(false); + _ivDockUpdateVisibility(); +} + +// ─── Modal de confirmation post-drag ──────────────────────────────────────── + +function _showRescheduleConfirmModal(iv, change) { + const fromTxt = `${iv.startTime}→${iv.endTime} · ${iv.techName || iv.techId || "?"}`; + const toTxt = `${change.newStartTime}→${change.newEndTime} · ${change.newTechName || change.newTechId}`; + const sameTech = String(iv.techId) === String(change.newTechId); + const sameTimes = (iv.startTime === change.newStartTime && iv.endTime === change.newEndTime); + if (sameTech && sameTimes) { + LOG.info("reschedule", "no change detected → ignore"); + // v2026.5.45 — si l'iv venait du dock et que le drop tombe sur un + // créneau identique à son origine (tech + heures), on n'ouvre pas le + // modal. Mais SANS restauration explicite ici, l'iv reste en limbo + // (retirée du dock à l'activation, jamais réinjectée) → invisible. Fix. + if (iv && iv._fromDock && _ivDockPendingRestore) { + _ivDock.items.set(String(_ivDockPendingRestore.actionId), _ivDockPendingRestore); + _ivDockPendingRestore = null; + _ivDockRender(); + _ivDockUpdateVisibility(); + } + return; + } + showAlertModal({ + title: "Confirmer le déplacement ?", + message: `
` + + `
De   : ${escapeHtml(fromTxt)}
` + + `
Vers : ${escapeHtml(toTxt)}
` + + `
`, + html: true, + buttons: [ + { + label: "Annuler", + variant: "secondary", + action: () => { + // L'iv vient du dock ? On la remet dedans puisque l'op n'a pas abouti. + if (iv && iv._fromDock && _ivDockPendingRestore) { + _ivDock.items.set(String(_ivDockPendingRestore.actionId), _ivDockPendingRestore); + _ivDockPendingRestore = null; + _ivDockRender(); + _ivDockUpdateVisibility(); + } + } + }, + { + label: "Confirmer", + variant: "primary", + action: async () => { + try { + await _applyReschedule(iv, change); + } catch (err) { + LOG.warn("reschedule", "confirm modal apply err", { err: err && err.message }); + showToast("Échec", err.message || "Impossible d'appliquer le déplacement"); + // Si reschedule a échoué et l'iv venait du dock, on la remet. + if (iv && iv._fromDock && _ivDockPendingRestore) { + _ivDock.items.set(String(_ivDockPendingRestore.actionId), _ivDockPendingRestore); + _ivDockPendingRestore = null; + _ivDockRender(); + _ivDockUpdateVisibility(); + } + } + } + } + ] + }); +} + +// ─── Application : appel API + refresh ────────────────────────────────────── + +async function _applyReschedule(iv, change) { + const oldStart = iv.startTime; + const oldEnd = iv.endTime; + const newDateDDMM = _isoToDDMMYYYY(change.newDateIso); + const newDateNoSep = _isoToDDMMYYYYNoSep(change.newDateIso); + const sNewMin = _hhmmToMin(change.newStartTime); + const eNewMin = _hhmmToMin(change.newEndTime); + const sOldMin = _hhmmToMin(oldStart); + const eOldMin = _hhmmToMin(oldEnd); + const oldDuration = (sOldMin != null && eOldMin != null) ? (eOldMin - sOldMin) : null; + const newDuration = (sNewMin != null && eNewMin != null) ? (eNewMin - sNewMin) : null; + const techChanged = String(iv.techId) !== String(change.newTechId); + const startChanged = oldStart !== change.newStartTime; + const dateChanged = (state.currentDate || "") !== change.newDateIso; + const durationChanged = (oldDuration !== null && newDuration !== null && oldDuration !== newDuration); + + showToast("Modification en cours", "Application sur EasyVista…"); + + // Étape 1 : si tech / date / heure début ont changé → Planning_schedule_action_Employee + if (techChanged || startChanged || dateChanged) { + const r1 = await sendMessage({ + type: "rescheduleAction", + actionId: iv.actionId, + employeeId: change.newTechId, + date: newDateDDMM, + hour: Math.floor(sNewMin / 60), + minute: sNewMin % 60 + }); + if (!r1 || !r1.ok) { + const err = (r1 && r1.error) || "rescheduleAction failed"; + throw new Error(err); + } + } + + // Étape 2 : si la durée a changé → fc_save_inspector pour ajuster end_time + if (durationChanged) { + const suffix = `act_${iv.actionId}_nb_0_date_${newDateNoSep}`; + const r2 = await sendMessage({ + type: "updateActionTimes", + actionId: iv.actionId, + suffix, + startDate: newDateDDMM, + endDate: newDateDDMM, + startTime: change.newStartTime, + endTime: change.newEndTime + }); + if (!r2 || !r2.ok) { + const err = (r2 && r2.error) || "updateActionTimes failed"; + throw new Error(err); + } + } + + showToast("Déplacement appliqué", "Rafraîchissement du planning…"); + + // Mise à jour visuelle IMMÉDIATE de state.currentData pour ne pas attendre + // que le cache serveur EV propage. Sans ça : + // - changement de date : la carte reste sur le jour source + // - changement d'heure : le chevauchement (rouge) n'est pas détecté + // parce que le re-fetch du XML peut retourner l'ancienne heure + if (dateChanged) { + _registerRecentMove(iv.actionId, state.currentDate, change.newDateIso); + if (state.currentData && Array.isArray(state.currentData.techs)) { + const aid = iv.actionId; + for (const t of state.currentData.techs) { + t.interventions = (t.interventions || []).filter(x => x.actionId !== aid); + } + try { renderFromData(state.currentData); } catch (e) { /* render fail tolérée */ } + } + } else if (state.currentData && Array.isArray(state.currentData.techs)) { + const aid = iv.actionId; + let moved = null, srcTech = null, srcIdx = -1; + for (const t of state.currentData.techs) { + const idx = (t.interventions || []).findIndex(x => x.actionId === aid); + if (idx >= 0) { moved = t.interventions[idx]; srcTech = t; srcIdx = idx; break; } + } + if (moved) { + moved.startTime = change.newStartTime; + moved.endTime = change.newEndTime; + if (techChanged) { + moved.techId = change.newTechId; + srcTech.interventions.splice(srcIdx, 1); + const dst = state.currentData.techs.find(t => String(t.id) === String(change.newTechId)); + if (dst) { + dst.interventions = dst.interventions || []; + dst.interventions.push(moved); + } + } + try { renderFromData(state.currentData); } catch (e) { /* render fail tolérée */ } + } + } + + // Refresh local pour refléter + if (typeof loadForDate === "function") { + await loadForDate(state.currentDate, { forceRefetch: true }); + } + + // Reschedule réussi : si l'iv venait du dock, on n'a plus à la restaurer. + if (iv && iv._fromDock) _ivDockPendingRestore = null; +} + +// ============================================================================ +// Dock latéral — interventions mises de côté +// État client uniquement (vidé à chaque rechargement). Permet au coordinateur +// de glisser une iv dans une zone tampon à droite, naviguer entre les jours, +// puis la déposer sur une autre date. Aucun appel EV tant qu'aucun drop final +// n'est validé : l'iv reste sur EV à sa date d'origine et est juste filtrée +// du rendu local. +// ============================================================================ + +const _ivDock = { + items: new Map(), // actionId → snapshot iv + maxItems: 12, + expanded: false, + visible: false, +}; +let _ivDockPendingRestore = null; // iv qui vient du dock, à remettre si le drop n'aboutit pas + +function _ivDockInit() { + if (document.getElementById("iv-dock")) return; + const dock = document.createElement("aside"); + dock.id = "iv-dock"; + dock.className = "iv-dock iv-dock--hidden"; + dock.innerHTML = + ` +
`; + document.body.appendChild(dock); + + document.getElementById("iv-dock-clear-btn").addEventListener("click", _ivDockClearAll); + + // v2026.5.45 — debouncé 500 ms (anti-flicker quand le curseur effleure + // le bord du dock). + dock.addEventListener("mouseenter", () => { + if (_ivDock.items.size > 0 || _rescheduleDrag.active) _ivDockSetExpandedDebounced(true); + }); + dock.addEventListener("mouseleave", () => { + if (!_rescheduleDrag.active) _ivDockSetExpandedDebounced(false); + }); + + _ivDockUpdateVisibility(); +} + +function _ivDockUpdateVisibility() { + const dock = document.getElementById("iv-dock"); + if (!dock) return; + const dragging = _rescheduleDrag.active; + const hasItems = _ivDock.items.size > 0; + + // Aucune raison d'afficher : caché complet + if (!dragging && !hasItems) { + dock.classList.add("iv-dock--hidden"); + dock.classList.remove("iv-dock--peep-min", "iv-dock--peep", "iv-dock--expanded"); + _ivDock.visible = false; + return; + } + dock.classList.remove("iv-dock--hidden"); + _ivDock.visible = true; + + // Hover sur le dock OU curseur proche du bord droit pendant un drag + // → déploiement complet + if (_ivDock.expanded) { + dock.classList.add("iv-dock--expanded"); + dock.classList.remove("iv-dock--peep", "iv-dock--peep-min"); + return; + } + + // Drag commencé alors qu'aucune iv n'est dans le dock → simple petit + // onglet pour signaler la présence du dock sans cacher le planning + if (dragging && !hasItems) { + dock.classList.add("iv-dock--peep-min"); + dock.classList.remove("iv-dock--peep", "iv-dock--expanded"); + return; + } + + // Le dock contient au moins une iv (drag actif ou pas) → mode + // intermédiaire qui laisse voir le numéro de la DS sans pour autant + // masquer le planning + dock.classList.add("iv-dock--peep"); + dock.classList.remove("iv-dock--peep-min", "iv-dock--expanded"); +} + +function _ivDockSetExpanded(expanded) { + // v2026.5.45 — toute mutation immédiate annule un éventuel délai + // debouncé en cours (cas des callers de cleanup : drop committé, abort). + if (_ivDockExpandTimer) { + clearTimeout(_ivDockExpandTimer); + _ivDockExpandTimer = null; + _ivDockExpandTarget = null; + } + _ivDock.expanded = !!expanded; + _ivDockUpdateVisibility(); +} + +// v2026.5.45 — délai de 500 ms pour expand/collapse, anti-flicker quand +// la souris s'approche/quitte le dock (drag) ou rentre/sort par hover. +// - re-call vers la même cible : ignoré (timer déjà programmé OU état stable). +// - re-call vers la cible opposée : annule le timer en cours, et reprogramme +// ou no-op si l'état courant est déjà la nouvelle cible. +// Les callers de cleanup utilisent _ivDockSetExpanded directement (immédiat). +let _ivDockExpandTimer = null; +let _ivDockExpandTarget = null; +const _IV_DOCK_EXPAND_DELAY_MS = 500; +function _ivDockSetExpandedDebounced(expanded) { + const target = !!expanded; + if (_ivDockExpandTarget === target) return; // déjà en route + if (_ivDockExpandTarget === null && _ivDock.expanded === target) return; // déjà arrivé + if (_ivDockExpandTimer) { // changement d'avis + clearTimeout(_ivDockExpandTimer); + _ivDockExpandTimer = null; + _ivDockExpandTarget = null; + } + if (_ivDock.expanded === target) return; // l'annulation suffit + _ivDockExpandTarget = target; + _ivDockExpandTimer = setTimeout(() => { + _ivDockExpandTimer = null; + _ivDockExpandTarget = null; + _ivDock.expanded = target; + _ivDockUpdateVisibility(); + }, _IV_DOCK_EXPAND_DELAY_MS); +} + +function _ivDockOnDragMove(ev) { + if (!_rescheduleDrag.active) return; + if (!_ivDock.visible) _ivDockUpdateVisibility(); + const w = window.innerWidth; + const nearRight = ev.clientX > w - 140; + // v2026.5.45 — on alimente le wrapper debouncé avec la cible voulue. + // Le wrapper gère l'annulation si l'intention change avant 500 ms (le + // curseur frôle le bord puis repart sans entrer franchement). + _ivDockSetExpandedDebounced(nearRight); +} + +function _ivDockHitTest(clientX, clientY) { + const dock = document.getElementById("iv-dock"); + if (!dock || dock.classList.contains("iv-dock--hidden")) return false; + // Pendant un drag, on est tolérant : tout drop dans la zone droite + // de la fenêtre (≤200 px du bord) compte comme drop sur le dock, + // peu importe que l'animation d'expansion soit terminée ou pas. + // Évite les "ratés" quand l'utilisateur lâche pile pendant la + // transition peep → expanded (200 ms de fenêtre sinon). + if (_rescheduleDrag.active) { + const w = window.innerWidth; + if (clientX >= w - 200) return true; + } + const r = dock.getBoundingClientRect(); + return clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom; +} + +function _ivDockAddItem(iv, sourceEl) { + if (!iv || iv.actionId == null) return false; + const aid = String(iv.actionId); + if (_ivDock.items.has(aid)) return true; + if (_ivDock.items.size >= _ivDock.maxItems) { + if (typeof showToast === "function") { + showToast("Dock plein", `Maximum ${_ivDock.maxItems} interventions à la fois — déposez d'abord celles déjà en attente.`); + } + return false; + } + // Capture du HTML rendu de la card source : on aura un look identique au + // planning (mêmes classes, même structure interne) sans devoir le ré-bâtir. + // On clone d'abord pour pouvoir retirer la classe `reschedule-source-hidden` + // (display:none) appliquée pendant le drag — sinon le clone serait invisible. + let html = null; + let srcNode = sourceEl; + if (!srcNode) { + srcNode = document.querySelector( + `.intervention-v2[data-action-id="${aid}"], .iv-mini-card[data-action-id="${aid}"]` + ); + } + if (srcNode) { + const clone = srcNode.cloneNode(true); + clone.classList.remove("reschedule-source-hidden"); + clone.removeAttribute("data-click-bound"); + html = clone.outerHTML; + } + // v2026.5.45 — couleur catégorie : on LIT directement la classe + // color-* posée sur la card source du planning. C'est la source de vérité + // visuelle (calculée au render, donc avec toutes les données disponibles). + // Garantit que le dock affiche exactement la même couleur que la planif. + let colorKeyFromSrc = null; + if (srcNode && srcNode.classList) { + for (const cls of srcNode.classList) { + if (cls.indexOf("color-") === 0) { colorKeyFromSrc = cls.slice(6); break; } + } + } + const snapshot = { + actionId: iv.actionId, + requestId: iv.requestId, + techId: iv.techId, + techName: iv.techName, + type: iv.type, + ref: iv.ref, + label: iv.label, + startDate: iv.startDate, + endDate: iv.endDate, + startTime: iv.startTime, + endTime: iv.endTime, + bulleContact: iv.bulleContact, + bulleLieu: iv.bulleLieu, + categoryLine: iv.categoryLine, + formLink: iv.formLink, + deadline: iv.deadline, + cssClass: iv.cssClass, + isPompier: iv.isPompier, + // v2026.5.45 — on capture la couleur catégorie AU MOMENT de l'add, + // tant qu'on a l'iv complète (avec bulleDescription/infobulle, dont + // dépendent isRollOut et isRecupAction). Sans ça, _ivDockRender qui + // rappelle deriveColorKey(snap) tombe sur "autre" → trait gris. + colorKey: colorKeyFromSrc + || (typeof deriveColorKey === "function" ? deriveColorKey(iv) : "") + || "autre", + // state.currentDate est toujours ISO (YYYY-MM-DD) → format sûr pour + // formatDateDM. iv.startDate peut être au format DD/MM/YYYY EV → on + // privilégie state.currentDate qui est l'origine effective d'affichage. + _originalDate: state.currentDate || iv.startDate || null, + _html: html, + }; + _ivDock.items.set(aid, snapshot); + _ivDockRender(); + _ivDockUpdateVisibility(); + return true; +} + +function _ivDockRemoveItem(actionId) { + if (!_ivDock.items.delete(String(actionId))) return; + _ivDockRender(); + _ivDockUpdateVisibility(); + if (typeof loadForDate === "function") { + loadForDate(state.currentDate, { forceRefetch: false }).catch(() => {}); + } +} + +function _ivDockClearAll() { + if (_ivDock.items.size === 0) return; + _ivDock.items.clear(); + _ivDockRender(); + _ivDockUpdateVisibility(); + if (typeof loadForDate === "function") { + loadForDate(state.currentDate, { forceRefetch: false }).catch(() => {}); + } +} + +function _ivDockFilterTechs(techs) { + if (!techs || _ivDock.items.size === 0) return techs; + for (const tech of techs) { + tech.interventions = (tech.interventions || []).filter(iv => !_ivDock.items.has(String(iv.actionId))); + } + return techs; +} + +function _ivDockRender() { + const list = document.getElementById("iv-dock-list"); + if (!list) return; + list.innerHTML = ""; + for (const [aid, snap] of _ivDock.items) { + // Layout dock minimaliste : juste la ref + la date d'origine, fond + // teinté par la couleur de la catégorie de l'iv. Pas d'heure (elle + // réapparaîtra au drag, dans la forme standard de la carte). + const wrapper = document.createElement("div"); + wrapper.className = "iv-dock-card reschedule-ghost"; + wrapper.dataset.actionId = aid; + // v2026.5.45 — la classe color-* + règle CSS ne s'appliquent visiblement + // pas (cause non identifiée). On force le style inline du border-left avec + // var(--c-) qui résout au niveau du wrapper. Inline > toute règle. + // data-color-key permet d'inspecter ce qui a été détecté. + const colorKey = snap.colorKey + || (typeof deriveColorKey === "function" ? deriveColorKey(snap) : "") + || "autre"; + wrapper.classList.add("color-" + colorKey); + wrapper.dataset.colorKey = colorKey; + wrapper.style.borderLeft = "4px solid var(--c-" + colorKey + ")"; + // v2026.5.45 — pas de title (= pas de tooltip natif au hover). + + const ref = snap.ref || (snap.actionId ? "#" + snap.actionId : "(sans réf)"); + // v2026.5.45 — durée prévue calculée depuis snap.startTime/endTime, on + // n'affiche plus la date d'origine. Format compact : "1h", "1h30", "45min". + let durationLabel = ""; + const sMin = _hhmmToMin(snap.startTime); + const eMin = _hhmmToMin(snap.endTime); + if (Number.isFinite(sMin) && Number.isFinite(eMin) && eMin > sMin) { + const total = eMin - sMin; + const h = Math.floor(total / 60); + const m = total % 60; + if (h === 0) durationLabel = m + "min"; + else if (m === 0) durationLabel = h + "h"; + else durationLabel = h + "h" + (m < 10 ? "0" + m : String(m)); + } + + wrapper.innerHTML = + `
+
${escapeHtml(ref)}
+ ${durationLabel ? `
${escapeHtml(durationLabel)}
` : ""} +
`; + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "iv-dock-card-remove"; + removeBtn.textContent = "×"; + wrapper.appendChild(removeBtn); + // v2026.5.45 — retrait par appui long (2 s) sur le bouton ×. + // Un clic simple ne fait RIEN (le mouseup avant 2 s annule le timer). + // mouseleave et bouton hors-zone annulent aussi pour éviter les retraits + // accidentels si l'utilisateur garde le doigt et déplace la souris. + let _holdRemoveTimer = null; + const _cancelHoldRemove = () => { + if (_holdRemoveTimer) { clearTimeout(_holdRemoveTimer); _holdRemoveTimer = null; } + removeBtn.classList.remove("holding"); + }; + removeBtn.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + e.stopPropagation(); + e.preventDefault(); + _cancelHoldRemove(); + removeBtn.classList.add("holding"); + _holdRemoveTimer = setTimeout(() => { + _holdRemoveTimer = null; + removeBtn.classList.remove("holding"); + _ivDockRemoveItem(aid); + }, 2000); + }); + removeBtn.addEventListener("mouseup", (e) => { e.stopPropagation(); _cancelHoldRemove(); }); + removeBtn.addEventListener("mouseleave", _cancelHoldRemove); + // Bloquer le click natif (qui se déclenche après mousedown+mouseup) pour + // éviter qu'il remonte au wrapper et qu'il déclenche autre chose. + removeBtn.addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); }); + + list.appendChild(wrapper); + + // Drag handle = la carte entière. Maintenir + bouger > 5 px lance le + // drag avec la carte sous sa forme standard. Clic seul = rien (pas + // de listener click). + wrapper.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + // Clic sur le bouton × : laisser le bouton gérer. + if (e.target.closest(".iv-dock-card-remove")) return; + e.preventDefault(); + e.stopPropagation(); + _ivDockStartDragFromDock(snap, e); + }); + } +} + +function _ivDockStartDragFromDock(snap, ev) { + // v2026.5.45 — on n'enlève PAS l'iv du dock au mousedown. Un clic + // simple (mousedown puis mouseup sans déplacement) ne doit rien faire et + // l'iv doit rester visible dans le dock — d'où l'absence de flicker. + // Le retrait effectif est différé à _activateRescheduleDrag, déclenché + // quand le seuil de 5 px est franchi (= vrai drag). _dockSnap permet à + // l'activation de retrouver le snap exact à retirer + à mémoriser pour + // restauration en cas d'annulation. + const ivForDrag = { ...snap, _fromDock: true, _dockHtml: snap._html, _dockSnap: snap }; + _startReschedulePotentialDrag(ivForDrag, ev); +} function buildInterventionRow(iv, cardEl) { const row = document.createElement("div"); @@ -8789,6 +11305,11 @@ function buildInterventionRow(iv, cardEl) { } else { timeEl.textContent = "—"; } + // (feature reschedule) : si l'iv est éditable, on attache les handlers + // de clic (modal) + drag (déplacement vers autre tech / créneau). + if (typeof _attachRescheduleHandlers === "function") { + _attachRescheduleHandlers(timeEl, iv); + } row.appendChild(timeEl); // ─── Ligne 2 droite : lieu / contact+tél / catégorie+signature ─────────── @@ -8921,12 +11442,16 @@ async function openInterventionInNewTab(iv, opts = {}) { session = resp && resp.session; } if (!session) { - // v4.2.5 : popup modale propre au lieu d'alert natif + // on appelle triggerReconnect() au lieu de openEasyVista() — + // déclenche la reconnexion SSO (ouvre un onglet EZV) ET surveille la + // nouvelle session pour recharger le viewer auto, exactement comme + // le bouton "Me reconnecter" de la bannière. showAlertModal({ title: "Impossible d'ouvrir la fiche", message: "Votre session EasyVista a expiré. Reconnectez-vous à EasyVista puis réessayez.", + injectCookiesPrompt: true, buttons: [ - { label: "Ouvrir EasyVista", variant: "primary", action: () => openEasyVista() }, + { label: "🔄 Me reconnecter", variant: "primary", action: () => triggerReconnect() }, { label: "Annuler", variant: "secondary", action: () => {} } ] }); @@ -9003,8 +11528,9 @@ async function openInterventionInNewTab(iv, opts = {}) { showAlertModal({ title: "Session EasyVista expirée", message: "Votre session a expiré pendant l'ouverture de la fiche. Reconnectez-vous à EasyVista puis réessayez.", + injectCookiesPrompt: true, buttons: [ - { label: "Ouvrir EasyVista", variant: "primary", action: () => openEasyVista() }, + { label: "🔄 Me reconnecter", variant: "primary", action: () => triggerReconnect() }, { label: "Annuler", variant: "secondary", action: () => {} } ] }); @@ -9014,7 +11540,7 @@ async function openInterventionInNewTab(iv, opts = {}) { message: "EasyVista est inaccessible pour le moment. Réessayez dans quelques instants.", buttons: [ { label: "Réessayer", variant: "primary", action: () => openInterventionInNewTab(iv, opts) }, - { label: "Ouvrir EasyVista", variant: "secondary", action: () => openEasyVista() }, + { label: "🔄 Me reconnecter", variant: "secondary", action: () => triggerReconnect() }, { label: "Annuler", variant: "secondary", action: () => {} } ] }); @@ -9235,6 +11761,21 @@ function extractContactNameAndPhone(raw) { function extractContacts(raw) { if (!raw) return []; let s = String(raw).trim(); + // v2026.5.45 — tronquer au 1er label fiche colle au contact, deux cas: + // 1) Label suivi de : ("Etage:", "Bureau:", etc.). + // 2) Label colle a un mot minuscule sans espace ni : ("secretariatEtage") + // cas frequent quand le \\n entre la valeur du contact et le label + // suivant a saute dans la source EV. + const _LABEL_RX = "[\u00C9E]tage|Bureau|Service|Mat[\u00E9e]riel|Probl[\u00E8e]me|TFS|Date|Heure|Lieu|Nom\\s+utilisateur|B[\u00E9e]n[\u00E9e]ficiaire"; + let _cutAt = -1; + const _mColon = s.match(new RegExp("(" + _LABEL_RX + ")\\s*:")); + if (_mColon) _cutAt = _mColon.index; + const _mGlued = s.match(new RegExp("[a-z\u00E0-\u00FF](" + _LABEL_RX + ")")); + if (_mGlued) { + const _gluedLabelStart = _mGlued.index + 1; + if (_cutAt < 0 || _gluedLabelStart < _cutAt) _cutAt = _gluedLabelStart; + } + if (_cutAt >= 0) s = s.substring(0, _cutAt).trim(); // Virer les labels parasites (Nom utilisateur, etc.) qui traînent s = s.replace(/\b(Nom utilisateur|Utilisateur)\s*:\s*[^\n]+/gi, ""); @@ -9383,7 +11924,7 @@ function splitOneContact(raw) { * - retire les éventuels "Nom utilisateur :" ou libellés * - retire les virgules en trop en fin * - v4.1.8 : tronque les commentaires parasites après le nom - * (ex: "Dupont, Jean S'annoncer à la réception" → "Dupont, Jean") + * (ex: "Nom, Prénom S'annoncer à la réception" → "Nom, Prénom") * - Conserve juste "Nom, Prénom" (ou "Nom Prénom" si pas de virgule) */ function cleanContactName(raw) { @@ -9391,9 +11932,9 @@ function cleanContactName(raw) { 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" + // Retirer parenthèses non fermées en fin : "Nom, Prénom (" → "Nom, Prénom" s = s.replace(/\s*\([^)]*$/g, " "); - // Retirer parenthèses non ouvertes en début : ")Bento" → "Bento" + // Retirer parenthèses non ouvertes en début : ")Nom" → "Nom" s = s.replace(/^[^(]*\)\s*/g, ""); // Retirer tout caractère parenthèse isolé restant s = s.replace(/[()]/g, " "); @@ -9462,8 +12003,8 @@ function cleanContactName(raw) { } /** - * Split un lieu du type "Lausanne/Rue Caroline 9 bis" en - * { ville: "Lausanne", adresse: "Rue Caroline 9 bis" } + * Split un lieu du type "/ " en + * { ville: "", adresse: " " } * Si format inconnu, retourne { ville: null, adresse: raw }. */ function splitLieu(raw) { @@ -9475,7 +12016,7 @@ function splitLieu(raw) { // v2026.5.16 : le format EasyVista peut avoir jusqu'à 3 parties séparées // par "/" : VILLE / ADRESSE / PRÉCISIONS (étage, bureau, indications). - // Exemple : "LAUSANNE / Av. de Beaulieu 19 / 4eme en face de l'ascenseur" + // Exemple : " / / " // On ne garde que VILLE + ADRESSE. Les précisions (3e partie et suivantes) // sont strippées — elles alourdissent la carte et sont disponibles dans // le tooltip détaillé. @@ -9746,7 +12287,7 @@ function updateInterventionRow(iv) { } // Check ✓ : ajouter/retirer/mettre à jour selon statut - // R11b : on gère AUSSI le ✓ pour terminated-pending en direct (avant on + // on gère AUSSI le ✓ pour terminated-pending en direct (avant on // ne créait le ✓ pending qu'au renderFromData final → l'user voyait les // ✓✓ apparaître au fur et à mesure mais les ✓ seulement à la fin). // rideau horaire — pas de ✓ tant que le seuil n'est pas atteint. @@ -9837,7 +12378,7 @@ function updateInterventionRow(iv) { if (iv.ref) slot.dataset.ref = iv.ref; } - // R11b : mini-card timeline (vue horizontale) — ✓/✓✓ live aussi. + // mini-card timeline (vue horizontale) — ✓/✓✓ live aussi. const miniCard = card.querySelector( `.iv-mini-card[data-iv-idx="${row.dataset.ivIdx}"]` ); @@ -10059,6 +12600,10 @@ function hideTooltip(opts = {}) { state.currentTooltipIv = null; currentTooltipPos = null; tooltipPositionMode = null; + // reset l'ancre tooltip pour ne pas reposition sur une source + // obsolète au prochain scroll. + _currentTooltipAnchorEl = null; + _currentTooltipAnchorOpts = null; bulleState.hoveredInBulle = false; bulleState.hoveredInRow = false; }, 500); @@ -10166,15 +12711,40 @@ function setTooltipViewportPosition(viewportX, viewportY) { // Listener global scroll : si on est en mode "abs", on compense pour que la // popup reste visuellement au même endroit pendant le scroll. +// référence à la source actuellement ancrée + options, pour permettre +// au scroll handler de réancrer le tooltip live à chaque scroll. Sans ça, +// position:fixed gardait le tooltip collé au viewport pendant que la source +// (absence/réservation/iv) défilait avec le contenu → décalage visuel. +let _currentTooltipAnchorEl = null; +let _currentTooltipAnchorOpts = null; + function reapplyTooltipPosition() { - if (!currentTooltipPos) return; const el = tooltipEl(); if (!el || !el.classList.contains("visible")) return; - if (tooltipPositionMode !== "abs") return; // fixed marche, rien à faire + // Si la popup est épinglée (.pinned-popup), elle a sa propre logique de + // positionnement (le user peut la déplacer) — on ne touche pas. + if (el.classList.contains("pinned-popup")) return; - // Compenser le scroll : la popup doit rester à currentTooltipPos dans le - // viewport. Pour ça, on ajoute l'écart entre le scroll actuel et le - // scroll au moment de l'ancrage. + // repositionner par rapport à la source ancrée pour suivre le + // scroll de la page (la source bouge, le tooltip suit). + if (_currentTooltipAnchorEl && _currentTooltipAnchorEl.isConnected) { + // Si la source est sortie de la zone visible → cacher le tooltip + // (sinon il flotterait collé en haut/bas du viewport hors contexte). + const r = _currentTooltipAnchorEl.getBoundingClientRect(); + const visible = r.bottom > 0 && r.top < window.innerHeight + && r.right > 0 && r.left < window.innerWidth; + if (!visible) { + hideTooltip({ force: true }); + return; + } + positionTooltipAnchored(_currentTooltipAnchorEl, _currentTooltipAnchorOpts || {}); + return; + } + + // Fallback ancien chemin : si pas d'ancre stockée, on garde la + // compensation manuelle (mode "abs") qui existait déjà. + if (!currentTooltipPos) return; + if (tooltipPositionMode !== "abs") return; const scrollX = window.scrollX || window.pageXOffset || 0; const scrollY = window.scrollY || window.pageYOffset || 0; const dx = scrollX - (el._absBasisScrollX || 0); @@ -10218,6 +12788,11 @@ function positionTooltipAnchored(sourceEl, opts) { console.warn("[positionTooltip] sourceEl null — pas de positionnement"); return; } + // on mémorise la source ancrée pour pouvoir repositionner le + // tooltip à chaque scroll (sinon il reste collé au viewport en + // position:fixed alors que la source bouge avec le scroll → décalage). + _currentTooltipAnchorEl = sourceEl; + _currentTooltipAnchorOpts = opts; const pad = 10; // padding entre source et popup const viewportMargin = 8; // marge par rapport aux bords @@ -10354,6 +12929,73 @@ function _rectsOverlap(a, b) { a.bottom <= b.top || a.top >= b.bottom); } +/** + * v2026.5.45 (issue #6) : amène un popup épinglé au premier plan en + * réinitialisant le z-index de tous les autres popups épinglés. Le popup + * cliqué passe à z-index 9 (juste sous la topbar à 10), les autres reprennent + * le z-index par défaut hérité du CSS (5). + * + * IMPORTANT : la règle CSS `.tooltip.pinned-popup { z-index: 5 !important; }` + * empêche un simple `style.zIndex = "9"` de prendre effet. Il FAUT utiliser + * `setProperty(..., 'important')` pour battre le `!important` du CSS. + * + * @author Quentin Rouiller + */ +function _bringPinnedPopupToFront(popup) { + if (!popup) return; + document.querySelectorAll('.pinned-popup').forEach(p => { + if (p !== popup) p.style.removeProperty('z-index'); + }); + popup.style.setProperty('z-index', '9', 'important'); +} + +// v2026.5.45 : mémorisation des dimensions resizées par l'utilisateur, +// keyée par "actionId|date". Persiste durant la session (pas en storage). +// Permet à un popup re-créé (re-pin après unpin, retour depuis le dock, etc.) +// de retrouver la taille que l'utilisateur lui avait donnée précédemment. +const _userPopupSizes = new Map(); +function _userPopupSizeKey(iv, date) { + if (!iv || !iv.actionId) return null; + return iv.actionId + "|" + (date || state.currentDate || ""); +} +function _rememberPopupSize(popup, iv) { + if (!popup || !iv) return; + const key = _userPopupSizeKey(iv, popup.dataset.originDate); + if (!key) return; + if (typeof ResizeObserver === "undefined") return; + if (popup._sizeObserver) { + try { popup._sizeObserver.disconnect(); } catch (_) {} + } + // box-sizing global est border-box → quand on lit la taille pour + // la ré-appliquer plus tard via style.width/height, il faut prendre la + // dimension BORDER-BOX (= offsetWidth/offsetHeight) sinon on stocke la + // taille du content-box (sans padding+border) → au re-pin on applique + // une valeur trop petite et le popup rétrécit à chaque cycle. + const ro = new ResizeObserver((entries) => { + for (const e of entries) { + const target = e.target; + const w = Math.round(target.offsetWidth); + const h = Math.round(target.offsetHeight); + if (w > 0 && h > 0 && (target.classList.contains("pinned-popup") || + target.classList.contains("soft-unpinned"))) { + _userPopupSizes.set(key, { w, h }); + } + } + }); + ro.observe(popup); + popup._sizeObserver = ro; +} +function _applyRememberedPopupSize(popup, iv, date) { + if (!popup || !iv) return false; + const key = _userPopupSizeKey(iv, date); + if (!key) return false; + const sz = _userPopupSizes.get(key); + if (!sz) return false; + popup.style.width = sz.w + "px"; + popup.style.height = sz.h + "px"; + return true; +} + /** * Cherche une position libre pour un popup de dimensions {w, h} près de la * ligne source `rowEl`. Essaie dans l'ordre : droite, gauche, dessous, dessus. @@ -10572,14 +13214,21 @@ function pinTooltip() { popup.dataset.actionId = iv.actionId || ""; popup.innerHTML = srcEl.innerHTML; - // v2026.5.40 r17 : conserver la même largeur que le tooltip live au moment - // du clic, pour que la taille de la fenêtre ne change pas à l'épinglage. - try { - const liveRect = srcEl.getBoundingClientRect(); - if (liveRect && liveRect.width > 100) { - popup.style.width = Math.round(liveRect.width) + "px"; - } - } catch (e) { /* ignore — fallback CSS 520px */ } + // Largeur/hauteur du popup épinglé. Priorité à la taille préservée + // d'un cycle dépingle → ré-épingle (pour que pinning/unpinning multiples + // ne change pas la taille). Sinon, on prend la largeur du tooltip live. + if (_pinTooltipPreservedSize) { + popup.style.width = _pinTooltipPreservedSize.width + "px"; + popup.style.height = _pinTooltipPreservedSize.height + "px"; + _pinTooltipPreservedSize = null; + } else { + try { + const liveRect = srcEl.getBoundingClientRect(); + if (liveRect && liveRect.width > 100) { + popup.style.width = Math.round(liveRect.width) + "px"; + } + } catch (e) { /* ignore — fallback CSS 520px */ } + } // v2026.5.18 : mémoriser la ref et la couleur pour le dock (pastille avec // couleur de catégorie + texte ref) @@ -10712,9 +13361,23 @@ function pinTooltip() { popup.appendChild(dragbar); _attachPopupDragHandler(popup, dragbar); + // v2026.5.45 (issue #6) : amener au PREMIER PLAN le popup au clic. + // Avant, tous les popups épinglés partageaient z-index 5 (sous la topbar) + // et l'ordre était figé par l'ordre d'épinglage → cliquer sur un popup en + // arrière ne le ramenait pas devant. Maintenant, au mousedown, on remet + // tous les autres au défaut et on monte celui cliqué à z-index 9 (juste + // sous la topbar qui est à 10). + popup.addEventListener("mousedown", () => { + _bringPinnedPopupToFront(popup); + }); + // v4.3.0 : le popup contient un clone du tooltip live, qui inclut le // bouton 📌. Dans un popup déjà épinglé, ce bouton devient "désépingler". // On intercepte le clic ici, avant qu'il remonte. + // v2026.5.45 : on gère aussi `copy-ref` (issue #5) et `reload` à + // l'intérieur du popup épinglé. Avant, seul `pin` était traité ; le + // bouton 📋 copie de référence ne réagissait pas aux clics dans un + // popup épinglé (le handler était uniquement sur le tooltip live). popup.addEventListener("click", (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; @@ -10723,9 +13386,51 @@ function pinTooltip() { e.stopPropagation(); e.preventDefault(); _softUnpinPopup(popup); + return; + } + if (action === "copy-ref") { + e.stopPropagation(); + e.preventDefault(); + const ref = btn.dataset.ref; + if (ref) { + navigator.clipboard.writeText(ref).then(() => { + btn.classList.add("copied"); + const original = btn.textContent; + btn.textContent = "✓"; + setTimeout(() => { + btn.classList.remove("copied"); + btn.textContent = original; + }, 1200); + }).catch(err => LOG.warn("clipboard", "copie ref échouée (popup épinglé)", + { ref, err: err && err.message })); + } + return; + } + if (action === "reload") { + e.stopPropagation(); + e.preventDefault(); + const iv = popup._linkedIv; + if (iv) reloadSingleIntervention(iv, btn); + return; + } + if (action === "open-fiche") { + e.stopPropagation(); + e.preventDefault(); + const iv = popup._linkedIv; + if (iv) { + const background = !!(e.ctrlKey || e.metaKey || e.button === 1); + openInterventionInNewTab(iv, { background }); + } + return; + } + if (action === "delete-item") { + e.stopPropagation(); + e.preventDefault(); + const actionId = btn.dataset.actionId; + const kind = btn.dataset.kind || "absence"; + _triggerDeleteItem(actionId, kind, btn); + return; } - // Les autres actions (reload, copy-ref, etc.) ne sont pas gérées ici ; - // on pourrait les ajouter plus tard si besoin. }); // Placer en (0,0) temporairement pour mesurer la taille @@ -10784,6 +13489,12 @@ function pinTooltip() { // par _softUnpinPopup pour pouvoir ré-épingler via le bouton 📌 restauré. popup._linkedIv = iv; + // v2026.5.45 : restaurer la taille mémorisée de l'utilisateur (issue + // resize qui se perdait après pin → unpin → re-pin), puis brancher l'observer + // pour mémoriser les futures modifications de taille. + _applyRememberedPopupSize(popup, iv, popup.dataset.originDate); + _rememberPopupSize(popup, iv); + // v2026.5.40 r9 : si la souris passe sur un popup épinglé, on ferme // IMMÉDIATEMENT toute petite popup timeline ou tooltip ouvert. Évite // que le contenu derrière le popup épinglé reste affiché parasitement. @@ -10991,9 +13702,18 @@ function _softUnpinPopup(el) { if (action === "pin") { console.log("[softUnpin] clic sur 📌 d'un popup soft-unpinned → ré-épinglage"); - // Retirer le popup soft-unpinned actuel + // Capture la taille du popup soft-unpinned pour la transmettre au + // nouveau popup épinglé (sinon il repart à la taille du tooltip live). + try { + const r = el.getBoundingClientRect(); + if (r && r.width > 100 && r.height > 50) { + _pinTooltipPreservedSize = { + width: Math.round(r.width), + height: Math.round(r.height), + }; + } + } catch (err) { _pinTooltipPreservedSize = null; } try { el.remove(); } catch (err) {} - // Simuler le survol de l'iv pour rendre le tooltip live, puis épingler state.currentTooltipIv = ivForRepin; pinTooltip(); } else if (action === "reload") { @@ -11010,8 +13730,28 @@ function _softUnpinPopup(el) { // 6. Basculer visuellement : retirer pinned-popup, mettre soft-unpinned. // La classe .soft-unpinned applique les styles "tooltip normal" // (pas de bordure bleue, pas de padding-top pour topbar disparue, etc.) + // On capture la taille AVANT le changement de classe pour pouvoir la + // restaurer si l'utilisateur ré-épingle (sinon le popup repart à la + // taille par défaut du tooltip live). + try { + const r = el.getBoundingClientRect(); + if (r && r.width > 100 && r.height > 50) { + el._lastPinnedSize = { width: Math.round(r.width), height: Math.round(r.height) }; + } + } catch (e) { /* ignore */ } el.classList.remove("pinned-popup"); el.classList.add("soft-unpinned"); + // Conserve la largeur/hauteur en inline pour ne pas que la perte de + // .pinned-popup (resize:both, min-width/height) fasse rétrécir le popup. + if (el._lastPinnedSize) { + el.style.width = el._lastPinnedSize.width + "px"; + el.style.height = el._lastPinnedSize.height + "px"; + } + // v2026.5.45 — auto-fermeture réactivée : le popup se ferme quand la + // souris quitte la zone (popup + cartes liées), ainsi que sur clic ailleurs + // / Échap. Pour ne pas interférer avec le drag&drop des cartes, le hit-test + // est ignoré tant que la classe documentElement.reschedule-dragging est posée. + _attachSoftUnpinAutoHide(el); // 7. Régénérer le tooltip hover si on est en train de survoler la même iv // (pour synchroniser l'icône 📌/📍 dans le tooltip live). @@ -11040,6 +13780,71 @@ function _softUnpinPopup(el) { console.log("[softUnpin] terminé — popup reste visible en mode tooltip normal"); } +// Taille préservée à travers un cycle dépingle → ré-épingle. Sans ça, le +// nouveau popup épinglé reprend la taille du tooltip live (par défaut), +// pas celle qu'avait le popup épinglé que l'utilisateur venait de +// désépingler — donc la taille "saute" à chaque cycle pin/unpin. +let _pinTooltipPreservedSize = null; + +/** + * Auto-fermeture du popup soft-unpinned quand la souris quitte sa zone + * et n'est plus sur aucune carte de la même iv. Utilise un mousemove + * document avec hit-test géométrique (plus fiable que mouseleave qui + * peut rater si la souris quitte par un bord en diagonale). + */ +function _attachSoftUnpinAutoHide(el) { + if (!el || el._autoHideAttached) return; + el._autoHideAttached = true; + const aid = el.dataset.actionId || ""; + let hideTimer = null; + const cancel = () => { + if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } + }; + const onMove = (e) => { + if (!document.body.contains(el) || !el.classList.contains("soft-unpinned")) { + cancel(); + document.removeEventListener("mousemove", onMove); + return; + } + // v2026.5.45 — pendant un drag de carte, on ne touche à rien : pas de + // hit-test, pas de timer démarré ni annulé. Le popup reste tel quel le + // temps du drag, on reprend l'évaluation à la mousemove suivante. + if (document.documentElement.classList.contains("reschedule-dragging")) { + return; + } + // Souris dans le popup ? + const r = el.getBoundingClientRect(); + const inPopup = e.clientX >= r.left && e.clientX <= r.right + && e.clientY >= r.top && e.clientY <= r.bottom; + // Souris dans une carte qui matche la même iv ? + let inCard = false; + if (!inPopup && aid) { + const cards = document.querySelectorAll( + `.intervention-v2[data-action-id="${aid}"], .iv-mini-card[data-action-id="${aid}"]` + ); + for (const c of cards) { + const cr = c.getBoundingClientRect(); + if (cr.width === 0 && cr.height === 0) continue; + if (e.clientX >= cr.left && e.clientX <= cr.right + && e.clientY >= cr.top && e.clientY <= cr.bottom) { inCard = true; break; } + } + } + if (inPopup || inCard) { + cancel(); + } else if (!hideTimer) { + hideTimer = setTimeout(() => { + if (!document.body.contains(el)) return; + el.classList.add("unpinning"); + setTimeout(() => { + try { el.remove(); } catch (e2) {} + document.removeEventListener("mousemove", onMove); + }, 180); + }, 300); + } + }; + document.addEventListener("mousemove", onMove); +} + /** * v2026.5.34 : pose un handler global (une seule fois) qui ferme les popups * en état .soft-unpinned quand l'user clique ailleurs, ou appuie sur Échap. @@ -11220,6 +14025,10 @@ function _reducePinnedPopup(popup) { return; } e.stopPropagation(); + // v2026.5.45 : fermer le mini-menu de survol s'il était visible. + // Sans ça, cliquer directement sur la pill restaurait le popup mais le + // menu (Agrandir / Fermer) restait orphelin à l'écran. + _hidePillHoverMenu(); _restorePinnedPopupFromDock(popup); }); @@ -12285,7 +15094,7 @@ function buildTooltipHTML(iv) { } else { rows.push(`
Référence
${refSafe}
`); } - // R12m : séparateur après la Référence pour aérer le tooltip. + // séparateur après la Référence pour aérer le tooltip. rows.push(`
`); } @@ -12322,7 +15131,7 @@ function buildTooltipHTML(iv) { // seuil franchi (12h matin / 15h aprèm). Avant ça, le commentaire // existe en mémoire mais reste invisible. if (iv._diagnosticTechComment && _isVerdictRevealed(iv)) { - // R12e : retire le préfixe "login: " du commentaire. + // retire le préfixe "login: " du commentaire. const stripped = iv._diagnosticTechComment.replace(/^[a-z0-9_]{3,12}\s*:\s*/, ""); const cmtSafe = escapeHtml(stripped); const info = iv._diagnosticActionInfo || {}; @@ -12343,7 +15152,7 @@ function buildTooltipHTML(iv) { : (isSuspended ? `${escapeHtml(labelText)}` : escapeHtml(labelText)); - // R12j : layout 2 colonnes — + // layout 2 colonnes — // gauche : badge "✅ Terminé (clos)" centré vertical // droite : meta (auteur+date) + commentaire empilés const metaHtml = (info.intervenant || metaDate) @@ -12417,6 +15226,15 @@ function formatActionTextMultiline(text) { const rx = new RegExp(`([^\\n])(${escapeRegex(label)}\\s*:\\s*)`, "g"); result = result.replace(rx, "$1\n$2"); } + // v2026.5.45 — passe additionnelle pour les labels colles a un mot + // minuscule SANS : derriere (cas "secretariatEtage"). On exige une lettre + // minuscule juste avant le label en majuscule pour eviter les faux + // positifs ("Marie Bureau" a un espace, donc pas de match). + for (const label of newlineLabels) { + if (!/^[A-Z\u00C9\u00C8\u00C0\u00C2\u00CE\u00D4\u00DB\u00C7]/.test(label)) continue; + const rxG = new RegExp(`([a-z\u00E0-\u00FF])(${escapeRegex(label)})\\b`, "g"); + result = result.replace(rxG, "$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 @@ -12556,8 +15374,19 @@ function showSessionNeeded() { document.getElementById("stats").classList.add("hidden"); const evUnr = document.getElementById("ev-unreachable"); if (evUnr) evUnr.classList.add("hidden"); + // éviter le doublon — quand on affiche l'écran plein "session + // nécessaire", on cache la bannière qui dirait la même chose. + if (typeof hideSessionExpiredBanner === "function") hideSessionExpiredBanner(); document.getElementById("cards").innerHTML = ""; - document.getElementById("session-needed").classList.remove("hidden"); + const sn = document.getElementById("session-needed"); + sn.classList.remove("hidden"); + // on injecte le sous-bandeau "Activer multi-onglets" dans + // l'écran plein lui aussi — sinon en arrivant sur un jour sans + // cache + sans session, l'utilisateur perdait l'invitation à + // activer la lecture du cookie. + if (typeof _injectCookiesPromptInBanner === "function") { + try { _injectCookiesPromptInBanner(sn); } catch (e) {} + } } function hideSessionNeeded() { @@ -12585,6 +15414,10 @@ function hideEvUnreachable() { // L'utilisateur voit toujours les données déjà chargées, mais est prévenu // que les mises à jour sont arrêtées. function showSessionExpiredBanner() { + // symétrique de showSessionNeeded — si l'écran plein "session + // nécessaire" est visible, on le cache pour ne pas avoir un doublon + // (la bannière en haut + l'écran plein en dessous). + if (typeof hideSessionNeeded === "function") hideSessionNeeded(); const b = document.getElementById("session-expired-banner"); if (b) { b.classList.remove("hidden"); @@ -12592,20 +15425,133 @@ function showSessionExpiredBanner() { // appelle triggerReconnect() au lieu de juste ouvrir un onglet. Ça // déclenche la reconnexion SSO ET l'auto-reload du viewer quand la // nouvelle session est détectée. - // On renomme aussi le bouton pour être explicite. const btn = b.querySelector("#session-banner-reconnect"); if (btn && !btn.dataset.boundReconnect) { btn.dataset.boundReconnect = "1"; btn.textContent = "🔄 Me reconnecter"; - // Retirer d'éventuels anciens listeners en clonant le bouton const clone = btn.cloneNode(true); btn.parentNode.replaceChild(clone, btn); clone.addEventListener("click", () => triggerReconnect()); } + // v2026.5.45 (issue #4) : si la permission "cookies" n'est pas + // accordée ET que l'utilisateur n'a pas dit "ne plus afficher", on + // injecte un sous-bandeau invitant à activer la gestion multi-onglets. + _injectCookiesPromptInBanner(b); } hideEvUnreachableBanner(); hideReconnectingBanner(); } + +// retire TOUTES les copies actuellement affichées du sous-bandeau +// "Activer multi-onglets" (bannière session expirée + écran plein +// "session nécessaire" + modals d'erreur fiche). Appelé quand +// l'utilisateur active la permission ou choisit "Ne plus afficher" : +// on évite que l'invitation reste visible ailleurs après l'action. +function _removeAllCookiesPrompts() { + document.querySelectorAll(".session-banner-cookies-prompt").forEach(el => { + try { el.remove(); } catch (e) {} + }); +} + +async function _injectCookiesPromptInBanner(banner) { + try { + // Si déjà injecté, ne rien faire (évite doublons aux ré-affichages). + if (banner.querySelector(".session-banner-cookies-prompt")) return; + const has = await new Promise(r => chrome.permissions.contains({ permissions: ["cookies"] }, r)); + if (has) return; // permission OK, rien à proposer + // Lire le flag dismiss depuis admin_config + const cfg = await loadAdminConfig(); + if (cfg.cookiesPromptDismissed) return; + + // sous-bandeau centré, plus court et factuel — titre courte + // + bouton clair + lien dismiss discret, dans un encart blanc + // semi-translucide qui se détache bien du rouge de la bannière. + const prompt = document.createElement("div"); + prompt.className = "session-banner-cookies-prompt"; + + const inner = document.createElement("div"); + inner.className = "session-banner-cookies-inner"; + + const promptTitle = document.createElement("div"); + promptTitle.className = "session-banner-cookies-title"; + promptTitle.innerHTML = "🔗 Multi-onglets EasyVista"; + inner.appendChild(promptTitle); + + const enableBtn = document.createElement("button"); + enableBtn.type = "button"; + enableBtn.className = "btn session-banner-cookies-btn"; + enableBtn.textContent = "Activer"; + enableBtn.title = "Permet à l'extension de lire le cookie de session EasyVista."; + enableBtn.addEventListener("click", async () => { + enableBtn.disabled = true; + LOG.warn("permissions", "📥 demande de permission cookies (bouton bannière session expirée)"); + try { + const granted = await new Promise(r => chrome.permissions.request({ permissions: ["cookies"] }, r)); + if (granted) { + LOG.warn("permissions", "✅ permission cookies ACCORDÉE via bannière"); + showToast("Multi-onglets activé", "L'extension lit désormais le cookie session."); + const adminCb = document.querySelector(".admin-cookie-row input[type='checkbox']"); + if (adminCb) adminCb.checked = true; + cfg.cookiesPromptDismissed = false; + await saveAdminConfig(cfg); + // retirer TOUS les prompts ouverts (bannière + écran plein + // + modal). Avant on ne retirait que celui-ci, donc s'il y avait + // un sous-bandeau dans la modal et un autre dans la bannière, + // l'autre restait visible inutilement. + _removeAllCookiesPrompts(); + } else { + LOG.warn("permissions", "❌ permission cookies REFUSÉE via bannière"); + showToast("Refusé", "L'extension reste sur l'ancien comportement."); + enableBtn.disabled = false; + } + } catch (err) { + LOG.warn("permissions", "request err", { err: err && err.message }); + enableBtn.disabled = false; + } + }); + + const dismissBtn = document.createElement("button"); + dismissBtn.type = "button"; + dismissBtn.className = "btn btn-icon session-banner-cookies-dismiss"; + dismissBtn.textContent = "Ne plus afficher"; + dismissBtn.title = "Cache cette suggestion. Réactivable depuis Paramètres → Diagnostics."; + dismissBtn.addEventListener("click", async () => { + LOG.warn("permissions", "🙈 utilisateur a cliqué « Ne plus afficher » dans la bannière"); + cfg.cookiesPromptDismissed = true; + await saveAdminConfig(cfg); + // retirer toutes les copies du sous-bandeau, pas juste celle-ci. + _removeAllCookiesPrompts(); + if (typeof showToast === "function") { + showToast("Suggestion masquée", + "Réactivable dans Paramètres → Diagnostics → Multi-onglets"); + } + }); + + // explication en 2 lignes — chaque info sur sa propre ligne pour + // éviter une coupure disgracieuse du mot "Lit" en fin de 1re ligne. + const promptExplain = document.createElement("div"); + promptExplain.className = "session-banner-cookies-explain"; + const line1 = document.createElement("div"); + line1.textContent = "Améliore nettement la liaison entre l'extension et votre session EasyVista."; + const line2 = document.createElement("div"); + line2.className = "session-banner-cookies-explain-sub"; + line2.textContent = "Lit uniquement le cookie de session correspondant."; + promptExplain.appendChild(line1); + promptExplain.appendChild(line2); + inner.appendChild(promptExplain); + + const btnsRow = document.createElement("div"); + btnsRow.className = "session-banner-cookies-actions"; + btnsRow.appendChild(enableBtn); + btnsRow.appendChild(dismissBtn); + inner.appendChild(btnsRow); + + prompt.appendChild(inner); + banner.appendChild(prompt); + } catch (e) { + LOG.warn("permissions", "_injectCookiesPromptInBanner err", { err: e && e.message }); + } +} function hideSessionExpiredBanner() { const b = document.getElementById("session-expired-banner"); if (b) b.classList.add("hidden");