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

5851 lines
222 KiB
JavaScript
Raw Permalink 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.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
};
// ─── 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
// Initialiser la date = aujourd'hui
state.currentDate = todayISO();
document.getElementById("date-picker").value = state.currentDate;
// v4.2 : l'auto-refresh 12h/15h a été supprimé. Les rafraîchissements sont
// désormais soit manuels (boutons Actualiser / Tout recharger), soit au
// premier chargement si aucun cache n'existe pour la date.
// Charger la sesson puis le planning
await refreshSessionAndLoad();
}
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;
hideSessionNeeded();
hideEvUnreachable();
hideSessionExpiredBanner();
hideEvUnreachableBanner();
fetchAndShowCurrentUser();
await loadForDate(state.currentDate);
}
// v4.2 : fetche l'utilisateur EasyVista connecté (via background.js) et
// l'affiche dans la topbar. En cas d'échec ou si aucun nom n'est trouvé,
// le badge reste caché.
async function fetchAndShowCurrentUser() {
try {
const resp = await sendMessage({ type: "fetchCurrentUser" });
if (!resp || !resp.ok || !resp.user) return;
const badge = document.getElementById("user-badge");
if (!badge) return;
const fullName = resp.user.name || resp.user.login || null;
if (!fullName) return;
const initials = computeUserInitials(fullName);
badge.textContent = initials;
badge.title = fullName;
// v4.2.3 : couleur unique dérivée du nom, dans la palette neutre du thème
badge.style.setProperty("--user-badge-color", colorFromName(fullName));
badge.classList.remove("hidden");
state.currentUser = resp.user;
} catch (err) {
console.warn("[currentUser] fetch failed:", err);
}
}
// 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;
}
if (!state.currentUser || !state.currentUser.name) return;
popup.textContent = state.currentUser.name;
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");
}
// ============================================================================
// 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 });
}
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
hideUserNamePopup();
// v4.2.4 : Échap ferme aussi la grande bulle anchored
const tip = tooltipEl();
if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
hideTooltip({ force: true });
}
// v4.3.0 : Échap ferme TOUS les popups épinglés (le user veut tout fermer)
if (typeof closeAllPinnedPopups === "function") {
closeAllPinnedPopups();
}
}
});
document.getElementById("open-ev-btn").addEventListener("click", openEasyVista);
// v4.2 : écran "EasyVista inaccessible"
const openEvBtn2 = document.getElementById("open-ev-btn-2");
if (openEvBtn2) openEvBtn2.addEventListener("click", openEasyVista);
const retryBtn = document.getElementById("retry-btn");
if (retryBtn) retryBtn.addEventListener("click", async () => {
hideEvUnreachable();
document.getElementById("loading").classList.remove("hidden");
await refreshSessionAndLoad();
});
// v4.1.12 : bindings bannière session expirée
const reconnectBtn = document.getElementById("session-banner-reconnect");
if (reconnectBtn) reconnectBtn.addEventListener("click", openEasyVista);
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() {
// Ouvrir sur le domaine externe (accessible depuis l'extérieur).
// Le domaine interne (itsma.etat-de-vaud.ch) n'est accessible que depuis le réseau VD.
// Une fois connecté, l'extension détectera automatiquement le PHPSESSID quel que
// soit le domaine où tu es connecté.
await chrome.tabs.create({ url: "https://itsma.vd.ch/" });
}
// 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);
}
// ============================================================================
// 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");
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);
// 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;
}
// 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" : ""));
// Reload le planning du jour pour voir l'absence
if (state.session) {
await loadForDate(state.currentDate, { 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 ferme tous les popups épinglés. Ils réfèrent à
// des interventions du jour courant, ils n'ont aucun sens sur un autre jour.
const previousDate = state.currentDate;
if (previousDate && previousDate !== isoDate && typeof closeAllPinnedPopups === "function") {
closeAllPinnedPopups();
}
state.currentDate = isoDate;
document.getElementById("date-picker").value = isoDate;
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
)
);
// 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.
// Comme ça les cartes s'enrichissent en 1-3 secondes au lieu d'attendre
// que l'utilisateur les survole une par une.
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)…`);
// forceAll : uniquement si refresh manuel (bouton "rafraichir").
// À la navigation normale entre dates, on ne refetch que les iv non
// encore enrichies (ficheFetched=false) — ça reprend là où on s'était
// arrêté si un refresh précédent a été interrompu par un changement de
// date.
await refreshStatuses(merged.techs, isoDate, { forceAll: !!opts.doStatusRefresh, myToken });
console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`);
}
// 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" : un coordinateur a bloqué un créneau.
// Dans le XML, action_type = "AL-Absence" pour ce genre de créneau, mais
// action_label contient le vrai pattern :
// action_label = "Xxxxx / Créé par : Nom, Prénom"
// Ex: "Ecrans / Créé par : Nom20, Prénom20"
// "Rollout / Créé par : Nom24, Prénom24"
// "Congés / Créé par : ..." → pas une réservation, c'est une absence
// "Maladie / Créé par : ..." → idem
// "Pompier / Créé par : ..." → idem
let effectiveType = actionType;
let reservationLabel = null;
let reservationCreator = null;
const reservationMatch = label.match(/^([^/]+?)\s*\/\s*Créé par\s*:\s*(.+)$/i);
if (reservationMatch) {
const label1 = reservationMatch[1].trim();
const creator = reservationMatch[2].trim();
// Les "absences" connues (Congés/Maladie/Pompier) restent des absences
if (/^(cong[ée]s|maladie|pompier)$/i.test(label1)) {
effectiveType = "AL-Absence";
} else {
// Tout autre label (Ecrans, Rollout, ...) → Réservation
effectiveType = "AL-Reservation";
reservationLabel = label1;
reservationCreator = creator;
}
}
// ─── 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)) {
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");
}
function isTechAbsent(tech, isoDate) {
const recurring = RECURRING_ABSENCES[tech.id];
if (recurring) {
const day = isoToDate(isoDate).getDay();
if (recurring.includes(day)) return true;
}
if (tech.interventions.length === 0) return false;
return tech.interventions.every(iv => iv.type === "AL-Absence" && !iv.isPompier);
}
// ============================================================================
// Construction d'une carte
// ============================================================================
// 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);
}
// v4.1.20 : cas spécifique Pillonel Olivier, absent tous les vendredis.
// Affichage d'un message explicite au lieu de "Pas d'intervention planifiée".
// v4.2 : prioritaire même si un bloc AL-Absence couvre le vendredi (ce qui
// est le cas normal), pour TOUJOURS afficher "Absent le vendredi".
const isPillonelFriday = isPillonelAbsentFriday(tech, isoDate);
// Absent sans interv → on stop là (après avoir posé le message Pillonel
// si vendredi).
if (isAbsent && realInterventions.length === 0) {
if (isPillonelFriday) {
const note = document.createElement("div");
note.className = "tech-absence-recurring";
note.textContent = "Absent le vendredi";
body.appendChild(note);
}
card.appendChild(body);
return card;
}
if (realInterventions.length === 0 && !isPompier) {
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));
}
card.appendChild(body);
return card;
}
// ============================================================================
// Timeline
// ============================================================================
function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) {
const DAY_START = 8 * 60;
const DAY_END = 18 * 60;
const DAY_LEN = DAY_END - DAY_START;
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) {
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.ref) {
refHeader.textContent = iv.ref;
} else {
refHeader.textContent = "—";
refHeader.classList.add("no-ref");
}
row.appendChild(refHeader);
// Check ✓ + bouton copier à droite de la ref (pas pour réservation)
if (statusClass && iv.type !== "AL-Reservation") {
const statusEl = document.createElement("div");
statusEl.className = "iv-status-check";
// 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") {
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;
}
// 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.
const rxLong = /(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(?<!\d)41\d{9}(?!\d)|(?<!\d)33\d{9}(?!\d))/g;
// SHORT : 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);
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 };
const idx = s.indexOf("/");
let ville, adresse;
if (idx < 0) {
ville = null;
adresse = s;
} else {
ville = s.substring(0, idx).trim();
adresse = s.substring(idx + 1).trim();
}
// Capitaliser la 1ère lettre des mots de voie (Rue, Chemin, Route, Avenue,
// Boulevard, Place, Quai, Impasse + abréviations Av., Ch., Rte, Bd)
if (adresse) {
adresse = adresse.replace(
/\b(rue|chemin|route|avenue|boulevard|place|quai|impasse|ruelle|allée|allee|passage|sentier|av\.?|ch\.?|rte\.?|bd\.?)\b/gi,
(match) => {
// Conserver la casse existante si déjà majuscule, sinon capitaliser
if (/^[A-ZÉÈÀÂÎÔÛÇ]/.test(match)) return match;
return match.charAt(0).toUpperCase() + match.slice(1).toLowerCase();
}
);
}
return { ville: ville || null, adresse: adresse || null };
}
/**
* Extrait la "signature planificateur" de la description d'action.
* Formats acceptés : "ECM 16.04", "JKF 17.04", "AWR 13/04/26", "ECM 16.04.2026".
* Parcourt d'abord les lignes depuis la fin (si la signature est sur sa ligne),
* sinon cherche à la fin de la description entière.
* Retourne null si rien trouvé.
*/
/**
* Normalise une date trouvée dans une signature :
* - "27/03" → "27.03"
* - "27.03" → "27.03"
* - "10/04/26" → "10.04" (on retire l'année)
* - "13/04/2026" → "13.04"
*/
function normalizeSignatureDate(date) {
if (!date) return "";
// Prendre les 2 premiers blocs de chiffres (JJ et MM) et les joindre avec "."
const parts = String(date).split(/[./]/);
if (parts.length < 2) return date;
const dd = parts[0].padStart(2, "0");
const mm = parts[1].padStart(2, "0");
return `${dd}.${mm}`;
}
function extractPlanifSignature(actionText) {
if (!actionText) return null;
// Formater le texte d'abord pour avoir des lignes séparées
const text = formatActionTextMultiline(String(actionText)).trim();
// 1. Dernière ligne non vide : regarder si c'est une signature (avec ou sans date)
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
if (lines.length > 0) {
const last = lines[lines.length - 1];
// 1a. Lettres (majuscules OU minuscules) + date
// Ex: "FRD 07/04", "csh 27.03", "AWR 13/04/26", "JKF 17.04"
const mFull = last.match(/^([A-Za-z]{2,4})\s+(\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)$/);
if (mFull) {
return `${mFull[1].toUpperCase()} ${normalizeSignatureDate(mFull[2])}`;
}
// 1b. Juste les lettres seules (JKF, NDV) sur leur propre ligne
const mSolo = last.match(/^([A-Za-z]{2,4})$/);
if (mSolo) return mSolo[1].toUpperCase();
}
// 2. Sinon chercher la dernière signature "lettres + date" collée en fin
let lastMatch = null;
let m;
const rxGlobal = /([A-Za-z]{2,4})\s+(\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)/g;
while ((m = rxGlobal.exec(text)) !== null) {
lastMatch = { sigs: m[1], date: m[2], pos: m.index };
}
if (lastMatch && lastMatch.pos >= text.length - 100) {
return `${lastMatch.sigs.toUpperCase()} ${normalizeSignatureDate(lastMatch.date)}`;
}
return null;
}
// 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) {
// 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
}, 120);
}
// v4.2 : détecte si l'utilisateur a une sélection de texte active dans la bulle.
// Utilisé pour empêcher la fermeture automatique tant qu'on n'a pas fini de
// sélectionner/copier.
function hasTextSelectionInTooltip() {
try {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return false;
const tip = tooltipEl();
if (!tip) return false;
const range = sel.getRangeAt(0);
// La sélection est dans la bulle si au moins un endpoint y est
return tip.contains(range.startContainer) || tip.contains(range.endContainer);
} catch {
return false;
}
}
function moveTooltip(e) {
// 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;
setTooltipViewportPosition(x, y);
}
// ============================================================================
// v4.3.0 : système de popups épinglés détachés
// ============================================================================
//
// Au lieu d'épingler le tooltip unique (qui empêchait d'afficher d'autres
// infos au survol), on clone son contenu en un popup indépendant :
// - Ancré DANS le contenu de la page (position: absolute + coordonnées
// document) → scrolle avec le contenu, pas avec le viewport.
// - Peut coexister avec d'autres popups épinglés (jusqu'à ce qu'il n'y
// ait plus de place disponible).
// - Persiste jusqu'à fermeture explicite (bouton ×, Échap, ou Ctrl×2 si 1 seul).
//
// Le tooltip live (#tooltip) garde son rôle initial : il se ferme au mouseleave.
const pinnedPopups = []; // [{el, iv, rect}]
/**
* Ancre la popup au contenu : ajoute le scrollY actuel au top viewport pour
* obtenir une position absolute document, qui scrolle avec le contenu.
*/
function _viewportToDocumentY(y) {
return y + (window.scrollY || window.pageYOffset || 0);
}
function _viewportToDocumentX(x) {
return x + (window.scrollX || window.pageXOffset || 0);
}
/**
* Teste si un rectangle {left, top, right, bottom} (en coords document)
* chevauche avec un popup déjà épinglé.
*/
function _rectsOverlap(a, b) {
return !(a.right <= b.left || a.left >= b.right ||
a.bottom <= b.top || a.top >= b.bottom);
}
/**
* Cherche une position libre pour un popup de dimensions {w, h} près de la
* ligne source `rowEl`. Essaie dans l'ordre : droite, gauche, dessous, dessus.
* Retourne {x, y} en coordonnées document, ou null si aucune position libre.
*/
function _findFreePopupPosition(rowEl, w, h) {
const pad = 14;
const rowRect = rowEl.getBoundingClientRect();
const viewportW = window.innerWidth;
const viewportH = window.innerHeight;
// 4 candidats, en coords viewport
const candidates = [
// Droite
{ x: rowRect.right + pad, y: rowRect.top, name: "droite" },
// Gauche
{ x: rowRect.left - w - pad, y: rowRect.top, name: "gauche" },
// Dessous
{ x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" },
// Dessus
{ x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" }
];
// Pour chaque candidat, clamper dans le viewport (marge 8px) et convertir
// en coord document, puis tester le chevauchement
for (const c of candidates) {
let x = c.x, y = c.y;
// Clamp horizontal dans le viewport
if (x < 4) x = 4;
if (x + w > viewportW - 8) x = viewportW - 8 - w;
// Clamp vertical dans le viewport
if (y < 4) y = 4;
if (y + h > viewportH - 8) y = viewportH - 8 - h;
// Si, après clamp, la popup chevaucherait la ligne source elle-même,
// on ignore ce candidat (on préfère une direction qui la laisse visible).
const rowRectClamped = {
left: rowRect.left, top: rowRect.top,
right: rowRect.right, bottom: rowRect.bottom
};
const candRect = { left: x, top: y, right: x + w, bottom: y + h };
if (_rectsOverlap(candRect, rowRectClamped)) continue;
// Test chevauchement avec les popups déjà épinglés
const docRect = {
left: _viewportToDocumentX(x),
top: _viewportToDocumentY(y),
right: _viewportToDocumentX(x + w),
bottom: _viewportToDocumentY(y + h)
};
let overlapsOther = false;
for (const p of pinnedPopups) {
if (_rectsOverlap(docRect, p.rect)) {
overlapsOther = true;
break;
}
}
if (!overlapsOther) {
// Position libre trouvée
return {
viewportX: x, viewportY: y,
docX: docRect.left, docY: docRect.top,
rect: docRect
};
}
}
return null;
}
/**
* v4.3.0 : épingle la bulle courante en la clonant dans un popup détaché
* ancré au contenu. Le tooltip live redevient disponible.
*/
function pinTooltip() {
if (!state.currentTooltipIv) return;
const srcEl = tooltipEl();
if (!srcEl) return;
const iv = state.currentTooltipIv;
// Chercher la ligne source (row iv-v2)
let rowEl = null;
if (iv.actionId) {
rowEl = document.querySelector(`.intervention-v2[data-action-id="${iv.actionId}"]`);
}
if (!rowEl) {
// Fallback : utiliser la position actuelle du tooltip live
rowEl = srcEl;
}
// Cloner le contenu du tooltip actuel en popup détaché
const popup = document.createElement("div");
popup.className = "tooltip pinned-popup visible";
popup.dataset.actionId = iv.actionId || "";
popup.innerHTML = srcEl.innerHTML;
// Ajouter un bouton × de fermeture (en plus du 📌)
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "pinned-popup-close";
closeBtn.innerHTML = "×";
closeBtn.title = "Désépingler (reste visible tant que la souris est dessus)";
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
// Désépinglage "mou" : on marque la popup comme non épinglée mais on la
// laisse visible tant que la souris est dessus. Elle disparaît quand la
// souris sort.
_softUnpinPopup(popup);
});
popup.appendChild(closeBtn);
// 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";
// 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();
}
/**
* 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();
// 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 });
}
/** Ferme toutes les popups épinglées (appelé par Échap ou changement de date). */
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());
}
/**
* 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;
// Clamper dans le document (pas sortir trop à gauche/haut)
if (newLeft < 4) newLeft = 4;
if (newTop < 4) newTop = 4;
popup.style.left = newLeft + "px";
popup.style.top = newTop + "px";
};
const onMouseUp = () => {
if (!dragging) return;
dragging = false;
popup.classList.remove("dragging");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
// 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");
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 : v4.3.0
// - Si 0 popup épinglé ET un tooltip live visible : épingler
// - Si EXACTEMENT 1 popup épinglé ET souris pas dessus : le fermer
// - Si 2+ popups épinglés : ne fait rien (ambigu, user doit utiliser Échap)
// On détecte 2 keydown Control dans une fenêtre de 400 ms.
let lastCtrlTs = 0;
document.addEventListener("keydown", (e) => {
if (e.key !== "Control") return;
if (e.repeat) return;
const now = performance.now();
if (now - lastCtrlTs < 400) {
lastCtrlTs = 0;
if (pinnedPopups.length === 0) {
// Aucun popup épinglé : épingler le tooltip live s'il y en a un
if (state.currentTooltipIv) pinTooltip();
} else if (pinnedPopups.length === 1) {
// 1 popup épinglé : le fermer si la souris n'est pas dessus
const p = pinnedPopups[0];
if (!p.el.matches(":hover")) {
_closePinnedPopup(p.el);
}
}
// 2+ popups : rien faire (Échap pour tout fermer)
} else {
lastCtrlTs = now;
}
});
// 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") {
// v4.3.0 : toujours épingler (le tooltip live clone son contenu en popup
// détaché). Pour désépingler, l'user utilise × sur le popup, ou Échap.
if (state.currentTooltipIv) {
pinTooltip();
}
} else if (action === "reload") {
// 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(() => {});
}
}
});
// 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));
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>`);
}
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" data-action="pin" title="Épingler la bulle (ou double-Ctrl). Cliquer à nouveau pour libérer.">📌</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" data-action="pin" title="Épingler la bulle (ou double-Ctrl). Cliquer à nouveau pour libérer.">📌</div>
</div><dl>${rows.join("")}</dl>`;
}
/**
* Met en forme un texte d'action EasyVista en ajoutant des retours à la ligne
* avant chaque étiquette connue ("Date :", "Lieu :", "Contact :", etc.).
* Transforme :
* "Date : 20.04 Heure : MatinLieu : Ville1/Rue1 1 bisContact : Nom..."
* En :
* "Date : 20.04 Heure : Matin
* Lieu : Ville1/Rue1 1 bis
* Contact : Nom..."
*/
function formatActionTextMultiline(text) {
if (!text) return "";
const newlineLabels = [
"Lieu", "Contact",
"Service", "Étage", "Bureau",
"Nom utilisateur",
"Problème", "A faire", "À faire",
"Matériel", "Materiel",
"Bénéficiaire", "Beneficiaire"
];
let result = String(text);
for (const label of newlineLabels) {
const rx = new RegExp(`([^\\n])(${escapeRegex(label)}\\s*:\\s*)`, "g");
result = result.replace(rx, "$1\n$2");
}
// Isoler la signature planificateur finale ("ECM 16.04", "csh 27.03", etc.)
// qui se trouve typiquement en fin sans préfixe de label.
// On utilise un look-behind pour ne PAS manger la lettre précédente
// (et donc ne pas couper le "F" de "FRD 07/04").
result = result.replace(/(?<=[^\n])(\s*)([A-Za-z]{2,4}\s+\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)\s*$/, "\n$2");
// Nettoyer
result = result.replace(/\n{2,}/g, "\n").trim();
return result;
}
function escapeRegex(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function row(label, value) {
return `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
}
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");
// Masquer la bannière EV si présente (on ne montre qu'une bannière à la fois)
hideEvUnreachableBanner();
}
function hideSessionExpiredBanner() {
const b = document.getElementById("session-expired-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");
}