Files
Planification/viewer.js
T
2026-04-22 13:00:00 +02:00

8232 lines
314 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// viewer.js — vue claire du planning techniciens
// ============================================================================
// Idée de base : on récupère tout depuis le XML EasyVista (calendar_block) en
// 1 seule requête. attr1/attr2/attr3 + textContent contiennent déjà ref,
// contact, lieu, catégorie, formLink, deadline. Plus besoin de faire 74
// requêtes xhr2 au chargement comme la v3. Le texte complet de l'action
// (Problème / À faire / Matériel) est lazy-load au hover, seulement si
// l'user survole la ligne.
//
// Fetch des fiches : séquentiel (1 par 1) au lieu d'en paralléliser. Le
// serveur EasyVista sérialise de toute façon, et ça rend l'abort instantané
// si l'user change de date en cours.
// Le cache est écrit toutes les 5 fiches (incrémental), pas juste à la fin.
// Comme ça si l'user change de date au milieu, ce qu'on a déjà fetché est
// pas perdu.
// ============================================================================
// ============================================================================
// Configuration
// ============================================================================
// Équipe : ID EasyVista → nom affiché
const TEAM = {
"76272": "Ciuppa, Mathieu",
"83725": "De Almeida Martins, Solange",
"66635": "Makonda, Yannick",
"92235": "Mamouni, Anas",
"90070": "Paisana, David",
"40944": "Pillonel, Olivier",
"72485": "Rosset, Pascal",
"86874": "Rouiller, Quentin"
};
// Absences récurrentes (id tech → [jour JS, 0=dim..6=sam])
const RECURRING_ABSENCES = {
"40944": [5] // Pillonel absent tous les vendredis
};
// Statuts EasyVista qui déclenchent l'affichage "clos"
const CLOSED_STATUS = ["Clôturé", "Cloture", "Clôture"];
const RESOLVED_STATUS = ["Résolu", "Resolu"];
// Statuts qui indiquent qu'une intervention a été supprimée/annulée
// → si présente dans le cache mais disparue du planning : on retire
const CANCELLED_STATUS = ["Annulé", "Annule", "Supprimé", "Supprime"];
// Clés de stockage
const LS_THEME = "planning_theme";
const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD
const CACHE_DAYS = 7;
// v4.1 : plus de constante de concurrence. Les fiches sont fetchées
// séquentiellement (1 à la fois) car le serveur EasyVista est lent de toute
// façon, et ça garantit un abort instantané + pas de race sur le DOM.
// ============================================================================
// Mapping de catégorie → titre court + couleur
// ============================================================================
const CATEGORY_TO_TITLE = [
// Arrivées / nouvelles installations → Installation (bleu)
[/Arriv[ée]e\s+ou\s+mutation/i, "Installation", "installation"],
[/Accessoire\s+pour\s+PC/i, "Installation", "installation"],
[/Nouveau\s+Poste\s+Windows/i, "Installation", "installation"],
[/Nouveau\s+Poste\s+macOS/i, "Installation", "installation"],
// Récupération / départ (vert)
[/D[ée]part\s+d[\u2018\u2019']un\s+utilisateur/i, "Récupération", "recup"],
[/Reprise\s+du\s+mat[ée]riel/i, "Récupération", "recup"],
// Remplacement (orange)
[/Remplacement\s+de\s+mat[ée]riel/i, "Remplacement", "remplacement"],
];
/**
* Détecte si le texte de l'action commence par "Roll Out".
*/
function isRollOut(iv) {
const texts = [
iv.bulleDescription,
iv.infobulle && iv.infobulle.aFaire,
iv.label
];
for (const t of texts) {
if (!t) continue;
if (/^\s*[«"']?\s*roll[\s\-]*out/i.test(String(t))) return true;
if (/(?:^|\bA faire\s*:\s*)roll[\s\-]*out/i.test(String(t))) return true;
}
return false;
}
/**
* Détecte si le texte de l'action mentionne une récupération de matériel.
* Accepté : "RÉCUPÉRATION DE MATÉRIEL" ou "Récupération" au début de l'action,
* ou dans "A faire : Récupération ...".
*/
function isRecupAction(iv) {
const texts = [
iv.bulleDescription,
iv.infobulle && iv.infobulle.aFaire,
iv.label
];
for (const t of texts) {
if (!t) continue;
const s = String(t);
if (/^\s*r[ée]cup[ée]ration/i.test(s)) return true;
if (/\bA\s+faire\s*:\s*r[ée]cup[ée]ration/i.test(s)) return true;
}
return false;
}
/**
* Dérive un titre court et une clé de couleur à partir d'une intervention.
* Priorité :
* 1. Si la ref commence par I260 → "Incident" (violet)
* 2. Si l'action commence par "Roll Out" → "Roll Out" (brun)
* 3. Si l'action mentionne récupération → "Récupération" (vert)
* 4. Sinon, mapping par catégorie (fiche)
* 5. Sinon, "Autres" (gris)
*/
function deriveShortTitle(iv) {
if (iv.type === "AL-Reservation") return "Réservation";
if (iv.ref && /^I\d/.test(iv.ref)) return "Incident";
if (isRollOut(iv)) return "Roll Out";
if (isRecupAction(iv)) return "Récupération";
const cat = iv.categoryLine || "";
if (!cat) return "Autres";
for (const [regex, title] of CATEGORY_TO_TITLE) {
if (regex.test(cat)) return title;
}
return "Autres";
}
function deriveColorKey(iv) {
if (iv.type === "AL-Reservation") return "reservation";
if (iv.type === "AL-Absence") return "absence"; // v5.0.15 : couleur noire/gris foncé
if (iv.ref && /^I\d/.test(iv.ref)) return "incident";
if (isRollOut(iv)) return "rollout";
if (isRecupAction(iv)) return "recup";
const cat = iv.categoryLine || "";
if (!cat) return "autre";
for (const [regex, , colorKey] of CATEGORY_TO_TITLE) {
if (regex.test(cat)) return colorKey;
}
return "autre";
}
// ============================================================================
// État global
// ============================================================================
let state = {
session: null, // { phpsessid, origin, tabId }
currentDate: null, // "YYYY-MM-DD" affiché
currentData: null, // résultat parsé (techs, stats, ...)
loading: false,
// v5.0.9 : timestamp (ms) auquel la session EV va expirer.
sessionExpireAt: null,
// v5.0.9 : true pendant une reconnexion en cours
reconnecting: false,
// v5.0.9 : true si la session est expirée (bannière rouge affichée)
sessionExpired: 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
// ou itsma.etat-de-vaud.ch selon qu'on est en externe ou interne).
// Conservée même quand state.session est null, pour savoir où rediriger
// lors de la reconnexion.
lastKnownOrigin: null,
// v5.0.11 : contexte réseau détecté ("internal" ou "external" ou null).
// Détecté automatiquement au démarrage par un HEAD test sur l'URL interne.
networkContext: null,
// v5.0.11 : timer (setTimeout id) pour le timeout de reconnexion 90 sec
reconnectTimeoutId: null
};
// v5.0.9 : constantes session
const SESSION_DURATION_MS = 30 * 60 * 1000; // 30 min
const SESSION_WARN_THRESHOLD_MS = 5 * 60 * 1000; // 5 min → affichage compteur
const SESSION_CRITICAL_THRESHOLD_MS = 2 * 60 * 1000; // 2 min → rouge + modal
// v5.0.11 : timeout de la reconnexion. Si l'user n'est pas reconnecté
// dans ce délai, on bascule en état "Reconnexion échouée" avec choix du réseau.
const RECONNECT_TIMEOUT_MS = 90 * 1000; // 90 sec
// ─── Annulation coopérative d'un refresh manuel (v3.1) ──────────────────────
// Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent
// isRefreshAborted() avant chaque fetch : si le jeton a changé ou si
// l'utilisateur a cliqué sur "Arrêter", ils s'arrêtent proprement.
//
// v3.2 : on ajoute une "abortPromise" par refresh. loadForDate race cette
// promesse avec son Promise.all, donc dès qu'on clique Arrêter, loadForDate
// sort immédiatement (masque le bouton, fait un toast), même si les fetches
// en cours continuent silencieusement. Le changement de token les rend
// inoffensifs (ils ne peuvent plus écrire le cache ni updater le DOM).
let currentRefreshToken = 0;
let abortedToken = -1;
let abortResolvers = new Map(); // token → resolve fn of the abort promise
function startNewRefresh() {
currentRefreshToken++;
return currentRefreshToken;
}
function makeAbortPromise(myToken) {
return new Promise(resolve => {
abortResolvers.set(myToken, resolve);
});
}
function abortCurrentRefresh() {
abortedToken = currentRefreshToken;
// Réveiller tous les loadForDate en attente (normalement un seul)
for (const [token, resolve] of abortResolvers) {
if (token <= currentRefreshToken) {
resolve("aborted");
abortResolvers.delete(token);
}
}
}
// v4.1.9 : isRefreshAborted(myToken) retourne true si :
// - un nouveau refresh a été lancé (currentRefreshToken > myToken), OU
// - l'utilisateur a explicitement cliqué "Arrêter" (abortedToken).
// Sans myToken fourni (compat), on ne teste que l'abort explicite.
function isRefreshAborted(myToken) {
if (abortedToken === currentRefreshToken) return true;
if (typeof myToken === "number" && myToken < currentRefreshToken) return true;
return false;
}
function cleanupAbortResolver(myToken) {
abortResolvers.delete(myToken);
}
// ============================================================================
// Boot
// ============================================================================
document.addEventListener("DOMContentLoaded", init);
async function init() {
initTheme();
bindTopbar();
bindTooltipInteractions();
initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal
initAppFooter(); // v4.2.9 : pied de page discret bas-droite
initAppClock(); // v5.0.0 : horloge HH:MM au milieu topbar
_applyViewMode(); // v2026.5.32 : appliquer la vue sauvegardée
initAdminMenu(); // v5.0.0 : menu admin caché (5 clics sur titre)
initSessionTimer(); // v5.0.9 : compteur de session EV (tick 1s)
initDateCustomPicker(); // v2026.5.17 : faux input date avec jour
// Initialiser la date = aujourd'hui
state.currentDate = todayISO();
document.getElementById("date-picker").value = state.currentDate;
updateDatePickerDayLabel(state.currentDate); // v2026.5.16 : label "Mardi"
// v5.0.11 : détecter le contexte réseau en arrière-plan (non bloquant)
detectNetworkContextAsync();
// Charger la sesson puis le planning
await refreshSessionAndLoad();
}
/**
* v5.0.11 : détecte si on est en interne (bureau VPN) ou externe (télétravail),
* de manière asynchrone au démarrage. Résultat utilisé pour choisir le bon
* domaine lors de la reconnexion.
*/
async function detectNetworkContextAsync(force = false) {
try {
const resp = await sendMessage({ type: "detectNetwork", force });
if (resp && resp.ok) {
state.networkContext = resp.context;
// Si on n'a pas encore de lastKnownOrigin, on prend celui du contexte détecté
if (!state.lastKnownOrigin) {
state.lastKnownOrigin = resp.origin;
}
console.log("[viewer] réseau détecté :", resp.context, "→", resp.origin);
}
} catch (e) {
console.warn("[viewer] détection réseau échouée", e);
}
}
async function refreshSessionAndLoad() {
const resp = await sendMessage({ type: "getSession" });
if (!resp.ok || !resp.session) {
// v4.2.5 : si un cache existe pour le jour demandé, on l'affiche avec
// une bannière "session expirée" sticky au-dessus. Sinon écran plein.
const cached = await readCache(state.currentDate);
if (cached) {
renderFromData({
techs: cached.techs,
targetDate: state.currentDate,
captureTime: cached.savedAt || null,
source: "cache"
});
showSessionExpiredBanner();
} else {
showSessionNeeded();
}
return;
}
state.session = resp.session;
// v5.0.10 : mémoriser l'origine courante pour la reconnexion si besoin
if (resp.session && resp.session.origin) {
state.lastKnownOrigin = resp.session.origin;
}
hideSessionNeeded();
hideEvUnreachable();
hideSessionExpiredBanner();
hideEvUnreachableBanner();
state.sessionExpired = false;
state.reconnecting = false;
fetchAndShowCurrentUser();
// v5.0.9 : à chaque démarrage/reconnexion, on suppose que la session vient
// d'être rafraîchie à 30 min. updateSessionIndicator va masquer le compteur.
markSessionActivity();
await loadForDate(state.currentDate);
}
/**
* v5.0.9 : doit être appelée à chaque requête EasyVista réussie. Reset le
* timer local à 30 min (la session serveur a été renouvelée implicitement).
*/
function markSessionActivity() {
state.sessionExpireAt = Date.now() + SESSION_DURATION_MS;
state.sessionPingDone = false; // reset le flag de ping
updateSessionIndicator();
}
// v4.2 : fetche l'utilisateur EasyVista connecté (via background.js) et
// l'affiche dans la topbar.
// v2026.5.26 : en cas d'échec, affiche un rond gris "?" + retry 60s (max 10 essais).
// v2026.5.34 : le badge est maintenant TOUJOURS visible (état "?" par défaut
// dans le HTML). Cette fonction met à jour le contenu (initiales
// quand succès, "?" quand échec). Logs abondants pour debug.
//
// État initial (HTML) : <button class="user-badge user-badge-unknown">?</button>
// État succès : initiales calculées + couleur dérivée du nom
// État échec : "?" + couleur grise (classe user-badge-unknown)
//
// Retry : 10 tentatives espacées de 60s (10 min max), arrêt au 1er succès.
let _currentUserRetryCount = 0;
const _CURRENT_USER_MAX_RETRIES = 10;
const _CURRENT_USER_RETRY_DELAY_MS = 60 * 1000;
async function fetchAndShowCurrentUser() {
const attemptId = _currentUserRetryCount + 1;
console.log(`[currentUser] tentative ${attemptId}/${_CURRENT_USER_MAX_RETRIES + 1} de fetchCurrentUser`);
const badge = document.getElementById("user-badge");
if (!badge) {
// Fallback défensif : pas de badge dans le DOM ? On log et on abandonne.
console.warn("[currentUser] badge DOM introuvable — abandon");
return;
}
let success = false;
let errorReason = null;
try {
const resp = await sendMessage({ type: "fetchCurrentUser" });
console.log("[currentUser] réponse reçue :", resp ? JSON.stringify(resp).substring(0, 200) : "(null)");
if (!resp) {
errorReason = "response_null";
} else if (!resp.ok) {
errorReason = resp.error || "ok_false";
} else if (!resp.user) {
errorReason = "user_null";
} else {
const fullName = resp.user.name || resp.user.login || null;
if (!fullName) {
errorReason = "name_empty";
} else {
// ✅ Succès : mise à jour du badge
const initials = computeUserInitials(fullName);
console.log(`[currentUser] SUCCÈS : "${fullName}" → initiales "${initials}"`);
badge.textContent = initials;
badge.title = fullName;
badge.style.setProperty("--user-badge-color", colorFromName(fullName));
badge.classList.remove("user-badge-unknown");
// On retire aussi "hidden" au cas où (compat ancienne version)
badge.classList.remove("hidden");
state.currentUser = resp.user;
success = true;
_currentUserRetryCount = 0; // reset compteur au succès
}
}
} catch (err) {
errorReason = "exception: " + String(err);
console.warn("[currentUser] exception durant sendMessage :", err);
}
if (success) return;
// ❌ Échec : on laisse le badge en état "inconnu" (déjà le cas par défaut)
// et on schedule un retry.
console.warn(`[currentUser] échec (raison: ${errorReason}) — badge reste en état "?"`);
// 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 = "?";
badge.title = "Utilisateur — cliquer pour accéder aux paramètres";
badge.style.setProperty("--user-badge-color", "#6b7280");
badge.classList.add("user-badge-unknown");
badge.classList.remove("hidden");
// Schedule retry si pas trop d'essais
if (_currentUserRetryCount < _CURRENT_USER_MAX_RETRIES) {
_currentUserRetryCount++;
console.log(`[currentUser] retry programmé : ${_currentUserRetryCount}/${_CURRENT_USER_MAX_RETRIES} dans ${_CURRENT_USER_RETRY_DELAY_MS / 1000}s`);
setTimeout(() => {
fetchAndShowCurrentUser();
}, _CURRENT_USER_RETRY_DELAY_MS);
} else {
console.warn("[currentUser] max retries atteint, arrêt du retry automatique. Le badge reste cliquable (⚙ Paramètres accessible).");
}
}
/**
* v2026.5.34 : déclenche un fetchAndShowCurrentUser() SI le user n'est pas
* encore connu (badge en état "?"). Appelée après chaque succès de planning
* pour profiter d'une session EV valide sans attendre le retry de 60s.
*
* Sans effet si :
* - state.currentUser est déjà renseigné (pas besoin de re-fetcher)
* - un retry est déjà en cours (évite les doublons)
*
* @param {string} reason - contexte pour les logs (ex: "after_load_success")
*/
function _maybeRetryFetchUser(reason) {
if (state.currentUser && state.currentUser.name) {
// User déjà connu, rien à faire
return;
}
const badge = document.getElementById("user-badge");
if (badge && !badge.classList.contains("user-badge-unknown")) {
// Badge n'est pas en état inconnu → user probablement connu par un autre chemin
return;
}
console.log(`[currentUser] relance opportuniste (raison: ${reason}) — user encore inconnu`);
// Reset le compteur puisqu'on a un nouveau contexte (session fraîche)
_currentUserRetryCount = 0;
fetchAndShowCurrentUser();
}
// v4.2.3 : calcule les initiales depuis un nom au format "Nom, Prénom" ou
// "Nom Prénom" ou "Prénom Nom". On prend la 1re lettre majuscule de chaque
// mot/segment significatif, limité à 2 caractères.
function computeUserInitials(fullName) {
if (!fullName) return "?";
// Format "Nom, Prénom" → prendre initiale avant virgule et après
let parts;
if (fullName.includes(",")) {
parts = fullName.split(",").map(s => s.trim()).filter(Boolean);
} else {
parts = fullName.split(/\s+/).filter(Boolean);
}
const letters = parts
.map(p => p.charAt(0))
.filter(c => /[A-Za--ÿ]/.test(c))
.slice(0, 2)
.join("")
.toUpperCase();
return letters || (fullName.charAt(0).toUpperCase() || "?");
}
// v4.2.3 : couleur déterministe à partir du nom. Palette neutre et sobre
// (tons tamisés), compatible avec les thèmes clair et sombre de l'extension.
function colorFromName(name) {
// Hash simple (djb2) pour dériver un index stable
let h = 5381;
for (let i = 0; i < name.length; i++) {
h = ((h << 5) + h + name.charCodeAt(i)) & 0xffffffff;
}
const palette = [
"#5b6372", // gris bleuté
"#6b7280", // gris neutre
"#4a5568", // ardoise
"#3b5a72", // bleu profond tamisé
"#4f6a5e", // vert sauge sombre
"#6b5a4f", // brun taupe
"#5d4a6b", // prune sombre
"#6a5a3a", // kaki bronze
"#3a5a5e", // sarcelle sombre
"#6c5c67" // mauve grisé
];
return palette[Math.abs(h) % palette.length];
}
// v4.2.3 : affiche/masque la popup nom complet sous la pastille
function toggleUserNamePopup() {
const badge = document.getElementById("user-badge");
const popup = document.getElementById("user-name-popup");
if (!badge || !popup) return;
if (!popup.classList.contains("hidden")) {
hideUserNamePopup();
return;
}
// v2026.5.17 : afficher aussi le temps restant de la session (MM:SS) avec
// une couleur qui dépend du seuil (vert/jaune/rouge).
// v2026.5.26 : si user inconnu, afficher "Utilisateur inconnu" + retry + réglages
popup.innerHTML = "";
const nameEl = document.createElement("div");
nameEl.className = "user-name-popup-name";
nameEl.textContent = state.currentUser.name;
popup.appendChild(nameEl);
const sessEl = document.createElement("div");
sessEl.className = "user-name-popup-session";
sessEl.id = "user-name-popup-session";
_renderUserPopupSessionLine(sessEl);
popup.appendChild(sessEl);
popup.classList.remove("hidden");
badge.classList.add("open");
// Positionne juste en dessous de la pastille
const r = badge.getBoundingClientRect();
popup.style.left = Math.max(8, r.left) + "px";
popup.style.top = (r.bottom + 6) + "px";
}
function hideUserNamePopup() {
const popup = document.getElementById("user-name-popup");
const badge = document.getElementById("user-badge");
if (popup) popup.classList.add("hidden");
if (badge) badge.classList.remove("open");
}
// v2026.5.17 : remplit la ligne "Session : MM:SS" avec couleur selon seuil.
// Recalcule à chaque appel — appelée aussi par le tick session pour rafraîchir.
function _renderUserPopupSessionLine(el) {
if (!el) return;
const remainingMs = _getSessionRemainingMs();
if (remainingMs == null) {
el.textContent = "Session : —";
el.className = "user-name-popup-session";
return;
}
const mins = Math.floor(remainingMs / 60000);
const secs = Math.floor((remainingMs % 60000) / 1000);
const txt = `Session : ${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
el.textContent = txt;
el.className = "user-name-popup-session";
if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS) {
el.classList.add("session-critical");
} else if (remainingMs <= SESSION_WARN_THRESHOLD_MS) {
el.classList.add("session-warn");
} else {
el.classList.add("session-ok");
}
}
// v2026.5.17 : récupère en ms le temps restant avant expiration de la session.
// Retourne null si on ne connaît pas encore (pas de session ouverte).
function _getSessionRemainingMs() {
if (!state.sessionExpireAt) return null;
const remaining = state.sessionExpireAt - Date.now();
return remaining > 0 ? remaining : 0;
}
// ============================================================================
// Thème clair/sombre
// ============================================================================
function initTheme() {
const saved = localStorage.getItem(LS_THEME);
const theme = (saved === "light" || saved === "dark") ? saved : detectDefaultTheme();
applyTheme(theme);
}
function detectDefaultTheme() {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
}
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
const icon = document.getElementById("theme-icon");
if (icon) icon.textContent = theme === "dark" ? "☀️" : "🌙";
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme") || "light";
const next = current === "dark" ? "light" : "dark";
applyTheme(next);
localStorage.setItem(LS_THEME, next);
}
// ============================================================================
// Topbar handlers
// ============================================================================
function bindTopbar() {
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
// v4.1.10 : 2 boutons de rafraichissement.
// - refresh-btn (Total) : force le re-fetch de toutes les fiches (même celles
// déjà enrichies), utile pour voir les statuts évoluer.
// - refresh-partial-btn (Partiel) : re-fetch juste le XML planning pour
// détecter nouvelles/disparues interventions, mais ne refetch PAS les
// fiches déjà connues → rapide.
document.getElementById("refresh-btn").addEventListener("click", () => {
setActiveRefreshButton("total");
refreshPlanning({ total: true });
});
const partialBtn = document.getElementById("refresh-partial-btn");
if (partialBtn) {
partialBtn.addEventListener("click", () => {
setActiveRefreshButton("partial");
refreshPlanning({ partial: true });
});
}
document.getElementById("abort-btn").addEventListener("click", () => {
// Feedback visuel instantané : masquer le bouton tout de suite, sans
// attendre que loadForDate finisse sa race.
showAbortButton(false);
abortCurrentRefresh();
showAbortToast();
});
document.getElementById("clear-cache-btn").addEventListener("click", onClearCache);
// v4.2.6 : boutons Absence et Douchette
const absenceBtn = document.getElementById("absence-btn");
if (absenceBtn) absenceBtn.addEventListener("click", showAbsenceModal);
const douchetteBtn = document.getElementById("douchette-btn");
if (douchetteBtn) douchetteBtn.addEventListener("click", showDouchetteModal);
document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1));
document.getElementById("nav-next").addEventListener("click", () => navigateDate(+1));
document.getElementById("nav-today").addEventListener("click", () => loadForDate(todayISO()));
document.getElementById("date-picker").addEventListener("change", (e) => {
if (e.target.value) loadForDate(e.target.value);
});
// v4.2.3 : clic sur la pastille d'initiales → toggle popup nom complet
const userBadge = document.getElementById("user-badge");
if (userBadge) {
userBadge.addEventListener("click", (e) => {
e.stopPropagation();
toggleUserNamePopup();
});
}
// Clic ailleurs ou touche Escape ferme la popup user
document.addEventListener("click", (e) => {
const popup = document.getElementById("user-name-popup");
if (popup && !popup.classList.contains("hidden")) {
// Ne pas fermer si le clic est dans la popup elle-même ou sur le badge
if (!e.target.closest("#user-name-popup") && !e.target.closest("#user-badge")) {
hideUserNamePopup();
}
}
// v4.2.4 : clic ailleurs ferme aussi la grande bulle d'interventoin
// quand elle est ouverte via clic timeline (mode "anchored"). Clic sur
// la bulle elle-même ou sur une timeline-slot ne ferme pas.
const tip = tooltipEl();
if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
if (!e.target.closest("#tooltip") && !e.target.closest(".timeline-slot")) {
hideTooltip({ force: true });
}
}
});
// v2026.5.20 : nouveau comportement de la touche Échap
// - Appui court : ferme uniquement le popup SOUS la souris (normal ou
// minimisé). Si la souris n'est sur aucun popup, ne fait rien.
// Ferme aussi le popup user-badge et la grande bulle anchored.
// - Maintenu ≥ 3 secondes : ferme TOUS les popups flottants, mais garde
// les pastilles dock (popups "réduits" en bas).
let _escHoldTimer = null;
let _escHoldTriggered = false;
const ESC_HOLD_MS = 3000;
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
// keydown peut se répéter si la touche est maintenue ; on ignore les répétitions.
if (e.repeat) return;
// Armer le timer "maintenu 3s"
_escHoldTriggered = false;
if (_escHoldTimer) clearTimeout(_escHoldTimer);
_escHoldTimer = setTimeout(() => {
_escHoldTriggered = true;
_escHoldTimer = null;
// Fermer TOUS les popups flottants (normaux + minimisés) mais pas les dockés
document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced)").forEach(p => {
try { p.remove(); } catch (err) {}
});
// Nettoyer la liste
for (let i = pinnedPopups.length - 1; i >= 0; i--) {
if (!document.body.contains(pinnedPopups[i].el)) {
pinnedPopups.splice(i, 1);
}
}
_ensureDockCloseAllBtn();
}, ESC_HOLD_MS);
});
document.addEventListener("keyup", (e) => {
if (e.key !== "Escape") return;
if (_escHoldTimer) {
clearTimeout(_escHoldTimer);
_escHoldTimer = null;
}
if (_escHoldTriggered) {
// On a déjà fait l'action "maintenu", ne rien faire de plus
_escHoldTriggered = false;
return;
}
// Appui court : fermer le popup sous la souris si applicable
hideUserNamePopup();
const tip = tooltipEl();
if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
hideTooltip({ force: true });
}
// Quel popup est sous la souris ? Utiliser :hover pour détecter
const hovered = document.querySelector(".pinned-popup:hover");
if (hovered && !hovered.classList.contains("pinned-popup-reduced")) {
// Retirer aussi de pinnedPopups
const idx = pinnedPopups.findIndex(p => p.el === hovered);
if (idx >= 0) pinnedPopups.splice(idx, 1);
hovered.remove();
_ensureDockCloseAllBtn();
}
});
// v5.0.10 : clic "Ouvrir EasyVista" sur l'écran plein → déclenche la
// reconnexion SSO + l'auto-reload du viewer dès que la nouvelle session
// est détectée (au lieu d'ouvrir juste un onglet).
document.getElementById("open-ev-btn").addEventListener("click", () => {
triggerReconnect();
});
// v4.2 : écran "EasyVista inaccessible"
const openEvBtn2 = document.getElementById("open-ev-btn-2");
if (openEvBtn2) openEvBtn2.addEventListener("click", openEasyVista);
const retryBtn = document.getElementById("retry-btn");
if (retryBtn) retryBtn.addEventListener("click", async () => {
hideEvUnreachable();
document.getElementById("loading").classList.remove("hidden");
await refreshSessionAndLoad();
});
// v4.1.12 : bindings bannière session expirée
const reconnectBtn = document.getElementById("session-banner-reconnect");
if (reconnectBtn) reconnectBtn.addEventListener("click", openEasyVista);
const closeBtn = document.getElementById("session-banner-close");
if (closeBtn) closeBtn.addEventListener("click", hideSessionExpiredBanner);
// v4.2.5 : bindings bannière "EasyVista inaccessible"
const evRetryBtn = document.getElementById("ev-unreachable-banner-retry");
if (evRetryBtn) evRetryBtn.addEventListener("click", async () => {
hideEvUnreachableBanner();
await refreshSessionAndLoad();
});
const evOpenBtn = document.getElementById("ev-unreachable-banner-open");
if (evOpenBtn) evOpenBtn.addEventListener("click", openEasyVista);
const evCloseBtn = document.getElementById("ev-unreachable-banner-close");
if (evCloseBtn) evCloseBtn.addEventListener("click", hideEvUnreachableBanner);
}
async function openEasyVista() {
// v5.0.10 : ouvrir sur le domaine le plus approprié :
// - lastKnownOrigin si on a déjà eu une session fonctionnelle (respecte
// interne vs externe selon le réseau)
// - session.origin si on a encore la session
// - itsma.vd.ch en fallback (domaine externe accessible de partout,
// même depuis le réseau VD il redirige vers l'interne transparent)
const origin = state.lastKnownOrigin
|| (state.session && state.session.origin)
|| "https://itsma.vd.ch";
await chrome.tabs.create({ url: origin + "/" });
}
// Navigation ±1 jour en sautant les week-ends
function navigateDate(direction) {
const d = isoToDate(state.currentDate);
d.setDate(d.getDate() + direction);
// Sauter les week-ends
while (d.getDay() === 0 || d.getDay() === 6) {
d.setDate(d.getDate() + direction);
}
loadForDate(dateToISO(d));
}
async function onClearCache() {
// v4.1.20 : modal central avec 2 choix (jour / tout) + annuler
showClearCacheModal();
}
// v4.1.20 : modal central de confirmation pour vider le cache. L'arrière-plan
// est flouté, l'utilisateur a deux choix explicites + Annuler.
function showClearCacheModal() {
// Ne pas ouvrir 2x si déjà affiché
if (document.getElementById("clear-cache-modal")) return;
const dateTxt = formatDateDM(state.currentDate);
const overlay = document.createElement("div");
overlay.id = "clear-cache-modal";
overlay.className = "modal-overlay";
overlay.innerHTML = `
<div class="modal-card" role="dialog" aria-labelledby="clear-cache-title">
<h2 id="clear-cache-title" class="modal-title">Vider le cache</h2>
<p class="modal-message">
Le cache stocke les données des interventions pour éviter de
re-télécharger à chaque ouverture. Que voulez-vous supprimer&nbsp;?
</p>
<div class="modal-actions">
<button type="button" class="btn btn-modal-danger" data-action="clear-day">
Vider le cache du ${dateTxt}
</button>
<button type="button" class="btn btn-modal-danger-strong" data-action="clear-all">
Vider tout le cache
</button>
<button type="button" class="btn btn-modal-cancel" data-action="cancel">
Annuler
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const close = () => {
overlay.remove();
};
overlay.addEventListener("click", async (e) => {
const action = e.target.closest("[data-action]")?.dataset.action;
if (!action) {
// Clic sur le fond (pas sur la carte) → fermer
if (e.target === overlay) close();
return;
}
if (action === "cancel") {
close();
return;
}
if (action === "clear-day") {
close();
await chrome.storage.local.remove(CACHE_PREFIX + state.currentDate);
await loadForDate(state.currentDate, { forceRefetch: true });
return;
}
if (action === "clear-all") {
close();
// Supprimer toutes les clés CACHE_PREFIX*
const all = await chrome.storage.local.get(null);
const toRemove = Object.keys(all).filter(k => k.startsWith(CACHE_PREFIX));
if (toRemove.length) {
await chrome.storage.local.remove(toRemove);
}
await loadForDate(state.currentDate, { forceRefetch: true });
return;
}
});
// Échap ferme la modale
const escHandler = (e) => {
if (e.key === "Escape") {
close();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
}
// ============================================================================
// v4.2.5 : modal d'alerte générique (session expirée / EV inaccessible /
// erreur d'ouverture). Remplace les alert() natives par une vraie popup
// avec flou autour, titre, message et boutons personnalisables.
// ============================================================================
/**
* Affiche un modal d'alerte.
* @param {Object} opts
* @param {string} opts.title - Titre
* @param {string} opts.message - Message (HTML autorisé si opts.html=true)
* @param {boolean} [opts.html=false] - Si true, message interprété comme HTML
* @param {Array<{label:string, variant:"primary"|"secondary"|"danger", action:(()=>void|Promise<void>)}>} opts.buttons
* Boutons (en bas du modal). Le 1er = focus par défaut.
*/
function showAlertModal(opts) {
// Si un alert modal est déjà affiché, l'enlever d'abord
const existing = document.getElementById("alert-modal");
if (existing) existing.remove();
const overlay = document.createElement("div");
overlay.id = "alert-modal";
overlay.className = "modal-overlay";
const card = document.createElement("div");
card.className = "modal-card";
card.setAttribute("role", "dialog");
card.setAttribute("aria-labelledby", "alert-modal-title");
const h = document.createElement("h2");
h.id = "alert-modal-title";
h.className = "modal-title";
h.textContent = opts.title || "";
card.appendChild(h);
const p = document.createElement("p");
p.className = "modal-message";
if (opts.html) {
p.innerHTML = opts.message || "";
} else {
p.textContent = opts.message || "";
}
card.appendChild(p);
const actions = document.createElement("div");
actions.className = "modal-actions";
(opts.buttons || []).forEach((btn, i) => {
const b = document.createElement("button");
b.type = "button";
b.className = "btn";
if (btn.variant === "primary") b.classList.add("btn-modal-primary");
else if (btn.variant === "danger") b.classList.add("btn-modal-danger-strong");
else b.classList.add("btn-modal-cancel");
b.textContent = btn.label;
b.addEventListener("click", async () => {
overlay.remove();
if (typeof btn.action === "function") {
try { await btn.action(); } catch (e) { console.error("[alert-modal]", e); }
}
});
actions.appendChild(b);
if (i === 0) setTimeout(() => b.focus(), 50);
});
card.appendChild(actions);
overlay.appendChild(card);
document.body.appendChild(overlay);
// Clic sur le fond (flou) → fermer
overlay.addEventListener("click", (e) => {
if (e.target === overlay) overlay.remove();
});
// Échap ferme la modale
const escHandler = (e) => {
if (e.key === "Escape") {
overlay.remove();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
}
// ============================================================================
// v4.2.9 : blocage du scroll en arrière-plan quand un modal est ouvert
// ============================================================================
//
// Un MutationObserver surveille l'apparition/disparition de tout élément
// .modal-overlay dans le body. Dès qu'il y en a au moins un, on ajoute la
// classe `modal-open` sur body → CSS bloque le scroll. Quand le dernier
// modal disparaît, la classe est retirée.
//
// Centralisé ici pour que TOUS les modals (existants et futurs) en profitent
// sans modification individuelle.
function initModalScrollLock() {
const updateLock = () => {
const hasModal = document.querySelector(".modal-overlay") !== null;
document.body.classList.toggle("modal-open", hasModal);
};
const observer = new MutationObserver(updateLock);
observer.observe(document.body, { childList: true, subtree: false });
updateLock(); // au cas où un modal serait déjà là au boot
}
// v4.2.9 : pied de page discret "QRO / vX.X.X" en bas à droite.
// La version est lue depuis le manifest (source unique de vérité).
function initAppFooter() {
if (document.querySelector(".app-footer")) return;
let version = "";
try {
const manifest = chrome && chrome.runtime && chrome.runtime.getManifest
? chrome.runtime.getManifest() : null;
if (manifest && manifest.version) version = "v" + manifest.version;
} catch (e) {}
const el = document.createElement("div");
el.className = "app-footer";
el.textContent = `QRO${version ? " / " + version : ""}`;
document.body.appendChild(el);
}
// v5.0.0 : horloge HH:MM au milieu de la topbar. Mise à jour toutes les 30s
// (les secondes ne sont pas affichées donc pas besoin d'un tick plus rapide).
function initAppClock() {
const el = document.getElementById("app-clock");
if (!el) return;
const dateEl = document.getElementById("app-clock-date");
const timeEl = document.getElementById("app-clock-time");
// v2026.5.16 : format "Mardi 21 avril 2026"
const JOURS = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"];
const MOIS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre"
];
let lastDateStr = "";
const tick = () => {
const d = new Date();
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
const timeStr = `${h}:${m}`;
if (timeEl) timeEl.textContent = timeStr;
else el.textContent = timeStr; // fallback si ancien markup
// Date complète : actualisée seulement si elle a changé (évite reflow inutile)
if (dateEl) {
const jour = JOURS[d.getDay()];
const num = d.getDate();
const mois = MOIS[d.getMonth()];
const annee = d.getFullYear();
const dateStr = `${jour} ${num} ${mois} ${annee}`;
if (dateStr !== lastDateStr) {
dateEl.textContent = dateStr;
lastDateStr = dateStr;
}
}
// v5.0.0 : profite du tick pour mettre à jour la ligne rouge "now"
updateNowLine();
};
tick();
// Tick toutes les 30s : ça garantit une MAJ rapide au changement de min
setInterval(tick, 30 * 1000);
}
// v2026.5.17 : met à jour le faux input date custom (ex: "Vendredi 24.04.2026")
// Remplace l'ancien updateDatePickerDayLabel. L'input date natif reste présent
// mais caché, et son onChange continue de déclencher le chargement.
const DAY_NAMES_FULL = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"];
function updateDatePickerDayLabel(isoDate) {
const el = document.getElementById("date-custom-label");
if (!el) return;
if (!isoDate) { el.textContent = ""; return; }
try {
const d = isoToDate(isoDate);
const day = DAY_NAMES_FULL[d.getDay()];
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
el.textContent = `${day} ${dd}.${mm}.${yyyy}`;
} catch (e) {
el.textContent = "";
}
}
// v2026.5.17 : brancher le faux input date — clic dessus ouvre le vrai input
// caché pour choisir une date.
function initDateCustomPicker() {
const custom = document.getElementById("date-custom");
const picker = document.getElementById("date-picker");
if (!custom || !picker) return;
const openPicker = () => {
try {
if (typeof picker.showPicker === "function") {
picker.showPicker();
} else {
picker.focus();
picker.click();
}
} catch (e) {
picker.focus();
}
};
custom.addEventListener("click", openPicker);
custom.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openPicker();
}
});
}
// v5.0.0 : ligne verticale rouge "heure actuelle" sur la timeline, visible
// UNIQUEMENT quand on affiche aujourd'hui. Appelée à chaque tick de l'horloge
// + après chaque render (cf renderFromData).
function updateNowLine() {
const isToday = state.currentDate === todayISO();
// Retirer toutes les lignes existantes d'abord
document.querySelectorAll(".timeline-now-line").forEach(el => el.remove());
if (!isToday) return;
// Calculer la position en % sur la timeline (DAY_START à DAY_END)
const now = new Date();
const nowMin = now.getHours() * 60 + now.getMinutes();
if (nowMin < DAY_START || nowMin > DAY_END) return; // hors plage affichée
const pct = ((nowMin - DAY_START) / DAY_LEN) * 100;
// Ajouter une ligne sur chaque barre timeline visible
document.querySelectorAll(".timeline-bar").forEach(bar => {
const line = document.createElement("div");
line.className = "timeline-now-line";
line.style.left = pct + "%";
bar.appendChild(line);
});
}
// v5.0.0 : menu admin caché. 5 clics consécutifs sur le titre "Planification"
// (avec max 2 secondes entre chaque clic) ouvrent le panneau admin.
function initAdminMenu() {
const title = document.getElementById("app-title");
if (!title) return;
let clicks = 0;
let resetTimer = null;
title.addEventListener("click", () => {
clicks++;
if (resetTimer) clearTimeout(resetTimer);
resetTimer = setTimeout(() => { clicks = 0; }, 2000);
if (clicks >= 5) {
clicks = 0;
clearTimeout(resetTimer);
showAdminPanel();
}
});
// Cursor pointer pour indiquer (discrètement) qu'il est cliquable
title.style.cursor = "default";
}
// ============================================================================
// v5.0.9 : Surveillance du timeout de session EasyVista
// ============================================================================
/**
* 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.
*/
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");
// Recharger le planning à la date courante sans perdre la position
await loadForDate(state.currentDate);
}
}
} catch (e) {
// Silencieux, on réessayera au prochain tick
}
}, 2000);
}
/**
* Met à jour l'affichage du compteur session dans la topbar.
* Règles :
* - Session expirée ou reconnexion → compteur caché (bannière gère l'affichage)
* - > 5 min restantes → compteur invisible
* - 2-5 min → jaune, bouton "Prolonger" visible
* - < 2 min → rouge pulse + modal automatique (une seule fois)
* - <= 0 → déclenche l'état "expirée"
*/
function updateSessionIndicator() {
const el = document.getElementById("app-session");
if (!el) return;
if (state.sessionExpired || state.reconnecting) {
el.classList.add("hidden");
return;
}
if (!state.sessionExpireAt) {
el.classList.add("hidden");
return;
}
const remainingMs = state.sessionExpireAt - Date.now();
if (remainingMs <= 0) {
handleSessionExpired();
return;
}
if (remainingMs > SESSION_WARN_THRESHOLD_MS) {
el.classList.add("hidden");
return;
}
// Zone d'alerte (< 5 min)
// v5.0.9 : avant d'afficher l'alerte, on fait UN ping de confirmation
// pour vérifier que le serveur est bien d'accord (compteur local parfois
// désynchronisé si plusieurs requêtes EV ont rafraîchi sans qu'on update
// notre horloge). Une seule fois par cycle.
if (!state.sessionPingDone) {
state.sessionPingDone = true;
sendMessage({ type: "getSessionRemaining" }).then(resp => {
if (resp && resp.ok && typeof resp.remainingMs === "number") {
state.sessionExpireAt = Date.now() + resp.remainingMs;
updateSessionIndicator();
}
}).catch(() => {});
// En attendant, on continue avec l'estimation locale
}
const mm = Math.floor(remainingMs / 60000);
const ss = Math.floor((remainingMs % 60000) / 1000);
const timeStr = `${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
el.classList.remove("hidden", "session-warn", "session-critical");
if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS) {
el.classList.add("session-critical");
if (!state._criticalModalShown) {
state._criticalModalShown = true;
showSessionCriticalModal();
}
} else {
el.classList.add("session-warn");
state._criticalModalShown = false;
}
el.innerHTML = `
<span class="session-icon">⏱</span>
<span class="session-time">${timeStr}</span>
<button type="button" class="session-extend-btn" title="Prolonger la session de 30 min">🔄 Prolonger</button>
`;
const extendBtn = el.querySelector(".session-extend-btn");
if (extendBtn) {
extendBtn.onclick = async () => {
extendBtn.disabled = true;
extendBtn.textContent = "…";
try {
const resp = await sendMessage({ type: "extendSession" });
if (resp && resp.ok && typeof resp.remainingMs === "number") {
state.sessionExpireAt = Date.now() + resp.remainingMs;
state.sessionPingDone = false;
state._criticalModalShown = false;
showToast("Session prolongée", "30 minutes de plus");
updateSessionIndicator();
} else {
throw new Error((resp && resp.error) || "erreur inconnue");
}
} catch (err) {
extendBtn.disabled = false;
extendBtn.textContent = "🔄 Prolonger";
if (err.message === "session_expired" || err.message === "no_session") {
handleSessionExpired();
}
}
};
}
// v2026.5.17 : si le popup user-badge est ouvert, rafraîchir la ligne "Session : MM:SS"
const sessLineInPopup = document.getElementById("user-name-popup-session");
if (sessLineInPopup) _renderUserPopupSessionLine(sessLineInPopup);
// v2026.5.17 : popup d'alerte "glissante" depuis le haut gauche
// - à 5 min : alerte standard (si pas encore affichée ni "plus tard")
// - à 2 min : alerte urgente (si pas encore affichée)
_handleSessionSlideAlerts(remainingMs);
}
/**
* v2026.5.17 : gère les 2 alertes popup glissant depuis le haut gauche.
* - Première alerte à 5 min (SESSION_WARN_THRESHOLD_MS). Reste affichée jusqu'à
* action manuelle (Prolonger ou Plus tard).
* - Si "Plus tard", une 2e alerte plus urgente réapparait à 2 min
* (SESSION_CRITICAL_THRESHOLD_MS).
*/
function _handleSessionSlideAlerts(remainingMs) {
if (remainingMs == null) return;
// Alerte à 5 min
if (remainingMs <= SESSION_WARN_THRESHOLD_MS
&& remainingMs > SESSION_CRITICAL_THRESHOLD_MS
&& !state._slideAlert5minShown) {
state._slideAlert5minShown = true;
_showSessionSlideAlert({ urgent: false });
}
// Alerte à 2 min (si déjà "Plus tard" sur l'alerte 5 min OU alerte 5 min jamais affichée)
if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS
&& !state._slideAlert2minShown) {
state._slideAlert2minShown = true;
// Cacher éventuellement l'ancienne alerte pour ré-afficher la nouvelle
_hideSessionSlideAlert();
_showSessionSlideAlert({ urgent: true });
}
}
function _showSessionSlideAlert({ urgent }) {
// Retirer l'ancienne si elle existe
_hideSessionSlideAlert();
const el = document.createElement("div");
el.id = "session-slide-alert";
el.className = "session-slide-alert" + (urgent ? " urgent" : "");
const title = urgent ? "⚠ Session expire dans 2 minutes !" : "⏱ Session expire dans 5 minutes";
el.innerHTML = `
<div class="session-slide-alert-title">${title}</div>
<div class="session-slide-alert-actions">
<button type="button" class="session-slide-alert-extend">🔄 Prolonger</button>
<button type="button" class="session-slide-alert-later">Plus tard</button>
</div>
`;
document.body.appendChild(el);
// Déclenche l'animation de slide-in (petite tempo pour que la transition parte)
requestAnimationFrame(() => el.classList.add("visible"));
// Action "Prolonger"
el.querySelector(".session-slide-alert-extend").addEventListener("click", async () => {
const extendBtn = el.querySelector(".session-slide-alert-extend");
extendBtn.disabled = true;
extendBtn.textContent = "…";
try {
const resp = await sendMessage({ type: "extendSession" });
if (resp && resp.ok && typeof resp.remainingMs === "number") {
state.sessionExpireAt = Date.now() + resp.remainingMs;
state.sessionPingDone = false;
state._criticalModalShown = false;
// Reset des flags d'alerte pour le prochain cycle
state._slideAlert5minShown = false;
state._slideAlert2minShown = false;
showToast("Session prolongée", "30 minutes de plus");
updateSessionIndicator();
_hideSessionSlideAlert();
} else {
throw new Error((resp && resp.error) || "erreur inconnue");
}
} catch (err) {
extendBtn.disabled = false;
extendBtn.textContent = "🔄 Prolonger";
}
});
// Action "Plus tard"
el.querySelector(".session-slide-alert-later").addEventListener("click", () => {
_hideSessionSlideAlert();
// Si c'est l'alerte 5 min qu'on dismissa, l'alerte 2 min reviendra
// automatiquement (state._slideAlert2minShown toujours false).
});
}
function _hideSessionSlideAlert() {
const el = document.getElementById("session-slide-alert");
if (!el) return;
el.classList.remove("visible");
setTimeout(() => { try { el.remove(); } catch (e) {} }, 250);
}
/**
* Appelée quand le compteur atteint 0 ou quand une requête EV échoue en
* session expirée. Affiche la bannière "Session expirée" avec bouton "Me
* reconnecter".
*/
function handleSessionExpired() {
if (state.sessionExpired) return;
state.sessionExpired = true;
state.sessionExpireAt = null;
state._criticalModalShown = false;
console.warn("[session] session EV expirée");
showSessionExpiredBanner();
const el = document.getElementById("app-session");
if (el) el.classList.add("hidden");
}
/**
* Modal auto quand < 2 min : alerte visuelle forte.
*/
function showSessionCriticalModal() {
showAlertModal({
title: "⚠️ Session EasyVista expire bientôt",
message: "Votre session EasyVista expire dans moins de 2 minutes. Cliquez sur « Prolonger » pour éviter d'être déconnecté.",
buttons: [
{ label: "Ignorer", variant: "secondary", action: () => {} },
{
label: "🔄 Prolonger maintenant",
variant: "primary",
action: async () => {
try {
const resp = await sendMessage({ type: "extendSession" });
if (resp && resp.ok && typeof resp.remainingMs === "number") {
state.sessionExpireAt = Date.now() + resp.remainingMs;
state.sessionPingDone = false;
state._criticalModalShown = false;
showToast("Session prolongée", "30 minutes de plus");
updateSessionIndicator();
}
} catch (e) {
handleSessionExpired();
}
}
}
]
});
}
/**
* Appelé au clic "Me reconnecter" dans la bannière. Ouvre EasyVista dans un
* nouvel onglet (déclenche Windows SSO Kerberos automatique). Le polling
* dans initSessionTimer détectera la nouvelle session et rechargera le viewer.
*
* v5.0.10 : utilise l'origine dynamique (interne ou externe selon le réseau).
* v5.0.11 : détecte le contexte réseau avant d'ouvrir (si pas déjà connu) +
* timeout 90s : si pas reconnecté après ce délai, propose choix manuel.
*
* @param {string} [forcedOrigin] - origine à forcer (pour le choix manuel
* dans le fallback après timeout). Si absent : détection auto.
*/
async function triggerReconnect(forcedOrigin) {
state.reconnecting = true;
hideSessionExpiredBanner();
hideReconnectFailedBanner();
showReconnectingBanner();
// Annuler tout timeout précédent
if (state.reconnectTimeoutId) {
clearTimeout(state.reconnectTimeoutId);
state.reconnectTimeoutId = null;
}
try {
let origin = forcedOrigin;
if (!origin) {
// v5.0.11 : re-détecter le réseau à chaque expiration pour gérer le
// cas où on a changé de contexte (bureau → TT) pendant la session.
await detectNetworkContextAsync(true);
origin = state.lastKnownOrigin
|| (state.session && state.session.origin)
|| "https://itsma.vd.ch";
}
console.log("[session] triggerReconnect → ouverture de", origin);
await sendMessage({ type: "openEasyVistaLogin", origin });
// Démarrer le timeout 90s : si pas reconnecté, basculer en mode "Échec"
state.reconnectTimeoutId = setTimeout(() => {
if (state.reconnecting && !state.session) {
console.warn("[session] reconnexion timeout 90s → bannière échec");
state.reconnecting = false;
hideReconnectingBanner();
showReconnectFailedBanner();
}
}, RECONNECT_TIMEOUT_MS);
} catch (err) {
console.warn("[session] openEasyVistaLogin failed:", err);
state.reconnecting = false;
hideReconnectingBanner();
showSessionExpiredBanner();
}
}
/**
* v5.0.11 : l'user clique "Annuler" pendant la reconnexion. On arrête le
* polling/timeout et on revient à l'état "Session expirée" normal.
*/
function cancelReconnect() {
if (state.reconnectTimeoutId) {
clearTimeout(state.reconnectTimeoutId);
state.reconnectTimeoutId = null;
}
state.reconnecting = false;
hideReconnectingBanner();
hideReconnectFailedBanner();
showSessionExpiredBanner();
}
// v5.0.0 : stockage des paramètres admin dans chrome.storage.local.
// Clé unique : "admin_config". Contient la config éditable (équipe,
// absences récurrentes, statuts etc.). Au 1er lancement : initialisée
// avec les valeurs hardcodées actuelles.
const ADMIN_CONFIG_KEY = "admin_config";
function getDefaultAdminConfig() {
return {
team: { ...TEAM }, // Clone pour ne pas modifier le hardcode
recurringAbsences: { ...RECURRING_ABSENCES }, // idem
groupId: "191",
evOrigins: ["https://itsma.etat-de-vaud.ch", "https://itsma.vd.ch"],
closedStatus: [...CLOSED_STATUS],
resolvedStatus: [...RESOLVED_STATUS],
cancelledStatus: [...CANCELLED_STATUS],
dayStart: 8,
dayEnd: 18,
cacheDays: 7
};
}
async function loadAdminConfig() {
try {
const stored = await chrome.storage.local.get(ADMIN_CONFIG_KEY);
if (stored && stored[ADMIN_CONFIG_KEY]) {
// Fusion avec les defaults (pour rajouter d'éventuelles nouvelles clés)
return { ...getDefaultAdminConfig(), ...stored[ADMIN_CONFIG_KEY] };
}
} catch (e) {
console.warn("[admin] loadAdminConfig err", e);
}
return getDefaultAdminConfig();
}
async function saveAdminConfig(cfg) {
try {
await chrome.storage.local.set({ [ADMIN_CONFIG_KEY]: cfg });
console.log("[admin] config sauvegardée");
return true;
} catch (e) {
console.error("[admin] saveAdminConfig err", e);
return false;
}
}
// v5.0.0 : affiche le panel admin plein écran.
async function showAdminPanel() {
// Ferme un éventuel panel existant
const existing = document.getElementById("admin-panel");
if (existing) existing.remove();
// Charge la config actuelle
const cfg = await loadAdminConfig();
// Overlay plein écran
const overlay = document.createElement("div");
overlay.id = "admin-panel";
overlay.className = "modal-overlay admin-overlay";
const card = document.createElement("div");
card.className = "admin-panel-card";
// En-tête
const header = document.createElement("div");
header.className = "admin-header";
const title = document.createElement("h2");
title.textContent = "⚙ Administration";
title.className = "admin-title";
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "admin-close-btn";
closeBtn.textContent = "×";
closeBtn.title = "Fermer (Échap)";
closeBtn.addEventListener("click", () => overlay.remove());
header.appendChild(title);
header.appendChild(closeBtn);
card.appendChild(header);
// Navigation latérale (onglets)
const body = document.createElement("div");
body.className = "admin-body";
const sidebar = document.createElement("nav");
sidebar.className = "admin-sidebar";
const content = document.createElement("div");
content.className = "admin-content";
const sections = [
{ id: "team", label: "Équipe", render: renderAdminSectionTeam },
{ id: "easyvista", label: "EasyVista", render: renderAdminSectionEV },
{ id: "appearance", label: "Apparence", render: renderAdminSectionAppearance },
{ id: "statuses", label: "Statuts", render: renderAdminSectionStatuses },
{ id: "diagnostics",label: "Diagnostics", render: renderAdminSectionDiagnostics }
];
let currentSection = "team";
const navButtons = {};
for (const section of sections) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "admin-nav-btn";
btn.textContent = section.label;
btn.dataset.section = section.id;
if (section.id === currentSection) btn.classList.add("active");
btn.addEventListener("click", () => {
currentSection = section.id;
for (const k in navButtons) navButtons[k].classList.remove("active");
btn.classList.add("active");
content.innerHTML = "";
section.render(content, cfg, () => saveAndReload(cfg));
});
navButtons[section.id] = btn;
sidebar.appendChild(btn);
}
body.appendChild(sidebar);
body.appendChild(content);
card.appendChild(body);
overlay.appendChild(card);
document.body.appendChild(overlay);
// Rendu initial : section "Équipe"
sections[0].render(content, cfg, () => saveAndReload(cfg));
// Échap ferme le panel
const escHandler = (e) => {
if (e.key === "Escape") {
overlay.remove();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
async function saveAndReload(updatedCfg) {
const ok = await saveAdminConfig(updatedCfg);
if (ok) {
showToast("Config enregistrée", "Rechargez l'extension pour appliquer");
} else {
showAlertModal({
title: "Erreur",
message: "Impossible d'enregistrer la configuration.",
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
}
}
}
// v5.0.0 : section "Équipe" du panel admin.
// v5.0.1 : affiche la liste complète du groupe EasyVista (20+ personnes),
// avec case à cocher "inclure dans la planification" pour chacune.
function renderAdminSectionTeam(container, cfg, saveFn) {
const h = document.createElement("h3");
h.textContent = "Équipe";
h.className = "admin-section-title";
container.appendChild(h);
const desc = document.createElement("p");
desc.className = "admin-section-desc";
desc.textContent = "Sélectionnez les personnes qui doivent apparaître dans la planification. Les IDs viennent d'EasyVista (bouton Détecter) ou peuvent être saisis manuellement.";
container.appendChild(desc);
// État local : liste {id, name, included, days:[0..6]}
// Au départ on remplit depuis cfg.team actuel, puis la détection EV
// enrichit cette liste.
const rows = [];
for (const [id, name] of Object.entries(cfg.team || {})) {
rows.push({
id,
name,
included: true,
days: (cfg.recurringAbsences[id] || []).slice()
});
}
const tableWrap = document.createElement("div");
tableWrap.className = "admin-team-wrap";
container.appendChild(tableWrap);
function render() {
tableWrap.innerHTML = "";
// Bouton "Détecter depuis EasyVista"
const detectBtn = document.createElement("button");
detectBtn.type = "button";
detectBtn.className = "btn btn-secondary";
detectBtn.textContent = "🔍 Détecter depuis EasyVista (groupe complet)";
detectBtn.style.marginBottom = "12px";
detectBtn.addEventListener("click", async () => {
detectBtn.disabled = true;
detectBtn.textContent = "Détection en cours…";
try {
const resp = await sendMessage({ type: "detectTeam" });
if (resp && resp.ok && resp.members && resp.members.length) {
// Merge : pour chaque membre détecté, ajoute à `rows` s'il n'y est
// pas déjà. S'il y est déjà, met à jour le nom (si meilleur).
for (const m of resp.members) {
const existing = rows.find(r => r.id === m.id);
if (existing) {
// Améliorer le nom si le nom actuel commence par "?"
if (m.name && !m.name.startsWith("?") && existing.name.startsWith("?")) {
existing.name = m.name;
}
} else {
rows.push({
id: m.id,
name: m.name || "? (" + m.id + ")",
included: !!m.alreadyInTeam, // coché si déjà dans l'équipe
days: []
});
}
}
showToast("Détecté", resp.members.length + " personne(s) dans le groupe");
render();
} else {
showAlertModal({
title: "Détection impossible",
message: (resp && resp.error) || "Aucune personne trouvée. Vérifiez que vous êtes connecté à EasyVista.",
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
}
} catch (err) {
console.warn("[admin] detectTeam err", err);
} finally {
detectBtn.disabled = false;
detectBtn.textContent = "🔍 Détecter depuis EasyVista (groupe complet)";
}
});
tableWrap.appendChild(detectBtn);
// Stats : nb inclus / total
const included = rows.filter(r => r.included).length;
const stats = document.createElement("div");
stats.className = "admin-section-desc";
stats.style.marginTop = "0";
stats.textContent = `${included} personne(s) incluse(s) sur ${rows.length} connue(s).`;
tableWrap.appendChild(stats);
// Table
const table = document.createElement("table");
table.className = "admin-team-table";
const thead = document.createElement("thead");
thead.innerHTML = "<tr><th>Inclure</th><th>ID</th><th>Nom affiché</th><th>Absences récurrentes</th><th></th></tr>";
table.appendChild(thead);
const tbody = document.createElement("tbody");
table.appendChild(tbody);
const days = ["Dim","Lun","Mar","Mer","Jeu","Ven","Sam"];
rows.forEach((r, idx) => {
const tr = document.createElement("tr");
if (!r.included) tr.classList.add("admin-row-excluded");
// Checkbox inclure
const tdInc = document.createElement("td");
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = r.included;
cb.addEventListener("change", () => {
r.included = cb.checked;
tr.classList.toggle("admin-row-excluded", !r.included);
stats.textContent = `${rows.filter(x => x.included).length} personne(s) incluse(s) sur ${rows.length} connue(s).`;
});
tdInc.appendChild(cb);
tr.appendChild(tdInc);
// ID
const tdId = document.createElement("td");
const inpId = document.createElement("input");
inpId.type = "text";
inpId.value = r.id;
inpId.placeholder = "76272";
inpId.className = "admin-input admin-input-id";
inpId.addEventListener("input", () => { r.id = inpId.value.trim(); });
tdId.appendChild(inpId);
tr.appendChild(tdId);
// Nom
const tdName = document.createElement("td");
const inpName = document.createElement("input");
inpName.type = "text";
inpName.value = r.name;
inpName.placeholder = "Dupont, Jean";
inpName.className = "admin-input";
inpName.addEventListener("input", () => { r.name = inpName.value.trim(); });
tdName.appendChild(inpName);
tr.appendChild(tdName);
// Jours d'absence récurrente
const tdAbs = document.createElement("td");
for (let d = 0; d < 7; d++) {
const lbl = document.createElement("label");
lbl.className = "admin-day-cb";
const cbd = document.createElement("input");
cbd.type = "checkbox";
cbd.checked = r.days.includes(d);
cbd.addEventListener("change", () => {
if (cbd.checked && !r.days.includes(d)) r.days.push(d);
if (!cbd.checked) r.days = r.days.filter(x => x !== d);
});
lbl.appendChild(cbd);
lbl.appendChild(document.createTextNode(days[d]));
tdAbs.appendChild(lbl);
}
tr.appendChild(tdAbs);
// Bouton supprimer ligne
const tdDel = document.createElement("td");
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "admin-del-btn";
delBtn.textContent = "🗑";
delBtn.title = "Retirer cette ligne";
delBtn.addEventListener("click", () => {
rows.splice(idx, 1);
render();
});
tdDel.appendChild(delBtn);
tr.appendChild(tdDel);
tbody.appendChild(tr);
});
tableWrap.appendChild(table);
// Bouton Ajouter manuellement
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "btn btn-secondary";
addBtn.textContent = "+ Ajouter manuellement";
addBtn.style.marginTop = "10px";
addBtn.addEventListener("click", () => {
rows.push({ id: "", name: "", included: true, days: [] });
render();
});
tableWrap.appendChild(addBtn);
// Bouton Enregistrer
const saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "btn btn-primary";
saveBtn.textContent = "💾 Enregistrer";
saveBtn.style.marginTop = "20px";
saveBtn.style.marginLeft = "10px";
saveBtn.addEventListener("click", () => {
// Reconstruire cfg.team et cfg.recurringAbsences à partir de rows
const newTeam = {};
const newRecAbs = {};
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();
}
cfg.team = newTeam;
cfg.recurringAbsences = newRecAbs;
saveFn();
});
tableWrap.appendChild(saveBtn);
}
render();
}
// v5.0.0 : sections suivantes (placeholders, à enrichir v5.0.1+)
function renderAdminSectionEV(container, cfg, saveFn) {
const h = document.createElement("h3");
h.textContent = "EasyVista";
h.className = "admin-section-title";
container.appendChild(h);
const desc = document.createElement("p");
desc.className = "admin-section-desc";
desc.textContent = "Section à venir dans v5.0.1. Origines EasyVista + group_id.";
container.appendChild(desc);
// Infos lecture seule pour l'instant
const pre = document.createElement("pre");
pre.className = "admin-readonly";
pre.textContent = JSON.stringify({
evOrigins: cfg.evOrigins,
groupId: cfg.groupId
}, null, 2);
container.appendChild(pre);
}
function renderAdminSectionAppearance(container, cfg, saveFn) {
const h = document.createElement("h3");
h.textContent = "Apparence";
h.className = "admin-section-title";
container.appendChild(h);
const desc = document.createElement("p");
desc.className = "admin-section-desc";
desc.textContent = "Section à venir dans v5.0.x. Heures journée, durée cache, thème.";
container.appendChild(desc);
const pre = document.createElement("pre");
pre.className = "admin-readonly";
pre.textContent = JSON.stringify({
dayStart: cfg.dayStart,
dayEnd: cfg.dayEnd,
cacheDays: cfg.cacheDays
}, null, 2);
container.appendChild(pre);
}
function renderAdminSectionStatuses(container, cfg, saveFn) {
const h = document.createElement("h3");
h.textContent = "Statuts";
h.className = "admin-section-title";
container.appendChild(h);
const desc = document.createElement("p");
desc.className = "admin-section-desc";
desc.textContent = "Section à venir dans v5.0.x. Mots-clés Clôturé / Résolu / Annulé.";
container.appendChild(desc);
const pre = document.createElement("pre");
pre.className = "admin-readonly";
pre.textContent = JSON.stringify({
closed: cfg.closedStatus,
resolved: cfg.resolvedStatus,
cancelled: cfg.cancelledStatus
}, null, 2);
container.appendChild(pre);
}
function renderAdminSectionDiagnostics(container, cfg, saveFn) {
const h = document.createElement("h3");
h.textContent = "Diagnostics";
h.className = "admin-section-title";
container.appendChild(h);
const version = (chrome && chrome.runtime && chrome.runtime.getManifest)
? chrome.runtime.getManifest().version : "?";
const info = document.createElement("div");
info.className = "admin-diag-grid";
info.innerHTML = `
<div><strong>Version</strong></div><div>${escapeHtml(version)}</div>
<div><strong>Date courante</strong></div><div>${escapeHtml(state.currentDate || "?")}</div>
<div><strong>Aujourd'hui</strong></div><div>${escapeHtml(todayISO())}</div>
<div><strong>Session EasyVista</strong></div><div>${state.session ? "✓ connecté (" + (state.session.origin || "?") + ")" : "✗ non détecté"}</div>
<div><strong>Popups épinglées</strong></div><div>${pinnedPopups.length}</div>
`;
container.appendChild(info);
// Bouton reset
const resetBtn = document.createElement("button");
resetBtn.type = "button";
resetBtn.className = "btn btn-danger";
resetBtn.textContent = "⚠ Réinitialiser la configuration (équipe, etc.)";
resetBtn.style.marginTop = "20px";
resetBtn.addEventListener("click", () => {
showAlertModal({
title: "Confirmer la réinitialisation",
message: "Remettre TOUTES les configurations aux valeurs par défaut ? (les techniciens ajoutés manuellement seront perdus)",
buttons: [
{ label: "Annuler", variant: "secondary", action: () => {} },
{
label: "Réinitialiser",
variant: "danger",
action: async () => {
await chrome.storage.local.remove(ADMIN_CONFIG_KEY);
showToast("Réinitialisé", "Rechargez la page pour voir les défauts");
}
}
]
});
});
container.appendChild(resetBtn);
}
// ============================================================================
// v4.2.6 : Modals Absence et Douchette
// ============================================================================
// Types d'absence EasyVista (extraits du HTML plan_set_holidays_popup.php)
const HOLIDAY_TYPES = [
{ guid: "{EF51F439-441E-4A68-9D1A-A6E0A85F32FE}", label: "Congés" },
{ guid: "{B5B887A7-DE5D-4CAB-B55E-7D01E5D0DF84}", label: "Déménagement" },
{ guid: "{8476B26C-DFE4-4256-B2B5-3CE1C9EC3479}", label: "Ecrans" },
{ guid: "{E7432422-55CB-4DB9-8A26-619D036E2155}", label: "Evènements spéciaux" },
{ guid: "{F9B8FFC6-5D64-4339-AAAF-166D6D3801DA}", label: "MAC" },
{ guid: "{0554F45A-9B31-43D7-A1E2-0407D74F3BB5}", label: "Maladie" },
{ guid: "{E8301A0F-B246-420A-863C-3837F1B581E0}", label: "PC" },
{ guid: "{60D70502-063D-45AD-9415-25C1C556105F}", label: "Pompier" },
{ guid: "{B343C590-1446-45BF-9CE6-790C759BA999}", label: "Réunion" },
{ guid: "{7E63F472-677E-4EFD-B822-1AF4DC163AEC}", label: "Rollout" },
{ guid: "{D45DEF80-9DDA-46BA-957E-B5B6D7F9D46A}", label: "Téléphones" },
{ guid: "{06BCAC52-5A8A-4D6D-9BC6-566AAF18666A}", label: "UTP" }
];
/**
* Formate une date ISO YYYY-MM-DD en DD/MM/YYYY (format EasyVista).
*/
function isoToEvDate(iso) {
if (!iso) return "";
const parts = iso.split("-");
if (parts.length !== 3) return iso;
return `${parts[2]}/${parts[1]}/${parts[0]}`;
}
/**
* Construit un bloc liste de techniciens avec checkboxes.
* @param {Object} opts
* @param {boolean} [opts.selectAll] - Afficher la case "Tout sélectionner"
* @returns {HTMLElement}
*/
function buildTechCheckboxList(opts = {}) {
const container = document.createElement("div");
container.className = "modal-tech-list";
const techIds = Object.keys(TEAM);
if (opts.selectAll) {
const allRow = document.createElement("label");
allRow.className = "modal-tech-item tech-selectall";
const allBox = document.createElement("input");
allBox.type = "checkbox";
allBox.className = "tech-select-all";
const allLabel = document.createElement("span");
allLabel.textContent = "Tout sélectionner";
allRow.appendChild(allBox);
allRow.appendChild(allLabel);
container.appendChild(allRow);
allBox.addEventListener("change", () => {
container.querySelectorAll(".tech-checkbox").forEach(cb => {
cb.checked = allBox.checked;
});
});
}
for (const id of techIds) {
const row = document.createElement("label");
row.className = "modal-tech-item";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.className = "tech-checkbox";
cb.value = id;
const label = document.createElement("span");
label.textContent = TEAM[id];
row.appendChild(cb);
row.appendChild(label);
container.appendChild(row);
// Cocher "Tout" si toutes les cases sont cochées (et décocher sinon)
cb.addEventListener("change", () => {
const allBox = container.querySelector(".tech-select-all");
if (!allBox) return;
const boxes = [...container.querySelectorAll(".tech-checkbox")];
allBox.checked = boxes.every(b => b.checked);
allBox.indeterminate = !allBox.checked && boxes.some(b => b.checked);
});
}
return container;
}
/**
* Récupère la liste des techIds cochés dans une liste de checkboxes.
*/
function getCheckedTechIds(container) {
return [...container.querySelectorAll(".tech-checkbox:checked")].map(cb => cb.value);
}
/**
* Ouvre la modal "Créer une absence".
*/
function showAbsenceModal() {
const existing = document.getElementById("absence-modal");
if (existing) existing.remove();
const overlay = document.createElement("div");
overlay.id = "absence-modal";
overlay.className = "modal-overlay";
const card = document.createElement("div");
card.className = "modal-card modal-wide";
card.setAttribute("role", "dialog");
// v5.0.0 : on mémorise la date affichée au moment de l'ouverture de la
// modal. Le reload après création se fait sur cette date précise, pas
// sur state.currentDate (qui aurait pu changer entre-temps).
const dateAtOpen = state.currentDate || todayISO();
const title = document.createElement("h2");
title.className = "modal-title";
title.textContent = "Créer une absence";
card.appendChild(title);
// Liste des techs (sans "Tout sélectionner" : on ne met quasi jamais tout
// le monde en absence, et c'est trop dangereux par erreur)
const techGroup = document.createElement("div");
techGroup.className = "modal-form-group";
const techLabel = document.createElement("label");
techLabel.className = "modal-form-label";
techLabel.textContent = "Technicien(s)";
techGroup.appendChild(techLabel);
const techList = buildTechCheckboxList({ selectAll: false });
techGroup.appendChild(techList);
card.appendChild(techGroup);
// Dates et heures : aujourd'hui ou le jour affiché, 08:00-18:00
const today = state.currentDate || todayISO();
const dateGroup = document.createElement("div");
dateGroup.className = "modal-form-group";
const dateLabel = document.createElement("label");
dateLabel.className = "modal-form-label";
dateLabel.textContent = "Date et heure de début";
dateGroup.appendChild(dateLabel);
const dateRow1 = document.createElement("div");
dateRow1.className = "modal-form-row";
const startDate = document.createElement("input");
startDate.type = "date";
startDate.className = "modal-form-input";
startDate.id = "absence-start-date";
startDate.value = today;
const startTime = document.createElement("input");
startTime.type = "time";
startTime.className = "modal-form-input";
startTime.id = "absence-start-time";
startTime.value = "08:00";
dateRow1.appendChild(startDate);
dateRow1.appendChild(startTime);
dateGroup.appendChild(dateRow1);
card.appendChild(dateGroup);
const endGroup = document.createElement("div");
endGroup.className = "modal-form-group";
const endLabel = document.createElement("label");
endLabel.className = "modal-form-label";
endLabel.textContent = "Date et heure de fin";
endGroup.appendChild(endLabel);
const endRow = document.createElement("div");
endRow.className = "modal-form-row";
const endDate = document.createElement("input");
endDate.type = "date";
endDate.className = "modal-form-input";
endDate.id = "absence-end-date";
endDate.value = today;
const endTime = document.createElement("input");
endTime.type = "time";
endTime.className = "modal-form-input";
endTime.id = "absence-end-time";
endTime.value = "18:00";
endRow.appendChild(endDate);
endRow.appendChild(endTime);
endGroup.appendChild(endRow);
card.appendChild(endGroup);
// v5.0.4 : presets rapides pour les horaires (matin / après-midi / journée)
const presetGroup = document.createElement("div");
presetGroup.className = "modal-form-group";
const presetLabel = document.createElement("label");
presetLabel.className = "modal-form-label";
presetLabel.textContent = "Presets rapides";
presetGroup.appendChild(presetLabel);
const presetRow = document.createElement("div");
presetRow.className = "modal-form-row modal-preset-row";
const presets = [
{ label: "Matin", start: "08:00", end: "12:00" },
{ label: "Après-midi", start: "13:00", end: "18:00" },
{ label: "Toute la journée", start: "08:00", end: "18:00" }
];
for (const p of presets) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-secondary modal-preset-btn";
btn.textContent = p.label;
btn.addEventListener("click", () => {
startTime.value = p.start;
endTime.value = p.end;
// Synchroniser visuellement la mise à jour et déclencher
// endDateTouched si besoin (la date reste inchangée)
startTime.dispatchEvent(new Event("input", { bubbles: true }));
endTime.dispatchEvent(new Event("input", { bubbles: true }));
});
presetRow.appendChild(btn);
}
presetGroup.appendChild(presetRow);
card.appendChild(presetGroup);
// v5.0.0 : la date de fin suit la date de début tant que l'user ne l'a
// pas explicitement modifiée. 95% des absences sont d'un seul jour, donc
// changer juste le start doit mettre à jour le end aussi.
let endDateTouched = false;
endDate.addEventListener("input", () => { endDateTouched = true; });
startDate.addEventListener("input", () => {
if (!endDateTouched || endDate.value < startDate.value) {
endDate.value = startDate.value;
}
});
// Type d'absence
const typeGroup = document.createElement("div");
typeGroup.className = "modal-form-group";
const typeLabel = document.createElement("label");
typeLabel.className = "modal-form-label";
typeLabel.textContent = "Type d'absence";
typeGroup.appendChild(typeLabel);
const typeSelect = document.createElement("select");
typeSelect.className = "modal-form-select";
typeSelect.id = "absence-type-select";
const emptyOpt = document.createElement("option");
emptyOpt.value = "";
emptyOpt.textContent = "— Choisir un type —";
typeSelect.appendChild(emptyOpt);
for (const t of HOLIDAY_TYPES) {
const opt = document.createElement("option");
opt.value = t.guid;
opt.textContent = t.label;
typeSelect.appendChild(opt);
}
typeGroup.appendChild(typeSelect);
card.appendChild(typeGroup);
// Boutons Appliquer / Annuler
const actions = document.createElement("div");
actions.className = "modal-actions horizontal";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "btn btn-modal-cancel";
cancelBtn.textContent = "Annuler";
cancelBtn.addEventListener("click", () => overlay.remove());
const applyBtn = document.createElement("button");
applyBtn.type = "button";
applyBtn.className = "btn btn-modal-primary";
applyBtn.textContent = "Appliquer";
applyBtn.addEventListener("click", async () => {
// Validation
const techIds = getCheckedTechIds(techList);
if (techIds.length === 0) {
showAlertModal({
title: "Sélection manquante",
message: "Choisissez au moins un technicien.",
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
return;
}
if (!typeSelect.value) {
showAlertModal({
title: "Sélection manquante",
message: "Choisissez un type d'absence.",
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
return;
}
const sd = startDate.value, st = startTime.value;
const ed = endDate.value, et = endTime.value;
if (!sd || !st || !ed || !et) {
showAlertModal({
title: "Dates/heures manquantes",
message: "Remplissez toutes les dates et heures.",
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
return;
}
// v5.0.0 : validation fin >= début pour ne pas envoyer des absences
// inversées à EasyVista (il les accepte mais elles n'apparaissent jamais
// dans le planning, cf bug constaté).
if (ed < sd || (ed === sd && et <= st)) {
showAlertModal({
title: "Dates incohérentes",
message: "La date/heure de fin doit être après la date/heure de début.",
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
return;
}
// Désactiver le bouton pendant l'envoi
applyBtn.disabled = true;
applyBtn.textContent = "Envoi…";
try {
await submitAbsence({
techIds: techIds,
startDate: sd,
startTime: st,
endDate: ed,
endTime: et,
typeGuid: typeSelect.value
});
overlay.remove();
showToast("Absence créée", techIds.length + " tech" + (techIds.length > 1 ? "s" : ""));
// v5.0.0 : reload le planning DE LA DATE AFFICHÉE AVANT (dateAtOpen),
// pas de state.currentDate qui a pu être modifié entre-temps (bug
// où le planning sautait à la date de début de l'absence).
if (state.session) {
await loadForDate(dateAtOpen, { forceRefetch: true });
}
} catch (err) {
applyBtn.disabled = false;
applyBtn.textContent = "Appliquer";
showAlertModal({
title: "Erreur lors de la création",
message: "Impossible de créer l'absence : " + (err.message || err),
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
}
});
actions.appendChild(cancelBtn);
actions.appendChild(applyBtn);
card.appendChild(actions);
overlay.appendChild(card);
document.body.appendChild(overlay);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) overlay.remove();
});
const escHandler = (e) => {
if (e.key === "Escape") {
overlay.remove();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
}
/**
* Envoie la requête de création d'absence à EasyVista.
* Appelle le background script qui fait le POST avec la bonne session.
*/
async function submitAbsence(opts) {
const resp = await sendMessage({
type: "submitAbsence",
techIds: opts.techIds,
startDate: isoToEvDate(opts.startDate),
startTime: opts.startTime + ":00", // HH:MM:SS
endDate: isoToEvDate(opts.endDate),
endTime: opts.endTime + ":00",
typeGuid: opts.typeGuid,
currentDate: isoToEvDate(opts.startDate)
});
if (!resp || !resp.ok) {
throw new Error(resp && resp.error ? resp.error : "erreur inconnue");
}
return resp;
}
/**
* Ouvre la modal "Envoyer la planification sur la douchette".
*/
function showDouchetteModal() {
const existing = document.getElementById("douchette-modal");
if (existing) existing.remove();
const overlay = document.createElement("div");
overlay.id = "douchette-modal";
overlay.className = "modal-overlay";
const card = document.createElement("div");
card.className = "modal-card";
card.setAttribute("role", "dialog");
const title = document.createElement("h2");
title.className = "modal-title";
title.textContent = "Envoyer la planification sur la douchette";
card.appendChild(title);
const msg = document.createElement("p");
msg.className = "modal-message";
msg.textContent = "Choisissez le ou les techniciens qui recevront la planification du jour sur leur douchette.";
card.appendChild(msg);
const techGroup = document.createElement("div");
techGroup.className = "modal-form-group";
const techList = buildTechCheckboxList({ selectAll: true });
techGroup.appendChild(techList);
card.appendChild(techGroup);
// Boutons
const actions = document.createElement("div");
actions.className = "modal-actions horizontal";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "btn btn-modal-cancel";
cancelBtn.textContent = "Annuler";
cancelBtn.addEventListener("click", () => overlay.remove());
const sendBtn = document.createElement("button");
sendBtn.type = "button";
sendBtn.className = "btn btn-modal-primary";
sendBtn.textContent = "Envoyer";
sendBtn.addEventListener("click", async () => {
const techIds = getCheckedTechIds(techList);
if (techIds.length === 0) {
showAlertModal({
title: "Sélection manquante",
message: "Choisissez au moins un technicien.",
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
return;
}
sendBtn.disabled = true;
sendBtn.textContent = "Envoi…";
try {
const result = await submitDouchette(techIds);
overlay.remove();
if (result && result.okCount > 0) {
showToast(
"Envoyé sur douchette",
result.okCount + "/" + techIds.length + " tech" + (techIds.length > 1 ? "s" : "")
);
}
if (result && result.errors && result.errors.length > 0) {
showAlertModal({
title: "Envoi partiellement échoué",
message: result.errors.length + " tech(s) n'ont pas pu recevoir : "
+ result.errors.map(e => TEAM[e.techId] || e.techId).join(", "),
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
}
} catch (err) {
sendBtn.disabled = false;
sendBtn.textContent = "Envoyer";
showAlertModal({
title: "Erreur lors de l'envoi",
message: "Impossible d'envoyer sur la douchette : " + (err.message || err),
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
}
});
actions.appendChild(cancelBtn);
actions.appendChild(sendBtn);
card.appendChild(actions);
overlay.appendChild(card);
document.body.appendChild(overlay);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) overlay.remove();
});
const escHandler = (e) => {
if (e.key === "Escape") {
overlay.remove();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
}
/**
* Envoie la planification sur la douchette de plusieurs techniciens.
* Retourne { okCount, errors: [{techId, error}] }.
*/
async function submitDouchette(techIds) {
const resp = await sendMessage({
type: "submitDouchette",
techIds: techIds,
currentDate: isoToEvDate(state.currentDate || todayISO())
});
if (!resp || !resp.ok) {
throw new Error(resp && resp.error ? resp.error : "erreur inconnue");
}
return resp;
}
// ============================================================================
// Date helpers
// ============================================================================
function todayISO() {
const d = new Date();
return dateToISO(d);
}
function dateToISO(d) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
function isoToDate(iso) {
const [y, m, d] = iso.split("-").map(n => parseInt(n, 10));
return new Date(y, m - 1, d);
}
function isoToDDMMYYYY(iso) {
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
}
function formatDateDM(iso) {
const [, m, d] = iso.split("-");
return `${d}/${m}`;
}
function isoToUnixDate(iso) {
// Renvoie le timestamp Unix à midi local du jour (pour que le serveur comprenne bien le jour demandé)
const d = isoToDate(iso);
d.setHours(12, 0, 0, 0);
return Math.floor(d.getTime() / 1000);
}
// ============================================================================
// Messages → background
// ============================================================================
function sendMessage(msg) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(msg, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve(response || {});
});
});
}
// ============================================================================
// Cache (chrome.storage.local)
// ============================================================================
async function readCache(isoDate) {
const key = CACHE_PREFIX + isoDate;
const obj = await chrome.storage.local.get(key);
return obj[key] || null;
}
async function writeCache(isoDate, data) {
const key = CACHE_PREFIX + isoDate;
await chrome.storage.local.set({ [key]: { ...data, savedAt: Date.now() } });
}
// ============================================================================
// Flux principal : charger une date
// ============================================================================
async function loadForDate(isoDate, opts = {}) {
// v4.3.1 : changer de date fermait tous les popups épinglés.
// v2026.5.17 : les popups épinglés restent maintenant ouverts entre dates,
// avec les données qu'ils avaient au moment de l'épinglage.
// v2026.5.18 : au changement de date, on réduit tous les popups épinglés
// dans la taskbar du bas (l'user peut les re-agrandir au clic).
const previousDate = state.currentDate;
if (previousDate && previousDate !== isoDate) {
_reduceAllPinnedPopups();
}
state.currentDate = isoDate;
document.getElementById("date-picker").value = isoDate;
updateDatePickerDayLabel(isoDate); // v2026.5.16 : label "Mardi" à côté
if (!state.session) {
// v4.2.5 : afficher le cache + bannière, plutôt que l'écran "session"
const cached = await readCache(isoDate);
if (cached) {
renderFromData({
techs: cached.techs,
targetDate: isoDate,
captureTime: cached.savedAt || null,
source: "cache"
});
showSessionExpiredBanner();
} else {
showSessionNeeded();
}
return;
}
// (v3.1.1) Tout chargement = un nouveau jeton d'annulation. Le bouton
// "Arrêter" apparaît pour TOUT refresh (clic manuel, navigation date,
// ouverture vue claire), pas juste refreshPlanning(). Le bouton disparaît
// quand le chargement est vraiment fini (finally).
const myToken = startNewRefresh();
showAbortButton(true);
const t0 = performance.now();
console.log(`[load] début pour ${isoDate} (token=${myToken})`);
// v4.1.14 : choix du bouton qui tourne
// - Clic explicite "Actualiser" → _fromPartialBtn → "partial"
// - Clic explicite "Tout recharger" → doStatusRefresh → "total"
// - Sinon (nav date / chargement auto) :
// - cache présent → "partial" (c'est juste un diff XML)
// - cache absent → "total" (on charge tout pour la 1re fois)
// La détermination se fait APRÈS readCache.
try {
// 1. Afficher immédiatement depuis le cache si disponible
const cached = await readCache(isoDate);
if (!opts._fromPartialBtn) {
if (opts.doStatusRefresh) {
setActiveRefreshButton("total");
} else {
setActiveRefreshButton(cached ? "partial" : "total");
}
}
if (cached && !opts.forceRefetch) {
renderFromData({
techs: cached.techs,
targetDate: isoDate,
captureTime: cached.savedAt || null,
source: "cache"
});
// v4.1.9 : on NE retourne PAS ici. On continue pour refetch le XML
// du planning afin de détecter les nouvelles iv et celles disparues
// (diff avec le cache). Les iv déjà présentes dans le cache gardent
// leur enrichissement (ficheActionText, statut) → pas de re-fetch
// inutile, seules les nouvelles passent par refreshStatuses.
} else {
showLoading();
}
if (isRefreshAborted(myToken)) return;
// 2. Fetch frais du planning (XML calendar_block — rapide, ~40 ko)
const tXml = performance.now();
const fresh = await fetchPlanningForDate(isoDate);
console.log(`[load] XML planning récupéré en ${Math.round(performance.now() - tXml)} ms`);
if (!fresh) return;
if (isRefreshAborted(myToken)) return;
// 3. Fusionner cache + frais
const merged = mergeCacheAndFresh(cached, fresh);
// 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)
// - un ticket CLÔTURÉ/RÉSOLU dans EasyVista (→ garder en vert ✓✓ double)
// - un ticket DÉPLACÉ (action ouverte au même tech autre jour) → retirer
// - un ticket ANNULÉ / autre → retirer
// L'analyse est asynchrone (re-fetch de chaque fiche) : on la lance en
// arrière-plan APRÈS le rendu initial pour ne pas bloquer l'UI.
// En attendant, les ghosts restent visibles avec un indicateur "en cours
// d'analyse" (petit spinner / opacité réduite).
const ghostsToAnalyze = [];
for (const tech of merged.techs) {
for (const iv of tech.interventions) {
if (iv.ghost) {
iv._disappearChecking = true; // marquer "en cours d'analyse"
ghostsToAnalyze.push({ tech, iv });
}
}
}
// 4. Afficher immédiatement (v4 : tout est déjà rempli depuis le XML !)
// Le calendar_block contient attr1/attr2/attr3 = contact/lieu/catégorie,
// et textContent = ref. Donc ce 1er rendu est DÉJÀ complet visuellement
// (manquent juste : statut clos/résolu, et détails dans le tooltip au
// survol). Plus d'étapes 5a et 5b successives comme en v3.
renderFromData({
techs: merged.techs,
targetDate: isoDate,
captureTime: Date.now(),
source: "fresh",
lastRefreshKind: activeRefreshButton // v4.1.20
});
console.log(`[load] 1er rendu complet à ${Math.round(performance.now() - t0)} ms`);
// v4.2.5 : analyser les ghosts (tickets disparus du planning) pour décider
// s'il faut les garder en vert (terminés par tech / clôturés) ou les
// retirer définitivement (déplacés / annulés). Asynchrone en arrière-plan.
if (ghostsToAnalyze.length > 0 && !isRefreshAborted(myToken)) {
console.log(`[load] analyse de ${ghostsToAnalyze.length} ticket(s) disparu(s)…`);
analyzeDisappearedInterventions(merged.techs, ghostsToAnalyze, myToken)
.then(() => {
if (!isRefreshAborted(myToken)) {
renderFromData({
techs: merged.techs,
targetDate: isoDate,
captureTime: Date.now(),
source: "fresh",
lastRefreshKind: activeRefreshButton
});
writeCache(isoDate, { techs: merged.techs }).catch(() => {});
}
})
.catch(err => console.error("[disappear-analysis]", err));
}
// 5. Fetch des fiches en arrière-plan UNIQUEMENT pour obtenir :
// - le statut Clôturé/Résolu (pour le ✓ vert et le fond vert)
// - le commentaire technicien (affiché dans le tooltip)
// - le checksum pour ouvrir la fiche (en vrai déjà dans formLink, mais
// on garde la fiche comme source de vérité pour le statut)
//
// v4.1 : fetch séquentiel (1 à la fois) avec cache écrit tous les 5 fiches.
// Voir refreshStatuses() pour les détails.
const needFetch = merged.techs.some(tech =>
tech.interventions.some(iv =>
iv.type === "AL-Intervention" && !iv.ficheFetched
)
);
// v5.0.6 : logs détaillés pour diagnostiquer pourquoi le fetch ne se
// lance pas.
const totalIv = merged.techs.reduce((s, t) => s + (t.interventions || []).length, 0);
const totalInterIv = merged.techs.reduce((s, t) =>
s + (t.interventions || []).filter(i => i.type === "AL-Intervention").length, 0);
const notFetched = merged.techs.reduce((s, t) =>
s + (t.interventions || []).filter(i => i.type === "AL-Intervention" && !i.ficheFetched).length, 0);
console.log(`[load] merged : ${merged.techs.length} techs, ${totalIv} iv totales, ${totalInterIv} interventions réelles, ${notFetched} sans fiche`);
console.log(`[load] needFetch = ${needFetch} | doStatusRefresh = ${!!opts.doStatusRefresh} | forceRefetch = ${!!opts.forceRefetch} | aborted = ${isRefreshAborted(myToken)}`);
// v4.3.2 : avant de lancer le fetch des fiches (lourd, ~460 KB chacune),
// on fait d'abord un batch xhr2 (léger, ~2-5 KB chacun) pour récupérer
// les vraies infos contact/lieu de toutes les interventions en parallèle.
if (!isRefreshAborted(myToken)) {
await prefetchAllXhr2(merged.techs, myToken, opts.doStatusRefresh);
}
if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted(myToken)) {
const tFiches = performance.now();
const nFiches = merged.techs.flatMap(t=>t.interventions).filter(i=>i.type==="AL-Intervention").length;
console.log(`[load] début fetch des ${nFiches} fiches (statuts)…`);
await refreshStatuses(merged.techs, isoDate, { forceAll: !!opts.doStatusRefresh, myToken });
console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`);
} else {
console.log(`[load] PAS DE FETCH : needFetch=${needFetch}, doStatusRefresh=${!!opts.doStatusRefresh}, aborted=${isRefreshAborted(myToken)}`);
}
// 6. Sauvegarder dans le cache (une seule fois, après que tout soit enrichi)
if (!isRefreshAborted(myToken)) {
await writeCache(isoDate, { techs: merged.techs });
}
if (!isRefreshAborted(myToken)) {
showRefreshDone();
console.log(`[load] TOTAL: ${Math.round(performance.now() - t0)} ms`);
} else {
// v4.1.9 : toast "annulé" uniquement si c'était un vrai clic "Arrêter",
// pas un simple changement de date (qui abort l'ancien silencieusement).
const wasExplicitAbort = (abortedToken === myToken);
console.log(`[load] annulé à ${Math.round(performance.now() - t0)} ms (explicite=${wasExplicitAbort})`);
if (wasExplicitAbort) showAbortToast();
}
} finally {
// Masquer le bouton "Arrêter" uniquement si c'est NOTRE chargement qui
// se termine (pas un chargement postérieur que l'utilisateur aurait lancé
// entre-temps en naviguant ailleurs).
if (currentRefreshToken === myToken) {
showAbortButton(false);
}
cleanupAbortResolver(myToken);
}
}
async function refreshPlanning(opts = {}) {
if (!state.session) {
await refreshSessionAndLoad();
return;
}
if (opts.partial) {
// v4.1.13 : _fromPartialBtn empêche loadForDate de reset activeRefreshButton à "total"
await loadForDate(state.currentDate, { doStatusRefresh: false, _fromPartialBtn: true });
} else {
await loadForDate(state.currentDate, { doStatusRefresh: true });
}
}
// ============================================================================
// Fetch du planning (via background)
// ============================================================================
async function fetchPlanningForDate(isoDate) {
setRefreshing(true);
try {
const unixDate = isoToUnixDate(isoDate);
const resp = await sendMessage({
type: "fetchPlanning",
session: state.session,
unixDate: unixDate
});
if (!resp.ok) {
// v4.2.5 : si le planning du jour est DÉJÀ rendu (cache), on affiche
// une bannière non bloquante en haut, le cache reste visible.
// Si rien n'est rendu (1er chargement, pas de cache), on affiche
// l'écran plein comme avant.
const hasCacheRendered =
document.getElementById("cards") &&
document.getElementById("cards").children.length > 0;
if (resp.error === "no_session" || resp.error === "session_expired") {
state.session = null;
if (hasCacheRendered) {
showSessionExpiredBanner();
} else {
showSessionNeeded();
}
} else if (resp.error === "ev_unreachable") {
if (hasCacheRendered) {
showEvUnreachableBanner();
} else {
showEvUnreachable();
}
} else {
showError("Erreur de fetch : " + (resp.error || "inconnue"));
}
return null;
}
// Safeguard (v3.1) : le serveur EasyVista répond parfois 200 avec un
// corps vide — typiquement quand la sesson vient d'être invalidée, ou
// quand il soupçonne du scraping (trop de requêtes parallèles). Dans
// les deux cas, on traite ça comme une sesson expirée : inutile de
// parser (ça ferait "Document is empty") ni de retry en boucle.
if (!resp.xml || resp.xml.length < 20) {
console.warn("[viewer] XML planning vide — session probablement invalide");
state.session = null;
const hasCacheRendered =
document.getElementById("cards") &&
document.getElementById("cards").children.length > 0;
if (hasCacheRendered) {
showSessionExpiredBanner();
} else {
showSessionNeeded();
}
return null;
}
// Parser le HTML complet du planning (contient TOUT : ref, catégorie,
// contact, lieu, description, formLinks, request_id + checksum)
const techs = parsePlanningXml(resp.xml, isoDate);
return { techs };
} catch (err) {
showError("Erreur inattendue : " + (err.message || err));
return null;
} finally {
setRefreshing(false);
}
}
// ============================================================================
// Parsing du XML du planning
// ============================================================================
/**
* Parse le XML retourné par planning_xhr.php?div=calendar_block.
* Contient les interventions (actions) par technicien, avec :
* - action_id, done_by_id, action_label (parfois juste "AL-Intervention"),
* - start_time / end_time, start_date / end_date,
* - formLink (eventName=formEvent&target=ACTIONID&checksum=...) pour ouvrir l'action,
* - request_id (ID de la fiche SD_REQUEST, utilisé pour ouvrir la fiche).
*/
function parsePlanningXml(xml, isoDate) {
const doc = new DOMParser().parseFromString(xml, "text/xml");
const parserError = doc.querySelector("parsererror");
if (parserError) {
console.warn("Parser error:", parserError.textContent);
}
const actionNodes = doc.querySelectorAll("action");
const byTechId = new Map();
for (const id of Object.keys(TEAM)) {
byTechId.set(id, { id, name: TEAM[id], interventions: [] });
}
for (const node of actionNodes) {
const iv = actionNodeToIntervention(node);
if (!iv) continue;
if (!byTechId.has(iv.techId)) continue;
if (!actionCoversDate(iv, isoDate)) continue;
byTechId.get(iv.techId).interventions.push(iv);
}
for (const tech of byTechId.values()) {
tech.interventions.sort((a, b) =>
(a.startTime || "").localeCompare(b.startTime || "")
);
}
return [...byTechId.values()];
}
function actionNodeToIntervention(node) {
const get = name => node.getAttribute(name) || "";
const actionId = get("action_id");
if (!actionId) return null;
const actionType = get("action_type");
const techId = get("done_by_id");
const label = get("action_label");
const cssClass = get("Css_Class");
const startDate = get("start_date");
const endDate = get("end_date");
const startTime = get("start_time");
const endTime = get("end_time");
const currentDate = get("current_date");
const formLink = get("formLink");
const deadline = get("max_resolution_date") || get("max_intervention_date");
const requestId = get("request_id");
// ─── v4 : infos enrichies disponibles directement dans le XML ──────────────
// EasyVista envoie déjà contact/lieu/catégorie dans attr1/attr2/attr3.
// La ref est dans le textContent du nœud (format "SYYMMDD_NNNNN (CM)" ou
// "IYYMMDD_NNNNN (SD)"). Plus besoin de fetcher xhr2 ni la fiche pour ça.
const attr1 = get("attr1"); // contact
const attr2 = get("attr2"); // lieu
const attr3 = get("attr3"); // catégorie complète
const nodeText = (node.textContent || "").trim();
// Extraire la ref en priorité du textContent (où elle est complète), sinon
// fallback sur le label. v4.1.9 : pattern générique [SI]\d+_\d+ (plus
// hardcodé sur "2..." qui était pour 2020-2029).
let ref = null;
const refFromText = nodeText.match(/\b([SI]\d{5,8}_\d{4,6})\b/);
if (refFromText) {
ref = refFromText[1];
} else {
const refFromLabel = label.match(/\b([SI]\d{5,8}_\d{4,6})\b/);
if (refFromLabel) ref = refFromLabel[1];
}
// Détection du type "Réservation" vs "Absence".
//
// v5.0.3 (simplifiée) : le label suit le pattern "Nom / Créé par : X Y".
//
// - Congés / Maladie / Pompier → AL-Absence (tech réellement absent)
// - TOUT LE RESTE (Ecrans, PC, MAC, Rollout, Téléphones, UTP, Réunion,
// Déménagement, Evènements spéciaux, Formation, ...)
// → AL-Reservation (créneau bloqué, tech pas absent)
//
// Cette règle simple évite les cas "absence toute la journée" déclenchés
// par erreur pour des réservations de type événement / réunion.
const ABSENCE_LABELS = /^(cong[ée]s|maladie|pompier)$/i;
let effectiveType = actionType;
let reservationLabel = null;
let reservationCreator = null;
const reservationMatch = label.match(/^([^/]+?)\s*\/\s*Créé par\s*:\s*(.+)$/i);
if (reservationMatch) {
const label1 = reservationMatch[1].trim();
const creator = reservationMatch[2].trim();
if (ABSENCE_LABELS.test(label1)) {
// Vraie absence du tech
effectiveType = "AL-Absence";
} else {
// Réservation : créneau bloqué (matériel ou activité), tech pas absent
effectiveType = "AL-Reservation";
reservationLabel = label1;
reservationCreator = creator;
}
}
// ─── v4 : pré-remplissage immédiat depuis les attributs XML ─────────────────
// On renseigne bulleContact/bulleLieu/categoryLine DÈS la création de l'objet.
// Plus besoin d'attendre xhr2 ou la fiche pour avoir l'affichage de base.
// Seuls restent à fetcher (en arrière-plan, sur fiche) : status.
// Et sur hover (lazy, seulement si l'user survole) : bulleDescription complet.
const isIntervention = effectiveType === "AL-Intervention";
const bulleContact = isIntervention && attr1 ? attr1 : null;
const bulleLieu = isIntervention && attr2 ? attr2 : null;
const categoryLine = isIntervention && attr3 ? attr3 : null;
return {
actionId: actionId,
requestId: requestId,
techId: techId,
label: label,
type: effectiveType, // "AL-Intervention" | "AL-Absence" | "AL-Reservation"
originalType: actionType, // type brut (pour debug)
reservationLabel: reservationLabel, // "Ecrans", "Rollout", etc.
reservationCreator: reservationCreator, // "Nom, Prénom" du coordinateur
cssClass: cssClass,
isPompier: /pompier/i.test(label) || /pompier/i.test(actionType),
ref: ref,
startDate: startDate,
endDate: endDate,
startTime: startTime,
endTime: endTime,
currentDate: currentDate,
formLink: formLink,
deadline: deadline,
// v4 : renseignés directement depuis le XML (plus d'attente de xhr2)
bulleContact: bulleContact,
bulleLieu: bulleLieu,
categoryLine: categoryLine,
bulleDescription: null, // reste null, rempli lazy au premier hover (xhr2)
infobulle: null, // reste null, rempli lazy aussi
status: null, // toujours rempli par fetch fiche (en arrière-plan)
// v4 : ficheTarget/Checksum déjà présents dans formLink (extraits à la demande)
ficheTarget: null,
ficheChecksum: null,
ficheFetched: false,
ficheFetchError: null,
xhr2Fetched: false, // lazy : passe à true après le 1er hover
xhr2Fetching: false, // évite les doubles fetchs simultanés
ghost: false
};
}
/**
* Parse le body de planning_xhr_2.php?id=ACTIONID (ou similaire).
* Format observé :
* @@DESCRIPTION_S@@...texte complet de l'action...@@DESCRIPTION_E@@
* @@LABEL_S@@AL-Intervention@@LABEL_E@@
* @@LAST_S@@Nom, Prénom@@LAST_E@@
* @@PLANNED_TIME_S@@@@PLANNED_TIME_E@@
* @@PLANNED_CHANGE_S@@@@PLANNED_CHANGE_E@@
*/
function parseXhr2Body(body) {
if (!body || typeof body !== "string") return null;
const out = { description: null, label: null, last: null };
const rxD = /@@DESCRIPTION_S@@([\s\S]*?)@@DESCRIPTION_E@@/;
const rxL = /@@LABEL_S@@([\s\S]*?)@@LABEL_E@@/;
const rxLa = /@@LAST_S@@([\s\S]*?)@@LAST_E@@/;
const md = body.match(rxD);
const ml = body.match(rxL);
const mla = body.match(rxLa);
if (md) out.description = md[1].trim();
if (ml) out.label = ml[1].trim();
if (mla) out.last = mla[1].trim();
return out;
}
// v4 : fetchBullesForInterventions (fetch xhr2 en masse au chargement) a été
// supprimée. Le contact/lieu/catégorie viennent maintenant directement des
// attributs attr1/attr2/attr3 du calendar_block. Pour le TEXTE complet de
// l'action (Problème/À faire/Matériel/TFS/...), voir ensureBulleDescription()
// qui lazy-load UNIQUEMENT au premier hover de l'intervention.
function actionCoversDate(iv, isoDate) {
if (!iv.startDate || !iv.endDate) return true; // manque info → on garde
const target = isoToDDMMYYYY(isoDate);
return ddmmyyyyLE(iv.startDate, target) && ddmmyyyyLE(target, iv.endDate);
}
function ddmmyyyyLE(a, b) {
// Compare deux dates JJ/MM/AAAA
const toNum = s => {
const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
return m ? parseInt(m[3] + m[2] + m[1], 10) : 0;
};
return toNum(a) <= toNum(b);
}
// ============================================================================
// Fusion cache ↔ fresh
// ============================================================================
function mergeCacheAndFresh(cached, fresh) {
// fresh.techs : liste des techs avec interventions d'aujourd'hui (depuis EasyVista)
// cached.techs : dernière liste sauvegardée pour ce jour (avec statuts)
//
// Règles v4 :
// - Le fresh APPORTE (depuis le XML calendar_block) : actionId, type,
// startTime/endTime, formLink, ref (textContent), bulleContact (attr1),
// bulleLieu (attr2), categoryLine (attr3), deadline.
// - Le cache APPORTE : status (clôturé/résolu),
// bulleDescription (lazy-load xhr2 au hover) + infobulle, ficheFetched,
// xhr2Fetched.
// - Règle générale : fresh wins sur les champs live, cache wins sur les
// champs enrichis qui ne sont pas dans le fresh.
// - Une interventoin en cache mais plus en fresh → marquée "ghost"
if (!cached || !cached.techs) {
return { techs: fresh.techs };
}
// Indexer le cache par actionId
const cachedByAction = new Map();
for (const tech of cached.techs) {
for (const iv of tech.interventions || []) {
cachedByAction.set(iv.actionId, iv);
}
}
const resultTechs = fresh.techs.map(t => ({ ...t, interventions: [] }));
const freshActionIds = new Set();
for (const tech of fresh.techs) {
const outTech = resultTechs.find(t => t.id === tech.id);
for (const iv of tech.interventions) {
freshActionIds.add(iv.actionId);
const cachedIv = cachedByAction.get(iv.actionId);
if (cachedIv) {
// On part du cache (qui a les champs enrichis), puis on remplace
// les champs "live" depuis le fresh (horaires, type, formLink).
const merged = {
...cachedIv,
// Champs live venant du fresh (le planning peut avoir bougé)
techId: iv.techId || cachedIv.techId,
type: iv.type || cachedIv.type,
label: iv.label || cachedIv.label,
cssClass: iv.cssClass || cachedIv.cssClass,
isPompier: iv.isPompier,
startDate: iv.startDate || cachedIv.startDate,
endDate: iv.endDate || cachedIv.endDate,
startTime: iv.startTime || cachedIv.startTime,
endTime: iv.endTime || cachedIv.endTime,
currentDate: iv.currentDate || cachedIv.currentDate,
formLink: iv.formLink || cachedIv.formLink,
deadline: iv.deadline || cachedIv.deadline,
requestId: iv.requestId || cachedIv.requestId,
// v4 : la ref du fresh est maintenant FIABLE (textContent XML),
// on la privilégie sur le cache (inversé vs v3).
ref: iv.ref || cachedIv.ref,
// v4 : categoryLine vient désormais du XML (attr3), on la privilégie.
categoryLine: iv.categoryLine || cachedIv.categoryLine,
// Contact/lieu : fresh est plus à jour (attr1/attr2 du XML)
bulleContact: iv.bulleContact || cachedIv.bulleContact,
bulleLieu: iv.bulleLieu || cachedIv.bulleLieu,
// bulleDescription : on privilégie le cache, qui contient le texte
// lazy-load au hover. Le fresh n'a pas ce texte (null au chargement).
bulleDescription: cachedIv.bulleDescription || iv.bulleDescription,
infobulle: cachedIv.infobulle || iv.infobulle,
xhr2Fetched: cachedIv.xhr2Fetched || iv.xhr2Fetched,
// ghost : on retire (cette intervention est bien là dans le fresh)
ghost: false
};
outTech.interventions.push(merged);
} else {
outTech.interventions.push(iv);
}
}
}
// Ajouter les interventions qui sont en cache mais plus en fresh
for (const tech of cached.techs) {
const outTech = resultTechs.find(t => t.id === tech.id);
if (!outTech) continue;
for (const iv of tech.interventions || []) {
if (!freshActionIds.has(iv.actionId)) {
// v5.0.1 : les absences et réservations supprimées côté EasyVista
// sont définitivement retirées (pas ghost). La logique ghost est
// conçue pour les interventions dont on veut garder trace en attendant
// la vérification du statut (clos/annulé). Absences/réservations n'ont
// pas de notion de statut, une disparition = suppression pure.
if (iv.type === "AL-Absence" || iv.type === "AL-Reservation") {
continue; // ne pas rajouter
}
const ghost = { ...iv, ghost: true };
outTech.interventions.push(ghost);
}
}
// Retrier
outTech.interventions.sort((a, b) =>
(a.startTime || "").localeCompare(b.startTime || "")
);
}
return { techs: resultTechs };
}
// ============================================================================
// v4.2.5 : analyse des tickets disparus du planning
// ============================================================================
//
// Pour chaque ticket qui était dans le cache mais n'est plus dans le XML
// fresh, on doit décider s'il faut :
// 1. Le GARDER en vert double ✓✓ → clôturé / résolu dans EasyVista
// 2. Le GARDER en vert simple ✓ → terminé par le tech (commentaire LOGIN:)
// 3. Le RETIRER → déplacé sur un autre jour / annulé / autre
//
// Logique (validée avec l'utilisateur) :
// a) Re-fetch la fiche
// b) Si statut global = CLOS ou RÉSOLU → garder, vert ✓✓
// c) Sinon parcourir les actions OUVERTES de la fiche :
// - Si action ouverte au nom du tech sur JOUR DIFFÉRENT → retirer (déplacée)
// - Sinon passer à l'étape d
// d) Parcourir les actions FERMÉES au nom du tech :
// - Si une action fermée contient un commentaire tech (pattern `LOGIN:
// commentaire` où LOGIN = alphanumérique 3-12 chars minuscule) → garder, vert ✓
// - Sinon → retirer
//
// Distinction action ouverte/fermée :
// Observation sur les HTML fournis : dans le JSON timeline de la fiche,
// l'action "AL-Intervention" apparaît SEULEMENT si elle a été complétée
// (fermée). Si elle est toujours ouverte, elle n'est pas dans le timeline.
// Les autres types d'actions ("Ajout d'informations", "Envoi de mail", etc.)
// apparaissent dès leur création.
// Regex pour détecter un commentaire tech dans le texte d'une action.
// Pattern : début de ligne OU <br> suivi d'un login court (3-12 caractères
// alphanumériques MINUSCULES) + ":" + espace + texte.
// Exemples qui matchent : "vyjuva: Casque remplacé", "awr: ok".
// Exemples qui NE matchent PAS :
// - "Service : X" (majuscule + pas un login)
// - "Nom2, Prénom2" (contient une virgule, pas un login)
// - "AWR 16/04/26" (pas de deux-points)
// - "Date : vendredi 17.04" (majuscule au début, c'est un champ)
const RX_LOGIN_COMMENTAIRE = /(?:^|\n|<br\s*\/?>)\s*([a-z0-9_]{3,12})\s*:\s+(\S[^\n<]{2,})/im;
/**
* 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)
*
* 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(/\\u0022/g, '"')
.replace(/\\\//g, '/');
const actions = [];
// Chercher chaque bloc "rows":[...]
const rowsRegex = /"rows":\[/g;
let m;
while ((m = rowsRegex.exec(decoded)) !== null) {
const start = m.index + m[0].length;
// Trouver la fin du array [...] correspondant (balance des crochets)
let j = start;
let depth = 1;
while (j < decoded.length && depth > 0) {
const c = decoded[j];
if (c === '[') depth++;
else if (c === ']') depth--;
j++;
}
const block = decoded.substring(start, j - 1);
const values = extractValuesFromRowBlock(block);
if (values.length < 12) continue;
// Une "vraie" action a 14 valeurs. On se contente de 12 minimum
// pour avoir au moins la description.
actions.push({
intervenant: decodeUnicodeEscapes(values[2] || ""),
type: decodeUnicodeEscapes(values[4] || ""),
dateCreation: values[8] || "",
dateFin: values[9] || "",
description: values[11] || ""
});
}
return actions;
}
/**
* Extrait les valeurs "value":"..." d'un bloc JSON row, gère les guillemets
* échappés (\").
*/
function extractValuesFromRowBlock(block) {
const values = [];
let i = 0;
while (i < block.length) {
const mIdx = block.indexOf('"value":"', i);
if (mIdx < 0) break;
const start = mIdx + '"value":"'.length;
let j = start;
while (j < block.length) {
if (block[j] === '\\') { j += 2; continue; }
if (block[j] === '"') break;
j++;
}
values.push(block.substring(start, j));
i = j + 1;
}
return values;
}
/**
* Décode les échappements Unicode \u00XX présents dans les valeurs extraites.
*/
function decodeUnicodeEscapes(s) {
if (!s) return s;
return s.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
}
/**
* Détermine si une action est "fermée" ou "ouverte".
* - Pour AL-Intervention : on cherche sa présence dans le JSON timeline de
* la fiche (via la valeur [13] qui contient un JSON avec "NAME".) Si cette
* action existe dans le JSON, elle est considérée fermée.
* - Pour les autres types : on considère fermée si dateFin est remplie et
* différente de dateCreation (approximation raisonnable observée sur les
* HTML fournis).
* - Actions système (Intervenant = "EZV_WS_REST_USER" ou vide) : ignorées
* dans le matching "action au nom du tech".
*
* Pour notre logique, ce qui compte vraiment :
* - Actions "AL-Intervention" fermées = présentes dans le bloc JSON
* "timeline" de la fiche (pas dans les "rows" HTML, qui les listent toutes)
*
* Plus simplement, je détecte la présence de AL-Intervention dans le HTML
* comme indicateur : si `"NAME":"AL-Intervention"` figure dans le JSON
* timeline, alors l'AL-Intervention est fermée.
*/
function hasClosedAlInterventionInHtml(html) {
if (!html) return false;
// Chercher dans le HTML brut (non décodé) le pattern de timeline
// `\u0022NAME\u0022:\u0022AL-Intervention\u0022`
return /\\u0022NAME\\u0022:\\u0022AL-Intervention\\u0022/.test(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.
*/
function hasTechCommentInDescription(description) {
if (!description) return false;
// Décoder unicode puis remplacer les <br> par \n pour faciliter le regex
const txt = decodeUnicodeEscapes(description)
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/?p[^>]*>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&');
return RX_LOGIN_COMMENTAIRE.test(txt);
}
/**
* Normalise un nom "Nom, Prénom" (insensible à la casse, accents ignorés)
* pour comparaison.
*/
function normalizeName(s) {
if (!s) return "";
return s
.toLowerCase()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/\s+/g, " ")
.trim();
}
/**
* Détermine si une action est au nom du technicien donné.
* Compare l'intervenant de l'action avec le nom du tech (insensible casse/accents).
* Ignore les actions système (EZV_WS_REST_USER, vide).
*/
function actionBelongsToTech(action, techName) {
const interv = normalizeName(action.intervenant);
if (!interv || interv === "ezv_ws_rest_user") return false;
const tech = normalizeName(techName);
if (!tech) return false;
// Le nom du tech dans notre config est souvent "Prénom Nom" alors que
// l'EasyVista affiche "Nom, Prénom". On accepte les deux ordres.
// Simple test : au moins un mot du nom tech (longueur > 2) est dans l'intervenant.
const techParts = tech.split(/[\s,]+/).filter(p => p.length >= 3);
if (techParts.length === 0) return false;
// Exiger que TOUS les mots significatifs du nom tech soient dans l'intervenant
return techParts.every(p => interv.includes(p));
}
/**
* Analyse les tickets disparus du planning et décide pour chacun s'il faut
* le garder en vert (terminé tech ou clôturé) ou le retirer.
*
* Modifie directement les tech.interventions en place (retire les ghosts à
* retirer, met à jour les propriétés des ghosts à garder).
*/
async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken) {
// Traiter en parallèle pour rester rapide (max 3 fiches en parallèle)
const concurrency = 3;
const queue = [...ghostsToAnalyze];
const workers = [];
for (let w = 0; w < concurrency; w++) {
workers.push((async () => {
while (queue.length > 0) {
if (isRefreshAborted(myToken)) return;
const { tech, iv } = queue.shift();
try {
await analyzeOneDisappearedIv(tech, iv);
} catch (err) {
console.warn("[disappear] analyse échouée pour", iv.actionId, err);
// En cas d'erreur, on garde l'iv visible mais sans marquage spécial
iv._disappearChecking = false;
iv.ghost = false; // on la laisse visible plutôt que perdre de l'info
iv._disappearStatus = "error";
}
}
})());
}
await Promise.all(workers);
// Filtrer les iv qui doivent être retirées définitivement
for (const tech of techs) {
tech.interventions = tech.interventions.filter(iv => !iv._disappearRemove);
}
}
/**
* Analyse une seule intervention disparue.
* Met à jour iv._disappearStatus ("closed" | "terminated" | "moved" | "cancelled")
* et iv._disappearRemove (true si à retirer).
*/
async function analyzeOneDisappearedIv(tech, iv) {
// v4.3.0 : court-circuit pour les réservations (AL-Reservation). Elles n'ont
// pas de notion de "terminé par tech" ni de statut clos/résolu à afficher
// (pas de fiche à ouvrir). Quand une réservation disparaît du planning,
// elle est juste retirée — inutile de re-fetcher sa fiche.
if (iv.type === "AL-Reservation") {
iv._disappearChecking = false;
iv._disappearStatus = "cancelled";
iv._disappearRemove = true;
return;
}
// Étape 1 : re-fetch la fiche
const resp = await sendMessage({
type: "fetchFiche",
formLink: iv.formLink
});
if (!resp || !resp.ok) {
// En cas d'erreur fetch : on garde visible (pas de décision)
iv._disappearChecking = false;
iv._disappearStatus = "error";
iv.ghost = false;
return;
}
const html = resp.html;
// Étape 2 : statut global de la fiche
const ficheData = parseFicheHtml(html);
const status = ficheData.status || iv.status || null;
iv.status = status; // garder à jour
if (isClosedStatus(status) || isResolvedStatus(status)) {
// CAS 1 : clôturé / résolu → garder, vert ✓✓ (double check)
iv._disappearChecking = false;
iv._disappearStatus = "closed";
iv._disappearRemove = false;
iv.ghost = false;
return;
}
// Étape 3 : parser toutes les actions de la fiche
const actions = parseAllActionsFromFicheHtml(html);
// Identifier les actions AL-Intervention au nom du tech.
//
// Pour savoir si une AL-Intervention spécifique est fermée ou ouverte,
// on utilise l'indicateur global `hasClosedAlInterventionInHtml` :
// - SI la fiche contient "AL-Intervention" dans le JSON timeline
// → l'action AL-Intervention est fermée (terminée par le tech)
// - SINON → elle est encore ouverte
const alActionsForTech = actions.filter(a =>
a.type === "AL-Intervention" && actionBelongsToTech(a, tech.name || tech.label || "")
);
const hasClosedAl = hasClosedAlInterventionInHtml(html);
// CAS 2 : action AL-Intervention encore ouverte au nom du tech
if (alActionsForTech.length > 0 && !hasClosedAl) {
// Vérifier sur quel jour elle est planifiée maintenant. Si on ne peut
// pas déterminer, on retire par prudence (elle a été bougée, sinon
// elle serait encore dans le fresh).
// On regarde si une action ouverte référence explicitement notre jour.
// Simple heuristique : on regarde les dates dans les descriptions.
iv._disappearChecking = false;
iv._disappearStatus = "moved";
iv._disappearRemove = true; // retirer (déplacée)
return;
}
// CAS 3 : action AL-Intervention FERMÉE au nom du tech → chercher un
// commentaire tech dans les descriptions des actions du tech.
if (alActionsForTech.length > 0 && hasClosedAl) {
const anyHasComment = alActionsForTech.some(a =>
hasTechCommentInDescription(a.description)
);
if (anyHasComment) {
// Terminée par le tech → garder, vert ✓ simple
iv._disappearChecking = false;
iv._disappearStatus = "terminated";
iv._disappearRemove = false;
iv.ghost = false;
return;
}
// Pas de commentaire détecté → retirer (annulée)
iv._disappearChecking = false;
iv._disappearStatus = "cancelled";
iv._disappearRemove = true;
return;
}
// CAS 4 : aucune action AL-Intervention au nom du tech dans la fiche →
// vérifier si une action quelconque au nom du tech existe avec commentaire.
// Si oui, on considère que le tech a travaillé dessus.
const anyActionForTech = actions.filter(a =>
actionBelongsToTech(a, tech.name || tech.label || "")
);
const anyHasComment = anyActionForTech.some(a =>
hasTechCommentInDescription(a.description)
);
if (anyHasComment) {
iv._disappearChecking = false;
iv._disappearStatus = "terminated";
iv._disappearRemove = false;
iv.ghost = false;
return;
}
// CAS 5 (défaut) : aucune trace claire du tech → retirer
iv._disappearChecking = false;
iv._disappearStatus = "cancelled";
iv._disappearRemove = true;
}
// ============================================================================
// Fetch des fiches individuelles (pour obtenir le statut et les détails)
// ============================================================================
async function refreshStatuses(techs, isoDate, opts = {}) {
const forceAll = !!opts.forceAll;
const myToken = opts.myToken;
// Construire la liste des interventions à fetcher, dans l'ordre de priorité :
// 1. Interventions du (des) pompier(s) en premier
// 2. Puis les autres techs par ordre alphabétique du nom de famille
// 3. (Les absents n'ont pas d'interventions à fetcher)
const sortedTechs = [...techs].sort((a, b) => compareTechs(a, b, isoDate));
const toFetch = [];
for (const tech of sortedTechs) {
for (const iv of tech.interventions) {
if (iv.type !== "AL-Intervention") continue;
if (!iv.formLink) continue;
// v4 : on skip les interventions déjà closes/résolues dont la fiche a
// déjà été fetchée une fois (statut + commentaire tech déjà récupérés).
// Le statut "Clôturé" ne change plus une fois atteint, pas la peine de
// refetcher à chaque refresh.
const statusClosed = isClosedStatus(iv.status) || isResolvedStatus(iv.status);
if (statusClosed && iv.ficheFetched) continue;
// v4.1.7 : pause/reprise par date. Sans forceAll (= chargement normal
// au retour sur une date), on skip les iv déjà enrichies (ficheFetched)
// pour ne pas refetcher inutilement. Un clic sur "rafraichir" active
// forceAll, ce qui refetche les non-closes même si déjà enrichies (pour
// voir passer les statuts "En cours" → "Exécution" → "Clôturé").
if (!forceAll && iv.ficheFetched) continue;
toFetch.push(iv);
}
}
if (toFetch.length === 0) return;
setRefreshing(true);
// v4.1.7 : barre de progression visible uniquement si on est en train de
// rafraichir la date actuellement affichée. Si l'user change de date
// pdt le refresh, isRefreshAborted() deviendra true et on sortira.
const showBar = (state.currentDate === isoDate);
if (showBar) {
updateProgressBar(0, toFetch.length);
showProgressBar();
}
try {
// v4.1 : SÉQUENTIEL (1 fiche à la fois) au lieu de 5 workers en parallèle.
// Raisons :
// - Le serveur EasyVista est lent et sérialise les requêtes de toute façon
// - L'abort devient instantané : un seul fetch en vol, si l'user change
// de date, le prochain await sendMessage() n'est même pas lancé
// - Plus de races de DOM (5 workers qui écrivaient la même carte en
// concurrence, ça générait des artefacts visuels)
//
// Cache incrémental : on sauve le cache toutes les CACHE_WRITE_EVERY fiches
// ET à la fin. Comme ça si l'user change de date en cours, on ne perd pas
// les statuts déjà récupérés.
const CACHE_WRITE_EVERY = 5;
let sinceLastCacheWrite = 0;
for (let i = 0; i < toFetch.length; i++) {
if (isRefreshAborted(myToken)) break;
await fetchAndUpdateIntervention(toFetch[i], myToken);
sinceLastCacheWrite++;
// Progression — uniquement si la barre concerne la date visible
if (showBar && state.currentDate === isoDate) {
updateProgressBar(i + 1, toFetch.length);
}
// Sauvegarde périodique du cache pdt le fetch
if (sinceLastCacheWrite >= CACHE_WRITE_EVERY) {
try {
await writeCache(isoDate, { techs });
sinceLastCacheWrite = 0;
} catch (err) {
console.warn("[cache] écriture intermédiaire échouée:", err);
}
}
}
// Si annulé : on laisse les résultats partiels dans le DOM et on sauve
// quand même ce qu'on a déjà récupéré (cache incrémental).
if (isRefreshAborted(myToken)) {
try { await writeCache(isoDate, { techs }); } catch {}
return;
}
// Résoudre le sort des ghosts
for (const tech of techs) {
tech.interventions = tech.interventions.filter(iv => {
if (!iv.ghost) return true;
if (CANCELLED_STATUS.includes(iv.status)) return false;
return true;
});
}
// Sauvegarde finale du cache
await writeCache(isoDate, { techs });
// Re-rendre pour afficher les mises à jour finales (ghosts filtrés,
// tri à jour, etc.). updateInterventionRow a déjà patché chaque ligne,
// mais ce re-render final garantit la cohérence globale.
renderFromData({
techs,
targetDate: isoDate,
captureTime: Date.now(),
source: "fresh+statuses",
lastRefreshKind: activeRefreshButton // v4.1.20
});
} finally {
setRefreshing(false);
if (showBar) hideProgressBar();
}
}
async function fetchAndUpdateIntervention(iv, myToken) {
try {
// Bail-out coopératif : si l'utilisateur a cliqué sur "Arrêter" ou a
// changé de date, on ne fetch pas cette intervention.
if (isRefreshAborted(myToken)) {
iv.ficheFetched = true;
iv.ficheFetchError = "aborted";
return;
}
// v4.1.2 : pour chaque interventoin on fait xhr2 PUIS fiche.
// - xhr2 : récupère les VRAIES infos contact/lieu (attr1/attr2 du XML
// sont parfois erronées si le tech a corrigé après planif).
// On met à jour la carte tout de suite avec les vraies infos.
// - fiche : récupère statut Clôturé/Résolu + commentaire tech + checksum
// valide pour l'ouverture au clic.
// ─── Étape 1 : xhr2 (rapide, ~400 o) ────────────────────────────────
if (!iv.xhr2Fetched && !isRefreshAborted(myToken)) {
try {
const xhr2Resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId });
// v4.1.9 : si on a été aborté pendant l'attente, ne PAS appliquer
// le résultat au DOM (on ne doit plus toucher à une ligne qui
// appartient à la date précédente).
if (isRefreshAborted(myToken)) return;
if (xhr2Resp && xhr2Resp.ok) {
const parsed = parseXhr2Body(xhr2Resp.body);
if (parsed) {
if (parsed.description) {
iv.bulleDescription = parsed.description;
const infob = parseActionText(parsed.description);
if (infob) iv.infobulle = infob;
}
if (parsed.label) iv.label = parsed.label;
iv.xhr2Fetched = true;
// Met à jour la carte avec les vraies infos xhr2
updateInterventionRow(iv);
}
}
} catch (err) {
console.warn("[xhr2] erreur iv", iv.actionId, err);
}
}
if (isRefreshAborted(myToken)) return;
// ─── Étape 2 : fetch fiche (statut + commentaire + checksum) ──────────
// Retry léger : une erreur réseau ponctuelle (timeout, 5xx) ne doit pas
// perdre la ligne. 1 seul retry après 400ms. Session expirée n'est PAS
// retryée (ça ne passera pas mieux la 2e fois).
let ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
if (isRefreshAborted(myToken)) return;
if (!ficheResp.ok && ficheResp.error !== "session_expired" && !isRefreshAborted(myToken)) {
await new Promise(r => setTimeout(r, 400));
if (!isRefreshAborted(myToken)) {
ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
}
}
if (isRefreshAborted(myToken)) return;
if (!ficheResp.ok) {
iv.ficheFetched = true;
iv.ficheFetchError = ficheResp.error || "fetch_failed";
if (ficheResp.error === "session_expired") {
state.session = null;
// v4.1.12 : afficher immédiatement la bannière de session expirée
// pour que l'utilisateur voie pourquoi le fetch s'arrête.
showSessionExpiredBanner();
}
return;
}
const fiche = parseFicheHtml(ficheResp.html);
iv.status = fiche.status;
// v4.2.5 : on retire définitivement le champ commentaireTech (obsolète
// depuis qu'on récupère l'action complète via l'API timeline).
delete iv.commentaireTech;
// Fallback : si l'XML n'avait pas la ref (rare, mais possible pour des
// actions hors-standard), on prend celle de la fiche.
if (fiche.rfc && !iv.ref) {
iv.ref = fiche.rfc;
}
// v4.1.18 : persister le formSenderGuid sur l'iv pour qu'il soit
// disponible au clic pour ouvrir la fiche avec le bon sender (S vs I).
if (fiche.formSenderGuid) {
iv.formSenderGuid = fiche.formSenderGuid;
}
// ─── Étape 3 : API timeline → texte complet de l'action ─────────────
// Le HTML brut de la fiche ne contient PAS les valeurs d'action (elles
// sont injectées côté client par Angular via un apel REST). On appelle
// donc le même endpoint REST qu'Angular pour récupérer la description
// complète, match par ACTION_ID === iv.actionId (fiable, numérique).
//
// Ce texte REMPLACE le texte xhr2 tronqué dans le tooltip.
// Si l'appel échoue ou ne trouve rien, on garde le fallback xhr2 dans
// iv.bulleDescription (déjà stocké à l'étape 1).
if (fiche.formId && fiche.formChecksum && fiche.formSenderGuid &&
iv.actionId && !isRefreshAborted(myToken)) {
try {
const tlResp = await sendMessage({
type: "fetchTimelineApi",
guid: fiche.formSenderGuid,
formId: fiche.formId,
formChecksum: fiche.formChecksum
});
if (isRefreshAborted(myToken)) return;
if (tlResp && tlResp.ok) {
const fullText = parseTimelineJsonForAction(tlResp.body, iv.actionId);
if (fullText) {
iv.ficheActionText = fullText;
}
} else if (tlResp && tlResp.error === "session_expired") {
state.session = null;
showSessionExpiredBanner();
}
} catch (err) {
console.warn("[timeline] erreur iv", iv.actionId, err);
}
}
// ─── Extraire le checksum pour ouvrir la fiche ─────────────────────
// STRICTEMENT IDENTIQUE à v4 originale (qui fonctionne pour l'ouverture) :
// - On n'extrait QUE si ficheChecksum n'est pas déjà là (une fois trouvé
// c'est bon, pas la peine de ré-extraire à chaque refresh et risquer
// de l'écraser avec une mauvaise valeur).
// - Pas de "Tentative 3" ultime : elle peut matcher le checksum du form
// principal qui n'est PAS le bon pour l'action → casse l'ouverture.
if (iv.requestId && !iv.ficheChecksum) {
// Tentative 1 : target=ID&checksum=... (pattern le plus courant)
const rx1 = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`);
const m1 = ficheResp.html.match(rx1);
if (m1) {
iv.ficheTarget = iv.requestId;
iv.ficheChecksum = m1[1];
} else {
// Tentative 2 : JSON formData
const rx2a = new RegExp(`"id"\\s*:\\s*"${iv.requestId}"[\\s\\S]{0,200}?"checksum"\\s*:\\s*"([a-f0-9]{40})"`);
const m2a = ficheResp.html.match(rx2a);
if (m2a) {
iv.ficheTarget = iv.requestId;
iv.ficheChecksum = m2a[1];
} else {
const rx2b = new RegExp(`"checksum"\\s*:\\s*"([a-f0-9]{40})"[\\s\\S]{0,200}?"id"\\s*:\\s*"${iv.requestId}"`);
const m2b = ficheResp.html.match(rx2b);
if (m2b) {
iv.ficheTarget = iv.requestId;
iv.ficheChecksum = m2b[1];
}
}
}
}
iv.ficheFetched = true;
// Rendu incrémental : mettre à jour la ligne dans le DOM immédiatement
// (statut clos → fond vert + ✓, commentaire tech dans le tooltip).
// v4.1.9 : ne touche au DOM que si on est toujours sur la même date
// qui a été demandée initialement (sinon on corromprait la nouvelle vue).
if (!isRefreshAborted(myToken)) {
updateInterventionRow(iv);
}
} catch (err) {
iv.ficheFetched = true;
iv.ficheFetchError = String(err);
console.warn("fetchAndUpdate error:", err);
}
}
/**
* v4 : Lazy-load du texte d'action détaillé au premier survol d'une intervention.
*
* Le calendar_block nous donne déjà contact/lieu/catégorie via attr1/attr2/attr3
* (planification initiale), mais pas le TEXTE COMPLET de l'action (Problème/
* À faire/Matériel/TFS/...) et surtout pas les VRAIES infos à jour : un tech
* peut avoir mis à jour le contact ou le lieu après la planification initiale,
* et ces vraies infos ne sont PAS dans attr1/attr2.
*
* Ce texte vient de planning_xhr_2.php. On le fetch à la demande (premier hover)
* pour ne pas surcharger le serveur au chargement initial.
*
* v4.1.2 : quand les infos arrivent, on MET À JOUR la carte car ces infos
* (venant du texte d'action validé par le tech) sont plus fiables que
* attr1/attr2 (planification initiale parfois erronée).
*/
// v4.3.2 : pré-fetch de tous les xhr2 en parallèle (batch).
// Objectif : avoir les VRAIES infos contact/lieu pour toutes les interventions
// AVANT que l'utilisateur se mette à les survoler. Comme le xhr2 est léger
// (2-5 KB), on peut en faire plusieurs en parallèle sans écrouler EasyVista.
//
// Params :
// techs : liste des techs avec leurs interventions
// myToken : jeton d'annulation (si l'user change de date, on s'arrête)
// forceAll : si true, re-fait le xhr2 même pour les inter déjà xhr2Fetched
// (utilisé par "Tout recharger")
async function prefetchAllXhr2(techs, myToken, forceAll) {
if (!techs) return;
// Lister les iv qui ont besoin d'un xhr2
const needed = [];
for (const tech of techs) {
for (const iv of tech.interventions || []) {
if (iv.type !== "AL-Intervention") continue;
if (!iv.actionId || iv.ghost) continue;
if (iv.xhr2Fetching) continue;
if (iv.xhr2Fetched && !forceAll) continue;
needed.push(iv);
}
}
if (needed.length === 0) return;
console.log(`[load] pré-fetch xhr2 batch : ${needed.length} interventoin(s)…`);
const t0 = performance.now();
// Si forceAll, reset le flag pour que ensureBulleDescription re-fetch
if (forceAll) {
for (const iv of needed) iv.xhr2Fetched = false;
}
// Batch en parallèle avec concurrency limitée (6) — assez rapide, pas trop
// aggressif sur EasyVista.
const concurrency = 6;
const queue = [...needed];
const workers = [];
for (let w = 0; w < concurrency; w++) {
workers.push((async () => {
while (queue.length > 0) {
if (isRefreshAborted(myToken)) return;
const iv = queue.shift();
try {
await ensureBulleDescription(iv);
} catch (err) {
console.warn("[prefetch xhr2] iv", iv.actionId, err);
}
}
})());
}
await Promise.all(workers);
console.log(`[load] pré-fetch xhr2 fini en ${Math.round(performance.now() - t0)} ms`);
}
async function ensureBulleDescription(iv) {
// Déjà chargé : rien à faire
if (iv.xhr2Fetched) return true;
// Fetch déjà en cours (évite les races si l'utilisateur survole plusieurs fois)
if (iv.xhr2Fetching) return false;
// Pas applicable (réservation, absence, ghost, ou pas d'actionId)
if (iv.type !== "AL-Intervention") return false;
if (!iv.actionId || iv.ghost) return false;
iv.xhr2Fetching = true;
try {
const resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId });
if (!resp || !resp.ok) return false;
const parsed = parseXhr2Body(resp.body);
if (!parsed) return false;
if (parsed.description) {
iv.bulleDescription = parsed.description;
const infob = parseActionText(parsed.description);
if (infob) {
iv.infobulle = infob;
}
}
if (parsed.label) iv.label = parsed.label;
iv.xhr2Fetched = true;
// Mettre à jour la carte : lieu/contact du xhr2 sont les VRAIES infos à
// jour (le tech les a peut-être corrigées après la planification initiale).
updateInterventionRow(iv);
return true;
} catch (err) {
console.warn("[xhr2 lazy] erreur iv", iv.actionId, err);
return false;
} finally {
iv.xhr2Fetching = false;
}
}
function isClosedStatus(s) {
return !!s && CLOSED_STATUS.some(x => s.includes(x));
}
function isResolvedStatus(s) {
return !!s && RESOLVED_STATUS.some(x => s.includes(x));
}
function isCancelledStatus(s) {
return !!s && CANCELLED_STATUS.some(x => s.includes(x));
}
// ============================================================================
// Parsing d'une fiche individuelle (HTML)
// ============================================================================
// v4 : simplifié. On ne cherche plus dans la fiche que :
// - le statut Clôturé/Résolu (pour le ✓ vert)
// - le commentaire technicien (affiché dans le tooltip)
// - la ref RFC_NUMBER (utilisée seulement en fallback, si le XML n'avait pas)
// Les autres extractions (categoryLine, intervenant, actionDescription) sont
// supprimées car ces infos viennent maintenant du XML attr1/attr2/attr3 ou du
// lazy-load xhr2 au hover.
/**
* Parse le HTML brut d'une fiche EasyVista (rendu serveur, ~460 Ko, NON hydraté
* par Angular donc ne contient PAS les valeurs d'actions — celles-ci sont
* chargées séparément via l'API timeline).
*
* Rôle : extraire les champs nécessaires :
* - status : STATUS_FR (affichage ✓ et fond vert si clos)
* - rfc : RFC_NUMBER (fallback si pas dans XML)
* - formId : id numérique du form (SD_REQUEST pour S... ou incident)
* - formChecksum : checksum du form (pour appel API timeline)
* - formSenderGuid : v4.1.9 — GUID du form (différent pour incident I...
* vs demande S...). Extrait dynamiquement depuis les
* liens target=FORM_ID&checksum=...&sender={GUID} du
* HTML lui-même. Pour les demandes S → C99ECD05..., pour
* les incidents I → 07ED9C68... (ou autre selon config).
*/
function parseFicheHtml(html) {
const out = {
status: null,
rfc: null,
formId: null,
formChecksum: null,
formSenderGuid: null
};
// STATUS_FR (valeur parfois encodée en \u00XX)
let m = html.match(/"dbFieldName"\s*:\s*"STATUS_FR"[^}]*?"value"\s*:\s*"([^"]{2,30})"/);
if (m) out.status = decodeJsonString(m[1]);
// RFC_NUMBER (fallback au cas où le XML n'aurait pas la ref)
m = html.match(/"dbFieldName"\s*:\s*"RFC_NUMBER"[^}]*?"value"\s*:\s*"([^"]{5,30})"/);
if (m) out.rfc = m[1];
// formData.form.{id,checksum} : indispensable pour l'API timeline.
// On matche dans les deux ordres possibles.
m = html.match(/var formData\s*=\s*\{"form":\{[^}]*?"checksum":"([a-f0-9]{40})"[^}]*?"id":"(\d+)"/);
if (m) {
out.formChecksum = m[1];
out.formId = m[2];
} else {
m = html.match(/var formData\s*=\s*\{"form":\{[^}]*?"id":"(\d+)"[^}]*?"checksum":"([a-f0-9]{40})"/);
if (m) {
out.formId = m[1];
out.formChecksum = m[2];
}
}
// v4.1.9 : déduire le GUID du form. On cherche dans le HTML un lien qui
// référence notre formId (target=FORM_ID...) avec un sender. C'est le GUID
// du form principal utilisé pour l'API timeline :
// - demande S... → {C99ECD05-3D48-4C62-ABF0-66292053AED6}
// - incident I... → {07ED9C68-6172-48EA-8A58-90912B0A283E}
// v4.1.10 (fix) : regex robuste qui accepte &, &amp;, et parcourt jusqu'à
// 300 chars entre target=ID et sender= (au lieu de stopper au 1er "/'/espace
// ce qui peut échouer sur certains HTML).
if (out.formId) {
const rx = new RegExp(
`target=${out.formId}(?:&(?:amp;)?\\w+=[^&"'\\s<>]*){0,10}?&(?:amp;)?sender=(%7B[A-F0-9\\-]{36}%7D)`,
"i"
);
const sm = html.match(rx);
if (sm) {
out.formSenderGuid = sm[1]; // garder encodé (déjà prêt pour URL)
} else {
// Fallback : chercher le GUID le plus fréquent associé à notre formId
// dans tout le HTML (tolérant à n'importe quelle séquence entre les 2).
const rxLoose = new RegExp(
`target=${out.formId}[\\s\\S]{0,300}?sender=(%7B[A-F0-9\\-]{36}%7D)`,
"gi"
);
const counts = new Map();
let lm;
while ((lm = rxLoose.exec(html)) !== null) {
counts.set(lm[1], (counts.get(lm[1]) || 0) + 1);
}
// Prendre le plus fréquent
let best = null;
let bestCount = 0;
for (const [guid, c] of counts) {
if (c > bestCount) { best = guid; bestCount = c; }
}
if (best) out.formSenderGuid = best;
}
// v4.1.10 (fix définitif) : si toujours pas trouvé, fallback par défaut
// sur le GUID des demandes S... (le plus courant). Pour les rares
// incidents I... où le HTML brut n'aurait aucun lien target=FORM_ID, le
// timeline ne sera pas chargé mais le reste fonctionne.
if (!out.formSenderGuid && out.rfc) {
if (/^S/i.test(out.rfc)) {
out.formSenderGuid = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D";
} else if (/^I/i.test(out.rfc)) {
out.formSenderGuid = "%7B07ED9C68-6172-48EA-8A58-90912B0A283E%7D";
}
}
}
return out;
}
/**
* Parse le JSON renvoyé par /api/v1/internal/forms/{GUID}/timeline et en
* extrait le texte de description complet pour UNE action donnée.
*
* Structure du JSON :
* { data: { data: {
* columns: [...13 cols],
* values: [ ← 1 entrée par action dans la fiche
* { rows: [
* {value:"..."}, // [0..10] statut, groupe, dates, etc.
* {value:"Date : ... Heure : ... Lieu : ..."}, // [11] DESCRIPTION ⭐
* {value:""},
* {value:"{\"ACTION_ID\":\"57700033\",...}"} // [13] JSON stringifié
* ] }
* ] }}}
*
* On cherche l'action dont rows[13].ACTION_ID === actionId ; si trouvée, on
* retourne rows[11] nettoyé (br→\n, entités décodées) ; sinon null.
*/
function parseTimelineJsonForAction(jsonText, actionId) {
if (!jsonText || !actionId) return null;
let data;
try {
data = JSON.parse(jsonText);
} catch (e) {
console.warn("[timeline] JSON parse failed:", e);
return null;
}
const values = data?.data?.data?.values;
if (!Array.isArray(values)) return null;
const targetId = String(actionId);
for (const entry of values) {
const rows = entry?.rows;
if (!Array.isArray(rows) || rows.length < 14) continue;
// rows[13] = JSON stringifié qui contient ACTION_ID
const extraRaw = rows[13]?.value;
if (!extraRaw || typeof extraRaw !== "string") continue;
let extra;
try {
extra = JSON.parse(extraRaw);
} catch {
continue;
}
if (String(extra.ACTION_ID) !== targetId) continue;
// Trouvé : extraire la description (rows[11]) et la nettoyer.
const rawDesc = rows[11]?.value || extra["AM_ACTION.DESCRIPTION"] || "";
const cleaned = cleanHtmlBlock(rawDesc);
return cleaned || null;
}
return null;
}
/**
* Nettoie un bloc HTML pour obtenir du texte brut lisible.
* - <br> (avec ou sans attributs) → \n
* - entités HTML décodées (&nbsp; &gt; etc.)
* - tags HTML restants supprimés
* - espaces multiples compactés
*/
function cleanHtmlBlock(html) {
if (!html) return "";
let s = html;
// <br>, <br/>, <br id="...">, <br style="..."> → \n
s = s.replace(/<br\b[^>]*>/gi, "\n");
// Entités HTML
s = s.replace(/&nbsp;/g, " ")
.replace(/&gt;/g, ">")
.replace(/&lt;/g, "<")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&")
.replace(/\u200b/g, ""); // zero-width space
// Tags HTML restants
s = s.replace(/<[^>]+>/g, "");
// Espaces compactés, lignes trimmed, lignes vides retirées
s = s.split("\n").map(l => l.trim().replace(/[ \t]+/g, " ")).filter(Boolean).join("\n");
return s;
}
function decodeJsonString(s) {
return s
.replace(/\\r/g, "")
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\\//g, "/")
.replace(/\\"/g, '"')
.replace(/\\\\/g, "\\")
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
try { return String.fromCharCode(parseInt(hex, 16)); }
catch { return _; }
});
}
/**
* Parse le texte d'une action au format :
* Date : lundi 20.04 Heure : matin
* Lieu : Ville1/Rue1 1
* Service : Service1/...
* Contact : Nom1, Prénom1 +41000000001
* ...
*
* → renvoie un objet { date, heure, lieu, service, contact, etage, bureau,
* probleme, aFaire, tfsAncien, tfsNouveau, materiel, dateProposee, autres }
*/
function parseActionText(text) {
if (!text) return null;
const out = { _raw: text };
// v4.2 : on track toutes les occurrences de "Contact" / "Personne de contact"
// pour détecter l'anomalie (les 2 présents = situation suspecte).
const contactOccurrences = []; // { kind: "contact"|"personne", value: string }
// Pré-filtrer les lignes "Date proposée par ..." : on NE prend PAS ce champ
// nulle part (ni en infobulle.dateProposee, ni dans autres).
const lines = text.split(/\n+/)
.map(l => l.trim())
.filter(Boolean)
.filter(l => !/^\s*date\s+propos[ée]e\s+par\b/i.test(l));
const labelMap = {
"date": "date",
"heure": "heure",
"lieu": "lieu",
"service": "service",
"contact": "contact",
"bénéficiaire": "beneficiaire",
"beneficiaire": "beneficiaire",
"étage": "etage",
"etage": "etage",
"bureau": "bureau",
"problème": "probleme",
"probleme": "probleme",
"a faire": "aFaire",
"à faire": "aFaire",
"matériel": "materiel",
"materiel": "materiel",
"tfs ancien poste": "tfsAncien",
"tfs nouveau poste": "tfsNouveau"
};
const autres = [];
for (const line of lines) {
// Si la ligne CONTIENT "Date proposée par ..." à l'intérieur (pas juste au
// début), on coupe cette partie-là avant de parser le reste.
let cleanLine = line.replace(/\bdate\s+propos[ée]e\s+par\s+(?:le\s+|la\s+)?contact\s*[:?]\s*\S+.*$/i, "").trim();
if (!cleanLine) continue;
// v4.2 : on détecte aussi "Personne de contact..." (spécifique à la demande
// / sur site / de l'entité quittée / interne / etc.). On la marque comme
// un 2e candidat possible pour le contact affiché.
const rxPersonne = /Personne\s+de\s+contact(?:\s+(?:sur\s+site|sp[ée]cifique[^:]*|de\s+l[''`]?entit[ée][^:]*|interne[^:]*))?\s*:\s*/gi;
let pm;
while ((pm = rxPersonne.exec(cleanLine)) !== null) {
// Valeur = jusqu'au prochain label connu OU fin de ligne
const after = cleanLine.substring(pm.index + pm[0].length);
const stop = after.search(/\b(Date|Heure|Lieu|Service|Contact|B[ée]n[ée]ficiaire|[ÉE]tage|Bureau|Probl[èe]me|A\s*faire|À\s*faire|Mat[ée]riel|TFS|Personne\s+de\s+contact|Num[ée]ro\s+de\s+t[ée]l[ée]phone)\s*:/i);
const val = (stop >= 0 ? after.substring(0, stop) : after).trim()
.replace(/[,;]+$/, "").trim();
if (val) {
contactOccurrences.push({ kind: "personne", value: val });
}
}
// "Date : lundi 20.04 Heure : matin" → split en plusieurs paires
const markers = [];
// v4.2 : on ajoute un lookbehind négatif (?<!Personne\s+de\s+) pour ne
// PAS matcher "Contact" à l'intérieur de "Personne de Contact".
// Sans ça on aurait un double match.
const rx = /(?<!Personne\s+de\s+)(Date|Heure|Lieu|Service|Contact|B[ée]n[ée]ficiaire|[ÉE]tage|Bureau|Probl[èe]me|A\s*faire|À\s*faire|Mat[ée]riel|TFS\s+ancien\s+poste|TFS\s+nouveau\s+poste)\s*:\s*/gi;
let m;
while ((m = rx.exec(cleanLine)) !== null) {
markers.push({ label: m[1], valueStart: m.index + m[0].length });
}
if (markers.length === 0) {
autres.push(cleanLine);
continue;
}
for (let i = 0; i < markers.length; i++) {
const mk = markers[i];
let val;
if (i + 1 < markers.length) {
const nextStart = cleanLine.indexOf(markers[i + 1].label, mk.valueStart);
val = cleanLine.substring(mk.valueStart, nextStart).trim();
} else {
val = cleanLine.substring(mk.valueStart).trim();
}
const keyNorm = mk.label.toLowerCase().replace(/\s+/g, " ");
const outKey = labelMap[keyNorm];
if (outKey && val) {
// v4.2 : on track aussi les "Contact" rencontrés dans contactOccurrences
if (outKey === "contact") {
contactOccurrences.push({ kind: "contact", value: val });
} else {
out[outKey] = out[outKey] ? out[outKey] + " / " + val : val;
}
}
}
}
// v4.2 : logique de sélection du contact + détection d'anomalie
// - 0 occurrence → rien
// - 1 "contact" → OK
// - 1 "personne" → OK (fallback)
// - ≥ 2 occurrences → anomalie : on garde la 1re mais on marque anomalie
// pour que l'UI affiche en rouge et que le caller sache
// qu'il vaut mieux garder l'ancien contact (xhr2).
if (contactOccurrences.length === 1) {
out.contact = contactOccurrences[0].value;
} else if (contactOccurrences.length >= 2) {
out.contactAnomalie = true;
// On prend quand même le 1er "contact" pur (pas "personne") si possible
const firstReal = contactOccurrences.find(x => x.kind === "contact");
out.contact = (firstReal || contactOccurrences[0]).value;
}
if (autres.length) out.autres = autres.join("\n");
return out;
}
// ============================================================================
// Rendu général
// ============================================================================
// Compteur de fetches en cours. La flèche tourne tant que ce compteur > 0.
// On le maintient manuellement au lieu d'un booléen pour gérer correctement
// les appels imbriqués (loadForDate + refreshStatuses en parallèle).
let refreshCounter = 0;
// Timer pour effacer le ✓ vert après 5 s
let refreshDoneTimer = null;
// v4.1.13 : quel bouton doit tourner pendant le refresh en cours.
// Valeurs : "total" (par défaut / chargement auto), "partial", ou "xml_only".
let activeRefreshButton = "total";
function setActiveRefreshButton(kind) {
activeRefreshButton = kind || "total";
// v4.1.20 : si le bouton Arrêter est affiché, le repositionner selon
// le nouveau type de refresh actif. Sinon rien à faire (il prendra sa
// position au prochain showAbortButton(true)).
positionAbortButton();
}
// v4.1.20 : place le bouton Arrêter à sa position correcte selon
// activeRefreshButton. Fonction idempotente, sûre à appeler plusieurs fois.
function positionAbortButton() {
const btn = document.getElementById("abort-btn");
if (!btn) return;
const partialBtn = document.getElementById("refresh-partial-btn");
const totalBtn = document.getElementById("refresh-btn");
if (!partialBtn || !totalBtn) return;
if (activeRefreshButton === "partial") {
// Entre Actualiser (partial) et Tout recharger (total)
if (btn.previousElementSibling !== partialBtn) {
totalBtn.parentNode.insertBefore(btn, totalBtn);
}
} else {
// Après Tout recharger
if (totalBtn.nextSibling !== btn) {
totalBtn.parentNode.insertBefore(btn, totalBtn.nextSibling);
}
}
}
function setRefreshing(on) {
const iconTotal = document.getElementById("refresh-icon");
const iconPartial = document.getElementById("refresh-partial-icon");
// Quel icône doit tourner ? Seulement celui correspondant au bouton
// qui a lancé le refresh (ou "total" par défaut).
const targetIcon = (activeRefreshButton === "partial") ? iconPartial : iconTotal;
if (on) {
refreshCounter++;
if (targetIcon) targetIcon.classList.add("spinning");
clearCheckMark();
// Afficher "rafraichissement en cours…" si on n'a pas déjà les données
updateCaptureInfoText();
} else {
refreshCounter = Math.max(0, refreshCounter - 1);
if (refreshCounter === 0) {
// Arrêt : stopper les deux icônes au cas où
if (iconTotal) iconTotal.classList.remove("spinning");
if (iconPartial) iconPartial.classList.remove("spinning");
}
updateCaptureInfoText();
}
}
// Force le rafraichissement du texte "MAJ HH:MM" ou "rafraichissement en cours…"
// selon refreshCounter.
function updateCaptureInfoText() {
if (state.currentData) {
renderCaptureInfo(state.currentData);
}
}
/**
* Appelé quand TOUS les fetches (y compris les fetches fiches en
* arrière-plan) sont terminés. Affiche un ✓ vert à côté de l'heure MAJ
* pendant 5 secondes.
*/
function showRefreshDone() {
const check = document.getElementById("refresh-check");
if (!check) return;
check.classList.remove("hidden");
check.classList.add("visible");
if (refreshDoneTimer) clearTimeout(refreshDoneTimer);
refreshDoneTimer = setTimeout(() => {
check.classList.remove("visible");
setTimeout(() => check.classList.add("hidden"), 300); // après transition
}, 5000);
}
function clearCheckMark() {
const check = document.getElementById("refresh-check");
if (check) {
check.classList.remove("visible");
check.classList.add("hidden");
}
if (refreshDoneTimer) {
clearTimeout(refreshDoneTimer);
refreshDoneTimer = null;
}
}
// ─── Barre de progression (v4.1.7) ─────────────────────────────────────
// État global : on affiche la progression du fetch en cours, uniquement si
// c'est le fetch de la page actuellement visible. Si l'utilisateur change
// de date, la barre suit la nouvelle date (son propre état).
function showProgressBar() {
const bar = document.getElementById("progress-bar");
if (bar) bar.classList.remove("hidden");
}
function hideProgressBar() {
const bar = document.getElementById("progress-bar");
if (bar) bar.classList.add("hidden");
updateProgressBar(0, 0);
}
function updateProgressBar(done, total) {
const fill = document.getElementById("progress-bar-fill");
const label = document.getElementById("progress-bar-label");
if (!fill || !label) return;
if (total <= 0) {
fill.style.width = "0%";
label.textContent = "";
return;
}
const pct = Math.min(100, Math.round((done / total) * 100));
fill.style.width = pct + "%";
// v4.1.20 : message différencié selon le type de refresh actif
const prefix = (activeRefreshButton === "partial") ? "Actualisation" : "Rafraîchissement";
label.textContent = `${prefix}${done} / ${total}`;
}
// Affiche/masque le bouton "Arrêter". N'est montré que pdt un refresh
// manuel (clic utilisateur), pas pendant les chargements normaux ni les
// refresh auto 12h/15h.
function showAbortButton(on) {
const btn = document.getElementById("abort-btn");
if (!btn) return;
if (on) {
positionAbortButton();
btn.classList.remove("hidden");
} else {
btn.classList.add("hidden");
}
}
/**
* Toast de feedback quand l'user clique Arrêter. Les fetches en cours peuvent
* encore prendre 1-2 secondes avant de se terminer (on ne peut pas vriament
* annuler un fetch() en cours), mais du point de vue de l'interface tout
* est arrêté : plus de mise à jour, plus de cache, plus rien.
*/
function showAbortToast() {
showToast("Rafraîchissement", "arrêté");
}
function renderFromData(data) {
state.currentData = data;
document.getElementById("loading").classList.add("hidden");
document.getElementById("error-box").classList.add("hidden");
document.getElementById("session-needed").classList.add("hidden");
document.getElementById("cards").classList.remove("hidden");
// v4.3.0 : détecter les conflits d'horaire entre interventions d'un même
// tech (même heure de début OU chevauchement).
detectOverlaps(data.techs);
// Calculer les stats
const stats = computeStats(data.techs, data.targetDate);
renderCaptureInfo(data, stats);
renderStats(stats);
renderCards(data);
}
// v4.3.0 : détection des conflits d'horaire entre interventions d'un même tech.
// Marque iv._hasOverlap = true pour chaque intervention en conflit avec une
// autre (même heure de début OU chevauchement de créneaux).
// Les absences récurrentes, tickets fantômes à retirer, et réservations
// sont ignorés (pas de conflit pertinent pour eux).
function detectOverlaps(techs) {
if (!techs) return;
for (const tech of techs) {
const ivs = (tech.interventions || []).filter(iv =>
iv && iv.startTime && iv.endTime &&
!iv._disappearRemove &&
iv.type !== "AL-Reservation" &&
// v4.3.2 : le pompier est une absence "tolérée" qui chevauche par
// nature les heures de travail (garde volontaire) — on l'exclut des
// conflits. En revanche les congés/maladies/formations restent
// détectés car une inter planifiée pdt une absence, c'est un vrai pb.
!iv.isPompier
);
// Reset flag sur toutes les inters du tech (y compris celles ignorées)
for (const iv of (tech.interventions || [])) {
iv._hasOverlap = false;
}
// Convertir HH:MM en minutes pour comparaison rapide
const toMin = (hhmm) => {
if (!hhmm) return null;
const parts = hhmm.split(":");
if (parts.length < 2) return null;
const h = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
if (isNaN(h) || isNaN(m)) return null;
return h * 60 + m;
};
// Comparer chaque paire
for (let i = 0; i < ivs.length; i++) {
for (let j = i + 1; j < ivs.length; j++) {
const a = ivs[i], b = ivs[j];
const aStart = toMin(a.startTime), aEnd = toMin(a.endTime);
const bStart = toMin(b.startTime), bEnd = toMin(b.endTime);
if (aStart === null || aEnd === null || bStart === null || bEnd === null) continue;
// Chevauchement = a commence avant que b finisse ET b commence avant que a finisse.
// Inclut aussi le cas "même heure de début" (aStart === bStart).
if (aStart < bEnd && bStart < aEnd) {
a._hasOverlap = true;
b._hasOverlap = true;
}
}
}
}
}
function renderCaptureInfo(data, stats) {
const info = document.getElementById("capture-info");
if (refreshCounter > 0) {
// v4.1.20 : message différencié selon le type de refresh actif
// - partial (Actualiser) → "Actualisation en cours…"
// - total (Tout recharger) → "rafraichissement en cours…"
if (activeRefreshButton === "partial") {
info.textContent = "Actualisation en cours…";
} else {
info.textContent = "Rafraîchissement en cours…";
}
info.classList.add("refreshing");
return;
}
info.classList.remove("refreshing");
const parts = [];
if (data.captureTime) {
const d = new Date(data.captureTime);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const today = new Date();
const isSameDay = d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate();
// v4.1.20 : préfixe selon le type de refresh qui a généré cette capture
// - lastRefreshKind === "partial" → "Actualisé à HH:MM"
// - lastRefreshKind === "total" → "Synchronisé à HH:MM"
// - data.source === "cache" → "Cache de HH:MM"
let prefix;
if (data.source === "cache") {
prefix = "Cache de ";
} else if (data.lastRefreshKind === "partial") {
prefix = "Actualisé à ";
} else {
prefix = "Synchronisé à ";
}
if (isSameDay) {
parts.push(`${prefix}${hh}:${mm}`);
} else {
const dd = String(d.getDate()).padStart(2, "0");
const mo = String(d.getMonth() + 1).padStart(2, "0");
let prefixDate;
if (data.source === "cache") {
prefixDate = "Cache du ";
} else if (data.lastRefreshKind === "partial") {
prefixDate = "Actualisé le ";
} else {
prefixDate = "Synchronisé le ";
}
parts.push(`${prefixDate}${dd}.${mo} ${hh}:${mm}`);
}
}
info.textContent = parts.join(" · ");
}
function computeStats(techs, targetDate) {
let pompiers = 0, absents = 0;
let totalInterventions = 0, morning = 0, afternoon = 0;
let closed = 0, resolved = 0;
for (const tech of techs) {
const isPompier = tech.interventions.some(iv => iv.isPompier);
const isAbsent = isTechAbsent(tech, targetDate);
if (isPompier) pompiers++;
if (isAbsent) absents++;
const real = tech.interventions.filter(iv =>
iv.type !== "AL-Absence" && !iv.isPompier
);
for (const iv of real) {
totalInterventions++;
const s = timeToMinutes(iv.startTime);
if (s !== null && s < 12 * 60) morning++;
else if (s !== null) afternoon++;
if (isClosedStatus(iv.status)) closed++;
else if (isResolvedStatus(iv.status)) resolved++;
}
}
return { totalTechs: techs.length, pompiers, absents, totalInterventions, morning, afternoon, closed, resolved };
}
function renderStats(s) {
const el = document.getElementById("stats");
el.innerHTML = `
<span class="global-stat global-stat-main"><b>${s.totalInterventions}</b> intervention${s.totalInterventions > 1 ? "s" : ""}</span>
<span class="global-stat global-stat-sub">(${s.morning} matin · ${s.afternoon} après-midi)</span>
${(s.closed + s.resolved > 0) ? `<span class="global-stat-sep">·</span><span class="global-stat"><b>${s.closed + s.resolved}</b> clos</span>` : ""}
<span class="global-stat-sep">·</span>
<span class="global-stat"><b>${s.totalTechs}</b> techs</span>
<span class="global-stat-sep">·</span>
<span class="global-stat"><b>${s.pompiers}</b> pompier${s.pompiers > 1 ? "s" : ""}</span>
<span class="global-stat-sep">·</span>
<span class="global-stat"><b>${s.absents}</b> absent${s.absents > 1 ? "s" : ""}</span>
`;
el.classList.remove("hidden");
}
function renderCards(data) {
const container = document.getElementById("cards");
container.innerHTML = "";
// Tri : pompier(s) > actifs alphabétique nom de famille > absents alphabétique
const sorted = [...data.techs].sort((a, b) => compareTechs(a, b, data.targetDate));
for (const tech of sorted) {
container.appendChild(buildCard(tech, data.targetDate));
}
}
function compareTechs(a, b, targetDate) {
const aP = a.interventions.some(iv => iv.isPompier);
const bP = b.interventions.some(iv => iv.isPompier);
if (aP && !bP) return -1;
if (bP && !aP) return 1;
const aAbs = isTechAbsent(a, targetDate);
const bAbs = isTechAbsent(b, targetDate);
if (aAbs && !bAbs) return 1;
if (bAbs && !aAbs) return -1;
// Sinon : alphabétique sur le nom de famille
// Les noms sont stockés au format "Nom, Prénom"
const aLast = (a.name || "").split(",")[0].trim();
const bLast = (b.name || "").split(",")[0].trim();
return aLast.localeCompare(bLast, "fr");
}
// v5.0.13 : un tech est considéré "absent toute la journée" uniquement si une
// absence couvre RÉELLEMENT du matin au soir (ou quasi), pas juste s'il a des
// absences (éventuellement partielles). Avant, une absence matin 08-12 seule
// faisait passer le tech en "absent toute la journée" car il n'avait QUE des
// absences. Maintenant on check explicitement que l'absence couvre ≥ 90% de
// la plage 08:00-18:00.
function isTechAbsent(tech, isoDate) {
const recurring = RECURRING_ABSENCES[tech.id];
if (recurring) {
const day = isoToDate(isoDate).getDay();
if (recurring.includes(day)) return true;
}
if (tech.interventions.length === 0) return false;
// Parmi les absences (hors pompier), est-ce qu'une seule couvre la journée ?
const fullDayAbsences = tech.interventions.filter(iv => {
if (iv.type !== "AL-Absence" || iv.isPompier) return false;
const startMin = timeToMinutes(iv.startTime);
const endMin = timeToMinutes(iv.endTime);
if (startMin == null || endMin == null) {
// Si on n'a pas d'horaires, on considère que c'est toute la journée
// (cas des absences multi-jours sans horaires précis)
return true;
}
// Absence couvre toute la journée si son créneau déborde largement
// la plage affichée (≥ 90%). Une demi-journée (4h) sur 10h = 40% → ne
// passera pas, donc on ne marquera pas le tech comme absent toute la journée.
const DAY_LEN_MIN = 10 * 60; // 08:00 → 18:00 = 10h
const clampedStart = Math.max(startMin, 8 * 60);
const clampedEnd = Math.min(endMin, 18 * 60);
const coveredMin = Math.max(0, clampedEnd - clampedStart);
return coveredMin >= 0.9 * DAY_LEN_MIN;
});
return fullDayAbsences.length > 0;
}
// ============================================================================
// Construction d'une carte
// ============================================================================
// v4.1.20 : détecte si tech = Pillonel Olivier ET jour = vendredi.
// Hardcodé car c'est une absence récurrente connue spécifique à lui.
function isPillonelAbsentFriday(tech, isoDate) {
if (!tech || !tech.name) return false;
// Normaliser le nom (tolère "Pillonel, Olivier", "Pillonel Olivier", etc.)
const name = tech.name.toLowerCase();
if (!name.includes("pillonel")) return false;
if (!name.includes("olivier")) return false;
// Jour de la semaine : 5 = vendredi (en JS, 0=dim, 1=lun, ..., 5=ven)
const d = isoToDate(isoDate);
return d.getDay() === 5;
}
function buildCard(tech, isoDate) {
const card = document.createElement("section");
card.className = "card";
card.dataset.techId = tech.id;
const isPompier = tech.interventions.some(iv => iv.isPompier);
const isAbsent = isTechAbsent(tech, isoDate);
if (isPompier) card.classList.add("is-pompier");
if (isAbsent) card.classList.add("is-absent");
const realInterventions = tech.interventions.filter(iv =>
iv.type !== "AL-Absence" && !iv.isPompier
);
const absenceBlocks = tech.interventions.filter(iv => iv.type === "AL-Absence");
const pompierBlocks = tech.interventions.filter(iv => iv.isPompier);
const morning = realInterventions.filter(iv => {
const s = timeToMinutes(iv.startTime);
return s !== null && s < 12 * 60;
}).length;
const afternoon = realInterventions.length - morning;
// --- Header ---
const header = document.createElement("div");
header.className = "card-header";
const nameEl = document.createElement("div");
nameEl.className = "card-tech-name";
nameEl.textContent = tech.name;
header.appendChild(nameEl);
if (isPompier || isAbsent) {
const badge = document.createElement("div");
badge.className = "card-tech-badge";
if (isPompier) {
badge.classList.add("badge-pompier");
badge.textContent = "Pompier";
} else {
badge.classList.add("badge-absent");
badge.textContent = "Absent";
}
header.appendChild(badge);
}
card.appendChild(header);
// --- Body ---
const body = document.createElement("div");
body.className = "card-body";
// Note statut
if (isPompier && pompierBlocks.length) {
const note = document.createElement("div");
note.className = "card-status-note pompier";
const pb = pompierBlocks[0];
if (pb.startDate && pb.endDate && pb.startDate !== pb.endDate) {
note.textContent = `En pompier du ${pb.startDate.substring(0, 5)} au ${pb.endDate.substring(0, 5)}`;
} else {
note.textContent = "En pompier aujourd'hui";
}
body.appendChild(note);
} else if (isAbsent && absenceBlocks.length) {
const note = document.createElement("div");
note.className = "card-status-note absent";
const ab = absenceBlocks[0];
if (ab.startDate && ab.endDate && ab.startDate !== ab.endDate) {
note.textContent = `Absent du ${ab.startDate.substring(0, 5)} au ${ab.endDate.substring(0, 5)}`;
} else {
note.textContent = "Absent toute la journée";
}
body.appendChild(note);
// v5.0.4 : tooltip au hover sur toute la carte absent (pas juste un
// bouton visible). Contient : détail période + bouton supprimer si
// c'est une absence supprimable (actionId réel, pas pompier récurrent).
if (ab.actionId && !ab.isPompier && !ab._recurring) {
// On attache le tooltip sur la CARD ENTIÈRE (card) — comme ça
// survoler n'importe où sur la zone grisée "absent" le déclenche.
const ivCopy = {
...ab,
type: "AL-Absence" // force pour buildTooltipHTML
};
card.addEventListener("mouseenter", (e) => {
showTooltip(e, ivCopy, card);
});
card.addEventListener("mouseleave", () => {
hideTooltip();
});
}
}
// v4.1.20 : cas spécifique Pillonel Olivier, absent tous les vendredis.
// Affichage d'un message explicite au lieu de "Pas d'intervention planifiée".
// v4.2 : prioritaire même si un bloc AL-Absence couvre le vendredi (ce qui
// est le cas normal), pour TOUJOURS afficher "Absent le vendredi".
const isPillonelFriday = isPillonelAbsentFriday(tech, isoDate);
// Absent sans interv → on stop là (après avoir posé le message Pillonel
// si vendredi).
if (isAbsent && realInterventions.length === 0) {
if (isPillonelFriday) {
const note = document.createElement("div");
note.className = "tech-absence-recurring";
note.textContent = "Absent le vendredi";
body.appendChild(note);
}
card.appendChild(body);
return card;
}
// v5.0.14 : si le tech n'a aucune intervention mais a des absences
// partielles (demi-journée) ou pompier, on veut quand même afficher la
// timeline avec les blocs absence visibles. Sans ça, une absence 08-12
// seule n'apparaissait jamais sur la carte (affichait juste "Pas
// d'intervention planifiée").
const hasPartialAbsences = absenceBlocks.some(ab => {
if (ab.isPompier) return false;
const s = timeToMinutes(ab.startTime);
const e = timeToMinutes(ab.endTime);
if (s === null || e === null) return false;
// Absence qui couvre PAS toute la journée → c'est partiel
return !(s <= DAY_START && e >= DAY_END);
});
if (realInterventions.length === 0 && !isPompier && !hasPartialAbsences) {
if (isPillonelFriday) {
const note = document.createElement("div");
note.className = "tech-absence-recurring";
note.textContent = "Absent le vendredi";
body.appendChild(note);
} else {
const empty = document.createElement("div");
empty.className = "card-empty";
empty.textContent = "Pas d'intervention planifiée";
body.appendChild(empty);
}
card.appendChild(body);
return card;
}
// Pillonel vendredi avec quand même des interv planifiées ? Rare mais possible.
if (isPillonelFriday && realInterventions.length > 0) {
const note = document.createElement("div");
note.className = "tech-absence-recurring";
note.textContent = "Absent le vendredi";
body.appendChild(note);
}
// Timeline
body.appendChild(buildTimeline(realInterventions, pompierBlocks, absenceBlocks, card, isPompier, isAbsent));
// Stats de carte
if (realInterventions.length > 0) {
const stats = document.createElement("div");
stats.className = "card-stats";
stats.innerHTML = `
<div class="stat-total">
<span class="stat-total-num">${realInterventions.length}</span>
<span class="stat-total-lbl">intervention${realInterventions.length > 1 ? "s" : ""}</span>
</div>
<div class="stat-split">
<span class="stat-split-item"><b>${morning}</b> matin</span>
<span class="stat-split-sep">·</span>
<span class="stat-split-item"><b>${afternoon}</b> après-midi</span>
</div>
`;
body.appendChild(stats);
}
// Liste interventions
for (const iv of realInterventions) {
body.appendChild(buildInterventionRow(iv, card));
}
// 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.
if (!isAbsent) {
const partialAbsences = absenceBlocks.filter(ab => {
if (ab.isPompier) return false;
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 || ""));
for (const ab of partialAbsences) {
body.appendChild(buildInterventionRow(ab, card));
}
}
card.appendChild(body);
return card;
}
// ============================================================================
// Timeline
// ============================================================================
// v5.0.0 : constantes timeline globales (avant : locales à buildTimeline),
// pour que updateNowLine puisse les utiliser aussi.
const DAY_START = 8 * 60; // 08:00 en minutes
const DAY_END = 18 * 60; // 18:00 en minutes
const DAY_LEN = DAY_END - DAY_START;
function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) {
const wrap = document.createElement("div");
wrap.className = "timeline";
if (isPompier) wrap.classList.add("timeline-pompier");
const bar = document.createElement("div");
bar.className = "timeline-bar";
const segments = [];
for (let i = 0; i < realInterventions.length; i++) {
const iv = realInterventions[i];
const s = timeToMinutes(iv.startTime);
const e = timeToMinutes(iv.endTime);
if (s === null || e === null) continue;
const cs = Math.max(s, DAY_START);
const ce = Math.min(e, DAY_END);
if (ce <= cs) continue;
segments.push({
kind: "intervention",
colorKey: deriveColorKey(iv),
iv, ivIdx: i,
start: cs, end: ce,
statusClass: getStatusClass(iv)
});
}
for (const ab of absenceBlocks || []) {
const s = timeToMinutes(ab.startTime);
const e = timeToMinutes(ab.endTime);
if (s === null || e === null) continue;
const cs = Math.max(s, DAY_START);
const ce = Math.min(e, DAY_END);
if (cs <= DAY_START && ce >= DAY_END) continue;
if (ce <= cs) continue;
segments.push({ kind: "absence", start: cs, end: ce, iv: ab });
}
// Calcul des trous (que si pas absent complet)
const occupiedRanges = segments.map(s => [s.start, s.end]).sort((a, b) => a[0] - b[0]);
const merged = [];
for (const [s, e] of occupiedRanges) {
if (merged.length && s <= merged[merged.length - 1][1]) {
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], e);
} else {
merged.push([s, e]);
}
}
const holes = [];
let cursor = DAY_START;
for (const [s, e] of merged) {
if (s > cursor) holes.push([cursor, s]);
cursor = Math.max(cursor, e);
}
if (cursor < DAY_END) holes.push([cursor, DAY_END]);
if (!isAbsent) {
for (const [s, e] of holes) {
if (e - s < 15) continue;
const h = document.createElement("div");
h.className = "timeline-hole";
h.style.left = ((s - DAY_START) / DAY_LEN) * 100 + "%";
h.style.width = ((e - s) / DAY_LEN) * 100 + "%";
h.dataset.startMin = s;
h.dataset.endMin = e;
h.dataset.kind = "hole";
bindTimelinePopover(h);
bar.appendChild(h);
}
}
for (const seg of segments) {
const el = document.createElement("div");
el.className = "timeline-slot kind-" + seg.kind;
if (seg.colorKey) el.classList.add("color-" + seg.colorKey);
if (seg.statusClass) el.classList.add(seg.statusClass);
el.style.left = ((seg.start - DAY_START) / DAY_LEN) * 100 + "%";
el.style.width = ((seg.end - seg.start) / DAY_LEN) * 100 + "%";
el.dataset.startMin = seg.start;
el.dataset.endMin = seg.end;
el.dataset.kind = seg.kind;
if (seg.iv) {
el.dataset.title = deriveShortTitle(seg.iv);
if (seg.iv.ref) el.dataset.ref = seg.iv.ref;
}
if (seg.ivIdx !== undefined) {
el.dataset.ivIdx = seg.ivIdx;
el.addEventListener("mouseenter", () => highlightIntervention(cardEl, seg.ivIdx, true));
el.addEventListener("mouseleave", () => highlightIntervention(cardEl, seg.ivIdx, false));
}
bindTimelinePopover(el);
bar.appendChild(el);
}
const noon = document.createElement("div");
noon.className = "timeline-noon";
noon.style.left = (((12 * 60) - DAY_START) / DAY_LEN) * 100 + "%";
bar.appendChild(noon);
wrap.appendChild(bar);
const scale = document.createElement("div");
scale.className = "timeline-scale";
for (const h of [8, 10, 12, 14, 16, 18]) {
const t = document.createElement("span");
t.className = "timeline-tick";
t.style.left = (((h * 60) - DAY_START) / DAY_LEN * 100) + "%";
t.textContent = h + "h";
scale.appendChild(t);
}
wrap.appendChild(scale);
return wrap;
}
function getStatusClass(iv) {
// v4.2.5 : priorité aux statuts de disparition analysés
if (iv._disappearStatus === "closed") return "status-closed";
if (iv._disappearStatus === "terminated") return "status-terminated";
if (iv._disappearStatus === "error") return null;
if (isClosedStatus(iv.status)) return "status-closed";
if (isResolvedStatus(iv.status)) return "status-resolved";
return null;
}
function bindTimelinePopover(el) {
el.addEventListener("mouseenter", (e) => showTimelinePopover(e, el));
// v4.2.3 : la petite popup timeline SUIT la souris (différent de la grande
// popup des lignes d'intervention qui est ancrée). On n'utilise pas
// moveTooltip() (no-op depuis v4.1.12) mais une fonction dédiée.
el.addEventListener("mousemove", (e) => moveTimelineTooltip(e));
el.addEventListener("mouseleave", hideTooltip);
// v4.2.3 : clic / double-clic / Ctrl+clic sur un segment timeline
// - clic simple : ferme la petite popup et ouvre la GRANDE popup
// (ancrée juste en dessous de la timeline, persistante pour permettre
// de sélectionner du texte / copier)
// - double-clic : ouvre la fiche EasyVista dans un nouvel onglet actif
// - Ctrl+clic (Cmd+clic sur Mac) : ouvre dans un onglet en arrière-plan
const kind = el.dataset.kind;
const ivIdxStr = el.dataset.ivIdx;
// Seulement sur les segments avec une interventoin (pas les "hole" libres
// ni certaines absences sans ivIdx)
if (ivIdxStr === undefined) return;
let singleClickTimer = null;
el.addEventListener("click", (e) => {
// Ctrl / Cmd / molette → ouvrir fiche en arrière-plan
if (e.ctrlKey || e.metaKey || e.button === 1) {
e.preventDefault();
e.stopPropagation();
openInterventionFromTimeline(el, { background: true });
return;
}
// Clic simple (sans Ctrl) : on attend un éventuel double-clic avant
// d'ouvrir la grande popup persistante.
e.stopPropagation();
if (singleClickTimer) clearTimeout(singleClickTimer);
singleClickTimer = setTimeout(() => {
singleClickTimer = null;
openPersistentTimelinePopup(el);
}, 250);
});
el.addEventListener("dblclick", (e) => {
// Annuler le clic simple en attente
if (singleClickTimer) { clearTimeout(singleClickTimer); singleClickTimer = null; }
e.preventDefault();
e.stopPropagation();
openInterventionFromTimeline(el, { background: false });
});
}
// v4.2.3 : positionne la petite popup timeline à côté du curseur
function moveTimelineTooltip(e) {
const tip = tooltipEl();
if (!tip || !tip.classList.contains("visible")) return;
// La popup ancrée (grande bulle) ne doit pas être déplacée par la souris
if (bulleState.pinned) return;
// Si la popup affiche une grande bulle d'intervention (classe pinned-like),
// on ne la bouge pas non plus : on la laisse ancrée.
if (tip.dataset.mode === "anchored") return;
const offsetX = 14, offsetY = 16;
let x = e.clientX + offsetX;
let y = e.clientY + offsetY;
const rect = tip.getBoundingClientRect();
// Ajuster si on sort de la fenêtre
if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offsetX;
if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offsetY;
if (x < 4) x = 4;
if (y < 4) y = 4;
// v4.2.4 : utiliser setTooltipViewportPosition pour bénéficier de la
// détection automatique fixed/abs (et donc de la stabilité au scroll).
setTooltipViewportPosition(x, y);
}
// v4.2.3 : trouve l'iv correspondant au segment timeline et ouvre sa fiche
function openInterventionFromTimeline(el, opts) {
const ivIdx = el.dataset.ivIdx;
if (ivIdx === undefined) return;
const cardEl = el.closest(".card");
if (!cardEl) return;
const row = cardEl.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`);
if (!row) return;
const actionId = row.dataset.actionId;
if (!actionId) return;
// recupere l'iv depuis state
const iv = findIvByActionId(actionId);
if (!iv) return;
openInterventionInNewTab(iv, opts || {});
}
function findIvByActionId(actionId) {
const data = state.currentData;
if (!data || !data.techs) return null;
for (const tech of data.techs) {
for (const iv of (tech.interventions || [])) {
if (String(iv.actionId) === String(actionId)) return iv;
}
}
return null;
}
// v4.2.3/4 : ouvre la GRANDE popup au clic sur un segment timeline, ancrée
// juste en dessous du segment. Pas épinglée : se ferme sur clic ailleurs,
// Échap, OU quand la souris quitte la popup elle-même (mouseleave).
function openPersistentTimelinePopup(el) {
const ivIdx = el.dataset.ivIdx;
if (ivIdx === undefined) return;
const cardEl = el.closest(".card");
if (!cardEl) return;
const row = cardEl.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`);
if (!row) return;
const actionId = row.dataset.actionId;
const iv = findIvByActionId(actionId);
if (!iv) return;
const tip = tooltipEl();
if (!tip) return;
// Nettoyer tout état précédent (ancrage, épinglage, timers)
bulleState.pinned = false;
bulleState.hoveredInBulle = false;
bulleState.hoveredInRow = false;
if (bulleState.hideTimer) { clearTimeout(bulleState.hideTimer); bulleState.hideTimer = null; }
tip.classList.remove("pinned");
// Construire la grande bulle
tip.innerHTML = buildTooltipHTML(iv);
tip.classList.remove("hidden");
tip.classList.add("visible");
// mode "anchored" : le hover ne doit pas la remplacer par une autre popup
tip.dataset.mode = "anchored";
state.currentTooltipIv = iv;
// Position : juste sous le segment timeline. D'abord on reset les coords
// pour que getBoundingClientRect() reflète la vraie taille du nouveau
// contenu.
tip.style.left = "-9999px";
tip.style.top = "0px";
// Forcer un reflow pour que tipRect soit à jour avec le nouveau contenu
const tipRect = tip.getBoundingClientRect();
const r = el.getBoundingClientRect();
let x = r.left;
let y = r.bottom + 8;
if (x + tipRect.width > window.innerWidth - 8) x = window.innerWidth - tipRect.width - 8;
if (x < 4) x = 4;
if (y + tipRect.height > window.innerHeight - 8) {
y = r.top - tipRect.height - 8;
}
if (y < 4) y = 4;
// Positionner proprement (avec détection auto fixed vs abs)
setTooltipViewportPosition(x, y);
}
function showTimelinePopover(e, el) {
const s = parseInt(el.dataset.startMin, 10);
const eMin = parseInt(el.dataset.endMin, 10);
const kind = el.dataset.kind;
const dur = eMin - s;
let html;
if (kind === "hole") {
const h = Math.floor(dur / 60);
const min = dur % 60;
let d;
if (h === 0) d = `${min} min`;
else if (min === 0) d = `${h} h`;
else d = `${h} h ${min} min`;
html = `<dl>
<dt>Libre</dt><dd>${minutesToTime(s)}${minutesToTime(eMin)}</dd>
<dt>Durée</dt><dd>${d} disponible</dd>
</dl>`;
} else {
const t = el.dataset.title || "";
const ref = el.dataset.ref || "";
const k = kind === "absence" ? "Absence" : "Intervention";
html = `<dl>
<dt>${k}</dt><dd>${minutesToTime(s)}${minutesToTime(eMin)}</dd>
${t ? `<dt>Type</dt><dd>${escapeHtml(t)}</dd>` : ""}
${ref ? `<dt>Réf</dt><dd>${escapeHtml(ref)}</dd>` : ""}
</dl>`;
}
const tip = tooltipEl();
// v4.2.3 : si une grande bulle est déjà ancrée (clic timeline), on ne
// la remplace pas par la petite popup hover.
if (tip.dataset.mode === "anchored") return;
// v4.2.4 : annuler tout hideTimer en cours pour éviter que la popup
// précédente, en train d'être masquée, masque AUSSI celle-ci juste après.
// Problème typique quand on passe rapidement d'un segment à un autre.
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
}
tip.innerHTML = html;
tip.classList.remove("hidden", "pinned");
tip.classList.add("visible");
// v4.2.3 : mode "hover" = petite popup qui suit la souris
tip.dataset.mode = "hover";
moveTimelineTooltip(e);
}
// ============================================================================
// Ligne d'interventoin
// ============================================================================
function buildInterventionRow(iv, cardEl) {
const row = document.createElement("div");
row.className = "intervention-v2";
row.dataset.actionId = iv.actionId;
if (iv.isPompier) row.classList.add("is-pompier-line");
// v4.3.3 : on ne marque plus les ghosts visuellement (classe is-ghost
// retirée). Les tickets disparus sont soit retirés (_disappearRemove),
// soit affichés en vert (_disappearStatus). Plus de barrage.
// v4.2.5 : indicateur "en cours d'analyse" (ticket disparu, on re-fetch
// la fiche pour décider de le garder en vert ou le retirer).
if (iv._disappearChecking) row.classList.add("_checking");
const colorKey = deriveColorKey(iv);
row.classList.add("color-" + colorKey);
const statusClass = getStatusClass(iv);
if (statusClass) row.classList.add(statusClass);
const ivIdx = cardEl._rowIdxCounter || 0;
cardEl._rowIdxCounter = ivIdx + 1;
row.dataset.ivIdx = ivIdx;
if (iv.formLink && !iv.ghost && iv.type !== "AL-Absence") {
row.classList.add("clickable");
// v4.1.8 : plus de title au survol (info déjà dans le tooltip en bas)
// Clic normal : ouvre l'onglet et change de page
// Ctrl/Cmd+Clic : ouvre en arrière-plan (reste sur le planning)
row.addEventListener("click", (e) => {
if (e.target.closest(".intervention-copy")) return;
const background = !!(e.ctrlKey || e.metaKey);
openInterventionInNewTab(iv, { background });
});
// Clic molette (button === 1) : ouvre en arrière-plan
// On utilise 'auxclick' pour les boutons du milieu/droite (standard W3C).
row.addEventListener("auxclick", (e) => {
if (e.button !== 1) return; // que la molette
if (e.target.closest(".intervention-copy")) return;
e.preventDefault();
openInterventionInNewTab(iv, { background: true });
});
// Empêcher le scroll auto quand on clique la molette sur la ligne
row.addEventListener("mousedown", (e) => {
if (e.button === 1) e.preventDefault();
});
}
// Pastille colorée à gauche (barre verticale, toute la hauteur)
const dot = document.createElement("div");
dot.className = "intervention-dot";
row.appendChild(dot);
// ─── Ligne 1 : Ref centrée (TITRE en gros + gras) ────────────────────────
const refHeader = document.createElement("div");
refHeader.className = "iv-ref-header";
if (iv.type === "AL-Reservation") {
refHeader.textContent = "Réservation";
refHeader.classList.add("is-reservation-title");
} else if (iv.type === "AL-Absence") {
// v5.0.15 : absence partielle (demi-journée) affichée comme une row
refHeader.textContent = "Absence";
refHeader.classList.add("is-absence-title");
} else if (iv.ref) {
refHeader.textContent = iv.ref;
} else {
refHeader.textContent = "—";
refHeader.classList.add("no-ref");
}
row.appendChild(refHeader);
// Check ✓ + bouton copier à droite de la ref (pas pour réservation / absence)
if (statusClass && iv.type !== "AL-Reservation" && iv.type !== "AL-Absence") {
const statusEl = document.createElement("div");
statusEl.className = "iv-status-check";
// v4.2.5 : ✓✓ double pour clôturé/résolu (statut officiel EasyVista)
// ✓ simple pour "terminé par tech" (commentaire LOGIN: détecté)
if (statusClass === "status-closed" || statusClass === "status-resolved") {
statusEl.textContent = "✓✓";
statusEl.classList.add("double");
} else {
statusEl.textContent = "✓";
}
row.appendChild(statusEl);
}
if (iv.ref && iv.type !== "AL-Reservation" && iv.type !== "AL-Absence") {
const copyBtn = document.createElement("button");
copyBtn.className = "intervention-copy";
copyBtn.type = "button";
copyBtn.title = "Copier la référence";
copyBtn.innerHTML = "📋";
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
copyRef(iv.ref, copyBtn);
});
row.appendChild(copyBtn);
}
// ─── Ligne 2 gauche : heures VERTICALES (début / ↓ / fin) ─────────────────
const timeEl = document.createElement("div");
timeEl.className = "iv-time-vertical";
// v4.3.0 : marquer rouge + icône ⚠ si conflit horaire détecté
if (iv._hasOverlap) {
timeEl.classList.add("iv-time-overlap");
}
if (iv.startTime && iv.endTime) {
const s = document.createElement("div");
s.className = "iv-time-start";
s.textContent = iv.startTime;
const sep = document.createElement("div");
sep.className = "iv-time-arrow";
sep.textContent = "↓";
const e = document.createElement("div");
e.className = "iv-time-end";
e.textContent = iv.endTime;
timeEl.appendChild(s);
timeEl.appendChild(sep);
timeEl.appendChild(e);
// v4.3.0 : icône d'alerte à côté des heures si conflit
if (iv._hasOverlap) {
const warn = document.createElement("div");
warn.className = "iv-time-overlap-warn";
warn.textContent = "⚠";
warn.title = "Conflit d'horaire avec une autre intervention";
timeEl.appendChild(warn);
}
} else {
timeEl.textContent = "—";
}
row.appendChild(timeEl);
// ─── Ligne 2 droite : lieu / contact+tél / catégorie+signature ───────────
// Pour une RÉSERVATION : affichage différent (par + sujet)
const rightCol = document.createElement("div");
rightCol.className = "iv-right";
if (iv.type === "AL-Reservation") {
// Bloc "Par Nom, Prénom" (en gras)
if (iv.reservationCreator) {
const parEl = document.createElement("div");
parEl.className = "iv-reservation-par";
parEl.textContent = "Par " + iv.reservationCreator;
rightCol.appendChild(parEl);
}
// Sujet (ex: "Ecrans", "Rollout")
if (iv.reservationLabel) {
const sujetEl = document.createElement("div");
sujetEl.className = "iv-reservation-sujet";
sujetEl.textContent = "Sujet : " + iv.reservationLabel;
rightCol.appendChild(sujetEl);
}
row.appendChild(rightCol);
// Tooltip (fixe, ne suit pas la souris — v4.1.12)
row.addEventListener("mouseenter", (e) => {
showTooltip(e, iv, row);
highlightIntervention(cardEl, ivIdx, true);
});
row.addEventListener("mouseleave", () => {
hideTooltip();
highlightIntervention(cardEl, ivIdx, false);
});
return row;
}
// v5.0.15 : absence partielle (demi-journée) affichée comme une row au
// même style que les réservations mais en gris foncé, avec le type d'absence
// (Congés, Maladie, Pompier) comme sujet.
if (iv.type === "AL-Absence") {
// Bloc "Par Nom, Prénom" si on a un créateur
if (iv.reservationCreator) {
const parEl = document.createElement("div");
parEl.className = "iv-reservation-par";
parEl.textContent = "Par " + iv.reservationCreator;
rightCol.appendChild(parEl);
}
// Type d'absence (Congés, Maladie, Pompier) si dispo dans label
const absenceTypeMatch = (iv.label || "").match(/^([^/]+?)\s*(?:\/|$)/);
const absenceType = absenceTypeMatch ? absenceTypeMatch[1].trim() : null;
if (absenceType) {
const sujetEl = document.createElement("div");
sujetEl.className = "iv-reservation-sujet";
sujetEl.textContent = "Type : " + absenceType;
rightCol.appendChild(sujetEl);
}
row.appendChild(rightCol);
// Tooltip au hover (avec bouton supprimer)
row.addEventListener("mouseenter", (e) => {
showTooltip(e, iv, row);
highlightIntervention(cardEl, ivIdx, true);
});
row.addEventListener("mouseleave", () => {
hideTooltip();
highlightIntervention(cardEl, ivIdx, false);
});
return row;
}
// v4.1.2 : priorité à iv.infobulle (venant du xhr2 = données réelles vérifiées
// par le tech sur place) puis fallback sur iv.bulleContact/iv.bulleLieu
// (venant de attr1/attr2 = planification initiale, parfois incorrecte).
const info = iv.infobulle || {};
const contactRaw = info.contact || iv.bulleContact || null;
const lieuRaw = info.lieu || iv.bulleLieu || null;
// Rendu initial de lieu + contacts dans rightCol
renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, info.contactAnomalie);
// ── Bas : Catégorie (à gauche) + Signature planificateur (à droite) ──────
const bottomEl = document.createElement("div");
bottomEl.className = "iv-bottom-line";
const categoryEl = document.createElement("span");
categoryEl.className = "iv-category";
categoryEl.textContent = deriveShortTitle(iv);
bottomEl.appendChild(categoryEl);
// v4.1.8 : extraire la signature depuis le texte COMPLET (fiche) en
// priorité, sinon depuis le xhr2 tronqué. Le xhr2 tronqué peut couper la
// signature, la fiche a toujours le texte complet.
const signature = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription);
if (signature) {
const sigEl = document.createElement("span");
sigEl.className = "iv-signature";
sigEl.textContent = signature;
bottomEl.appendChild(sigEl);
}
rightCol.appendChild(bottomEl);
row.appendChild(rightCol);
// Tooltip (fixe, ne suit pas la souris — v4.1.12)
row.addEventListener("mouseenter", (e) => {
showTooltip(e, iv, row);
highlightIntervention(cardEl, ivIdx, true);
});
row.addEventListener("mouseleave", () => {
hideTooltip();
highlightIntervention(cardEl, ivIdx, false);
});
return row;
}
// Sender correct pour ouvrir une fiche EasyVista (vu dans les URLs qui marchent)
const FICHE_SENDER = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D";
async function openInterventionInNewTab(iv, opts = {}) {
if (!iv.formLink) return;
// Toast de feedback visuel dès le clic
showToast("Ouverture", iv.ref || iv.actionId);
// Récupérer la session actuelle pour construire une URL valide
let session = state.session;
if (!session) {
const resp = await sendMessage({ type: "getSession" });
session = resp && resp.session;
}
if (!session) {
// v4.2.5 : popup modale propre au lieu d'alert natif
showAlertModal({
title: "Impossible d'ouvrir la fiche",
message: "Votre session EasyVista a expiré. Reconnectez-vous à EasyVista puis réessayez.",
buttons: [
{ label: "Ouvrir EasyVista", variant: "primary", action: () => openEasyVista() },
{ label: "Annuler", variant: "secondary", action: () => {} }
]
});
return;
}
if (!iv.requestId) {
showAlertModal({
title: "Impossible d'ouvrir la fiche",
message: "L'identifiant de la fiche est manquant. Essayez d'actualiser le planning (bouton Actualiser).",
buttons: [
{ label: "OK", variant: "secondary", action: () => {} }
]
});
return;
}
let target = null;
let checksum = null;
// v4.1.4 : on fetch TOUJOURS la fiche à la volée au clic pour extraire un
// checksum FRAIS. Ne pas utiliser iv.ficheChecksum du cache : les checksums
// EasyVista peuvent expirer entre le fetch arrière-plan et le clic utilisateur.
//
// Retry automatique en cas d'échec du pattern checksum.
{
console.log("[click] fetch fiche fraîche pour iv", iv.actionId, "requestId=", iv.requestId);
let attempts = 0;
const maxAttempts = 2;
while (attempts < maxAttempts && (!target || !checksum)) {
attempts++;
try {
const ficheResp = await sendMessage({
type: "fetchFiche",
formLink: iv.formLink
});
if (!ficheResp.ok) {
if (attempts >= maxAttempts) {
// v4.2.5 : popup modale selon le type d'erreur
if (ficheResp.error === "no_session" || ficheResp.error === "session_expired") {
showAlertModal({
title: "Session EasyVista expirée",
message: "Votre session a expiré pendant l'ouverture de la fiche. Reconnectez-vous à EasyVista puis réessayez.",
buttons: [
{ label: "Ouvrir EasyVista", variant: "primary", action: () => openEasyVista() },
{ label: "Annuler", variant: "secondary", action: () => {} }
]
});
} else if (ficheResp.error === "ev_unreachable") {
showAlertModal({
title: "EasyVista inaccessible",
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: "Annuler", variant: "secondary", action: () => {} }
]
});
} else {
showAlertModal({
title: "Impossible d'ouvrir la fiche",
message: "Une erreur est survenue : " + (ficheResp.error || "inconnue"),
buttons: [
{ label: "OK", variant: "secondary", action: () => {} }
]
});
}
return;
}
continue; // retry
}
// Extraire le checksum lié au requestId précis
const rx = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`, 'g');
const allMatches = [...ficheResp.html.matchAll(rx)];
console.log(`[click] Trouvé ${allMatches.length} occurrence(s) de target=${iv.requestId}&checksum=... dans HTML de la fiche (taille ${ficheResp.html.length})`);
allMatches.forEach((m, idx) => console.log(` [${idx}] checksum = ${m[1]}`));
if (allMatches.length === 0) {
// v4.2.5 : le warning précédent était alarmiste pour rien.
// Tentative 1 peut légitimement échouer (cache stale côté EV).
// On log en info, on retry, et en dernier recours on ouvre quand
// même la fiche (avec un target de fallback) plutôt que de bloquer.
console.info(`[click] tentative ${attempts}/${maxAttempts}: pattern target=${iv.requestId}&checksum=... introuvable dans HTML de la fiche (taille ${ficheResp.html.length})`);
if (attempts >= maxAttempts) {
// Fallback : tenter avec le requestId seul, sans checksum précis.
// Ça ouvre une URL EasyVista valide qui redirige vers la fiche.
console.info(`[click] fallback sans checksum précis pour ${iv.requestId}`);
target = iv.requestId;
checksum = null;
break;
}
await new Promise(r => setTimeout(r, 300));
continue;
}
// On prend le PREMIER checksum trouvé (comme avant, comportement v4)
target = iv.requestId;
checksum = allMatches[0][1];
console.log(`[click] checksum retenu: ${checksum}`);
// On stocke aussi en cache pour accélérer le prochain clic (au cas où)
iv.ficheTarget = target;
iv.ficheChecksum = checksum;
} catch (err) {
if (attempts >= maxAttempts) {
// v4.2.5 : popup modale au lieu d'alert
showAlertModal({
title: "Erreur lors de l'ouverture de la fiche",
message: "Une erreur s'est produite : " + (err && err.message ? err.message : String(err)),
buttons: [
{ label: "Réessayer", variant: "primary", action: () => openInterventionInNewTab(iv, opts) },
{ label: "Annuler", variant: "secondary", action: () => {} }
]
});
return;
}
}
}
}
// v4.1.18 : sender à utiliser dépend du type de fiche :
// - demande S... → {C99ECD05-...}
// - incident I... → {07ED9C68-...}
// On préfère le formSenderGuid extrait du HTML de la fiche si connu, sinon
// fallback sur préfixe de la ref.
let sender = FICHE_SENDER;
if (iv.formSenderGuid) {
sender = iv.formSenderGuid;
} else if (iv.ref && /^I/i.test(iv.ref)) {
sender = "%7B07ED9C68-6172-48EA-8A58-90912B0A283E%7D";
}
// Construire l'URL qui fonctionne (format identique à l'URL manuelle qui
// marche dans le navigateur quand on ouvre une fiche depuis l'UI EasyVista).
const internalurltime = Math.floor(Date.now() / 1000);
// v4.2.5 : si on n'a pas pu extraire le checksum précis (fallback après
// retry), on omet le paramètre checksum. EasyVista acceptera l'URL et
// redirigera vers la fiche correspondant au target.
const urlParts = [
`${session.origin}/index.php`,
`?PHPSESSID=${encodeURIComponent(session.phpsessid)}`,
`&internalurltime=${internalurltime}`,
`&eventName=formEvent`,
`&target=${encodeURIComponent(target)}`,
];
if (checksum) {
urlParts.push(`&checksum=${encodeURIComponent(checksum)}`);
}
urlParts.push(`&sender=${sender}`);
const url = urlParts.join("");
console.log("[click] ouverture fiche iv=", iv.actionId, "ref=", iv.ref, "target=", target, "bg=", !!opts.background);
// Si background (Ctrl+Clic ou clic molette) : onglet ouvert mais pas actif,
// on reste sur la page du planning.
await chrome.tabs.create({ url, active: !opts.background });
}
const TOAST_MAX = 3;
const TOAST_DURATION_MS = 2400;
/**
* Affiche un toast en bas à droite. S'empile, max 3, animations in/out.
*/
function showToast(label, ref) {
const stack = document.getElementById("toast-stack");
if (!stack) return;
// Si on dépasse le max, supprimer le plus ancien (= premier enfant)
while (stack.children.length >= TOAST_MAX) {
const oldest = stack.firstChild;
if (oldest) stack.removeChild(oldest);
}
const toast = document.createElement("div");
toast.className = "toast";
const labelEl = document.createElement("span");
labelEl.className = "toast-label";
labelEl.textContent = label;
const refEl = document.createElement("span");
refEl.className = "toast-ref";
refEl.textContent = ref || "…";
toast.appendChild(labelEl);
toast.appendChild(refEl);
stack.appendChild(toast);
// Forcer reflow puis animer en entrée
void toast.offsetWidth;
toast.classList.add("visible");
// Auto-disparition après TOAST_DURATION_MS
setTimeout(() => {
toast.classList.remove("visible");
toast.classList.add("leaving");
setTimeout(() => {
if (toast.parentNode === stack) stack.removeChild(toast);
}, 220);
}, TOAST_DURATION_MS);
}
/**
* Formate un numéro de téléphone suisse / français.
* 079 123 45 67 (mobile CH)
* 021 123 45 67 (fixe CH)
* +41 79 123 45 67
* +33 1 23 45 67 89
* Si le format n'est pas reconnu, renvoie le numéro tel quel (avec les chiffres seuls).
*/
function formatPhone(raw) {
if (!raw) return null;
const digits = String(raw).replace(/[^\d+]/g, "");
if (!digits) return null;
// +41 (Suisse international, 9 chiffres après +41)
let m = digits.match(/^\+41(\d{9})$/);
if (m) {
const d = m[1];
return `+41 ${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
}
// v4.2 : 41XXXXXXXXX sans + (format EasyVista qui colle parfois le préfixe)
m = digits.match(/^41(\d{9})$/);
if (m) {
const d = m[1];
return `+41 ${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
}
// +33 (France)
m = digits.match(/^\+33(\d{9})$/);
if (m) {
const d = m[1];
return `+33 ${d.slice(0, 1)} ${d.slice(1, 3)} ${d.slice(3, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
}
// v4.2 : 33XXXXXXXXX sans +
m = digits.match(/^33(\d{9})$/);
if (m) {
const d = m[1];
return `+33 ${d.slice(0, 1)} ${d.slice(1, 3)} ${d.slice(3, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
}
// 0XX XXX XX XX (fixe ou mobile CH, 10 chiffres commençant par 0)
m = digits.match(/^0(\d{9})$/);
if (m) {
const d = m[1];
return `0${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
}
// Numéro court interne (5 chiffres) : 78999, 68999, 88999, etc.
m = digits.match(/^(\d{5})$/);
if (m) {
return m[1]; // tel quel (déjà court et lisible)
}
// Fallback : retour brut
return digits;
}
/**
* Extrait le numéro de téléphone d'une chaîne contact.
* Accepte les préfixes : +41, +33, 07x, 02x, 03x (CH), 01-09 FR.
* Retourne un objet { name, phone } où phone est déjà formaté.
*/
function extractContactNameAndPhone(raw) {
if (!raw) return { name: null, phone: null };
const contacts = extractContacts(raw);
if (contacts.length === 0) return { name: null, phone: null };
// Pour compat avec l'ancien usage qui ne prend qu'1 contact
return contacts[0];
}
/**
* Extrait TOUS les contacts d'une chaîne (potentiellement plusieurs séparés
* par "ou", "/", des retours à la ligne, etc.).
* Retourne un tableau [{ name, phone }, { name, phone }, ...]
* Format d'entrée typique :
* "Nom1, Prénom1 +41000000001"
* "Nom1, Prénom1 +41000000001 ou Nom2, Prénom2 +41000000002"
* "Nom1, Prénom1 +41...\nNom2, Prénom2 +41..."
*/
function extractContacts(raw) {
if (!raw) return [];
let s = String(raw).trim();
// Virer les labels parasites (Nom utilisateur, etc.) qui traînent
s = s.replace(/\b(Nom utilisateur|Utilisateur)\s*:\s*[^\n]+/gi, "");
// v4.2.3 : séparer sur plus de délimiteurs pour gérer les cas type
// "Nom1 Prénom1 +41XXXXXXXXX et Nom2 Prénom2 0XXXXXXXXX"
// Délimiteurs acceptés :
// - " ou " / " et " / " and " (mots de liaison)
// - " / " suivi d'une majuscule (nouveau contact)
// - " ; " (point-virgule)
// - saut de ligne
// IMPORTANT : on ne touche PAS aux virgules (car "Nom, Prénom" en contient).
const parts = s.split(/\s+ou\s+|\s+et\s+|\s+and\s+|\s*;\s*|\n+|\s*\/\s*(?=[A-ZÉÈÀÂÎÔÛÇ])/i)
.map(p => p.trim())
.filter(Boolean);
const results = [];
for (const part of parts) {
const { name, phone } = splitOneContact(part);
if (name || phone) results.push({ name, phone });
}
return results;
}
/**
* Split UN seul bloc "Nom Prénom +41... [autres tels] [commentaires]" en
* { name, phone }.
*
* Stratégie robuste (v4.1.8) :
* - On cherche TOUS les numéros de téléphone (long ou court).
* - Le nom = ce qui précède le PREMIER numéro.
* - Le champ phone concatène les numéros trouvés (séparés par " / ").
* - Ce qui suit les numéros (commentaires "S'annoncer à la réception...",
* "téléphone à l'utilisateur") est JETÉ : ça ne fait pas partie du contact.
*
* Pattern numéro (inchangé, connu pour marcher) :
* Long : +41 / +33 / 0X suivi de 8+ caractères de [chiffres espaces . -]
* Court: 5 chiffres isolés (entre espaces, parenthèses, ou début/fin)
*/
function splitOneContact(raw) {
if (!raw) return { name: null, phone: null };
// v4.1.20 : regex plus permissives pour tolérer les erreurs humaines :
// - pas d'espace après le numéro (ex: "021555555Textecoller")
// - pas d'espace/parenthèse avant un court numéro
// LONG : +41 / +33 / 0X suivis de chiffres/espaces/points/tirets
// On ne limite plus par séparateur après — on laisse le moteur
// consommer le numéro le plus long possible (greedy) puis on
// s'arrête dès qu'on tombe sur un caractère non numérique.
// v4.2 : on accepte aussi le format "41XXXXXXXXX" sans + devant (fréquent
// quand EasyVista concatène "prefixe+tel" sans espace : Nom,
// Prénom 41XXXXXXXXX → extraire 41XXXXXXXXX puis reformater en
// +41 XX XXX XX XX). On exige exactement 11 chiffres collés pour
// éviter de matcher des codes postaux ou autres nombres.
// v2026.5.16 : ne PAS matcher si le numéro est précédé d'une lettre ou
// d'un underscore (identifiants style XXXX_NNNNNNNN, ABC123456,
// SERIAL_0123456789). On ajoute un lookbehind négatif (?<![A-Za-z_]).
const rxLong = /(?<![A-Za-z_])(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(?<!\d)41\d{9}(?!\d)|(?<!\d)33\d{9}(?!\d))/g;
// SHORT : numéro interne court (5 chiffres).
// - v4.1.20 : accepte "12345Texte" (pas de séparateur après)
// - v4.2.3 : accepte aussi les formats AVEC ESPACES au sein du numéro,
// typique du Canton de Vaud : "7 68 43", "6 12 34", "8 90 12".
// Doit commencer par 6, 7 ou 8 (plan de numérotation interne VD).
// Pattern : [678] + (4 autres chiffres, avec ou sans espaces/points
// intercalés, mais pas plus d'un séparateur à la fois entre 2 chiffres).
// - v4.2.3 : la version "collée" classique (5 chiffres sans espace, tout
// chiffre de début) reste acceptée comme fallback.
const rxShort = /(?:^|[\s(\/])([678](?:[\s.\-]?\d){4})(?!\d)|(?:^|[\s(\/])(\d{5})(?!\d)/g;
// Trouver toutes les positions de match pour LONG et SHORT
const matches = [];
let mm;
while ((mm = rxLong.exec(raw)) !== null) {
// v4.1.20 : on ne garde que si au moins 8 chiffres pour un long
// (élimine les fausses captures "0 1" ou "01 2")
const digitsOnly = mm[1].replace(/\D/g, "");
if (digitsOnly.length >= 9) {
matches.push({ start: mm.index, end: mm.index + mm[1].length, tel: mm[1] });
}
}
while ((mm = rxShort.exec(raw)) !== null) {
// v4.2.3 : soit le 1er groupe (format avec espaces "7 68 43"), soit le
// 2e groupe (format collé "12345") a matché.
const rawTel = mm[1] || mm[2];
if (!rawTel) continue;
// On normalise en 5 chiffres sans séparateur
const shortTel = rawTel.replace(/[\s.\-]/g, "");
if (!/^\d{5}$/.test(shortTel)) continue;
const rawStart = mm.index + mm[0].indexOf(rawTel);
const rawEnd = rawStart + rawTel.length;
const overlaps = matches.some(x => rawStart < x.end && rawEnd > x.start);
if (!overlaps) {
matches.push({ start: rawStart, end: rawEnd, tel: shortTel });
}
}
matches.sort((a, b) => a.start - b.start);
let name = raw;
let phone = null;
if (matches.length > 0) {
name = raw.substring(0, matches[0].start).trim();
const tels = matches.map(x => formatPhone(x.tel)).filter(Boolean);
phone = tels.length > 0 ? tels.join(" / ") : null;
}
name = cleanContactName(name);
// v2026.5.16 : dernier garde-fou — rejeter les "noms" qui ressemblent
// à des fragments de description technique plutôt qu'à des vrais contacts.
// Exemples rejetés :
// - "1x" (quantité isolée)
// - "1x pc" (quantité + type matériel)
// - "pc XNNNNNN" (type + numéro de série)
// - "XXXX_NNNNNNNN" (identifiant matériel)
// Critères d'un vrai nom : contient au moins un mot qui commence par une
// majuscule ET n'est pas juste un identifiant technique.
if (name) {
const looksLikeIdentifier = /^[A-Z]{2,}[_\-]\d+$/.test(name); // XXXX_NNNNNNNN
const startsWithQuantity = /^\d+x(\s|$)/i.test(name); // "1x" ou "1x pc"
const noCapitalWord = !/\b[A-ZÉÈÀÂÎÔÛÇ][a-zéèàâîôûç]+/.test(name); // aucun mot "Xxxxx"
const hasOnlyTechTokens = /^(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout)(\s+(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout|[A-Z]\d+))*\s*$/i.test(name);
if (looksLikeIdentifier || startsWithQuantity || hasOnlyTechTokens || (noCapitalWord && !phone)) {
name = null;
}
}
return { name, phone };
}
/**
* Nettoie le nom du contact :
* - retire tout ce qui est dans des parenthèses (...)
* - retire les éventuels "Nom utilisateur :" ou libellés
* - retire les virgules en trop en fin
* - v4.1.8 : tronque les commentaires parasites après le nom
* (ex: "Dupont, Jean S'annoncer à la réception" → "Dupont, Jean")
* - Conserve juste "Nom, Prénom" (ou "Nom Prénom" si pas de virgule)
*/
function cleanContactName(raw) {
if (!raw) return null;
let s = String(raw);
// Retirer parenthèses COMPLÈTES et leur contenu : (RH), (support)...
s = s.replace(/\s*\([^)]*\)\s*/g, " ");
// Retirer parenthèses non fermées en fin : "Bento, Joao (" → "Bento, Joao"
s = s.replace(/\s*\([^)]*$/g, " ");
// Retirer parenthèses non ouvertes en début : ")Bento" → "Bento"
s = s.replace(/^[^(]*\)\s*/g, "");
// Retirer tout caractère parenthèse isolé restant
s = s.replace(/[()]/g, " ");
// Retirer labels type "Nom utilisateur :", "Utilisateur :", "Bénéficiaire :"
s = s.replace(/\b(Nom utilisateur|Utilisateur|B[ée]n[ée]ficiaire)\s*:\s*[^\n,]*/gi, "");
// v4.1.20 : virer les commentaires parasites fréquents AVANT la logique
// des 4-mots (ils peuvent apparaître au tout début quand EasyVista n'a
// pas de nom saisi et commence directement par un commentaire).
// On détecte et coupe DÈS que ces expressions apparaissent.
// NOTE: on évite \b avant/après les caractères accentués (à, é) car
// \b est basé sur [a-zA-Z0-9_] et donne de faux négatifs.
const parasitePhrases = [
// Instructions d'appel (avec "à" ou "a")
/t[ée]l[ée]phone(?:r)?\s*[àa]\s*l[''`]?utilisateur/gi,
/t[ée]l[ée]phone(?:r)?\s*[àa](?:\s|$)/gi,
/t[ée]l[ée]phone(?:r)?\s*[àa]$/gi,
/\bappeler?\s+l[''`]?utilisateur\b/gi,
/\bappeler?\s+(?:le\s+)?b[ée]n[ée]ficiaire\b/gi,
/\bappeler?\s+la\s+personne\b/gi,
/\bappeler?\s+[àa]\s+/gi,
/\brappeler?\s+l[''`]?utilisateur\b/gi,
/\brappeler?\s+(?:le\s+)?b[ée]n[ée]ficiaire\b/gi,
// Instructions de présentation
/s[''`]annoncer?\s+[àa]\s+(?:la\s+r[ée]ception|l[''`]?accueil|.+?)(?=\.|,|$)/gi,
/\bse\s+pr[ée]senter\s+[àa]\s+.+?(?=\.|,|$)/gi,
// Autres
/\bbonjour\b/gi,
/\bmerci\b/gi,
// v4.1.20 : mots isolés qui restent parfois après les nettoyages ci-dessus
/\butilisateur\b/gi,
/\bb[ée]n[ée]ficiaire\b/gi
];
for (const rx of parasitePhrases) {
s = s.replace(rx, " ");
}
// Espaces multiples → un seul
s = s.replace(/\s{2,}/g, " ").trim();
// Ponctuation en bord
s = s.replace(/^[\s,;:.\-]+|[\s,;:.\-]+$/g, "").trim();
if (!s) return null;
// v4.1.8 : tronquer les commentaires parasites qui suivent le nom.
const words = s.split(/\s+/);
const keep = [];
for (let i = 0; i < words.length; i++) {
const w = words[i];
if (i === 0) { keep.push(w); continue; }
if (/^(de|da|du|van|von|le|la|del|di|der)$/i.test(w)) { keep.push(w); continue; }
if (keep.length >= 2 && /^[a-zéèêàâîôûç]/.test(w)) break;
if (keep.length >= 4) break;
keep.push(w);
}
s = keep.join(" ");
s = s.replace(/[\s,;:.\-]+$/, "").trim();
// v4.1.20 : dernier garde-fou : si le résultat final est juste un mot
// parasite (ex: "téléphone" tout seul, "appeler" tout seul), on retourne
// null plutôt qu'afficher un faux nom.
if (/^(t[ée]l[ée]phone|t[ée]l|appeler?|rappeler?|s[''`]?annoncer|bonjour|merci)$/i.test(s)) {
return null;
}
return s || null;
}
/**
* Split un lieu du type "Lausanne/Rue Caroline 9 bis" en
* { ville: "Lausanne", adresse: "Rue Caroline 9 bis" }
* Si format inconnu, retourne { ville: null, adresse: raw }.
*/
function splitLieu(raw) {
if (!raw) return { ville: null, adresse: null };
let s = String(raw).trim();
// Retirer un / final (avec ou sans espaces)
s = s.replace(/\s*\/\s*$/, "").trim();
if (!s) return { ville: null, adresse: null };
// 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"
// 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é.
const parts = s.split("/").map(p => p.trim()).filter(Boolean);
let ville, adresse;
if (parts.length === 0) {
return { ville: null, adresse: null };
} else if (parts.length === 1) {
// Pas de slash : tout est l'adresse
ville = null;
adresse = parts[0];
} else {
// 2+ parties : ville = 1ère, adresse = 2e, on ignore le reste
ville = parts[0];
adresse = parts[1];
}
// Capitaliser la 1ère lettre des mots de voie (Rue, Chemin, Route, Avenue,
// Boulevard, Place, Quai, Impasse + abréviations Av., Ch., Rte, Bd)
if (adresse) {
adresse = adresse.replace(
/\b(rue|chemin|route|avenue|boulevard|place|quai|impasse|ruelle|allée|allee|passage|sentier|av\.?|ch\.?|rte\.?|bd\.?)\b/gi,
(match) => {
if (/^[A-ZÉÈÀÂÎÔÛÇ]/.test(match)) return match;
return match.charAt(0).toUpperCase() + match.slice(1).toLowerCase();
}
);
}
return { ville: ville || null, adresse: adresse || null };
}
/**
* Extrait la "signature planificateur" de la description d'action.
* Formats acceptés : "ECM 16.04", "JKF 17.04", "AWR 13/04/26", "ECM 16.04.2026".
* Parcourt d'abord les lignes depuis la fin (si la signature est sur sa ligne),
* sinon cherche à la fin de la description entière.
* Retourne null si rien trouvé.
*/
/**
* Normalise une date trouvée dans une signature :
* - "27/03" → "27.03"
* - "27.03" → "27.03"
* - "10/04/26" → "10.04" (on retire l'année)
* - "13/04/2026" → "13.04"
*/
function normalizeSignatureDate(date) {
if (!date) return "";
// Prendre les 2 premiers blocs de chiffres (JJ et MM) et les joindre avec "."
const parts = String(date).split(/[./]/);
if (parts.length < 2) return date;
const dd = parts[0].padStart(2, "0");
const mm = parts[1].padStart(2, "0");
return `${dd}.${mm}`;
}
function extractPlanifSignature(actionText) {
if (!actionText) return null;
// Formater le texte d'abord pour avoir des lignes séparées
const text = formatActionTextMultiline(String(actionText)).trim();
// 1. Dernière ligne non vide : regarder si c'est une signature (avec ou sans date)
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
if (lines.length > 0) {
const last = lines[lines.length - 1];
// 1a. Lettres (majuscules OU minuscules) + date
// Ex: "FRD 07/04", "csh 27.03", "AWR 13/04/26", "JKF 17.04"
const mFull = last.match(/^([A-Za-z]{2,4})\s+(\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)$/);
if (mFull) {
return `${mFull[1].toUpperCase()} ${normalizeSignatureDate(mFull[2])}`;
}
// 1b. Juste les lettres seules (JKF, NDV) sur leur propre ligne
const mSolo = last.match(/^([A-Za-z]{2,4})$/);
if (mSolo) return mSolo[1].toUpperCase();
}
// 2. Sinon chercher la dernière signature "lettres + date" collée en fin
let lastMatch = null;
let m;
const rxGlobal = /([A-Za-z]{2,4})\s+(\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)/g;
while ((m = rxGlobal.exec(text)) !== null) {
lastMatch = { sigs: m[1], date: m[2], pos: m.index };
}
if (lastMatch && lastMatch.pos >= text.length - 100) {
return `${lastMatch.sigs.toUpperCase()} ${normalizeSignatureDate(lastMatch.date)}`;
}
return null;
}
// v4.1.1 : shortMeta() et buildMetaDom() supprimées (code mort, héritage v1).
// Le rendu actuel utilise renderLieuContactBlocks() + buildInterventionRow().
async function copyRef(ref, btn) {
if (!ref) return;
try {
await navigator.clipboard.writeText(ref);
btn.classList.add("copied");
btn.textContent = "✓";
setTimeout(() => { btn.classList.remove("copied"); btn.textContent = "📋"; }, 1200);
} catch {
alert("Référence : " + ref);
}
}
// ─── Rendu incrémental (v3.1) ───────────────────────────────────────────────
// Met à jour UNE ligne d'interventoin dans le DOM (après qu'un fetch fiche
// ait enrichi l'objet iv avec sa ref, son statut, etc.). Appelée par
// fetchAndUpdateIntervention pour afficher la ref dès qu'elle arrive, sans
// attendre que tous les workers aient fini ni re-rendre toute la vue.
//
// Doit rester en phase avec la structure DOM construite par
// buildInterventionRow (classes iv-ref-header, iv-status-check,
// intervention-copy, intervention-dot, timeline-slot...).
const ALL_COLOR_CLASSES = [
"color-livraison", "color-installation", "color-recup",
"color-remplacement", "color-incident", "color-rollout",
"color-reservation", "color-autre"
];
/**
* (Re)génère les blocs Lieu et Contact(s) dans le conteneur .iv-right.
* Supprime d'abord les anciens blocs (.iv-lieu-block + .iv-contact-line),
* puis insère les nouveaux AVANT le bloc .iv-bottom-line (si présent) pour
* conserver l'ordre d'affichage. Utilisé à la création ET lors de la
* mise à jour après fetch de la fiche.
*/
function renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, contactAnomalie) {
// Supprime les anciens blocs lieu/contact
rightCol.querySelectorAll(".iv-lieu-block, .iv-contact-line").forEach(el => el.remove());
const contacts = extractContacts(contactRaw);
const { ville, adresse } = splitLieu(lieuRaw);
// Point d'insertion : avant .iv-bottom-line (catégorie + signature), sinon à la fin
const anchor = rightCol.querySelector(".iv-bottom-line");
const insert = (el) => {
if (anchor) rightCol.insertBefore(el, anchor);
else rightCol.appendChild(el);
};
// ── Lieu : ville (MAJUSCULES GRAS) puis adresse (italique noir) ──────────
if (ville || adresse) {
const lieuBlock = document.createElement("div");
lieuBlock.className = "iv-lieu-block";
if (ville) {
const villeEl = document.createElement("div");
villeEl.className = "iv-lieu-ville";
villeEl.textContent = ville.toUpperCase();
lieuBlock.appendChild(villeEl);
}
if (adresse) {
const addrEl = document.createElement("div");
addrEl.className = "iv-lieu-adresse";
addrEl.textContent = adresse;
lieuBlock.appendChild(addrEl);
}
insert(lieuBlock);
}
// ── Contact(s) + téléphone — un par ligne si plusieurs ──────────────────
for (const c of contacts) {
if (!c.name && !c.phone) continue;
const contactEl = document.createElement("div");
contactEl.className = "iv-contact-line";
// v4.2 : si anomalie (les 2 champs Contact + Personne de contact existent
// dans l'action), afficher en rouge pour signaler à l'user de vérifier.
if (contactAnomalie) contactEl.classList.add("iv-contact-anomalie");
if (c.name) {
const nameSpan = document.createElement("span");
nameSpan.className = "iv-contact";
nameSpan.textContent = c.name;
contactEl.appendChild(nameSpan);
}
if (c.phone) {
if (c.name) {
const sep = document.createElement("span");
sep.className = "iv-sep";
sep.textContent = " | ";
contactEl.appendChild(sep);
}
const phoneSpan = document.createElement("span");
phoneSpan.className = "iv-phone";
phoneSpan.textContent = c.phone;
contactEl.appendChild(phoneSpan);
}
insert(contactEl);
}
}
function updateInterventionRow(iv) {
// Réservations : pas concerné (pas de fetch fiche pour elles)
if (iv.type === "AL-Reservation") return;
const row = document.querySelector(
`.intervention-v2[data-action-id="${iv.actionId}"]`
);
if (!row) return;
// Classes de statut sur la ligne
const sc = getStatusClass(iv);
row.classList.remove("status-closed", "status-resolved", "status-terminated");
if (sc) row.classList.add(sc);
// Classe de couleur sur la ligne (la pastille hérite via CSS)
const colorKey = deriveColorKey(iv);
row.classList.remove(...ALL_COLOR_CLASSES);
row.classList.add("color-" + colorKey);
// Ref (le titre gros en haut de la ligne)
const refEl = row.querySelector(".iv-ref-header");
if (refEl) {
if (iv.ref) {
refEl.textContent = iv.ref;
refEl.classList.remove("no-ref");
} else {
refEl.textContent = "—";
refEl.classList.add("no-ref");
}
}
// Check ✓ : ajouter/retirer/mettre à jour selon statut
let checkEl = row.querySelector(".iv-status-check");
if (sc) {
// v4.2.5 : ✓✓ pour clos/résolu, ✓ pour terminé tech
const isDouble = (sc === "status-closed" || sc === "status-resolved");
const desiredText = isDouble ? "✓✓" : "✓";
if (!checkEl) {
checkEl = document.createElement("div");
checkEl.className = "iv-status-check";
// Insérer après la ref (avant le bouton copier s'il existe)
const copy = row.querySelector(".intervention-copy");
if (copy) row.insertBefore(checkEl, copy);
else if (refEl && refEl.nextSibling) row.insertBefore(checkEl, refEl.nextSibling);
else row.appendChild(checkEl);
}
checkEl.textContent = desiredText;
checkEl.classList.toggle("double", isDouble);
} else if (checkEl) {
checkEl.remove();
}
// Bouton 📋 copier : ajouter si on a maintenant une ref et qu'il n'existe pas
let copyBtn = row.querySelector(".intervention-copy");
if (iv.ref && !copyBtn) {
copyBtn = document.createElement("button");
copyBtn.className = "intervention-copy";
copyBtn.type = "button";
copyBtn.title = "Copier la référence";
copyBtn.innerHTML = "📋";
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
copyRef(iv.ref, copyBtn);
});
row.appendChild(copyBtn);
}
// Catégorie affichée en bas (dépend de la ref pour Incident, etc.)
const catEl = row.querySelector(".iv-category");
if (catEl) catEl.textContent = deriveShortTitle(iv);
// v4.1.8 : signature planificateur (XXX JJ.MM). Si le texte fiche (complet)
// est arrivé, il peut maintenant fournir une signature que le xhr2 tronqué
// n'avait pas. On met à jour le span .iv-signature en conséquence.
const bottomEl = row.querySelector(".iv-bottom-line");
if (bottomEl) {
let sigEl = bottomEl.querySelector(".iv-signature");
const sig = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription);
if (sig) {
if (!sigEl) {
sigEl = document.createElement("span");
sigEl.className = "iv-signature";
bottomEl.appendChild(sigEl);
}
sigEl.textContent = sig;
} else if (sigEl) {
sigEl.remove();
}
}
// v4.1.2 : régénérer les blocs lieu/contact depuis les valeurs actuelles.
// Priorité à iv.infobulle (xhr2 lazy, vraies infos) puis attr1/attr2 (planif).
const rightCol = row.querySelector(".iv-right");
if (rightCol) {
const info = iv.infobulle || {};
const contactRaw = info.contact || iv.bulleContact || null;
const lieuRaw = info.lieu || iv.bulleLieu || null;
renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, info.contactAnomalie);
}
// Segment timeline correspondant : même couleur + même classe statut
const card = row.closest(".card");
if (card && row.dataset.ivIdx !== undefined) {
const slot = card.querySelector(
`.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]`
);
if (slot) {
slot.classList.remove("status-closed", "status-resolved", "status-terminated", ...ALL_COLOR_CLASSES);
slot.classList.add("color-" + colorKey);
if (sc) slot.classList.add(sc);
// Maj du dataset pour le popover (titre + ref)
slot.dataset.title = deriveShortTitle(iv);
if (iv.ref) slot.dataset.ref = iv.ref;
}
}
}
// ============================================================================
// Tooltip
// ============================================================================
const tooltipEl = () => document.getElementById("tooltip");
// v4.1.10 : état persistant de la bulle
// - pinned : une fois épinglée (double Ctrl), la bulle reste à sa position,
// ne suit plus la souris, et ne se ferme ni au mouseleave ni au
// mouseleave suivant. On peut sélectionner le texte dedans.
// Clic hors bulle (ailleurs que sur une autre intervention) ou
// nouveau double-Ctrl → désépingle.
// - hoveredInBulle : si la souris entre DANS la bulle elle-même, la bulle
// reste visible même si elle n'est pas épinglée. Elle ne
// disparaît que quand la souris sort à la fois de la carte ET
// de la bulle.
let bulleState = {
pinned: false,
hoveredInBulle: false,
hoveredInRow: false,
hideTimer: null
};
function showTooltip(e, iv, rowEl) {
// v2026.5.19 : pendant qu'un popup épinglé est en cours de drag, on ignore
// les mouseenter sur les cartes — sinon en survolant une carte on déclenche
// l'ouverture d'un nouveau tooltip par-dessus ce qu'on est en train de bouger.
if (state._popupDragging) return;
// v4.1.15 : si la bulle est épinglée sur une autre iv, on NE REMPLACE PAS
// son contenu (l'user veut garder la fiche épinglée même en survolant
// d'autres cartes).
if (bulleState.pinned && state.currentTooltipIv && state.currentTooltipIv !== iv) {
return;
}
const el = tooltipEl();
el.innerHTML = buildTooltipHTML(iv);
el.classList.remove("hidden");
el.classList.add("visible");
// Conserver le pinned si on revient sur la même iv
if (bulleState.pinned && state.currentTooltipIv === iv) {
el.classList.add("pinned");
} else {
el.classList.remove("pinned");
}
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
}
bulleState.hoveredInRow = true;
// v4.1.12 : positionner la bulle UNE SEULE FOIS au mouseenter, près de la
// carte (row) et pas du curseur. Elle ne bouge plus pdt le survol.
// v4.1.15 : si pinned, NE PAS repositionner (la bulle doit rester fixe).
if (!bulleState.pinned) {
positionTooltipAnchored(rowEl || (e && e.currentTarget));
}
// v4 : lazy-load du texte complet de l'action au premier hover.
// Sans await : on affiche le tooltip IMMÉDIATEMENT avec ce qu'on a (lieu,
// contact, catégorie, ref venant du XML) ; quand le xhr2 arrive (50-200 ms
// plus tard typiquement), on régénère le tooltip s'il est encore visible.
if (iv && iv.type === "AL-Intervention" && !iv.xhr2Fetched && !iv.xhr2Fetching) {
ensureBulleDescription(iv).then(ok => {
// Si ça a marché ET que le tooltip est toujours visible sur CETTE iv,
// on régénère le HTML pour afficher les détails Problème/À faire/Matériel.
if (!ok) return;
const tip = tooltipEl();
if (!tip.classList.contains("visible")) return;
// Vérifie qu'on affiche toujours la même interventoin (pas un autre hover
// intervenu entretemps)
if (state.currentTooltipIv === iv) {
tip.innerHTML = buildTooltipHTML(iv);
}
});
}
// Mémoriser quelle iv est actuellement affichée (utilisé pour éviter
// d'écraser un tooltip différent si un autre hover s'est produit entretemps)
state.currentTooltipIv = iv;
}
function hideTooltip(opts = {}) {
// Si la bulle est épinglée, on ignore (sauf force: true = unpin explicite)
if (bulleState.pinned && !opts.force) return;
bulleState.hoveredInRow = false;
// Petit délai : laisse le temps à la souris d'ENTRER dans la bulle elle-même
// (si l'user veut sélectionner du texte). On annule la fermeture si
// hoveredInBulle passe à true entre-temps.
if (bulleState.hideTimer) clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = setTimeout(() => {
if (bulleState.hoveredInBulle || bulleState.hoveredInRow) return;
if (bulleState.pinned) return;
// v4.2 : si l'utilisateur a une sélection de texte ACTIVE dans la bulle,
// on ne ferme pas (sinon la sélection disparaît avant d'avoir pu copier).
if (!opts.force && hasTextSelectionInTooltip()) return;
const el = tooltipEl();
el.classList.remove("visible", "pinned");
el.classList.add("hidden");
// v4.2.4 : reset du mode d'ancrage et de la détection de position
if (el.dataset) {
delete el.dataset.mode;
}
state.currentTooltipIv = null;
currentTooltipPos = null;
tooltipPositionMode = null; // re-détecter à la prochaine ouverture
}, 1000); // v2026.5.17 : délai 1s au lieu de 120ms pour laisser le temps
// à l'user d'atteindre le popup depuis la carte
}
// v4.2 : détecte si l'utilisateur a une sélection de texte active dans la bulle.
// Utilisé pour empêcher la fermeture automatique tant qu'on n'a pas fini de
// sélectionner/copier.
function hasTextSelectionInTooltip() {
try {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return false;
const tip = tooltipEl();
if (!tip) return false;
const range = sel.getRangeAt(0);
// La sélection est dans la bulle si au moins un endpoint y est
return tip.contains(range.startContainer) || tip.contains(range.endContainer);
} catch {
return false;
}
}
function moveTooltip(e) {
// Historique : avant on suivait la souris. Maintenant la bulle est fixe
// (placée une seule fois au mouseenter). Cette fonction est là juste pour
// pas casser les appels existants.
}
// ============================================================================
// Positionnement du tooltip
// ============================================================================
// On positionne avec style.left/top en coords VIEWPORT (comme position:fixed).
// Si un ancêtre casse position:fixed (transform, filter, backdrop-filter ou
// contain), on détecte ça empiriquement au 1er placement via
// getBoundingClientRect — et on bascule en "abs" : mêmes coords mais on
// compense le scroll manuellement pour garder la bulle stable à l'écran.
// ============================================================================
// Position stockée : targetLeft / targetTop = coordonnées VIEWPORT désirées
// (où la popup doit apparaître à l'écran, peu importe le scroll).
let currentTooltipPos = null;
// Mode de positionnement, détecté empiriquement :
// null : pas encore détecté
// "fixed" : position:fixed marche → on laisse le navigateur gérer au scroll
// "abs" : position:fixed cassée → on compense manuellement au scroll
let tooltipPositionMode = null;
function setTooltipViewportPosition(viewportX, viewportY) {
const el = tooltipEl();
if (!el) return;
currentTooltipPos = { x: viewportX, y: viewportY };
// Appliquer la position en supposant que position:fixed marche
el.style.left = viewportX + "px";
el.style.top = viewportY + "px";
// Détection empirique au 1er positionnement : on compare la position
// réelle du tooltip (getBoundingClientRect) à la position demandée.
// Si ça correspond (à 1px près), position:fixed fonctionne. Sinon
// c'est qu'un ancêtre a cassé le containing block.
if (tooltipPositionMode === null) {
const r = el.getBoundingClientRect();
const deltaX = Math.abs(r.left - viewportX);
const deltaY = Math.abs(r.top - viewportY);
if (deltaX <= 1 && deltaY <= 1) {
tooltipPositionMode = "fixed";
} else {
tooltipPositionMode = "abs";
console.info(
"[tooltip] position:fixed cassée par un ancêtre, passage en mode compensé au scroll. " +
`delta=(${deltaX.toFixed(1)}, ${deltaY.toFixed(1)})`
);
}
}
// Si mode "abs" : le top/left qu'on vient de poser est en réalité interprété
// par rapport au containing block (pas le viewport). On doit compenser
// immédiatement pour placer la popup au bon endroit visuellement.
if (tooltipPositionMode === "abs") {
const r = el.getBoundingClientRect();
const offsetX = viewportX - r.left; // écart à corriger
const offsetY = viewportY - r.top;
// Nouvelle valeur absolute qui produit la position viewport voulue
const absLeft = parseFloat(el.style.left) + offsetX;
const absTop = parseFloat(el.style.top) + offsetY;
el.style.left = absLeft + "px";
el.style.top = absTop + "px";
// Mémoriser pour compenser au scroll
el._absBasisLeft = absLeft;
el._absBasisTop = absTop;
el._absBasisScrollX = window.scrollX || window.pageXOffset || 0;
el._absBasisScrollY = window.scrollY || window.pageYOffset || 0;
}
}
// Listener global scroll : si on est en mode "abs", on compense pour que la
// popup reste visuellement au même endroit pendant le scroll.
function reapplyTooltipPosition() {
if (!currentTooltipPos) return;
const el = tooltipEl();
if (!el || !el.classList.contains("visible")) return;
if (tooltipPositionMode !== "abs") return; // fixed marche, rien à faire
// 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.
const scrollX = window.scrollX || window.pageXOffset || 0;
const scrollY = window.scrollY || window.pageYOffset || 0;
const dx = scrollX - (el._absBasisScrollX || 0);
const dy = scrollY - (el._absBasisScrollY || 0);
el.style.left = ((el._absBasisLeft || 0) + dx) + "px";
el.style.top = ((el._absBasisTop || 0) + dy) + "px";
}
function positionTooltipAnchored(rowEl) {
const el = tooltipEl();
if (!rowEl || !el) return;
const pad = 14;
const rowRect = rowEl.getBoundingClientRect();
const tipRect = el.getBoundingClientRect();
// Position X : à droite de la ligne par défaut
let x = rowRect.right + pad;
if (x + tipRect.width > window.innerWidth - 8) {
x = rowRect.left - tipRect.width - pad;
}
if (x < 4) x = 4;
// Position Y : aligné en haut de la ligne
let y = rowRect.top;
if (y + tipRect.height > window.innerHeight - 8) {
y = window.innerHeight - tipRect.height - 8;
}
if (y < 4) y = 4;
// v2026.5.17 : éviter le chevauchement avec les popups épinglés existants.
// On teste la position candidate, et si elle chevauche un popup épinglé,
// on essaie d'autres candidats (gauche de la carte, au-dessous, au-dessus).
const tipW = tipRect.width || 320;
const tipH = tipRect.height || 200;
const pinnedRects = _getPinnedPopupsViewportRects();
if (pinnedRects.length) {
const candidates = [
{ x, y, label: "right" },
{ x: rowRect.left - tipW - pad, y: rowRect.top, label: "left" },
{ x: rowRect.left, y: rowRect.bottom + pad, label: "below" },
{ x: rowRect.left, y: rowRect.top - tipH - pad, label: "above" }
];
for (const c of candidates) {
// Borne dans le viewport
if (c.x < 4) c.x = 4;
if (c.x + tipW > window.innerWidth - 8) c.x = window.innerWidth - tipW - 8;
if (c.y < 4) c.y = 4;
if (c.y + tipH > window.innerHeight - 8) c.y = window.innerHeight - tipH - 8;
const testRect = { left: c.x, top: c.y, right: c.x + tipW, bottom: c.y + tipH };
const overlaps = pinnedRects.some(pr => _rectsOverlap(testRect, pr));
if (!overlaps) {
x = c.x; y = c.y;
break;
}
}
}
setTooltipViewportPosition(x, y);
}
/**
* v2026.5.17 : retourne les rectangles (en coords viewport) de tous les popups
* actuellement épinglés et visibles (non réduits). Utilisé pour anti-chevauchement.
*/
function _getPinnedPopupsViewportRects() {
const rects = [];
document.querySelectorAll(".pinned-popup").forEach(p => {
if (p.classList.contains("pinned-popup-reduced")) return; // docké, pas à l'écran
const r = p.getBoundingClientRect();
if (r.width > 0 && r.height > 0) rects.push(r);
});
return rects;
}
// ============================================================================
// v4.3.0 : système de popups épinglés détachés
// ============================================================================
//
// Au lieu d'épingler le tooltip unique (qui empêchait d'afficher d'autres
// infos au survol), on clone son contenu en un popup indépendant :
// - Ancré DANS le contenu de la page (position: absolute + coordonnées
// document) → scrolle avec le contenu, pas avec le viewport.
// - Peut coexister avec d'autres popups épinglés (jusqu'à ce qu'il n'y
// ait plus de place disponible).
// - Persiste jusqu'à fermeture explicite (bouton ×, Échap, ou Ctrl×2 si 1 seul).
//
// Le tooltip live (#tooltip) garde son rôle initial : il se ferme au mouseleave.
const pinnedPopups = []; // [{el, iv, rect}]
/**
* Ancre la popup au contenu : ajoute le scrollY actuel au top viewport pour
* obtenir une position absolute document, qui scrolle avec le contenu.
*/
function _viewportToDocumentY(y) {
return y + (window.scrollY || window.pageYOffset || 0);
}
function _viewportToDocumentX(x) {
return x + (window.scrollX || window.pageXOffset || 0);
}
/**
* Teste si un rectangle {left, top, right, bottom} (en coords document)
* chevauche avec un popup déjà épinglé.
*/
function _rectsOverlap(a, b) {
return !(a.right <= b.left || a.left >= b.right ||
a.bottom <= b.top || a.top >= b.bottom);
}
/**
* Cherche une position libre pour un popup de dimensions {w, h} près de la
* ligne source `rowEl`. Essaie dans l'ordre : droite, gauche, dessous, dessus.
* Retourne {x, y} en coordonnées document, ou null si aucune position libre.
*/
function _findFreePopupPosition(rowEl, w, h) {
const pad = 14;
const rowRect = rowEl.getBoundingClientRect();
// v2026.5.20 : utiliser la safe area (en dessous topbar, au-dessus dock)
const safe = _getPopupSafeArea();
// 4 candidats d'abord, autour de la row source (en coords viewport)
const candidates = [
{ x: rowRect.right + pad, y: rowRect.top, name: "droite" },
{ x: rowRect.left - w - pad, y: rowRect.top, name: "gauche" },
{ x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" },
{ x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" }
];
// v2026.5.20 : ajouter une grille de positions de fallback couvrant toute
// la safe area (pas de 60px × 60px) — garantit qu'on trouve ~toujours une
// place, sauf si vraiment trop de popups actifs.
const availW = safe.right - safe.left;
const availH = safe.bottom - safe.top;
if (availW > w + 20 && availH > h + 20) {
for (let y = safe.top; y + h <= safe.bottom; y += 60) {
for (let x = safe.left; x + w <= safe.right; x += 60) {
candidates.push({ x, y, name: "grid" });
}
}
}
// Tester chaque candidat dans l'ordre
for (const c of candidates) {
let x = c.x, y = c.y;
// Clamp dans la safe area
if (x < safe.left) x = safe.left;
if (x + w > safe.right) x = safe.right - w;
if (x < safe.left) continue; // popup plus large que safe area
if (y < safe.top) y = safe.top;
if (y + h > safe.bottom) y = safe.bottom - h;
if (y < safe.top) continue;
// Si, après clamp, la popup chevaucherait la ligne source elle-même,
// on ignore ce candidat (on préfère une direction qui la laisse visible).
const rowRectClamped = {
left: rowRect.left, top: rowRect.top,
right: rowRect.right, bottom: rowRect.bottom
};
const candRect = { left: x, top: y, right: x + w, bottom: y + h };
// v2026.5.20 : pour les 4 candidats principaux, on refuse de chevaucher
// la row ; pour les candidats "grid" de fallback, on l'accepte
// (on veut une place à tout prix).
if (c.name !== "grid" && _rectsOverlap(candRect, rowRectClamped)) continue;
// Test chevauchement avec les popups déjà épinglés (coords document)
const docRect = {
left: _viewportToDocumentX(x),
top: _viewportToDocumentY(y),
right: _viewportToDocumentX(x + w),
bottom: _viewportToDocumentY(y + h)
};
let overlapsOther = false;
for (const p of pinnedPopups) {
// Ne pas comparer avec un popup qui est dans le dock (réduit)
if (p.el && p.el.classList && p.el.classList.contains("pinned-popup-reduced")) continue;
if (_rectsOverlap(docRect, p.rect)) {
overlapsOther = true;
break;
}
}
if (!overlapsOther) {
return {
viewportX: x, viewportY: y,
docX: docRect.left, docY: docRect.top,
rect: docRect
};
}
}
// v2026.5.20 : ultime fallback — accepter de chevaucher mais décaler
// un peu par rapport au 1er popup épinglé existant. Évite complètement
// le "Pas de place" injuste.
if (pinnedPopups.length > 0) {
const last = pinnedPopups[pinnedPopups.length - 1];
let x = (last.rect.left - (window.scrollX || 0)) + 30;
let y = (last.rect.top - (window.scrollY || 0)) + 30;
if (x + w > safe.right) x = safe.right - w;
if (y + h > safe.bottom) y = safe.bottom - h;
if (x < safe.left) x = safe.left;
if (y < safe.top) y = safe.top;
const docRect = {
left: _viewportToDocumentX(x),
top: _viewportToDocumentY(y),
right: _viewportToDocumentX(x + w),
bottom: _viewportToDocumentY(y + h)
};
return {
viewportX: x, viewportY: y,
docX: docRect.left, docY: docRect.top,
rect: docRect
};
}
return null;
}
/**
* v4.3.0 : épingle la bulle courante en la clonant dans un popup détaché
* ancré au contenu. Le tooltip live redevient disponible.
*/
function pinTooltip() {
if (!state.currentTooltipIv) return;
const srcEl = tooltipEl();
if (!srcEl) return;
const iv = state.currentTooltipIv;
// v2026.5.21 : unicité actionId + date. Si un popup pour la même ref
// ET la même date est déjà épinglé, on le supprime et on re-crée un nouveau
// (user a choisi ce comportement : "tu supprime le popup actuellement
// épinglé et tu répingle la nouvelle fenêtre").
const currentDate = state.currentDate || "";
const existingKey = (iv.actionId || "") + "|" + currentDate;
for (let i = pinnedPopups.length - 1; i >= 0; i--) {
const p = pinnedPopups[i];
if (!p || !p.el) continue;
const aid = p.el.dataset.actionId || "";
const d = p.el.dataset.originDate || "";
if (aid + "|" + d === existingKey) {
// Retirer l'ancien (popup + pastille dock éventuelle)
if (p.el._linkedPill) {
try { p.el._linkedPill.remove(); } catch (e) {}
}
try { p.el.remove(); } catch (e) {}
pinnedPopups.splice(i, 1);
}
}
// Nettoyer un éventuel dock devenu vide
const dockEl = document.getElementById("pinned-popups-dock");
if (dockEl && dockEl.querySelectorAll(".pinned-popup-dock-pill").length === 0) {
dockEl.classList.remove("visible");
const closeAllBtn = document.getElementById("pinned-popups-close-all");
if (closeAllBtn) closeAllBtn.remove();
}
// Chercher la ligne source (row iv-v2)
let rowEl = null;
if (iv.actionId) {
rowEl = document.querySelector(`.intervention-v2[data-action-id="${iv.actionId}"]`);
}
if (!rowEl) {
// Fallback : utiliser la position actuelle du tooltip live
rowEl = srcEl;
}
// Cloner le contenu du tooltip actuel en popup détaché
const popup = document.createElement("div");
popup.className = "tooltip pinned-popup visible";
popup.dataset.actionId = iv.actionId || "";
popup.innerHTML = srcEl.innerHTML;
// v2026.5.18 : mémoriser la ref et la couleur pour le dock (pastille avec
// couleur de catégorie + texte ref)
popup.dataset.ref = iv.ref || "";
popup.dataset.colorKey = (typeof deriveColorKey === "function" ? deriveColorKey(iv) : "autre") || "autre";
// v2026.5.19 : mémoriser aussi la date pour l'afficher sur la pastille dock
popup.dataset.originDate = state.currentDate || "";
// v2026.5.17 : masquer l'icône 📌 du contenu cloné (redondante car le
// popup a sa propre topbar avec le bouton "désépingler" 📍 explicite)
const oldPin = popup.querySelector('.tooltip-pinbtn[data-action="pin"]');
if (oldPin) oldPin.remove();
// v2026.5.17 : topbar avec 3 boutons pour un popup épinglé :
// v2026.5.18 : swap des actions — _ réduit dans le dock, ▭ minimise flottant
// _ = Réduire (docké dans la taskbar du bas)
// ▭ = Minimiser (popup reste flottant mais compact, juste la ref)
// 📍 = Désépingler (l'icône d'épingle "plantée" ; clic = retire l'épingle)
const topbar = document.createElement("div");
topbar.className = "pinned-popup-topbar";
// Bouton Réduire (icône _ )
const reduceBtn = document.createElement("button");
reduceBtn.type = "button";
reduceBtn.className = "pinned-popup-btn pinned-popup-reduce";
reduceBtn.innerHTML = "_";
reduceBtn.title = "Réduire (docké en bas de l'écran)";
reduceBtn.addEventListener("click", (e) => {
e.stopPropagation();
_reducePinnedPopup(popup);
});
topbar.appendChild(reduceBtn);
// Bouton Minimiser (icône ▭ )
const minBtn = document.createElement("button");
minBtn.type = "button";
minBtn.className = "pinned-popup-btn pinned-popup-minimize";
minBtn.innerHTML = "▭";
minBtn.title = "Minimiser (reste flottant mais compact)";
minBtn.addEventListener("click", (e) => {
e.stopPropagation();
_minimizePinnedPopup(popup);
});
topbar.appendChild(minBtn);
// v2026.5.19 : Bouton Actualiser (même icône SVG que le tooltip standard)
// Re-fetch la fiche de l'intervention pour mettre à jour les infos (statut,
// commentaires, action text) sans recharger le planning entier.
const refreshBtn = document.createElement("button");
refreshBtn.type = "button";
refreshBtn.className = "pinned-popup-btn pinned-popup-refresh";
refreshBtn.innerHTML = '<svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8a6 6 0 1 0 1.76-4.24M2 3v3h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
refreshBtn.title = "Actualiser les informations de cette intervention";
refreshBtn.addEventListener("click", async (e) => {
e.stopPropagation();
if (refreshBtn.classList.contains("spinning")) return;
refreshBtn.classList.add("spinning");
try {
await _refreshPinnedPopupIv(popup, iv);
} finally {
setTimeout(() => refreshBtn.classList.remove("spinning"), 300);
}
});
topbar.appendChild(refreshBtn);
// Bouton Désépingler (icône épingle plantée)
const unpinBtn = document.createElement("button");
unpinBtn.type = "button";
unpinBtn.className = "pinned-popup-btn pinned-popup-unpin";
unpinBtn.innerHTML = "📍";
unpinBtn.title = "Désépingler (se ferme quand la souris sort)";
unpinBtn.addEventListener("click", (e) => {
e.stopPropagation();
_softUnpinPopup(popup);
});
topbar.appendChild(unpinBtn);
popup.appendChild(topbar);
// v4.3.3 : barre de drag en haut, pour déplacer la popup à la souris.
// Ancrée en haut à 22px de haut ; le padding-top de la popup est augmenté
// côté CSS pour ne pas que le contenu soit caché derrière.
const dragbar = document.createElement("div");
dragbar.className = "pinned-popup-dragbar";
dragbar.title = "Glissez pour déplacer";
popup.appendChild(dragbar);
_attachPopupDragHandler(popup, dragbar);
// 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.
popup.addEventListener("click", (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === "pin") {
e.stopPropagation();
e.preventDefault();
_softUnpinPopup(popup);
}
// 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
popup.style.position = "absolute";
popup.style.left = "-9999px";
popup.style.top = "-9999px";
popup.style.visibility = "hidden";
document.body.appendChild(popup);
// Mesurer après rendu
const pRect = popup.getBoundingClientRect();
const w = pRect.width;
const h = pRect.height;
// Chercher une position libre
const pos = _findFreePopupPosition(rowEl, w, h);
if (!pos) {
// Pas de place : retirer et afficher un toast
popup.remove();
showToast("Pas de place", "Fermez une popup épinglée");
return;
}
// Appliquer la position (coords document = position: absolute)
popup.style.left = pos.docX + "px";
popup.style.top = pos.docY + "px";
popup.style.visibility = "visible";
// v2026.5.20 : clamper dans la safe area (topbar + dock)
_clampPopupInSafeArea(popup);
// Enregistrer dans la liste
pinnedPopups.push({ el: popup, iv: iv, rect: pos.rect });
// v4.3.0 : libérer le tooltip live (il redevient utilisable pour d'autres survols)
bulleState.pinned = false;
bulleState.hoveredInRow = false;
bulleState.hoveredInBulle = false;
srcEl.classList.remove("visible", "pinned");
srcEl.classList.add("hidden");
if (srcEl.dataset) delete srcEl.dataset.mode;
state.currentTooltipIv = null;
currentTooltipPos = null;
tooltipPositionMode = null;
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
}
}
/** Ferme un popup épinglé donné. */
function _closePinnedPopup(el) {
const idx = pinnedPopups.findIndex(p => p.el === el);
if (idx >= 0) pinnedPopups.splice(idx, 1);
el.remove();
}
/**
* v2026.5.19 : re-fetch les infos d'une intervention et met à jour le contenu
* du popup épinglé correspondant. Utilise fetchAndUpdateIntervention qui fait
* xhr2 + fiche, puis régénère le HTML du tooltip avec buildTooltipHTML.
*/
async function _refreshPinnedPopupIv(popup, iv) {
if (!popup || !iv) return;
try {
// Forcer le refetch : on invalide les flags qui disent "déjà fetché"
iv.xhr2Fetched = false;
iv.xhr2Fetching = false;
iv.ficheFetched = false;
iv.ficheFetching = false;
// Token de refresh actuel (pour que fetchAndUpdateIntervention ne soit
// pas abortée par les checks isRefreshAborted)
const token = (typeof currentRefreshToken !== "undefined") ? currentRefreshToken : 0;
await fetchAndUpdateIntervention(iv, token);
// Régénérer le HTML du tooltip avec les nouvelles infos.
// On doit réinjecter juste le contenu, en gardant la topbar et la dragbar
// (qui ne sont PAS dans le tooltip source, elles sont propres au popup).
const topbar = popup.querySelector(".pinned-popup-topbar");
const dragbar = popup.querySelector(".pinned-popup-dragbar");
const newHtml = buildTooltipHTML(iv);
popup.innerHTML = newHtml;
// Virer aussi la vieille icône 📌 si elle revient dans le rebuild
const oldPin = popup.querySelector('.tooltip-pinbtn[data-action="pin"]');
if (oldPin) oldPin.remove();
// Remettre topbar et dragbar
if (topbar) popup.appendChild(topbar);
if (dragbar) popup.appendChild(dragbar);
} catch (err) {
console.warn("[refresh-popup]", err);
}
}
/**
* Désépinglage "mou" : la popup n'est plus considérée épinglée (elle n'est
* plus dans pinnedPopups, donc le comptage pour Ctrl×2 etc. ignore) mais on
* la laisse visible. Elle disparait quand la souris sort.
*/
function _softUnpinPopup(el) {
// Retirer de la liste (pour le comptage Ctrl×2) mais garder le DOM
const idx = pinnedPopups.findIndex(p => p.el === el);
if (idx >= 0) pinnedPopups.splice(idx, 1);
// v4.3.3 corr : basculer visuellement en tooltip normal (retirer tous les
// attributs visuels du mode épinglé : bordure bleue, dragbar, bouton ×,
// padding-top, etc.). La classe .soft-unpinned fait ça côté CSS.
// On retire .pinned-popup pour que les règles visuelles lourdes
// disparaissent, tout en gardant la popup au même endroit (position
// absolute conservée).
el.classList.remove("pinned-popup");
el.classList.add("soft-unpinned");
// Icône 📌 → 📍 pour le clin d'œil (même si elle va bientôt disparaitre)
const pinBtn = el.querySelector('[data-action="pin"]');
if (pinBtn) pinBtn.textContent = "📍";
// Supprimer les éléments propres au mode épinglé : barre de drag et ×
const dragbar = el.querySelector(".pinned-popup-dragbar");
if (dragbar) dragbar.remove();
const closeBtn = el.querySelector(".pinned-popup-close");
if (closeBtn) closeBtn.remove();
// v2026.5.17 : retirer aussi la nouvelle topbar et le conteneur minimisé
const topbar = el.querySelector(".pinned-popup-topbar");
if (topbar) topbar.remove();
el.classList.remove("pinned-popup-minimized");
el.classList.remove("pinned-popup-reduced");
// v2026.5.18 : retirer aussi la pastille du dock si elle existe
if (el._linkedPill) {
try { el._linkedPill.remove(); } catch (e) {}
el._linkedPill = null;
}
// Si le dock est vide, le cacher ; mettre à jour le bouton "Fermer tous"
const dock = document.getElementById("pinned-popups-dock");
if (dock && dock.querySelectorAll(".pinned-popup-dock-pill").length === 0) {
dock.classList.remove("visible");
const closeAllBtn = document.getElementById("pinned-popups-close-all");
if (closeAllBtn) closeAllBtn.remove();
} else {
_ensureDockCloseAllBtn();
}
// v2026.5.22 : si le tooltip hover est actuellement affiché pour la même
// intervention que celle qu'on désépingle, il faut regénérer son HTML pour
// que l'icône passe de 📍 (active rouge) à 📌 (non active) — sinon l'user
// voit l'ancienne icône et croit qu'il est toujours épinglé.
const tip = tooltipEl();
if (tip && tip.classList.contains("visible") && state.currentTooltipIv) {
tip.innerHTML = buildTooltipHTML(state.currentTooltipIv);
}
// Helper qui joue l'animation de sortie puis supprime le DOM
const animateAndRemove = () => {
el.classList.add("unpinning");
setTimeout(() => el.remove(), 180);
};
if (!el.matches(":hover")) {
animateAndRemove();
return;
}
// Souris dessus : on ne supprime pas tout de suite. On attend mouseleave
// et à ce moment on joue l'animation de sortie et on supprime.
el.addEventListener("mouseleave", animateAndRemove, { once: true });
}
// ============================================================================
// v2026.5.17 : États d'un popup épinglé
// - Normal (complet, flottant)
// - Minimisé (compact, flottant, juste la ref + topbar)
// - Réduit (docké dans la taskbar en bas de l'écran)
// ============================================================================
/**
* Passe un popup épinglé en mode Minimisé : on ne montre plus que la ref,
* dans un petit cadre flottant toujours drag-able.
*
* v2026.5.19 : au lieu de masquer tout le contenu via CSS et tenter de
* réafficher la ref (fragile), on crée un élément dédié `.pinned-popup-minref`
* qui contient juste la ref + la date. Cet élément est ajouté/retiré au besoin.
*/
function _minimizePinnedPopup(popup) {
if (!popup) return;
popup.classList.add("pinned-popup-minimized");
// Adapter les boutons topbar : [▭] devient [⬆] (agrandir)
const minBtn = popup.querySelector(".pinned-popup-minimize");
if (minBtn) {
minBtn.innerHTML = "⬆";
minBtn.title = "Agrandir";
// On retire les anciens listeners en clonant l'élément
const newBtn = minBtn.cloneNode(true);
minBtn.replaceWith(newBtn);
newBtn.addEventListener("click", (e) => {
e.stopPropagation();
_expandPinnedPopup(popup);
});
}
// Créer un élément dédié pour afficher la ref en mode minimisé
let minRef = popup.querySelector(".pinned-popup-minref");
if (!minRef) {
minRef = document.createElement("div");
minRef.className = "pinned-popup-minref";
const refText = popup.dataset.ref || "(sans ref)";
minRef.textContent = refText;
minRef.title = "Cliquer pour agrandir";
minRef.addEventListener("click", (e) => {
e.stopPropagation();
_expandPinnedPopup(popup);
});
popup.appendChild(minRef);
}
}
/**
* Repasse un popup minimisé en mode Normal (complet).
*/
function _expandPinnedPopup(popup) {
if (!popup) return;
popup.classList.remove("pinned-popup-minimized");
// Restaurer bouton Minimiser
const minBtn = popup.querySelector(".pinned-popup-minimize");
if (minBtn) {
minBtn.innerHTML = "▭";
minBtn.title = "Minimiser (reste flottant mais compact)";
const newBtn = minBtn.cloneNode(true);
minBtn.replaceWith(newBtn);
newBtn.addEventListener("click", (e) => {
e.stopPropagation();
_minimizePinnedPopup(popup);
});
}
// Retirer l'élément ref dédié (s'il existe)
const minRef = popup.querySelector(".pinned-popup-minref");
if (minRef) minRef.remove();
}
/**
* Passe un popup épinglé en mode Réduit : il disparaît de son emplacement
* flottant et vient s'ajouter dans une taskbar en bas de l'écran sous forme
* de pastille cliquable.
*/
function _reducePinnedPopup(popup) {
if (!popup) return;
// Récupérer la référence pour le label de la pastille
// v2026.5.18 : préférer le dataset.ref mémorisé à la création plutôt que
// le textContent (qui peut contenir "—" si la ref n'était pas encore
// disponible à l'épinglage)
const refEl = popup.querySelector(".iv-ref-header");
const label = popup.dataset.ref
|| (refEl ? (refEl.textContent || "").trim() : "")
|| "Popup";
const colorKey = popup.dataset.colorKey || "autre";
// S'assurer que la taskbar du bas existe
let dock = document.getElementById("pinned-popups-dock");
if (!dock) {
dock = document.createElement("div");
dock.id = "pinned-popups-dock";
dock.className = "pinned-popups-dock";
document.body.appendChild(dock);
}
// Créer la pastille dock
// v2026.5.18 : le fond de la pastille prend la couleur de catégorie
// (via la classe color-XXX déjà utilisée ailleurs dans le CSS)
// v2026.5.19 : pastille à 2 lignes — ref (gras) + date origine (petit)
const pill = document.createElement("button");
pill.type = "button";
pill.className = "pinned-popup-dock-pill color-" + colorKey;
pill.title = "Cliquer pour agrandir";
const pillRef = document.createElement("span");
pillRef.className = "pinned-popup-dock-pill-ref";
pillRef.textContent = label;
pill.appendChild(pillRef);
// Date d'origine (ex: "21.04")
const originDate = popup.dataset.originDate || "";
if (originDate) {
const pillDate = document.createElement("span");
pillDate.className = "pinned-popup-dock-pill-date";
pillDate.textContent = _formatDateShort(originDate);
pill.appendChild(pillDate);
}
// Mémoriser la position/taille du popup avant de le masquer
const rect = popup.getBoundingClientRect();
popup.dataset.prevLeft = popup.style.left || (rect.left + "px");
popup.dataset.prevTop = popup.style.top || (rect.top + "px");
popup.dataset.prevWidth = popup.style.width || "";
// Cacher le popup (on le garde en DOM pour conserver son état et restaurer
// instantanément)
popup.classList.add("pinned-popup-reduced");
// Associer pill ↔ popup
pill._linkedPopup = popup;
popup._linkedPill = pill;
pill.addEventListener("click", (e) => {
e.stopPropagation();
_restorePinnedPopupFromDock(popup);
});
// v2026.5.20 : mini-menu au survol (Agrandir / Fermer)
pill.addEventListener("mouseenter", () => {
_showPillHoverMenu(pill, popup);
});
pill.addEventListener("mouseleave", (e) => {
// Le menu peut être sous la souris — on ne ferme pas si on entre dans le menu
_schedulePillMenuClose();
});
dock.appendChild(pill);
dock.classList.add("visible");
// v2026.5.18 : s'assurer qu'il y a un bouton "Fermer tous" si 2+ popups
_ensureDockCloseAllBtn();
// v2026.5.20 : le dock qui apparaît peut chevaucher des popups flottants —
// les reclamper pour qu'ils restent dans la safe area.
_reclampAllFloatingPopups();
}
/**
* v2026.5.20 : affiche un mini-menu au-dessus d'une pastille dock au survol.
* Contient 2 actions : Agrandir, Fermer.
*/
let _pillMenuCloseTimer = null;
function _showPillHoverMenu(pill, popup) {
// Annuler une fermeture en cours
if (_pillMenuCloseTimer) {
clearTimeout(_pillMenuCloseTimer);
_pillMenuCloseTimer = null;
}
// S'il existe déjà un menu pour un autre pill, le fermer
const existing = document.getElementById("pill-hover-menu");
if (existing) {
if (existing._linkedPill === pill) return; // déjà pour ce pill
existing.remove();
}
const menu = document.createElement("div");
menu.id = "pill-hover-menu";
menu.className = "pill-hover-menu";
menu._linkedPill = pill;
menu._linkedPopup = popup;
const restoreBtn = document.createElement("button");
restoreBtn.type = "button";
restoreBtn.className = "pill-hover-menu-btn";
restoreBtn.innerHTML = '<span class="pill-menu-ico">⬆</span> Agrandir';
restoreBtn.addEventListener("click", (e) => {
e.stopPropagation();
_hidePillHoverMenu();
_restorePinnedPopupFromDock(popup);
});
menu.appendChild(restoreBtn);
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "pill-hover-menu-btn pill-hover-menu-close";
closeBtn.innerHTML = '<span class="pill-menu-ico">✕</span> Fermer';
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
_hidePillHoverMenu();
// Retirer le popup de la liste et supprimer le DOM
const idx = pinnedPopups.findIndex(p => p.el === popup);
if (idx >= 0) pinnedPopups.splice(idx, 1);
try { popup.remove(); } catch (err) {}
try { pill.remove(); } catch (err) {}
const dock = document.getElementById("pinned-popups-dock");
if (dock && dock.querySelectorAll(".pinned-popup-dock-pill").length === 0) {
dock.classList.remove("visible");
const closeAllBtn = document.getElementById("pinned-popups-close-all");
if (closeAllBtn) closeAllBtn.remove();
_reclampAllFloatingPopups();
} else {
_ensureDockCloseAllBtn();
}
});
menu.appendChild(closeBtn);
document.body.appendChild(menu);
// Positionner au-dessus de la pastille
const r = pill.getBoundingClientRect();
const menuR = menu.getBoundingClientRect();
let left = r.left + (r.width / 2) - (menuR.width / 2);
if (left < 4) left = 4;
if (left + menuR.width > window.innerWidth - 4) left = window.innerWidth - menuR.width - 4;
menu.style.left = left + "px";
menu.style.top = (r.top - menuR.height - 8) + "px";
// Garder ouvert si la souris entre dans le menu
menu.addEventListener("mouseenter", () => {
if (_pillMenuCloseTimer) {
clearTimeout(_pillMenuCloseTimer);
_pillMenuCloseTimer = null;
}
});
menu.addEventListener("mouseleave", () => {
_schedulePillMenuClose();
});
}
function _schedulePillMenuClose() {
if (_pillMenuCloseTimer) clearTimeout(_pillMenuCloseTimer);
_pillMenuCloseTimer = setTimeout(() => {
_hidePillHoverMenu();
_pillMenuCloseTimer = null;
}, 250);
}
function _hidePillHoverMenu() {
const existing = document.getElementById("pill-hover-menu");
if (existing) existing.remove();
}
/**
* v2026.5.20 : calcule la safe area pour les popups épinglés.
* Retourne {top, bottom, left, right} en coords viewport.
* - top : hauteur de la topbar (les popups ne doivent pas passer dessous)
* - bottom : top du dock si visible, sinon hauteur viewport
*/
function _getPopupSafeArea() {
let topLimit = 4;
const topbar = document.querySelector("header.topbar");
if (topbar) {
const r = topbar.getBoundingClientRect();
if (r.bottom > topLimit) topLimit = r.bottom + 4;
}
let bottomLimit = window.innerHeight - 4;
const dock = document.getElementById("pinned-popups-dock");
if (dock && dock.classList.contains("visible")) {
const r = dock.getBoundingClientRect();
if (r.top < bottomLimit) bottomLimit = r.top - 4;
}
return { top: topLimit, bottom: bottomLimit, left: 4, right: window.innerWidth - 4 };
}
/**
* v2026.5.20 : contraint un popup flottant (en coords document via style.left/top)
* dans la safe area. Appelé à l'épinglage, pendant le drag, et quand le dock
* apparaît/disparaît.
*/
function _clampPopupInSafeArea(popup) {
if (!popup) return;
if (popup.classList.contains("pinned-popup-reduced")) return; // pas clamp si docké
const safe = _getPopupSafeArea();
const rect = popup.getBoundingClientRect();
const w = rect.width || popup.offsetWidth || 280;
const h = rect.height || popup.offsetHeight || 200;
// Les coords viewport actuelles
const vLeft = rect.left;
const vTop = rect.top;
// Calcul des coords viewport cibles après clamp
let newVLeft = vLeft;
let newVTop = vTop;
if (newVLeft < safe.left) newVLeft = safe.left;
if (newVLeft + w > safe.right) newVLeft = safe.right - w;
if (newVLeft < safe.left) newVLeft = safe.left; // si popup plus large que viewport
if (newVTop < safe.top) newVTop = safe.top;
if (newVTop + h > safe.bottom) newVTop = safe.bottom - h;
if (newVTop < safe.top) newVTop = safe.top;
if (newVLeft === vLeft && newVTop === vTop) return; // rien à faire
// Différence = appliquer au style.left / style.top (qui sont en document coords)
const dx = newVLeft - vLeft;
const dy = newVTop - vTop;
const curLeft = parseFloat(popup.style.left) || 0;
const curTop = parseFloat(popup.style.top) || 0;
popup.style.left = (curLeft + dx) + "px";
popup.style.top = (curTop + dy) + "px";
}
/**
* Réclampe tous les popups flottants (utile après apparition/disparition du dock).
*/
function _reclampAllFloatingPopups() {
document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced)").forEach(p => {
_clampPopupInSafeArea(p);
});
}
/**
* v2026.5.19 : réduit TOUS les popups épinglés actuellement ouverts (en mode
* normal ou minimisé) dans la taskbar du bas. Appelé au changement de date.
*/
function _reduceAllPinnedPopups() {
const popups = document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced)");
popups.forEach(popup => {
try { _reducePinnedPopup(popup); } catch (e) {}
});
}
/**
* v2026.5.19 : ISO date (YYYY-MM-DD) → format court "DD.MM" pour le dock.
*/
function _formatDateShort(iso) {
if (!iso) return "";
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return iso;
return `${m[3]}.${m[2]}`;
}
/**
* v2026.5.18 : ajoute (ou met à jour) le bouton "Fermer tous" dans le dock
* quand au moins 2 popups épinglés existent (réduits OU affichés).
* Le bouton est placé à droite du dock.
*/
function _ensureDockCloseAllBtn() {
const dock = document.getElementById("pinned-popups-dock");
if (!dock) return;
const allPinned = document.querySelectorAll(".pinned-popup");
let closeAllBtn = document.getElementById("pinned-popups-close-all");
if (allPinned.length >= 2) {
if (!closeAllBtn) {
closeAllBtn = document.createElement("button");
closeAllBtn.type = "button";
closeAllBtn.id = "pinned-popups-close-all";
closeAllBtn.className = "pinned-popups-close-all";
closeAllBtn.textContent = "✕ Fermer tous";
closeAllBtn.title = "Fermer tous les popups épinglés";
closeAllBtn.addEventListener("click", (e) => {
e.stopPropagation();
closeAllPinnedPopups();
});
dock.appendChild(closeAllBtn);
} else {
// Remettre à la fin (après les pastilles éventuellement ajoutées)
dock.appendChild(closeAllBtn);
}
dock.classList.add("visible");
} else if (closeAllBtn) {
closeAllBtn.remove();
}
}
/**
* Ramène un popup réduit en mode Normal : retire la pastille du dock et
* réaffiche le popup flottant à sa position d'avant réduction.
*/
function _restorePinnedPopupFromDock(popup) {
if (!popup) return;
popup.classList.remove("pinned-popup-reduced");
// Si le popup était minimisé avant d'être réduit, on l'agrandit direct
// (la demande était : "Si la reduit et rappeller s'affiche en grand direct")
popup.classList.remove("pinned-popup-minimized");
const minBtn = popup.querySelector(".pinned-popup-minimize");
if (minBtn) {
minBtn.innerHTML = "▭";
minBtn.title = "Minimiser (reste flottant mais compact)";
const newBtn = minBtn.cloneNode(true);
minBtn.replaceWith(newBtn);
newBtn.addEventListener("click", (e) => {
e.stopPropagation();
_minimizePinnedPopup(popup);
});
}
// Supprimer la pastille associée
if (popup._linkedPill) {
popup._linkedPill.remove();
popup._linkedPill = null;
}
// Si le dock est vide (sauf le bouton "Fermer tous"), le masquer
const dock = document.getElementById("pinned-popups-dock");
if (dock) {
const remainingPills = dock.querySelectorAll(".pinned-popup-dock-pill").length;
if (remainingPills === 0) {
dock.classList.remove("visible");
const closeAllBtn = document.getElementById("pinned-popups-close-all");
if (closeAllBtn) closeAllBtn.remove();
} else {
_ensureDockCloseAllBtn();
}
}
}
/** Ferme toutes les popups épinglées (appelé par Échap ou changement de date). */
/**
* v5.0.1 : helper pour déclencher la suppression d'une absence ou réservation.
* Affiche la modal de confirmation, puis appelle le background.
*/
function _triggerDeleteItem(actionId, kind, triggerBtn) {
if (!actionId) return;
const label = kind === "reservation" ? "cette réservation" : "cette absence";
showAlertModal({
title: "Confirmer la suppression",
message: `Voulez-vous vraiment supprimer ${label} ? Cette action est irréversible.`,
buttons: [
{ label: "Annuler", variant: "secondary", action: () => {} },
{
label: "Supprimer",
variant: "danger",
action: async () => {
if (triggerBtn) {
triggerBtn.disabled = true;
triggerBtn.textContent = "Suppression…";
}
try {
const resp = await sendMessage({
type: "deletePlanningItem",
actionId: actionId,
kind: kind
});
if (!resp || !resp.ok) {
throw new Error(resp && resp.error ? resp.error : "erreur inconnue");
}
showToast("Supprimé", "L'élément a été retiré du planning.");
unpinTooltip();
closeAllPinnedPopups();
if (state.session) {
await loadForDate(state.currentDate, { forceRefetch: true });
}
} catch (err) {
showAlertModal({
title: "Erreur lors de la suppression",
message: "Impossible de supprimer : " + (err.message || err),
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
if (triggerBtn) {
triggerBtn.disabled = false;
triggerBtn.textContent = "🗑 Supprimer l'absence";
}
}
}
}
]
});
}
function closeAllPinnedPopups() {
for (const p of pinnedPopups.slice()) {
p.el.remove();
}
pinnedPopups.length = 0;
// Fermer aussi les popups en état soft-unpinned qui trainent encore
document.querySelectorAll(".pinned-popup.soft-unpinned").forEach(el => el.remove());
// v2026.5.18 : supprimer aussi les éléments du dock
document.querySelectorAll(".pinned-popup").forEach(el => el.remove());
document.querySelectorAll(".pinned-popup-dock-pill").forEach(el => el.remove());
const closeAllBtn = document.getElementById("pinned-popups-close-all");
if (closeAllBtn) closeAllBtn.remove();
const dock = document.getElementById("pinned-popups-dock");
if (dock) dock.classList.remove("visible");
}
/**
* v4.3.3 : permet de déplacer une popup épinglée à la souris via sa barre
* de drag. Met à jour les coords document (position absolute) et le rect
* mémorisé dans pinnedPopups pour que les nouvelles popups évitent bien
* la nouvelle position.
*/
function _attachPopupDragHandler(popup, dragbar) {
let dragging = false;
let startMouseX = 0, startMouseY = 0;
let startLeft = 0, startTop = 0;
const onMouseMove = (e) => {
if (!dragging) return;
const dx = e.clientX - startMouseX;
const dy = e.clientY - startMouseY;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
// v2026.5.20 : clamper dans la safe area (topbar en haut, dock en bas,
// bordures viewport gauche/droite). On calcule en coords viewport puis
// on applique en coords document.
popup.style.left = newLeft + "px";
popup.style.top = newTop + "px";
_clampPopupInSafeArea(popup);
};
const onMouseUp = () => {
if (!dragging) return;
dragging = false;
popup.classList.remove("dragging");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
// v2026.5.19 : retirer la classe body et le flag global après un petit
// délai pour laisser le temps au mouseleave de la carte de se propager
// sans déclencher de tooltip parasite.
document.body.classList.remove("popup-dragging");
setTimeout(() => { state._popupDragging = false; }, 50);
// Mettre à jour le rect mémorisé pour la détection de chevauchement
const entry = pinnedPopups.find(p => p.el === popup);
if (entry) {
const l = parseFloat(popup.style.left) || 0;
const t = parseFloat(popup.style.top) || 0;
const w = popup.offsetWidth;
const h = popup.offsetHeight;
entry.rect = { left: l, top: t, right: l + w, bottom: t + h };
}
};
dragbar.addEventListener("mousedown", (e) => {
// Seulement bouton gauche
if (e.button !== 0) return;
e.preventDefault();
dragging = true;
startMouseX = e.clientX;
startMouseY = e.clientY;
startLeft = parseFloat(popup.style.left) || 0;
startTop = parseFloat(popup.style.top) || 0;
popup.classList.add("dragging");
// v2026.5.19 : flag global pour que showTooltip ignore les mouseenter
// pendant le drag. Ajout d'une classe sur <body> qui désactive les
// pointer-events sur les cartes.
state._popupDragging = true;
document.body.classList.add("popup-dragging");
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
}
// v4.1.14/19 : recharger UNIQUEMENT cette intervention. Fetch DIRECT sans
// passer par isRefreshAborted (pour ne pas être bloqué par un abort global
// ou un refresh précédent). Animation sur le bouton ↻ de la bulle.
async function reloadSingleIntervention(iv, btnEl) {
if (!iv || iv.type === "AL-Reservation") return;
// Empêcher double-clic en cours
if (iv._reloading) return;
iv._reloading = true;
// v4.1.19 : NE PAS reset les champs AVANT le fetch (sinon si le fetch
// échoue ou est interrompu, on perd les données précédentes). On les
// mettra à jour uniquement si le fetch réussit.
const previousState = {
xhr2Fetched: iv.xhr2Fetched,
ficheFetched: iv.ficheFetched,
ficheActionText: iv.ficheActionText,
ficheFetchError: iv.ficheFetchError,
bulleDescription: iv.bulleDescription,
infobulle: iv.infobulle,
status: iv.status,
label: iv.label,
ficheChecksum: iv.ficheChecksum,
ficheTarget: iv.ficheTarget,
formSenderGuid: iv.formSenderGuid
};
// Marquer le bouton ↻ comme en cours (visuel immédiat)
if (btnEl) btnEl.classList.add("spinning");
// v4.1.19 : toast de feedback en bas à droite
showToast("Rafraîchissement", iv.ref || iv.actionId);
try {
// ─── xhr2 (rapide) ─────────────────────────────────────────────────
try {
const xhr2Resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId });
if (xhr2Resp && xhr2Resp.ok) {
const parsed = parseXhr2Body(xhr2Resp.body);
if (parsed) {
if (parsed.description) {
iv.bulleDescription = parsed.description;
const infob = parseActionText(parsed.description);
if (infob) iv.infobulle = infob;
}
if (parsed.label) iv.label = parsed.label;
iv.xhr2Fetched = true;
}
}
} catch (err) {
console.warn("[reloadSingle/xhr2] iv", iv.actionId, err);
}
// ─── fiche HTML ────────────────────────────────────────────────────
const ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
if (ficheResp.ok) {
const fiche = parseFicheHtml(ficheResp.html);
iv.status = fiche.status;
if (fiche.rfc && !iv.ref) iv.ref = fiche.rfc;
if (fiche.formSenderGuid) iv.formSenderGuid = fiche.formSenderGuid;
// ─── timeline API : texte complet ──────────────────────────────
if (fiche.formId && fiche.formChecksum && fiche.formSenderGuid && iv.actionId) {
try {
const tlResp = await sendMessage({
type: "fetchTimelineApi",
guid: fiche.formSenderGuid,
formId: fiche.formId,
formChecksum: fiche.formChecksum
});
if (tlResp && tlResp.ok) {
const fullText = parseTimelineJsonForAction(tlResp.body, iv.actionId);
if (fullText) iv.ficheActionText = fullText;
}
} catch (err) {
console.warn("[reloadSingle/timeline] iv", iv.actionId, err);
}
}
// ─── Extraire checksum pour ouverture ───────────────────────────
if (iv.requestId && !iv.ficheChecksum) {
const rx1 = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`);
const m1 = ficheResp.html.match(rx1);
if (m1) {
iv.ficheTarget = iv.requestId;
iv.ficheChecksum = m1[1];
}
}
iv.ficheFetched = true;
iv.ficheFetchError = null;
} else {
iv.ficheFetchError = ficheResp.error || "fetch_failed";
if (ficheResp.error === "session_expired") {
state.session = null;
showSessionExpiredBanner();
}
}
// Mettre à jour la carte (statut clos → ✓ vert, catégorie, etc.)
updateInterventionRow(iv);
// Si la bulle est toujours ouverte sur cette iv, régénérer son HTML
const tip = tooltipEl();
if (tip.classList.contains("visible") && state.currentTooltipIv === iv) {
tip.innerHTML = buildTooltipHTML(iv);
}
// Sauvegarder le cache
try {
const cached = await readCache(state.currentDate);
if (cached && cached.techs) {
for (const tech of cached.techs) {
for (let i = 0; i < (tech.interventions || []).length; i++) {
if (tech.interventions[i].actionId === iv.actionId) {
tech.interventions[i] = iv;
}
}
}
await writeCache(state.currentDate, { techs: cached.techs });
}
} catch (err) {
console.warn("[reloadSingle/cache]", err);
}
// v4.1.19 : toast de succès
showToast("Mis à jour", iv.ref || iv.actionId);
} catch (err) {
console.warn("[reloadSingle] erreur iv", iv.actionId, err);
// Restaurer l'état précédent en cas d'erreur globale
Object.assign(iv, previousState);
} finally {
iv._reloading = false;
if (btnEl) btnEl.classList.remove("spinning");
}
}
function unpinTooltip() {
bulleState.pinned = false;
const el = tooltipEl();
el.classList.remove("pinned");
// v4.1.13 : test immédiat si la souris est toujours dans la bulle ou sur
// la ligne. Si ni l'un ni l'autre, on ferme tout de suite (sans timer).
if (!bulleState.hoveredInBulle && !bulleState.hoveredInRow) {
el.classList.remove("visible");
el.classList.add("hidden");
if (el.dataset) delete el.dataset.mode;
state.currentTooltipIv = null;
currentTooltipPos = null;
tooltipPositionMode = null;
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
}
}
// Sinon : la bulle reste visible, et c'est le mouseleave qui la fermera
// normalement quand la souris sortira.
}
// v4.1.10 : interactions bulle (double-Ctrl pour pin/unpin, hover dans la
// bulle pour persistance, clic hors pour unpin).
function bindTooltipInteractions() {
const el = tooltipEl();
if (!el) return;
// v4.1.17 : ré-applique la position au scroll de la page (safety net
// contre un ancêtre qui casserait position:fixed silencieusement).
window.addEventListener("scroll", reapplyTooltipPosition, { passive: true });
window.addEventListener("resize", () => {
// Au resize, on laisse fermer la bulle (position probablement invalidée)
if (bulleState.pinned) return;
hideTooltip({ force: true });
});
// v4.1.17 : bloquer le scroll de la page quand la souris est DANS la
// bulle. Le scroll interne de la bulle (overflow-y auto) reste OK.
// On utilise "wheel" non-passif pour pouvoir preventDefault.
el.addEventListener("wheel", (e) => {
// Si la bulle a un scroll interne et n'est pas à la limite, laisser
// le scroll naturel se faire. Sinon, bloquer le scroll global.
const canScrollDown = el.scrollTop + el.clientHeight < el.scrollHeight;
const canScrollUp = el.scrollTop > 0;
if ((e.deltaY > 0 && !canScrollDown) || (e.deltaY < 0 && !canScrollUp)) {
e.preventDefault();
}
// Ne pas laisser le scroll se propager au body
e.stopPropagation();
}, { passive: false });
// Hover sur la bulle elle-même : empêche la fermeture
el.addEventListener("mouseenter", () => {
bulleState.hoveredInBulle = true;
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
}
});
el.addEventListener("mouseleave", () => {
bulleState.hoveredInBulle = false;
if (!bulleState.hoveredInRow && !bulleState.pinned) {
hideTooltip();
}
});
// Double-Ctrl : v2026.5.21 — toggle sur l'intervention survolée
// - Si tooltip visible et iv pas encore épinglée pour cette date : épingle
// - Si tooltip visible et iv déjà épinglée pour cette date : désépingle
// - Sinon (pas de tooltip visible) : rien
// On détecte 2 keydown Control dans une fenêtre de 400 ms.
let lastCtrlTs = 0;
document.addEventListener("keydown", (e) => {
if (e.key !== "Control") return;
if (e.repeat) return;
const now = performance.now();
if (now - lastCtrlTs < 400) {
lastCtrlTs = 0;
const iv = state.currentTooltipIv;
if (!iv) return;
const pinState = _getPinStateForIv(iv);
if (pinState.isPinned && pinState.popup) {
// Déjà épinglée : désépingle (ferme le popup correspondant)
const idx = pinnedPopups.findIndex(p => p.el === pinState.popup);
if (idx >= 0) pinnedPopups.splice(idx, 1);
// Fermer aussi la pastille dock si elle existe
if (pinState.popup._linkedPill) {
try { pinState.popup._linkedPill.remove(); } catch (e) {}
}
try { pinState.popup.remove(); } catch (e) {}
// Nettoyer dock si vide
const dock = document.getElementById("pinned-popups-dock");
if (dock && dock.querySelectorAll(".pinned-popup-dock-pill").length === 0) {
dock.classList.remove("visible");
const closeAllBtn = document.getElementById("pinned-popups-close-all");
if (closeAllBtn) closeAllBtn.remove();
} else {
_ensureDockCloseAllBtn();
}
// Mettre à jour le tooltip hover (📍 → 📌)
const tip = tooltipEl();
if (tip && tip.classList.contains("visible")) {
tip.innerHTML = buildTooltipHTML(iv);
}
} else {
// Pas encore épinglée : épingle
pinTooltip();
}
} else {
lastCtrlTs = now;
}
});
// v4.1.13 : clic sur le bouton 📌 ou ↻ (bouton d'action de la bulle)
el.addEventListener("click", (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
e.stopPropagation();
e.preventDefault();
const action = btn.dataset.action;
if (action === "pin") {
// v2026.5.22 : clic sur 📌/📍 dans le tooltip hover = TOUJOURS réépingler
// à la position actuelle. Si un popup existe déjà pour cette iv+date,
// il est supprimé avant d'en créer un nouveau à côté de la carte survolée.
// (La suppression de l'ancien est faite dans pinTooltip() qui gère
// l'unicité actionId+date — v2026.5.21.)
// Pour désépingler : bouton 📍 dans la topbar du popup, double-Ctrl, ou Échap.
if (state.currentTooltipIv) {
pinTooltip();
}
} else if (action === "reload") {
// v4.1.14 : recharger uniquement l'intervention actuellement affichée
if (state.currentTooltipIv) {
reloadSingleIntervention(state.currentTooltipIv, btn);
}
} else if (action === "copy-ref") {
// v4.1.15 : copier la référence depuis la bulle
const ref = btn.dataset.ref;
if (ref) {
navigator.clipboard.writeText(ref).then(() => {
btn.classList.add("copied");
const original = btn.textContent;
btn.textContent = "✓";
setTimeout(() => {
btn.classList.remove("copied");
btn.textContent = original;
}, 1200);
}).catch(() => {});
}
} else if (action === "delete-item") {
// v5.0.0 : supprimer absence/réservation (depuis tooltip)
const actionId = btn.dataset.actionId;
const kind = btn.dataset.kind || "absence";
_triggerDeleteItem(actionId, kind, btn);
}
});
// Clic hors bulle : unpin si épinglé.
// Attention : ne pas déclencher sur clic DANS la bulle (elle contient du
// texte sélectionnable), ni sur clic sur une interventoin (qui ouvre la
// fiche — le user n'attend pas que la bulle reste épinglée dans ce cas
// mais le comportement "ouvrir la fiche" reste prioritaire).
document.addEventListener("mousedown", (e) => {
if (!bulleState.pinned) return;
// Clic dans la bulle → on laisse (sélection de texte)
if (el.contains(e.target)) return;
// Dans tous les autres cas (y compris clic sur une autre interventoin),
// on désépingle. Si c'était un clic sur intervention, le handler
// d'ouverture de la fiche s'exécutera ensuite normalement.
unpinTooltip();
});
}
function buildTooltipHTML(iv) {
const i = iv.infobulle || {};
const rows = [];
// Cas spécial : réservation (créneau bloqué par un coordinateur)
if (iv.type === "AL-Reservation") {
rows.push(`<dt>Type</dt><dd><span class="status-pill other" style="background:var(--c-reservation);color:#fff">Réservation</span></dd>`);
if (iv.startTime && iv.endTime) {
rows.push(row("Horaire", `${iv.startTime}${iv.endTime}`));
}
if (iv.reservationLabel) rows.push(row("Sujet", iv.reservationLabel));
if (iv.reservationCreator) rows.push(row("Par", iv.reservationCreator));
// v5.0.0 : bouton supprimer pour les réservations (avec confirmation)
rows.push(`<dt></dt><dd><button type="button" class="tooltip-delete-btn" data-action="delete-item" data-action-id="${escapeHtml(iv.actionId || "")}" data-kind="reservation">🗑 Supprimer cette réservation</button></dd>`);
return `<dl>${rows.join("")}</dl>`;
}
// v5.0.0 : cas spécial absence (congé, maladie, formation, pompier, ...)
if (iv.type === "AL-Absence") {
const label = iv.label || "Absence";
rows.push(`<dt>Type</dt><dd><span class="status-pill other">${escapeHtml(label)}</span></dd>`);
if (iv.startTime && iv.endTime) {
rows.push(row("Horaire", `${iv.startTime}${iv.endTime}`));
}
// Pour les absences récurrentes (Pillonel vendredi), pas d'actionId réel
// → pas de bouton supprimer. Pour les autres → oui.
if (iv.actionId) {
rows.push(`<dt></dt><dd><button type="button" class="tooltip-delete-btn" data-action="delete-item" data-action-id="${escapeHtml(iv.actionId)}" data-kind="absence">🗑 Supprimer cette absence</button></dd>`);
}
return `<dl>${rows.join("")}</dl>`;
}
// Statut en premier (si connu)
if (iv.status) {
let cls = "other";
if (isClosedStatus(iv.status)) cls = "closed";
else if (isResolvedStatus(iv.status)) cls = "resolved";
else if (/en cours|ex[ée]cution/i.test(iv.status)) cls = "ongoing";
rows.push(`<dt>Statut</dt><dd><span class="status-pill ${cls}">${escapeHtml(iv.status)}</span></dd>`);
}
if (iv.startTime && iv.endTime) {
rows.push(row("Horaire", `${iv.startTime}${iv.endTime}`));
}
// ─── Texte d'action : fiche (complet) en priorité, sinon xhr2 (tronqué) ──
// v4.1.8 : un seul bloc "Action" qui s'enrichit automatiquement. Au début,
// le xhr2 tronqué s'affiche ; dès que le fetch timeline est revenu,
// iv.ficheActionText remplace le texte dans le même bloc.
const actionText = iv.ficheActionText ||
(iv.bulleDescription ? formatActionTextMultiline(iv.bulleDescription) : null);
if (actionText) {
const htmlAction = escapeHtml(actionText).replace(/\n/g, "<br>");
rows.push(`<dt>Action</dt><dd class="description">${htmlAction}</dd>`);
} else {
// Si pas de description (même pas de xhr2), afficher les infos structurées qu'on a
const hasAction = !!(i.date || i.heure || i.lieu || i.contact || i.service ||
i.probleme || i.aFaire || i.materiel);
if (i.date || i.heure) {
const dh = [i.date, i.heure].filter(Boolean).join(" · ");
if (dh) rows.push(row("Quand", dh));
}
const contact = i.contact || iv.bulleContact;
if (contact) rows.push(row("Contact", contact));
const lieu = i.lieu || iv.bulleLieu;
if (lieu) rows.push(row("Lieu", lieu));
if (i.service) rows.push(row("Service", i.service));
if (i.probleme) rows.push(row("Problème", i.probleme));
if (i.aFaire) rows.push(row("À faire", i.aFaire));
if (!hasAction && !contact && !lieu) {
if (iv.ficheFetched) {
rows.push(`<dt>Info</dt><dd style="color:var(--text-faint)">Aucun détail pour cette intervention.</dd>`);
} else {
rows.push(`<dt>Info</dt><dd style="color:var(--text-faint)">Chargement des détails…</dd>`);
}
}
}
// Deadline (si connue et différente)
if (iv.deadline && !i.date) rows.push(row("Deadline", iv.deadline));
if (iv.ref) {
rows.push(`<hr>`);
// v4.1.15 : ref avec bouton copier inline
const refSafe = escapeHtml(iv.ref);
rows.push(`<dt>Référence</dt><dd class="tt-ref-cell"><span class="tt-ref-val">${refSafe}</span><button class="tt-copy-btn" data-action="copy-ref" data-ref="${refSafe}" title="Copier la référence">📋</button></dd>`);
}
if (iv.ghost) {
rows.push(`<hr>`);
rows.push(`<dt>⚠</dt><dd>Intervention disparue d'EasyVista (clôturée, déplacée ou annulée)</dd>`);
} else if (iv.formLink) {
rows.push(`<hr>`);
rows.push(`<dt></dt><dd style="color:var(--text-faint);font-size:11px">Cliquer pour ouvrir la fiche</dd>`);
}
// v2026.5.21 : icône épingle adaptative
// 📌 (épingle couchée) = pas encore épinglée, clic pour épingler
// 📍 (épingle plantée, rouge) = déjà épinglée pour cette (ref + date)
// — clic pour désépingler. NB : le popup a sa propre topbar avec un
// bouton 📍 explicite ; celui-ci dans le tooltip hover sert surtout
// à montrer visuellement l'état à l'user au survol.
const _pinState = _getPinStateForIv(iv);
const _pinIcon = _pinState.isPinned ? "📍" : "📌";
const _pinTitle = _pinState.isPinned
? "Cette intervention est déjà épinglée pour ce jour. Cliquer pour désépingler."
: "Épingler la bulle (ou double-Ctrl). Cliquer à nouveau pour libérer.";
const _pinClass = _pinState.isPinned ? " tooltip-pinbtn-active" : "";
if (rows.length === 0) {
return `<div class="tooltip-actions">
<div class="tooltip-actionbtn" data-action="reload" title="Recharger uniquement cette intervention">
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8a6 6 0 1 0 1.76-4.24M2 3v3h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="tooltip-actionbtn tooltip-pinbtn${_pinClass}" data-action="pin" title="${_pinTitle}">${_pinIcon}</div>
</div><dl><dt>Info</dt><dd>Aucun détail disponible</dd></dl>`;
}
// v4.1.13/14 : boutons d'action en haut à droite (recharger + épingler)
return `<div class="tooltip-actions">
<div class="tooltip-actionbtn" data-action="reload" title="Recharger uniquement cette intervention">
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8a6 6 0 1 0 1.76-4.24M2 3v3h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="tooltip-actionbtn tooltip-pinbtn${_pinClass}" data-action="pin" title="${_pinTitle}">${_pinIcon}</div>
</div><dl>${rows.join("")}</dl>`;
}
/**
* v2026.5.21 : retourne l'état d'épinglage pour une intervention donnée
* (pour la date actuellement affichée). Utilisé pour afficher 📌 vs 📍
* dans le tooltip hover.
*/
function _getPinStateForIv(iv) {
if (!iv || !iv.actionId) return { isPinned: false };
const date = state.currentDate || "";
const key = iv.actionId + "|" + date;
for (const p of pinnedPopups) {
if (!p || !p.el) continue;
const aid = p.el.dataset.actionId || "";
const d = p.el.dataset.originDate || "";
if (aid + "|" + d === key) {
return { isPinned: true, popup: p.el };
}
}
return { isPinned: false };
}
/**
* Met en forme un texte d'action EasyVista en ajoutant des retours à la ligne
* avant chaque étiquette connue ("Date :", "Lieu :", "Contact :", etc.).
* Transforme :
* "Date : 20.04 Heure : MatinLieu : Ville1/Rue1 1 bisContact : Nom..."
* En :
* "Date : 20.04 Heure : Matin
* Lieu : Ville1/Rue1 1 bis
* Contact : Nom..."
*/
function formatActionTextMultiline(text) {
if (!text) return "";
const newlineLabels = [
"Lieu", "Contact",
"Service", "Étage", "Bureau",
"Nom utilisateur",
"Problème", "A faire", "À faire",
"Matériel", "Materiel",
"Bénéficiaire", "Beneficiaire"
];
let result = String(text);
for (const label of newlineLabels) {
const rx = new RegExp(`([^\\n])(${escapeRegex(label)}\\s*:\\s*)`, "g");
result = result.replace(rx, "$1\n$2");
}
// Isoler la signature planificateur finale ("ECM 16.04", "csh 27.03", etc.)
// qui se trouve typiquement en fin sans préfixe de label.
// On utilise un look-behind pour ne PAS manger la lettre précédente
// (et donc ne pas couper le "F" de "FRD 07/04").
result = result.replace(/(?<=[^\n])(\s*)([A-Za-z]{2,4}\s+\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)\s*$/, "\n$2");
// Nettoyer
result = result.replace(/\n{2,}/g, "\n").trim();
return result;
}
function escapeRegex(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function row(label, value) {
return `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function highlightIntervention(cardEl, ivIdx, on) {
const row = cardEl.querySelector(`.intervention[data-iv-idx="${ivIdx}"]`);
const slot = cardEl.querySelector(`.timeline-slot[data-iv-idx="${ivIdx}"]`);
if (row) row.classList.toggle("highlight", on);
if (slot) slot.classList.toggle("highlight", on);
}
// ============================================================================
// Helpers temps
// ============================================================================
function timeToMinutes(hhmm) {
if (!hhmm) return null;
const m = hhmm.match(/^(\d{1,2}):(\d{2})$/);
if (!m) return null;
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
}
function minutesToTime(mins) {
const h = Math.floor(mins / 60);
const m = mins % 60;
return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
}
// ============================================================================
// Écrans d'erreur
// ============================================================================
function showLoading() {
document.getElementById("loading").classList.remove("hidden");
document.getElementById("error-box").classList.add("hidden");
document.getElementById("session-needed").classList.add("hidden");
document.getElementById("stats").classList.add("hidden");
document.getElementById("cards").innerHTML = "";
}
function showError(msg) {
document.getElementById("loading").classList.add("hidden");
document.getElementById("stats").classList.add("hidden");
document.getElementById("session-needed").classList.add("hidden");
const evUnr = document.getElementById("ev-unreachable");
if (evUnr) evUnr.classList.add("hidden");
document.getElementById("cards").innerHTML = "";
const box = document.getElementById("error-box");
box.textContent = msg;
box.classList.remove("hidden");
}
function showSessionNeeded() {
document.getElementById("loading").classList.add("hidden");
document.getElementById("error-box").classList.add("hidden");
document.getElementById("stats").classList.add("hidden");
const evUnr = document.getElementById("ev-unreachable");
if (evUnr) evUnr.classList.add("hidden");
document.getElementById("cards").innerHTML = "";
document.getElementById("session-needed").classList.remove("hidden");
}
function hideSessionNeeded() {
document.getElementById("session-needed").classList.add("hidden");
}
// v4.2 : écran plein "EasyVista inaccessible" (différent de session expirée).
function showEvUnreachable() {
document.getElementById("loading").classList.add("hidden");
document.getElementById("error-box").classList.add("hidden");
document.getElementById("stats").classList.add("hidden");
document.getElementById("session-needed").classList.add("hidden");
document.getElementById("cards").innerHTML = "";
const el = document.getElementById("ev-unreachable");
if (el) el.classList.remove("hidden");
}
function hideEvUnreachable() {
const el = document.getElementById("ev-unreachable");
if (el) el.classList.add("hidden");
}
// v4.1.12 : bannière non bloquante "session expirée". Affichée quand le
// fetch détecte une session morte EN COURS DE ROUTE (pas au démarrage).
// L'utilisateur voit toujours les données déjà chargées, mais est prévenu
// que les mises à jour sont arrêtées.
function showSessionExpiredBanner() {
const b = document.getElementById("session-expired-banner");
if (b) {
b.classList.remove("hidden");
// v5.0.10 : rebrancher le bouton "Ouvrir EasyVista" natif pour qu'il
// 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());
}
}
hideEvUnreachableBanner();
hideReconnectingBanner();
}
function hideSessionExpiredBanner() {
const b = document.getElementById("session-expired-banner");
if (b) b.classList.add("hidden");
}
// v5.0.9 : bannière affichée pendant la reconnexion (remplace la bannière
// expirée après clic sur "Me reconnecter")
// v5.0.11 : ajoute un bouton "Annuler" pour interrompre le processus.
function showReconnectingBanner() {
let b = document.getElementById("session-reconnecting-banner");
if (!b) {
b = document.createElement("div");
b.id = "session-reconnecting-banner";
b.className = "banner-reconnecting";
const topbar = document.querySelector(".topbar") || document.querySelector("header") || document.body;
if (topbar.nextSibling) {
topbar.parentNode.insertBefore(b, topbar.nextSibling);
} else {
document.body.insertBefore(b, document.body.firstChild);
}
}
b.innerHTML = `
<span class="banner-spinner">⏳</span>
<span class="banner-text">Reconnexion à EasyVista en cours… Connectez-vous dans l'onglet qui vient de s'ouvrir.</span>
<button type="button" class="banner-cancel-btn">Annuler</button>
`;
const cancelBtn = b.querySelector(".banner-cancel-btn");
if (cancelBtn) cancelBtn.addEventListener("click", () => cancelReconnect());
b.classList.remove("hidden");
hideSessionExpiredBanner();
hideReconnectFailedBanner();
}
function hideReconnectingBanner() {
const b = document.getElementById("session-reconnecting-banner");
if (b) b.classList.add("hidden");
}
// v5.0.11 : bannière "Reconnexion échouée" avec choix manuel du réseau
// (Bureau/Télétravail). Affichée après timeout 90s de reconnexion.
function showReconnectFailedBanner() {
let b = document.getElementById("session-reconnect-failed-banner");
if (!b) {
b = document.createElement("div");
b.id = "session-reconnect-failed-banner";
b.className = "banner-reconnect-failed";
const topbar = document.querySelector(".topbar") || document.querySelector("header") || document.body;
if (topbar.nextSibling) {
topbar.parentNode.insertBefore(b, topbar.nextSibling);
} else {
document.body.insertBefore(b, document.body.firstChild);
}
}
b.innerHTML = `
<span class="banner-icon">⚠</span>
<span class="banner-text">
<strong>Reconnexion échouée.</strong>
Indiquez où vous êtes pour réessayer :
</span>
<button type="button" class="banner-btn-primary" data-origin="https://itsma.etat-de-vaud.ch">🏢 Au bureau</button>
<button type="button" class="banner-btn-primary" data-origin="https://itsma.vd.ch">🏠 En télétravail</button>
<button type="button" class="banner-cancel-btn">Annuler</button>
`;
// Boutons de choix de réseau : retry avec origine forcée
b.querySelectorAll(".banner-btn-primary").forEach(btn => {
btn.addEventListener("click", () => {
const origin = btn.dataset.origin;
hideReconnectFailedBanner();
triggerReconnect(origin);
});
});
// Bouton Annuler : retour à la bannière "Session expirée" simple
const cancelBtn = b.querySelector(".banner-cancel-btn");
if (cancelBtn) cancelBtn.addEventListener("click", () => {
hideReconnectFailedBanner();
showSessionExpiredBanner();
});
b.classList.remove("hidden");
hideSessionExpiredBanner();
hideReconnectingBanner();
}
function hideReconnectFailedBanner() {
const b = document.getElementById("session-reconnect-failed-banner");
if (b) b.classList.add("hidden");
}
// v4.2.5 : bannière non bloquante "EasyVista inaccessible"
function showEvUnreachableBanner() {
const b = document.getElementById("ev-unreachable-banner");
if (b) b.classList.remove("hidden");
// On masque la bannière session expirée (1 seule bannière à la fois)
hideSessionExpiredBanner();
}
function hideEvUnreachableBanner() {
const b = document.getElementById("ev-unreachable-banner");
if (b) b.classList.add("hidden");
}