8990 lines
345 KiB
JavaScript
8990 lines
345 KiB
JavaScript
// ============================================================================
|
||
// viewer.js — vue claire du planning techniciens
|
||
// ============================================================================
|
||
// Idée de base : on récupère tout depuis le XML EasyVista (calendar_block) en
|
||
// 1 seule requête. attr1/attr2/attr3 + textContent contiennent déjà ref,
|
||
// contact, lieu, catégorie, formLink, deadline. Plus besoin de faire 74
|
||
// requêtes xhr2 au chargement comme la v3. Le texte complet de l'action
|
||
// (Problème / À faire / Matériel) est lazy-load au hover, seulement si
|
||
// l'user survole la ligne.
|
||
//
|
||
// Fetch des fiches : séquentiel (1 par 1) au lieu d'en paralléliser. Le
|
||
// serveur EasyVista sérialise de toute façon, et ça rend l'abort instantané
|
||
// si l'user change de date en cours.
|
||
// Le cache est écrit toutes les 5 fiches (incrémental), pas juste à la fin.
|
||
// Comme ça si l'user change de date au milieu, ce qu'on a déjà fetché est
|
||
// pas perdu.
|
||
// ============================================================================
|
||
|
||
// ============================================================================
|
||
// Configuration
|
||
// ============================================================================
|
||
|
||
// Équipe : ID EasyVista → nom affiché
|
||
const TEAM = {
|
||
"76272": "Ciuppa, Mathieu",
|
||
"83725": "De Almeida Martins, Solange",
|
||
"66635": "Makonda, Yannick",
|
||
"92235": "Mamouni, Anas",
|
||
"90070": "Paisana, David",
|
||
"40944": "Pillonel, Olivier",
|
||
"72485": "Rosset, Pascal",
|
||
"86874": "Rouiller, Quentin"
|
||
};
|
||
|
||
// Absences récurrentes (id tech → [jour JS, 0=dim..6=sam])
|
||
const RECURRING_ABSENCES = {
|
||
"40944": [5] // Pillonel absent tous les vendredis
|
||
};
|
||
|
||
// Statuts EasyVista qui déclenchent l'affichage "clos"
|
||
const CLOSED_STATUS = ["Clôturé", "Cloture", "Clôture"];
|
||
const RESOLVED_STATUS = ["Résolu", "Resolu"];
|
||
// Statuts qui indiquent qu'une intervention a été supprimée/annulée
|
||
// → si présente dans le cache mais disparue du planning : on retire
|
||
const CANCELLED_STATUS = ["Annulé", "Annule", "Supprimé", "Supprime"];
|
||
|
||
// Clés de stockage
|
||
const LS_THEME = "planning_theme";
|
||
const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD
|
||
const CACHE_DAYS = 7;
|
||
|
||
// v4.1 : plus de constante de concurrence. Les fiches sont fetchées
|
||
// séquentiellement (1 à la fois) car le serveur EasyVista est lent de toute
|
||
// façon, et ça garantit un abort instantané + pas de race sur le DOM.
|
||
|
||
// ============================================================================
|
||
// Mapping de catégorie → titre court + couleur
|
||
// ============================================================================
|
||
|
||
const CATEGORY_TO_TITLE = [
|
||
// Arrivées / nouvelles installations → Installation (bleu)
|
||
[/Arriv[ée]e\s+ou\s+mutation/i, "Installation", "installation"],
|
||
[/Accessoire\s+pour\s+PC/i, "Installation", "installation"],
|
||
[/Nouveau\s+Poste\s+Windows/i, "Installation", "installation"],
|
||
[/Nouveau\s+Poste\s+macOS/i, "Installation", "installation"],
|
||
// Récupération / départ (vert)
|
||
[/D[ée]part\s+d[\u2018\u2019']un\s+utilisateur/i, "Récupération", "recup"],
|
||
[/Reprise\s+du\s+mat[ée]riel/i, "Récupération", "recup"],
|
||
// Remplacement (orange)
|
||
[/Remplacement\s+de\s+mat[ée]riel/i, "Remplacement", "remplacement"],
|
||
];
|
||
|
||
/**
|
||
* Détecte si le texte de l'action commence par "Roll Out".
|
||
*/
|
||
function isRollOut(iv) {
|
||
const texts = [
|
||
iv.bulleDescription,
|
||
iv.infobulle && iv.infobulle.aFaire,
|
||
iv.label
|
||
];
|
||
for (const t of texts) {
|
||
if (!t) continue;
|
||
if (/^\s*[«"']?\s*roll[\s\-]*out/i.test(String(t))) return true;
|
||
if (/(?:^|\bA faire\s*:\s*)roll[\s\-]*out/i.test(String(t))) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Détecte si le texte de l'action mentionne une récupération de matériel.
|
||
* Accepté : "RÉCUPÉRATION DE MATÉRIEL" ou "Récupération" au début de l'action,
|
||
* ou dans "A faire : Récupération ...".
|
||
*/
|
||
function isRecupAction(iv) {
|
||
const texts = [
|
||
iv.bulleDescription,
|
||
iv.infobulle && iv.infobulle.aFaire,
|
||
iv.label
|
||
];
|
||
for (const t of texts) {
|
||
if (!t) continue;
|
||
const s = String(t);
|
||
if (/^\s*r[ée]cup[ée]ration/i.test(s)) return true;
|
||
if (/\bA\s+faire\s*:\s*r[ée]cup[ée]ration/i.test(s)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Dérive un titre court et une clé de couleur à partir d'une intervention.
|
||
* Priorité :
|
||
* 1. Si la ref commence par I260 → "Incident" (violet)
|
||
* 2. Si l'action commence par "Roll Out" → "Roll Out" (brun)
|
||
* 3. Si l'action mentionne récupération → "Récupération" (vert)
|
||
* 4. Sinon, mapping par catégorie (fiche)
|
||
* 5. Sinon, "Autres" (gris)
|
||
*/
|
||
function deriveShortTitle(iv) {
|
||
if (iv.type === "AL-Reservation") return "Réservation";
|
||
if (iv.ref && /^I\d/.test(iv.ref)) return "Incident";
|
||
if (isRollOut(iv)) return "Roll Out";
|
||
if (isRecupAction(iv)) return "Récupération";
|
||
const cat = iv.categoryLine || "";
|
||
if (!cat) return "Autres";
|
||
for (const [regex, title] of CATEGORY_TO_TITLE) {
|
||
if (regex.test(cat)) return title;
|
||
}
|
||
return "Autres";
|
||
}
|
||
|
||
function deriveColorKey(iv) {
|
||
if (iv.type === "AL-Reservation") return "reservation";
|
||
if (iv.type === "AL-Absence") return "absence"; // v5.0.15 : couleur noire/gris foncé
|
||
if (iv.ref && /^I\d/.test(iv.ref)) return "incident";
|
||
if (isRollOut(iv)) return "rollout";
|
||
if (isRecupAction(iv)) return "recup";
|
||
const cat = iv.categoryLine || "";
|
||
if (!cat) return "autre";
|
||
for (const [regex, , colorKey] of CATEGORY_TO_TITLE) {
|
||
if (regex.test(cat)) return colorKey;
|
||
}
|
||
return "autre";
|
||
}
|
||
|
||
// ============================================================================
|
||
// État global
|
||
// ============================================================================
|
||
|
||
let state = {
|
||
session: null, // { phpsessid, origin, tabId }
|
||
currentDate: null, // "YYYY-MM-DD" affiché
|
||
currentData: null, // résultat parsé (techs, stats, ...)
|
||
loading: false,
|
||
// v5.0.9 : timestamp (ms) auquel la session EV va expirer.
|
||
sessionExpireAt: null,
|
||
// v5.0.9 : true pendant une reconnexion en cours
|
||
reconnecting: false,
|
||
// v5.0.9 : true si la session est expirée (bannière rouge affichée)
|
||
sessionExpired: false,
|
||
// v5.0.9 : true si on a déjà fait le ping de confirmation < 5 min
|
||
sessionPingDone: false,
|
||
// v5.0.10 : dernière origine EV connue comme fonctionnelle (itsma.vd.ch
|
||
// ou itsma.etat-de-vaud.ch selon qu'on est en externe ou interne).
|
||
// Conservée même quand state.session est null, pour savoir où rediriger
|
||
// lors de la reconnexion.
|
||
lastKnownOrigin: null,
|
||
// v5.0.11 : contexte réseau détecté ("internal" ou "external" ou null).
|
||
// Détecté automatiquement au démarrage par un HEAD test sur l'URL interne.
|
||
networkContext: null,
|
||
// v5.0.11 : timer (setTimeout id) pour le timeout de reconnexion 90 sec
|
||
reconnectTimeoutId: null
|
||
};
|
||
|
||
// v5.0.9 : constantes session
|
||
const SESSION_DURATION_MS = 30 * 60 * 1000; // 30 min
|
||
const SESSION_WARN_THRESHOLD_MS = 5 * 60 * 1000; // 5 min → affichage compteur
|
||
const SESSION_CRITICAL_THRESHOLD_MS = 2 * 60 * 1000; // 2 min → rouge + modal
|
||
|
||
// v5.0.11 : timeout de la reconnexion. Si l'user n'est pas reconnecté
|
||
// dans ce délai, on bascule en état "Reconnexion échouée" avec choix du réseau.
|
||
const RECONNECT_TIMEOUT_MS = 90 * 1000; // 90 sec
|
||
|
||
// ─── Annulation coopérative d'un refresh manuel (v3.1) ──────────────────────
|
||
// Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent
|
||
// isRefreshAborted() avant chaque fetch : si le jeton a changé ou si
|
||
// l'utilisateur a cliqué sur "Arrêter", ils s'arrêtent proprement.
|
||
//
|
||
// v3.2 : on ajoute une "abortPromise" par refresh. loadForDate race cette
|
||
// promesse avec son Promise.all, donc dès qu'on clique Arrêter, loadForDate
|
||
// sort immédiatement (masque le bouton, fait un toast), même si les fetches
|
||
// en cours continuent silencieusement. Le changement de token les rend
|
||
// inoffensifs (ils ne peuvent plus écrire le cache ni updater le DOM).
|
||
let currentRefreshToken = 0;
|
||
let abortedToken = -1;
|
||
let abortResolvers = new Map(); // token → resolve fn of the abort promise
|
||
|
||
function startNewRefresh() {
|
||
currentRefreshToken++;
|
||
return currentRefreshToken;
|
||
}
|
||
function makeAbortPromise(myToken) {
|
||
return new Promise(resolve => {
|
||
abortResolvers.set(myToken, resolve);
|
||
});
|
||
}
|
||
function abortCurrentRefresh() {
|
||
abortedToken = currentRefreshToken;
|
||
// Réveiller tous les loadForDate en attente (normalement un seul)
|
||
for (const [token, resolve] of abortResolvers) {
|
||
if (token <= currentRefreshToken) {
|
||
resolve("aborted");
|
||
abortResolvers.delete(token);
|
||
}
|
||
}
|
||
}
|
||
// v4.1.9 : isRefreshAborted(myToken) retourne true si :
|
||
// - un nouveau refresh a été lancé (currentRefreshToken > myToken), OU
|
||
// - l'utilisateur a explicitement cliqué "Arrêter" (abortedToken).
|
||
// Sans myToken fourni (compat), on ne teste que l'abort explicite.
|
||
function isRefreshAborted(myToken) {
|
||
if (abortedToken === currentRefreshToken) return true;
|
||
if (typeof myToken === "number" && myToken < currentRefreshToken) return true;
|
||
return false;
|
||
}
|
||
function cleanupAbortResolver(myToken) {
|
||
abortResolvers.delete(myToken);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Boot
|
||
// ============================================================================
|
||
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
|
||
async function init() {
|
||
initTheme();
|
||
bindTopbar();
|
||
bindTooltipInteractions();
|
||
initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal
|
||
initAppFooter(); // v4.2.9 : pied de page discret bas-droite
|
||
initAppClock(); // v5.0.0 : horloge HH:MM au milieu topbar
|
||
_applyViewMode(); // v2026.5.32 : appliquer la vue sauvegardée
|
||
initAdminMenu(); // v5.0.0 : menu admin caché (5 clics sur titre)
|
||
initSessionTimer(); // v5.0.9 : compteur de session EV (tick 1s)
|
||
initDateCustomPicker(); // v2026.5.17 : faux input date avec jour
|
||
|
||
// Initialiser la date = aujourd'hui
|
||
state.currentDate = todayISO();
|
||
document.getElementById("date-picker").value = state.currentDate;
|
||
updateDatePickerDayLabel(state.currentDate); // v2026.5.16 : label "Mardi"
|
||
|
||
// v5.0.11 : détecter le contexte réseau en arrière-plan (non bloquant)
|
||
detectNetworkContextAsync();
|
||
|
||
// Charger la sesson puis le planning
|
||
await refreshSessionAndLoad();
|
||
}
|
||
|
||
/**
|
||
* v5.0.11 : détecte si on est en interne (bureau VPN) ou externe (télétravail),
|
||
* de manière asynchrone au démarrage. Résultat utilisé pour choisir le bon
|
||
* domaine lors de la reconnexion.
|
||
*/
|
||
async function detectNetworkContextAsync(force = false) {
|
||
try {
|
||
const resp = await sendMessage({ type: "detectNetwork", force });
|
||
if (resp && resp.ok) {
|
||
state.networkContext = resp.context;
|
||
// Si on n'a pas encore de lastKnownOrigin, on prend celui du contexte détecté
|
||
if (!state.lastKnownOrigin) {
|
||
state.lastKnownOrigin = resp.origin;
|
||
}
|
||
console.log("[viewer] réseau détecté :", resp.context, "→", resp.origin);
|
||
}
|
||
} catch (e) {
|
||
console.warn("[viewer] détection réseau échouée", e);
|
||
}
|
||
}
|
||
|
||
async function refreshSessionAndLoad() {
|
||
const resp = await sendMessage({ type: "getSession" });
|
||
if (!resp.ok || !resp.session) {
|
||
// v4.2.5 : si un cache existe pour le jour demandé, on l'affiche avec
|
||
// une bannière "session expirée" sticky au-dessus. Sinon écran plein.
|
||
const cached = await readCache(state.currentDate);
|
||
if (cached) {
|
||
renderFromData({
|
||
techs: cached.techs,
|
||
targetDate: state.currentDate,
|
||
captureTime: cached.savedAt || null,
|
||
source: "cache"
|
||
});
|
||
showSessionExpiredBanner();
|
||
} else {
|
||
showSessionNeeded();
|
||
}
|
||
return;
|
||
}
|
||
state.session = resp.session;
|
||
// v5.0.10 : mémoriser l'origine courante pour la reconnexion si besoin
|
||
if (resp.session && resp.session.origin) {
|
||
state.lastKnownOrigin = resp.session.origin;
|
||
}
|
||
hideSessionNeeded();
|
||
hideEvUnreachable();
|
||
hideSessionExpiredBanner();
|
||
hideEvUnreachableBanner();
|
||
state.sessionExpired = false;
|
||
state.reconnecting = false;
|
||
fetchAndShowCurrentUser();
|
||
// v5.0.9 : à chaque démarrage/reconnexion, on suppose que la session vient
|
||
// d'être rafraîchie à 30 min. updateSessionIndicator va masquer le compteur.
|
||
markSessionActivity();
|
||
await loadForDate(state.currentDate);
|
||
}
|
||
|
||
/**
|
||
* v5.0.9 : doit être appelée à chaque requête EasyVista réussie. Reset le
|
||
* timer local à 30 min (la session serveur a été renouvelée implicitement).
|
||
*/
|
||
function markSessionActivity() {
|
||
state.sessionExpireAt = Date.now() + SESSION_DURATION_MS;
|
||
state.sessionPingDone = false; // reset le flag de ping
|
||
updateSessionIndicator();
|
||
}
|
||
|
||
// v4.2 : fetche l'utilisateur EasyVista connecté (via background.js) et
|
||
// l'affiche dans la topbar.
|
||
// v2026.5.26 : en cas d'échec, affiche un rond gris "?" + retry 60s (max 10 essais).
|
||
// v2026.5.34 : le badge est maintenant TOUJOURS visible (état "?" par défaut
|
||
// dans le HTML). Cette fonction met à jour le contenu (initiales
|
||
// quand succès, "?" quand échec). Logs abondants pour debug.
|
||
//
|
||
// État initial (HTML) : <button class="user-badge user-badge-unknown">?</button>
|
||
// État succès : initiales calculées + couleur dérivée du nom
|
||
// État échec : "?" + couleur grise (classe user-badge-unknown)
|
||
//
|
||
// Retry : 10 tentatives espacées de 60s (10 min max), arrêt au 1er succès.
|
||
let _currentUserRetryCount = 0;
|
||
const _CURRENT_USER_MAX_RETRIES = 10;
|
||
const _CURRENT_USER_RETRY_DELAY_MS = 60 * 1000;
|
||
|
||
async function fetchAndShowCurrentUser() {
|
||
const attemptId = _currentUserRetryCount + 1;
|
||
console.log(`[currentUser] tentative ${attemptId}/${_CURRENT_USER_MAX_RETRIES + 1} de fetchCurrentUser`);
|
||
|
||
const badge = document.getElementById("user-badge");
|
||
if (!badge) {
|
||
// Fallback défensif : pas de badge dans le DOM ? On log et on abandonne.
|
||
console.warn("[currentUser] badge DOM introuvable — abandon");
|
||
return;
|
||
}
|
||
|
||
let success = false;
|
||
let errorReason = null;
|
||
|
||
try {
|
||
const resp = await sendMessage({ type: "fetchCurrentUser" });
|
||
console.log("[currentUser] réponse reçue :", resp ? JSON.stringify(resp).substring(0, 200) : "(null)");
|
||
|
||
if (!resp) {
|
||
errorReason = "response_null";
|
||
} else if (!resp.ok) {
|
||
errorReason = resp.error || "ok_false";
|
||
} else if (!resp.user) {
|
||
errorReason = "user_null";
|
||
} else {
|
||
const fullName = resp.user.name || resp.user.login || null;
|
||
if (!fullName) {
|
||
errorReason = "name_empty";
|
||
} else {
|
||
// ✅ Succès : mise à jour du badge
|
||
const initials = computeUserInitials(fullName);
|
||
console.log(`[currentUser] SUCCÈS : "${fullName}" → initiales "${initials}"`);
|
||
badge.textContent = initials;
|
||
badge.title = fullName;
|
||
badge.style.setProperty("--user-badge-color", colorFromName(fullName));
|
||
badge.classList.remove("user-badge-unknown");
|
||
// On retire aussi "hidden" au cas où (compat ancienne version)
|
||
badge.classList.remove("hidden");
|
||
state.currentUser = resp.user;
|
||
success = true;
|
||
_currentUserRetryCount = 0; // reset compteur au succès
|
||
}
|
||
}
|
||
} catch (err) {
|
||
errorReason = "exception: " + String(err);
|
||
console.warn("[currentUser] exception durant sendMessage :", err);
|
||
}
|
||
|
||
if (success) return;
|
||
|
||
// ❌ Échec : on laisse le badge en état "inconnu" (déjà le cas par défaut)
|
||
// et on schedule un retry.
|
||
console.warn(`[currentUser] échec (raison: ${errorReason}) — badge reste en état "?"`);
|
||
|
||
// Défense : s'assurer que le badge est bien en état inconnu (au cas où
|
||
// une mise à jour partielle a eu lieu puis échoué).
|
||
badge.textContent = "?";
|
||
badge.title = "Utilisateur — cliquer pour accéder aux paramètres";
|
||
badge.style.setProperty("--user-badge-color", "#6b7280");
|
||
badge.classList.add("user-badge-unknown");
|
||
badge.classList.remove("hidden");
|
||
|
||
// Schedule retry si pas trop d'essais
|
||
if (_currentUserRetryCount < _CURRENT_USER_MAX_RETRIES) {
|
||
_currentUserRetryCount++;
|
||
console.log(`[currentUser] retry programmé : ${_currentUserRetryCount}/${_CURRENT_USER_MAX_RETRIES} dans ${_CURRENT_USER_RETRY_DELAY_MS / 1000}s`);
|
||
setTimeout(() => {
|
||
fetchAndShowCurrentUser();
|
||
}, _CURRENT_USER_RETRY_DELAY_MS);
|
||
} else {
|
||
console.warn("[currentUser] max retries atteint, arrêt du retry automatique. Le badge reste cliquable (⚙ Paramètres accessible).");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* v2026.5.34 : déclenche un fetchAndShowCurrentUser() SI le user n'est pas
|
||
* encore connu (badge en état "?"). Appelée après chaque succès de planning
|
||
* pour profiter d'une session EV valide sans attendre le retry de 60s.
|
||
*
|
||
* Sans effet si :
|
||
* - state.currentUser est déjà renseigné (pas besoin de re-fetcher)
|
||
* - un retry est déjà en cours (évite les doublons)
|
||
*
|
||
* @param {string} reason - contexte pour les logs (ex: "after_load_success")
|
||
*/
|
||
function _maybeRetryFetchUser(reason) {
|
||
if (state.currentUser && state.currentUser.name) {
|
||
// User déjà connu, rien à faire
|
||
return;
|
||
}
|
||
const badge = document.getElementById("user-badge");
|
||
if (badge && !badge.classList.contains("user-badge-unknown")) {
|
||
// Badge n'est pas en état inconnu → user probablement connu par un autre chemin
|
||
return;
|
||
}
|
||
console.log(`[currentUser] relance opportuniste (raison: ${reason}) — user encore inconnu`);
|
||
// Reset le compteur puisqu'on a un nouveau contexte (session fraîche)
|
||
_currentUserRetryCount = 0;
|
||
fetchAndShowCurrentUser();
|
||
}
|
||
|
||
// v4.2.3 : calcule les initiales depuis un nom au format "Nom, Prénom" ou
|
||
// "Nom Prénom" ou "Prénom Nom". On prend la 1re lettre majuscule de chaque
|
||
// mot/segment significatif, limité à 2 caractères.
|
||
function computeUserInitials(fullName) {
|
||
if (!fullName) return "?";
|
||
// Format "Nom, Prénom" → prendre initiale avant virgule et après
|
||
let parts;
|
||
if (fullName.includes(",")) {
|
||
parts = fullName.split(",").map(s => s.trim()).filter(Boolean);
|
||
} else {
|
||
parts = fullName.split(/\s+/).filter(Boolean);
|
||
}
|
||
const letters = parts
|
||
.map(p => p.charAt(0))
|
||
.filter(c => /[A-Za-zÀ-ÿ]/.test(c))
|
||
.slice(0, 2)
|
||
.join("")
|
||
.toUpperCase();
|
||
return letters || (fullName.charAt(0).toUpperCase() || "?");
|
||
}
|
||
|
||
// v4.2.3 : couleur déterministe à partir du nom. Palette neutre et sobre
|
||
// (tons tamisés), compatible avec les thèmes clair et sombre de l'extension.
|
||
function colorFromName(name) {
|
||
// Hash simple (djb2) pour dériver un index stable
|
||
let h = 5381;
|
||
for (let i = 0; i < name.length; i++) {
|
||
h = ((h << 5) + h + name.charCodeAt(i)) & 0xffffffff;
|
||
}
|
||
const palette = [
|
||
"#5b6372", // gris bleuté
|
||
"#6b7280", // gris neutre
|
||
"#4a5568", // ardoise
|
||
"#3b5a72", // bleu profond tamisé
|
||
"#4f6a5e", // vert sauge sombre
|
||
"#6b5a4f", // brun taupe
|
||
"#5d4a6b", // prune sombre
|
||
"#6a5a3a", // kaki bronze
|
||
"#3a5a5e", // sarcelle sombre
|
||
"#6c5c67" // mauve grisé
|
||
];
|
||
return palette[Math.abs(h) % palette.length];
|
||
}
|
||
|
||
// v4.2.3 : affiche/masque la popup nom complet sous la pastille
|
||
function toggleUserNamePopup() {
|
||
const badge = document.getElementById("user-badge");
|
||
const popup = document.getElementById("user-name-popup");
|
||
if (!badge || !popup) return;
|
||
if (!popup.classList.contains("hidden")) {
|
||
hideUserNamePopup();
|
||
return;
|
||
}
|
||
|
||
// v2026.5.17 : afficher aussi le temps restant de la session (MM:SS) avec
|
||
// une couleur qui dépend du seuil (vert/jaune/rouge).
|
||
// v2026.5.26 : si user inconnu, afficher "Utilisateur inconnu" + retry + réglages
|
||
popup.innerHTML = "";
|
||
const nameEl = document.createElement("div");
|
||
nameEl.className = "user-name-popup-name";
|
||
if (state.currentUser && state.currentUser.name) {
|
||
nameEl.textContent = state.currentUser.name;
|
||
} else {
|
||
nameEl.textContent = "Utilisateur inconnu";
|
||
nameEl.style.fontStyle = "italic";
|
||
nameEl.style.color = "var(--text-muted)";
|
||
}
|
||
popup.appendChild(nameEl);
|
||
|
||
const sessEl = document.createElement("div");
|
||
sessEl.className = "user-name-popup-session";
|
||
sessEl.id = "user-name-popup-session";
|
||
_renderUserPopupSessionLine(sessEl);
|
||
popup.appendChild(sessEl);
|
||
|
||
// v2026.5.25 : bouton Paramètres (remplace les 5 clics sur le titre)
|
||
// v2026.5.32 : bouton "Vue" pour basculer Vue classique ↔ Vue horizontale
|
||
const viewBtn = document.createElement("button");
|
||
viewBtn.type = "button";
|
||
viewBtn.className = "user-name-popup-settings";
|
||
const currentView = _getCurrentView();
|
||
viewBtn.innerHTML = '<span class="settings-ico">⊞</span> Vue : '
|
||
+ (currentView === "horizontal" ? "Horizontale" : "Classique");
|
||
viewBtn.title = "Changer la disposition du planning";
|
||
viewBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
hideUserNamePopup();
|
||
_toggleView();
|
||
});
|
||
popup.appendChild(viewBtn);
|
||
|
||
const settingsBtn = document.createElement("button");
|
||
settingsBtn.type = "button";
|
||
settingsBtn.className = "user-name-popup-settings";
|
||
settingsBtn.innerHTML = '<span class="settings-ico">⚙</span> Paramètres';
|
||
settingsBtn.title = "Ouvrir les paramètres d'administration";
|
||
settingsBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
hideUserNamePopup();
|
||
if (typeof showAdminPanel === "function") {
|
||
showAdminPanel();
|
||
}
|
||
});
|
||
popup.appendChild(settingsBtn);
|
||
|
||
popup.classList.remove("hidden");
|
||
badge.classList.add("open");
|
||
// Positionne juste en dessous de la pastille
|
||
const r = badge.getBoundingClientRect();
|
||
popup.style.left = Math.max(8, r.left) + "px";
|
||
popup.style.top = (r.bottom + 6) + "px";
|
||
}
|
||
function hideUserNamePopup() {
|
||
const popup = document.getElementById("user-name-popup");
|
||
const badge = document.getElementById("user-badge");
|
||
if (popup) popup.classList.add("hidden");
|
||
if (badge) badge.classList.remove("open");
|
||
}
|
||
|
||
// v2026.5.17 : remplit la ligne "Session : MM:SS" avec couleur selon seuil.
|
||
// Recalcule à chaque appel — appelée aussi par le tick session pour rafraîchir.
|
||
function _renderUserPopupSessionLine(el) {
|
||
if (!el) return;
|
||
const remainingMs = _getSessionRemainingMs();
|
||
if (remainingMs == null) {
|
||
el.textContent = "Session : —";
|
||
el.className = "user-name-popup-session";
|
||
return;
|
||
}
|
||
const mins = Math.floor(remainingMs / 60000);
|
||
const secs = Math.floor((remainingMs % 60000) / 1000);
|
||
const txt = `Session : ${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||
el.textContent = txt;
|
||
el.className = "user-name-popup-session";
|
||
if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS) {
|
||
el.classList.add("session-critical");
|
||
} else if (remainingMs <= SESSION_WARN_THRESHOLD_MS) {
|
||
el.classList.add("session-warn");
|
||
} else {
|
||
el.classList.add("session-ok");
|
||
}
|
||
}
|
||
|
||
// v2026.5.17 : récupère en ms le temps restant avant expiration de la session.
|
||
// Retourne null si on ne connaît pas encore (pas de session ouverte).
|
||
function _getSessionRemainingMs() {
|
||
if (!state.sessionExpireAt) return null;
|
||
const remaining = state.sessionExpireAt - Date.now();
|
||
return remaining > 0 ? remaining : 0;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Thème clair/sombre
|
||
// ============================================================================
|
||
|
||
function initTheme() {
|
||
const saved = localStorage.getItem(LS_THEME);
|
||
const theme = (saved === "light" || saved === "dark") ? saved : detectDefaultTheme();
|
||
applyTheme(theme);
|
||
}
|
||
|
||
function detectDefaultTheme() {
|
||
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||
return "dark";
|
||
}
|
||
return "light";
|
||
}
|
||
|
||
function applyTheme(theme) {
|
||
document.documentElement.setAttribute("data-theme", theme);
|
||
const icon = document.getElementById("theme-icon");
|
||
if (icon) icon.textContent = theme === "dark" ? "☀️" : "🌙";
|
||
}
|
||
|
||
function toggleTheme() {
|
||
const current = document.documentElement.getAttribute("data-theme") || "light";
|
||
const next = current === "dark" ? "light" : "dark";
|
||
applyTheme(next);
|
||
localStorage.setItem(LS_THEME, next);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Topbar handlers
|
||
// ============================================================================
|
||
|
||
function bindTopbar() {
|
||
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
|
||
// v4.1.10 : 2 boutons de rafraichissement.
|
||
// - refresh-btn (Total) : force le re-fetch de toutes les fiches (même celles
|
||
// déjà enrichies), utile pour voir les statuts évoluer.
|
||
// - refresh-partial-btn (Partiel) : re-fetch juste le XML planning pour
|
||
// détecter nouvelles/disparues interventions, mais ne refetch PAS les
|
||
// fiches déjà connues → rapide.
|
||
document.getElementById("refresh-btn").addEventListener("click", () => {
|
||
setActiveRefreshButton("total");
|
||
refreshPlanning({ total: true });
|
||
});
|
||
const partialBtn = document.getElementById("refresh-partial-btn");
|
||
if (partialBtn) {
|
||
partialBtn.addEventListener("click", () => {
|
||
setActiveRefreshButton("partial");
|
||
refreshPlanning({ partial: true });
|
||
});
|
||
}
|
||
document.getElementById("abort-btn").addEventListener("click", () => {
|
||
// Feedback visuel instantané : masquer le bouton tout de suite, sans
|
||
// attendre que loadForDate finisse sa race.
|
||
showAbortButton(false);
|
||
abortCurrentRefresh();
|
||
showAbortToast();
|
||
});
|
||
document.getElementById("clear-cache-btn").addEventListener("click", onClearCache);
|
||
|
||
// v4.2.6 : boutons Absence et Douchette
|
||
const absenceBtn = document.getElementById("absence-btn");
|
||
if (absenceBtn) absenceBtn.addEventListener("click", showAbsenceModal);
|
||
const douchetteBtn = document.getElementById("douchette-btn");
|
||
if (douchetteBtn) douchetteBtn.addEventListener("click", showDouchetteModal);
|
||
|
||
document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1));
|
||
document.getElementById("nav-next").addEventListener("click", () => navigateDate(+1));
|
||
document.getElementById("nav-today").addEventListener("click", () => loadForDate(todayISO()));
|
||
|
||
document.getElementById("date-picker").addEventListener("change", (e) => {
|
||
if (e.target.value) loadForDate(e.target.value);
|
||
});
|
||
|
||
// v4.2.3 : clic sur la pastille d'initiales → toggle popup nom complet
|
||
const userBadge = document.getElementById("user-badge");
|
||
if (userBadge) {
|
||
userBadge.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
toggleUserNamePopup();
|
||
});
|
||
}
|
||
// Clic ailleurs ou touche Escape ferme la popup user
|
||
document.addEventListener("click", (e) => {
|
||
const popup = document.getElementById("user-name-popup");
|
||
if (popup && !popup.classList.contains("hidden")) {
|
||
// Ne pas fermer si le clic est dans la popup elle-même ou sur le badge
|
||
if (!e.target.closest("#user-name-popup") && !e.target.closest("#user-badge")) {
|
||
hideUserNamePopup();
|
||
}
|
||
}
|
||
// v4.2.4 : clic ailleurs ferme aussi la grande bulle d'interventoin
|
||
// quand elle est ouverte via clic timeline (mode "anchored"). Clic sur
|
||
// la bulle elle-même ou sur une timeline-slot ne ferme pas.
|
||
const tip = tooltipEl();
|
||
if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
|
||
if (!e.target.closest("#tooltip") && !e.target.closest(".timeline-slot")) {
|
||
hideTooltip({ force: true });
|
||
}
|
||
}
|
||
});
|
||
// v2026.5.20 : nouveau comportement de la touche Échap
|
||
// - Appui court : ferme uniquement le popup SOUS la souris (normal ou
|
||
// minimisé). Si la souris n'est sur aucun popup, ne fait rien.
|
||
// Ferme aussi le popup user-badge et la grande bulle anchored.
|
||
// - Maintenu ≥ 3 secondes : ferme TOUS les popups flottants, mais garde
|
||
// les pastilles dock (popups "réduits" en bas).
|
||
let _escHoldTimer = null;
|
||
let _escHoldTriggered = false;
|
||
const ESC_HOLD_MS = 3000;
|
||
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key !== "Escape") return;
|
||
// keydown peut se répéter si la touche est maintenue ; on ignore les répétitions.
|
||
if (e.repeat) return;
|
||
// Armer le timer "maintenu 3s"
|
||
_escHoldTriggered = false;
|
||
if (_escHoldTimer) clearTimeout(_escHoldTimer);
|
||
_escHoldTimer = setTimeout(() => {
|
||
_escHoldTriggered = true;
|
||
_escHoldTimer = null;
|
||
// Fermer TOUS les popups flottants (normaux + minimisés) mais pas les dockés
|
||
document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced)").forEach(p => {
|
||
try { p.remove(); } catch (err) {}
|
||
});
|
||
// Nettoyer la liste
|
||
for (let i = pinnedPopups.length - 1; i >= 0; i--) {
|
||
if (!document.body.contains(pinnedPopups[i].el)) {
|
||
pinnedPopups.splice(i, 1);
|
||
}
|
||
}
|
||
_ensureDockCloseAllBtn();
|
||
}, ESC_HOLD_MS);
|
||
});
|
||
|
||
document.addEventListener("keyup", (e) => {
|
||
if (e.key !== "Escape") return;
|
||
if (_escHoldTimer) {
|
||
clearTimeout(_escHoldTimer);
|
||
_escHoldTimer = null;
|
||
}
|
||
if (_escHoldTriggered) {
|
||
// On a déjà fait l'action "maintenu", ne rien faire de plus
|
||
_escHoldTriggered = false;
|
||
return;
|
||
}
|
||
// Appui court : fermer le popup sous la souris si applicable
|
||
hideUserNamePopup();
|
||
const tip = tooltipEl();
|
||
if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
|
||
hideTooltip({ force: true });
|
||
}
|
||
// Quel popup est sous la souris ? Utiliser :hover pour détecter
|
||
const hovered = document.querySelector(".pinned-popup:hover");
|
||
if (hovered && !hovered.classList.contains("pinned-popup-reduced")) {
|
||
// Retirer aussi de pinnedPopups
|
||
const idx = pinnedPopups.findIndex(p => p.el === hovered);
|
||
if (idx >= 0) pinnedPopups.splice(idx, 1);
|
||
hovered.remove();
|
||
_ensureDockCloseAllBtn();
|
||
}
|
||
});
|
||
|
||
// v5.0.10 : clic "Ouvrir EasyVista" sur l'écran plein → déclenche la
|
||
// reconnexion SSO + l'auto-reload du viewer dès que la nouvelle session
|
||
// est détectée (au lieu d'ouvrir juste un onglet).
|
||
document.getElementById("open-ev-btn").addEventListener("click", () => {
|
||
triggerReconnect();
|
||
});
|
||
|
||
// v4.2 : écran "EasyVista inaccessible"
|
||
const openEvBtn2 = document.getElementById("open-ev-btn-2");
|
||
if (openEvBtn2) openEvBtn2.addEventListener("click", openEasyVista);
|
||
const retryBtn = document.getElementById("retry-btn");
|
||
if (retryBtn) retryBtn.addEventListener("click", async () => {
|
||
hideEvUnreachable();
|
||
document.getElementById("loading").classList.remove("hidden");
|
||
await refreshSessionAndLoad();
|
||
});
|
||
|
||
// v4.1.12 : bindings bannière session expirée
|
||
const reconnectBtn = document.getElementById("session-banner-reconnect");
|
||
if (reconnectBtn) reconnectBtn.addEventListener("click", openEasyVista);
|
||
const closeBtn = document.getElementById("session-banner-close");
|
||
if (closeBtn) closeBtn.addEventListener("click", hideSessionExpiredBanner);
|
||
|
||
// v4.2.5 : bindings bannière "EasyVista inaccessible"
|
||
const evRetryBtn = document.getElementById("ev-unreachable-banner-retry");
|
||
if (evRetryBtn) evRetryBtn.addEventListener("click", async () => {
|
||
hideEvUnreachableBanner();
|
||
await refreshSessionAndLoad();
|
||
});
|
||
const evOpenBtn = document.getElementById("ev-unreachable-banner-open");
|
||
if (evOpenBtn) evOpenBtn.addEventListener("click", openEasyVista);
|
||
const evCloseBtn = document.getElementById("ev-unreachable-banner-close");
|
||
if (evCloseBtn) evCloseBtn.addEventListener("click", hideEvUnreachableBanner);
|
||
}
|
||
|
||
async function openEasyVista() {
|
||
// v5.0.10 : ouvrir sur le domaine le plus approprié :
|
||
// - lastKnownOrigin si on a déjà eu une session fonctionnelle (respecte
|
||
// interne vs externe selon le réseau)
|
||
// - session.origin si on a encore la session
|
||
// - itsma.vd.ch en fallback (domaine externe accessible de partout,
|
||
// même depuis le réseau VD il redirige vers l'interne transparent)
|
||
const origin = state.lastKnownOrigin
|
||
|| (state.session && state.session.origin)
|
||
|| "https://itsma.vd.ch";
|
||
await chrome.tabs.create({ url: origin + "/" });
|
||
}
|
||
|
||
// Navigation ±1 jour en sautant les week-ends
|
||
function navigateDate(direction) {
|
||
const d = isoToDate(state.currentDate);
|
||
d.setDate(d.getDate() + direction);
|
||
// Sauter les week-ends
|
||
while (d.getDay() === 0 || d.getDay() === 6) {
|
||
d.setDate(d.getDate() + direction);
|
||
}
|
||
loadForDate(dateToISO(d));
|
||
}
|
||
|
||
async function onClearCache() {
|
||
// v4.1.20 : modal central avec 2 choix (jour / tout) + annuler
|
||
showClearCacheModal();
|
||
}
|
||
|
||
// v4.1.20 : modal central de confirmation pour vider le cache. L'arrière-plan
|
||
// est flouté, l'utilisateur a deux choix explicites + Annuler.
|
||
function showClearCacheModal() {
|
||
// Ne pas ouvrir 2x si déjà affiché
|
||
if (document.getElementById("clear-cache-modal")) return;
|
||
|
||
const dateTxt = formatDateDM(state.currentDate);
|
||
|
||
const overlay = document.createElement("div");
|
||
overlay.id = "clear-cache-modal";
|
||
overlay.className = "modal-overlay";
|
||
overlay.innerHTML = `
|
||
<div class="modal-card" role="dialog" aria-labelledby="clear-cache-title">
|
||
<h2 id="clear-cache-title" class="modal-title">Vider le cache</h2>
|
||
<p class="modal-message">
|
||
Le cache stocke les données des interventions pour éviter de
|
||
re-télécharger à chaque ouverture. Que voulez-vous supprimer ?
|
||
</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);
|
||
}
|
||
|
||
// v2026.5.32 : bascule entre Vue classique (cards) et Vue horizontale (rows)
|
||
// Persisté dans localStorage (clé : "view_mode"). Défaut : "classic".
|
||
const VIEW_MODE_KEY = "view_mode";
|
||
|
||
function _getCurrentView() {
|
||
try {
|
||
const v = localStorage.getItem(VIEW_MODE_KEY);
|
||
return v === "horizontal" ? "horizontal" : "classic";
|
||
} catch (e) {
|
||
return "classic";
|
||
}
|
||
}
|
||
|
||
function _setCurrentView(mode) {
|
||
try {
|
||
localStorage.setItem(VIEW_MODE_KEY, mode === "horizontal" ? "horizontal" : "classic");
|
||
} catch (e) {}
|
||
_applyViewMode();
|
||
}
|
||
|
||
function _toggleView() {
|
||
const current = _getCurrentView();
|
||
const next = current === "horizontal" ? "classic" : "horizontal";
|
||
_setCurrentView(next);
|
||
}
|
||
|
||
function _applyViewMode() {
|
||
const mode = _getCurrentView();
|
||
document.documentElement.classList.remove("view-classic", "view-horizontal");
|
||
document.documentElement.classList.add("view-" + mode);
|
||
}
|
||
|
||
// v5.0.0 : horloge HH:MM au milieu de la topbar. Mise à jour toutes les 30s
|
||
// (les secondes ne sont pas affichées donc pas besoin d'un tick plus rapide).
|
||
// v2026.5.27 : date courte "Jeudi 23.04.26" sur la même ligne que l'heure,
|
||
// séparées par un gros point "•", même taille que l'heure.
|
||
function initAppClock() {
|
||
const el = document.getElementById("app-clock");
|
||
if (!el) return;
|
||
const dateEl = document.getElementById("app-clock-date");
|
||
const timeEl = document.getElementById("app-clock-time");
|
||
|
||
const JOURS = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"];
|
||
|
||
let lastDateStr = "";
|
||
const tick = () => {
|
||
const d = new Date();
|
||
const h = String(d.getHours()).padStart(2, "0");
|
||
const m = String(d.getMinutes()).padStart(2, "0");
|
||
const timeStr = `${h}:${m}`;
|
||
if (timeEl) timeEl.textContent = timeStr;
|
||
else el.textContent = timeStr; // fallback si ancien markup
|
||
|
||
// Date courte : "Jeudi 23.04.26"
|
||
if (dateEl) {
|
||
const jour = JOURS[d.getDay()];
|
||
const dd = String(d.getDate()).padStart(2, "0");
|
||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||
const yy = String(d.getFullYear()).slice(-2);
|
||
const dateStr = `${jour} ${dd}.${mm}.${yy}`;
|
||
if (dateStr !== lastDateStr) {
|
||
dateEl.textContent = dateStr;
|
||
lastDateStr = dateStr;
|
||
}
|
||
}
|
||
// v5.0.0 : profite du tick pour mettre à jour la ligne rouge "now"
|
||
updateNowLine();
|
||
};
|
||
tick();
|
||
// Tick toutes les 30s : ça garantit une MAJ rapide au changement de min
|
||
setInterval(tick, 30 * 1000);
|
||
}
|
||
|
||
// v2026.5.17 : met à jour le faux input date custom (ex: "Vendredi 24.04.2026")
|
||
// Remplace l'ancien updateDatePickerDayLabel. L'input date natif reste présent
|
||
// mais caché, et son onChange continue de déclencher le chargement.
|
||
const DAY_NAMES_FULL = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"];
|
||
function updateDatePickerDayLabel(isoDate) {
|
||
const el = document.getElementById("date-custom-label");
|
||
if (!el) return;
|
||
if (!isoDate) { el.textContent = ""; return; }
|
||
try {
|
||
const d = isoToDate(isoDate);
|
||
const day = DAY_NAMES_FULL[d.getDay()];
|
||
const dd = String(d.getDate()).padStart(2, "0");
|
||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||
const yyyy = d.getFullYear();
|
||
el.textContent = `${day} ${dd}.${mm}.${yyyy}`;
|
||
} catch (e) {
|
||
el.textContent = "";
|
||
}
|
||
}
|
||
|
||
// v2026.5.17 : brancher le faux input date — clic dessus ouvre le vrai input
|
||
// caché pour choisir une date.
|
||
function initDateCustomPicker() {
|
||
const custom = document.getElementById("date-custom");
|
||
const picker = document.getElementById("date-picker");
|
||
if (!custom || !picker) return;
|
||
const openPicker = () => {
|
||
try {
|
||
if (typeof picker.showPicker === "function") {
|
||
picker.showPicker();
|
||
} else {
|
||
picker.focus();
|
||
picker.click();
|
||
}
|
||
} catch (e) {
|
||
picker.focus();
|
||
}
|
||
};
|
||
custom.addEventListener("click", openPicker);
|
||
custom.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
openPicker();
|
||
}
|
||
});
|
||
}
|
||
|
||
// v5.0.0 : ligne verticale rouge "heure actuelle" sur la timeline, visible
|
||
// UNIQUEMENT quand on affiche aujourd'hui. Appelée à chaque tick de l'horloge
|
||
// + après chaque render (cf renderFromData).
|
||
function updateNowLine() {
|
||
const isToday = state.currentDate === todayISO();
|
||
// Retirer toutes les lignes existantes d'abord
|
||
document.querySelectorAll(".timeline-now-line").forEach(el => el.remove());
|
||
if (!isToday) return;
|
||
// Calculer la position en % sur la timeline (DAY_START à DAY_END)
|
||
const now = new Date();
|
||
const nowMin = now.getHours() * 60 + now.getMinutes();
|
||
if (nowMin < DAY_START || nowMin > DAY_END) return; // hors plage affichée
|
||
const pct = ((nowMin - DAY_START) / DAY_LEN) * 100;
|
||
// Ajouter une ligne sur chaque barre timeline visible
|
||
document.querySelectorAll(".timeline-bar").forEach(bar => {
|
||
const line = document.createElement("div");
|
||
line.className = "timeline-now-line";
|
||
line.style.left = pct + "%";
|
||
bar.appendChild(line);
|
||
});
|
||
}
|
||
|
||
// v5.0.0 : menu admin caché via 5 clics sur le titre "Planification".
|
||
// v2026.5.25 : SUPPRIMÉ — l'accès au panneau admin se fait désormais via le
|
||
// bouton "⚙ Paramètres" du popup user-badge (clic sur les initiales).
|
||
function initAdminMenu() {
|
||
const title = document.getElementById("app-title");
|
||
if (!title) return;
|
||
title.style.cursor = "default";
|
||
// Plus de handler de clic : les 5 clics n'ouvrent plus rien.
|
||
}
|
||
|
||
// ============================================================================
|
||
// v5.0.9 : Surveillance du timeout de session EasyVista
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Initialise le tick du compteur de session (toutes les secondes).
|
||
* Pas de requête réseau : décompte purement local depuis state.sessionExpireAt.
|
||
* En parallèle, un polling 2s actif uniquement en reconnexion, pour détecter
|
||
* dès que l'user s'est reconnecté dans l'onglet EasyVista ouvert.
|
||
*/
|
||
function initSessionTimer() {
|
||
setInterval(() => {
|
||
updateSessionIndicator();
|
||
}, 1000);
|
||
|
||
// Polling actif UNIQUEMENT pendant une reconnexion pour détecter le nouveau
|
||
// PHPSESSID dès qu'il apparaît dans un onglet EV. Rien d'envoyé au serveur
|
||
// en dehors de ça.
|
||
setInterval(async () => {
|
||
if (!state.reconnecting) return;
|
||
try {
|
||
const resp = await sendMessage({ type: "getSession" });
|
||
if (resp && resp.ok && resp.session && resp.session.phpsessid) {
|
||
const oldPhpsessid = state.session ? state.session.phpsessid : null;
|
||
if (resp.session.phpsessid !== oldPhpsessid) {
|
||
console.log("[session] nouvelle session détectée après reconnexion :", resp.session.phpsessid);
|
||
// v5.0.11 : annuler le timeout de reconnexion puisque ça a marché
|
||
if (state.reconnectTimeoutId) {
|
||
clearTimeout(state.reconnectTimeoutId);
|
||
state.reconnectTimeoutId = null;
|
||
}
|
||
state.session = resp.session;
|
||
if (resp.session.origin) state.lastKnownOrigin = resp.session.origin;
|
||
state.reconnecting = false;
|
||
state.sessionExpired = false;
|
||
hideReconnectingBanner();
|
||
hideSessionExpiredBanner();
|
||
hideReconnectFailedBanner();
|
||
markSessionActivity();
|
||
showToast("Reconnecté", "Session EasyVista renouvelée");
|
||
// v2026.5.34 : relancer fetchUser tout de suite (au lieu d'attendre
|
||
// le retry de 60s) — la session vient d'être renouvelée, c'est le
|
||
// meilleur moment pour récupérer le user.
|
||
_maybeRetryFetchUser("session_reconnected");
|
||
// Recharger le planning à la date courante sans perdre la position
|
||
await loadForDate(state.currentDate);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Silencieux, on réessayera au prochain tick
|
||
}
|
||
}, 2000);
|
||
}
|
||
|
||
/**
|
||
* Met à jour l'affichage du compteur session dans la topbar.
|
||
* Règles :
|
||
* - Session expirée ou reconnexion → compteur caché (bannière gère l'affichage)
|
||
* - > 5 min restantes → compteur invisible
|
||
* - 2-5 min → jaune, bouton "Prolonger" visible
|
||
* - < 2 min → rouge pulse + modal automatique (une seule fois)
|
||
* - <= 0 → déclenche l'état "expirée"
|
||
*/
|
||
function updateSessionIndicator() {
|
||
const el = document.getElementById("app-session");
|
||
if (!el) return;
|
||
|
||
if (state.sessionExpired || state.reconnecting) {
|
||
el.classList.add("hidden");
|
||
return;
|
||
}
|
||
if (!state.sessionExpireAt) {
|
||
el.classList.add("hidden");
|
||
return;
|
||
}
|
||
|
||
const remainingMs = state.sessionExpireAt - Date.now();
|
||
|
||
if (remainingMs <= 0) {
|
||
handleSessionExpired();
|
||
return;
|
||
}
|
||
|
||
if (remainingMs > SESSION_WARN_THRESHOLD_MS) {
|
||
el.classList.add("hidden");
|
||
return;
|
||
}
|
||
|
||
// Zone d'alerte (< 5 min)
|
||
// v5.0.9 : avant d'afficher l'alerte, on fait UN ping de confirmation
|
||
// pour vérifier que le serveur est bien d'accord (compteur local parfois
|
||
// désynchronisé si plusieurs requêtes EV ont rafraîchi sans qu'on update
|
||
// notre horloge). Une seule fois par cycle.
|
||
if (!state.sessionPingDone) {
|
||
state.sessionPingDone = true;
|
||
sendMessage({ type: "getSessionRemaining" }).then(resp => {
|
||
if (resp && resp.ok && typeof resp.remainingMs === "number") {
|
||
state.sessionExpireAt = Date.now() + resp.remainingMs;
|
||
updateSessionIndicator();
|
||
}
|
||
}).catch(() => {});
|
||
// En attendant, on continue avec l'estimation locale
|
||
}
|
||
|
||
const mm = Math.floor(remainingMs / 60000);
|
||
const ss = Math.floor((remainingMs % 60000) / 1000);
|
||
const timeStr = `${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
|
||
el.classList.remove("hidden", "session-warn", "session-critical");
|
||
|
||
if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS) {
|
||
el.classList.add("session-critical");
|
||
if (!state._criticalModalShown) {
|
||
state._criticalModalShown = true;
|
||
showSessionCriticalModal();
|
||
}
|
||
} else {
|
||
el.classList.add("session-warn");
|
||
state._criticalModalShown = false;
|
||
}
|
||
|
||
el.innerHTML = `
|
||
<span class="session-icon">⏱</span>
|
||
<span class="session-time">${timeStr}</span>
|
||
<button type="button" class="session-extend-btn" title="Prolonger la session de 30 min">🔄 Prolonger</button>
|
||
`;
|
||
const extendBtn = el.querySelector(".session-extend-btn");
|
||
if (extendBtn) {
|
||
extendBtn.onclick = async () => {
|
||
extendBtn.disabled = true;
|
||
extendBtn.textContent = "…";
|
||
try {
|
||
const resp = await sendMessage({ type: "extendSession" });
|
||
if (resp && resp.ok && typeof resp.remainingMs === "number") {
|
||
state.sessionExpireAt = Date.now() + resp.remainingMs;
|
||
state.sessionPingDone = false;
|
||
state._criticalModalShown = false;
|
||
showToast("Session prolongée", "30 minutes de plus");
|
||
updateSessionIndicator();
|
||
} else {
|
||
throw new Error((resp && resp.error) || "erreur inconnue");
|
||
}
|
||
} catch (err) {
|
||
extendBtn.disabled = false;
|
||
extendBtn.textContent = "🔄 Prolonger";
|
||
if (err.message === "session_expired" || err.message === "no_session") {
|
||
handleSessionExpired();
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
// v2026.5.17 : si le popup user-badge est ouvert, rafraîchir la ligne "Session : MM:SS"
|
||
const sessLineInPopup = document.getElementById("user-name-popup-session");
|
||
if (sessLineInPopup) _renderUserPopupSessionLine(sessLineInPopup);
|
||
|
||
// v2026.5.17 : popup d'alerte "glissante" depuis le haut gauche
|
||
// - à 5 min : alerte standard (si pas encore affichée ni "plus tard")
|
||
// - à 2 min : alerte urgente (si pas encore affichée)
|
||
_handleSessionSlideAlerts(remainingMs);
|
||
}
|
||
|
||
/**
|
||
* v2026.5.17 : gère les 2 alertes popup glissant depuis le haut gauche.
|
||
* - Première alerte à 5 min (SESSION_WARN_THRESHOLD_MS). Reste affichée jusqu'à
|
||
* action manuelle (Prolonger ou Plus tard).
|
||
* - Si "Plus tard", une 2e alerte plus urgente réapparait à 2 min
|
||
* (SESSION_CRITICAL_THRESHOLD_MS).
|
||
*/
|
||
function _handleSessionSlideAlerts(remainingMs) {
|
||
if (remainingMs == null) return;
|
||
|
||
// Alerte à 5 min
|
||
if (remainingMs <= SESSION_WARN_THRESHOLD_MS
|
||
&& remainingMs > SESSION_CRITICAL_THRESHOLD_MS
|
||
&& !state._slideAlert5minShown) {
|
||
state._slideAlert5minShown = true;
|
||
_showSessionSlideAlert({ urgent: false });
|
||
}
|
||
|
||
// Alerte à 2 min (si déjà "Plus tard" sur l'alerte 5 min OU alerte 5 min jamais affichée)
|
||
if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS
|
||
&& !state._slideAlert2minShown) {
|
||
state._slideAlert2minShown = true;
|
||
// Cacher éventuellement l'ancienne alerte pour ré-afficher la nouvelle
|
||
_hideSessionSlideAlert();
|
||
_showSessionSlideAlert({ urgent: true });
|
||
}
|
||
}
|
||
|
||
function _showSessionSlideAlert({ urgent }) {
|
||
// Retirer l'ancienne si elle existe
|
||
_hideSessionSlideAlert();
|
||
|
||
const el = document.createElement("div");
|
||
el.id = "session-slide-alert";
|
||
el.className = "session-slide-alert" + (urgent ? " urgent" : "");
|
||
const title = urgent ? "⚠ Session expire dans 2 minutes !" : "⏱ Session expire dans 5 minutes";
|
||
el.innerHTML = `
|
||
<div class="session-slide-alert-title">${title}</div>
|
||
<div class="session-slide-alert-actions">
|
||
<button type="button" class="session-slide-alert-extend">🔄 Prolonger</button>
|
||
<button type="button" class="session-slide-alert-later">Plus tard</button>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(el);
|
||
// Déclenche l'animation de slide-in (petite tempo pour que la transition parte)
|
||
requestAnimationFrame(() => el.classList.add("visible"));
|
||
|
||
// Action "Prolonger"
|
||
el.querySelector(".session-slide-alert-extend").addEventListener("click", async () => {
|
||
const extendBtn = el.querySelector(".session-slide-alert-extend");
|
||
extendBtn.disabled = true;
|
||
extendBtn.textContent = "…";
|
||
try {
|
||
const resp = await sendMessage({ type: "extendSession" });
|
||
if (resp && resp.ok && typeof resp.remainingMs === "number") {
|
||
state.sessionExpireAt = Date.now() + resp.remainingMs;
|
||
state.sessionPingDone = false;
|
||
state._criticalModalShown = false;
|
||
// Reset des flags d'alerte pour le prochain cycle
|
||
state._slideAlert5minShown = false;
|
||
state._slideAlert2minShown = false;
|
||
showToast("Session prolongée", "30 minutes de plus");
|
||
updateSessionIndicator();
|
||
_hideSessionSlideAlert();
|
||
} else {
|
||
throw new Error((resp && resp.error) || "erreur inconnue");
|
||
}
|
||
} catch (err) {
|
||
extendBtn.disabled = false;
|
||
extendBtn.textContent = "🔄 Prolonger";
|
||
}
|
||
});
|
||
|
||
// Action "Plus tard"
|
||
el.querySelector(".session-slide-alert-later").addEventListener("click", () => {
|
||
_hideSessionSlideAlert();
|
||
// Si c'est l'alerte 5 min qu'on dismissa, l'alerte 2 min reviendra
|
||
// automatiquement (state._slideAlert2minShown toujours false).
|
||
});
|
||
}
|
||
|
||
function _hideSessionSlideAlert() {
|
||
const el = document.getElementById("session-slide-alert");
|
||
if (!el) return;
|
||
el.classList.remove("visible");
|
||
setTimeout(() => { try { el.remove(); } catch (e) {} }, 250);
|
||
}
|
||
|
||
/**
|
||
* Appelée quand le compteur atteint 0 ou quand une requête EV échoue en
|
||
* session expirée. Affiche la bannière "Session expirée" avec bouton "Me
|
||
* reconnecter".
|
||
*/
|
||
function handleSessionExpired() {
|
||
if (state.sessionExpired) return;
|
||
state.sessionExpired = true;
|
||
state.sessionExpireAt = null;
|
||
state._criticalModalShown = false;
|
||
console.warn("[session] session EV expirée");
|
||
showSessionExpiredBanner();
|
||
const el = document.getElementById("app-session");
|
||
if (el) el.classList.add("hidden");
|
||
}
|
||
|
||
/**
|
||
* Modal auto quand < 2 min : alerte visuelle forte.
|
||
*/
|
||
function showSessionCriticalModal() {
|
||
showAlertModal({
|
||
title: "⚠️ Session EasyVista expire bientôt",
|
||
message: "Votre session EasyVista expire dans moins de 2 minutes. Cliquez sur « Prolonger » pour éviter d'être déconnecté.",
|
||
buttons: [
|
||
{ label: "Ignorer", variant: "secondary", action: () => {} },
|
||
{
|
||
label: "🔄 Prolonger maintenant",
|
||
variant: "primary",
|
||
action: async () => {
|
||
try {
|
||
const resp = await sendMessage({ type: "extendSession" });
|
||
if (resp && resp.ok && typeof resp.remainingMs === "number") {
|
||
state.sessionExpireAt = Date.now() + resp.remainingMs;
|
||
state.sessionPingDone = false;
|
||
state._criticalModalShown = false;
|
||
showToast("Session prolongée", "30 minutes de plus");
|
||
updateSessionIndicator();
|
||
}
|
||
} catch (e) {
|
||
handleSessionExpired();
|
||
}
|
||
}
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Appelé au clic "Me reconnecter" dans la bannière. Ouvre EasyVista dans un
|
||
* nouvel onglet (déclenche Windows SSO Kerberos automatique). Le polling
|
||
* dans initSessionTimer détectera la nouvelle session et rechargera le viewer.
|
||
*
|
||
* v5.0.10 : utilise l'origine dynamique (interne ou externe selon le réseau).
|
||
* v5.0.11 : détecte le contexte réseau avant d'ouvrir (si pas déjà connu) +
|
||
* timeout 90s : si pas reconnecté après ce délai, propose choix manuel.
|
||
*
|
||
* @param {string} [forcedOrigin] - origine à forcer (pour le choix manuel
|
||
* dans le fallback après timeout). Si absent : détection auto.
|
||
*/
|
||
async function triggerReconnect(forcedOrigin) {
|
||
state.reconnecting = true;
|
||
hideSessionExpiredBanner();
|
||
hideReconnectFailedBanner();
|
||
showReconnectingBanner();
|
||
|
||
// Annuler tout timeout précédent
|
||
if (state.reconnectTimeoutId) {
|
||
clearTimeout(state.reconnectTimeoutId);
|
||
state.reconnectTimeoutId = null;
|
||
}
|
||
|
||
try {
|
||
let origin = forcedOrigin;
|
||
if (!origin) {
|
||
// v5.0.11 : re-détecter le réseau à chaque expiration pour gérer le
|
||
// cas où on a changé de contexte (bureau → TT) pendant la session.
|
||
await detectNetworkContextAsync(true);
|
||
origin = state.lastKnownOrigin
|
||
|| (state.session && state.session.origin)
|
||
|| "https://itsma.vd.ch";
|
||
}
|
||
console.log("[session] triggerReconnect → ouverture de", origin);
|
||
await sendMessage({ type: "openEasyVistaLogin", origin });
|
||
|
||
// Démarrer le timeout 90s : si pas reconnecté, basculer en mode "Échec"
|
||
state.reconnectTimeoutId = setTimeout(() => {
|
||
if (state.reconnecting && !state.session) {
|
||
console.warn("[session] reconnexion timeout 90s → bannière échec");
|
||
state.reconnecting = false;
|
||
hideReconnectingBanner();
|
||
showReconnectFailedBanner();
|
||
}
|
||
}, RECONNECT_TIMEOUT_MS);
|
||
} catch (err) {
|
||
console.warn("[session] openEasyVistaLogin failed:", err);
|
||
state.reconnecting = false;
|
||
hideReconnectingBanner();
|
||
showSessionExpiredBanner();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* v5.0.11 : l'user clique "Annuler" pendant la reconnexion. On arrête le
|
||
* polling/timeout et on revient à l'état "Session expirée" normal.
|
||
*/
|
||
function cancelReconnect() {
|
||
if (state.reconnectTimeoutId) {
|
||
clearTimeout(state.reconnectTimeoutId);
|
||
state.reconnectTimeoutId = null;
|
||
}
|
||
state.reconnecting = false;
|
||
hideReconnectingBanner();
|
||
hideReconnectFailedBanner();
|
||
showSessionExpiredBanner();
|
||
}
|
||
|
||
|
||
// v5.0.0 : stockage des paramètres admin dans chrome.storage.local.
|
||
// Clé unique : "admin_config". Contient la config éditable (équipe,
|
||
// absences récurrentes, statuts etc.). Au 1er lancement : initialisée
|
||
// avec les valeurs hardcodées actuelles.
|
||
const ADMIN_CONFIG_KEY = "admin_config";
|
||
|
||
function getDefaultAdminConfig() {
|
||
return {
|
||
team: { ...TEAM }, // Clone pour ne pas modifier le hardcode
|
||
recurringAbsences: { ...RECURRING_ABSENCES }, // idem
|
||
groupId: "191",
|
||
evOrigins: ["https://itsma.etat-de-vaud.ch", "https://itsma.vd.ch"],
|
||
closedStatus: [...CLOSED_STATUS],
|
||
resolvedStatus: [...RESOLVED_STATUS],
|
||
cancelledStatus: [...CANCELLED_STATUS],
|
||
dayStart: 8,
|
||
dayEnd: 18,
|
||
cacheDays: 7
|
||
};
|
||
}
|
||
|
||
async function loadAdminConfig() {
|
||
try {
|
||
const stored = await chrome.storage.local.get(ADMIN_CONFIG_KEY);
|
||
if (stored && stored[ADMIN_CONFIG_KEY]) {
|
||
// Fusion avec les defaults (pour rajouter d'éventuelles nouvelles clés)
|
||
return { ...getDefaultAdminConfig(), ...stored[ADMIN_CONFIG_KEY] };
|
||
}
|
||
} catch (e) {
|
||
console.warn("[admin] loadAdminConfig err", e);
|
||
}
|
||
return getDefaultAdminConfig();
|
||
}
|
||
|
||
async function saveAdminConfig(cfg) {
|
||
try {
|
||
await chrome.storage.local.set({ [ADMIN_CONFIG_KEY]: cfg });
|
||
console.log("[admin] config sauvegardée");
|
||
return true;
|
||
} catch (e) {
|
||
console.error("[admin] saveAdminConfig err", e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// v5.0.0 : affiche le panel admin plein écran.
|
||
async function showAdminPanel() {
|
||
// Ferme un éventuel panel existant
|
||
const existing = document.getElementById("admin-panel");
|
||
if (existing) existing.remove();
|
||
|
||
// Charge la config actuelle
|
||
const cfg = await loadAdminConfig();
|
||
|
||
// Overlay plein écran
|
||
const overlay = document.createElement("div");
|
||
overlay.id = "admin-panel";
|
||
overlay.className = "modal-overlay admin-overlay";
|
||
|
||
const card = document.createElement("div");
|
||
card.className = "admin-panel-card";
|
||
|
||
// En-tête
|
||
const header = document.createElement("div");
|
||
header.className = "admin-header";
|
||
const title = document.createElement("h2");
|
||
title.textContent = "⚙ Administration";
|
||
title.className = "admin-title";
|
||
const closeBtn = document.createElement("button");
|
||
closeBtn.type = "button";
|
||
closeBtn.className = "admin-close-btn";
|
||
closeBtn.textContent = "×";
|
||
closeBtn.title = "Fermer (Échap)";
|
||
closeBtn.addEventListener("click", () => overlay.remove());
|
||
header.appendChild(title);
|
||
header.appendChild(closeBtn);
|
||
card.appendChild(header);
|
||
|
||
// Navigation latérale (onglets)
|
||
const body = document.createElement("div");
|
||
body.className = "admin-body";
|
||
|
||
const sidebar = document.createElement("nav");
|
||
sidebar.className = "admin-sidebar";
|
||
|
||
const content = document.createElement("div");
|
||
content.className = "admin-content";
|
||
|
||
const sections = [
|
||
{ id: "team", label: "Équipe", render: renderAdminSectionTeam },
|
||
{ id: "easyvista", label: "EasyVista", render: renderAdminSectionEV },
|
||
{ id: "appearance", label: "Apparence", render: renderAdminSectionAppearance },
|
||
{ id: "statuses", label: "Statuts", render: renderAdminSectionStatuses },
|
||
{ id: "diagnostics",label: "Diagnostics", render: renderAdminSectionDiagnostics }
|
||
];
|
||
|
||
let currentSection = "team";
|
||
|
||
const navButtons = {};
|
||
for (const section of sections) {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "admin-nav-btn";
|
||
btn.textContent = section.label;
|
||
btn.dataset.section = section.id;
|
||
if (section.id === currentSection) btn.classList.add("active");
|
||
btn.addEventListener("click", () => {
|
||
currentSection = section.id;
|
||
for (const k in navButtons) navButtons[k].classList.remove("active");
|
||
btn.classList.add("active");
|
||
content.innerHTML = "";
|
||
section.render(content, cfg, () => saveAndReload(cfg));
|
||
});
|
||
navButtons[section.id] = btn;
|
||
sidebar.appendChild(btn);
|
||
}
|
||
|
||
body.appendChild(sidebar);
|
||
body.appendChild(content);
|
||
card.appendChild(body);
|
||
overlay.appendChild(card);
|
||
document.body.appendChild(overlay);
|
||
|
||
// Rendu initial : section "Équipe"
|
||
sections[0].render(content, cfg, () => saveAndReload(cfg));
|
||
|
||
// Échap ferme le panel
|
||
const escHandler = (e) => {
|
||
if (e.key === "Escape") {
|
||
overlay.remove();
|
||
document.removeEventListener("keydown", escHandler);
|
||
}
|
||
};
|
||
document.addEventListener("keydown", escHandler);
|
||
|
||
async function saveAndReload(updatedCfg) {
|
||
const ok = await saveAdminConfig(updatedCfg);
|
||
if (ok) {
|
||
showToast("Config enregistrée", "Rechargez l'extension pour appliquer");
|
||
} else {
|
||
showAlertModal({
|
||
title: "Erreur",
|
||
message: "Impossible d'enregistrer la configuration.",
|
||
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// v5.0.0 : section "Équipe" du panel admin.
|
||
// v5.0.1 : affiche la liste complète du groupe EasyVista (20+ personnes),
|
||
// avec case à cocher "inclure dans la planification" pour chacune.
|
||
function renderAdminSectionTeam(container, cfg, saveFn) {
|
||
const h = document.createElement("h3");
|
||
h.textContent = "Équipe";
|
||
h.className = "admin-section-title";
|
||
container.appendChild(h);
|
||
|
||
const desc = document.createElement("p");
|
||
desc.className = "admin-section-desc";
|
||
desc.textContent = "Sélectionnez les personnes qui doivent apparaître dans la planification. Les IDs viennent d'EasyVista (bouton Détecter) ou peuvent être saisis manuellement.";
|
||
container.appendChild(desc);
|
||
|
||
// État local : liste {id, name, included, days:[0..6]}
|
||
// Au départ on remplit depuis cfg.team actuel, puis la détection EV
|
||
// enrichit cette liste.
|
||
const rows = [];
|
||
for (const [id, name] of Object.entries(cfg.team || {})) {
|
||
rows.push({
|
||
id,
|
||
name,
|
||
included: true,
|
||
days: (cfg.recurringAbsences[id] || []).slice()
|
||
});
|
||
}
|
||
|
||
const tableWrap = document.createElement("div");
|
||
tableWrap.className = "admin-team-wrap";
|
||
container.appendChild(tableWrap);
|
||
|
||
function render() {
|
||
tableWrap.innerHTML = "";
|
||
|
||
// Bouton "Détecter depuis EasyVista"
|
||
const detectBtn = document.createElement("button");
|
||
detectBtn.type = "button";
|
||
detectBtn.className = "btn btn-secondary";
|
||
detectBtn.textContent = "🔍 Détecter depuis EasyVista (groupe complet)";
|
||
detectBtn.style.marginBottom = "12px";
|
||
detectBtn.addEventListener("click", async () => {
|
||
detectBtn.disabled = true;
|
||
detectBtn.textContent = "Détection en cours…";
|
||
try {
|
||
const resp = await sendMessage({ type: "detectTeam" });
|
||
if (resp && resp.ok && resp.members && resp.members.length) {
|
||
// Merge : pour chaque membre détecté, ajoute à `rows` s'il n'y est
|
||
// pas déjà. S'il y est déjà, met à jour le nom (si meilleur).
|
||
for (const m of resp.members) {
|
||
const existing = rows.find(r => r.id === m.id);
|
||
if (existing) {
|
||
// Améliorer le nom si le nom actuel commence par "?"
|
||
if (m.name && !m.name.startsWith("?") && existing.name.startsWith("?")) {
|
||
existing.name = m.name;
|
||
}
|
||
} else {
|
||
rows.push({
|
||
id: m.id,
|
||
name: m.name || "? (" + m.id + ")",
|
||
included: !!m.alreadyInTeam, // coché si déjà dans l'équipe
|
||
days: []
|
||
});
|
||
}
|
||
}
|
||
showToast("Détecté", resp.members.length + " personne(s) dans le groupe");
|
||
render();
|
||
} else {
|
||
showAlertModal({
|
||
title: "Détection impossible",
|
||
message: (resp && resp.error) || "Aucune personne trouvée. Vérifiez que vous êtes connecté à EasyVista.",
|
||
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.warn("[admin] detectTeam err", err);
|
||
} finally {
|
||
detectBtn.disabled = false;
|
||
detectBtn.textContent = "🔍 Détecter depuis EasyVista (groupe complet)";
|
||
}
|
||
});
|
||
tableWrap.appendChild(detectBtn);
|
||
|
||
// Stats : nb inclus / total
|
||
const included = rows.filter(r => r.included).length;
|
||
const stats = document.createElement("div");
|
||
stats.className = "admin-section-desc";
|
||
stats.style.marginTop = "0";
|
||
stats.textContent = `${included} personne(s) incluse(s) sur ${rows.length} connue(s).`;
|
||
tableWrap.appendChild(stats);
|
||
|
||
// Table
|
||
const table = document.createElement("table");
|
||
table.className = "admin-team-table";
|
||
const thead = document.createElement("thead");
|
||
thead.innerHTML = "<tr><th>Inclure</th><th>ID</th><th>Nom affiché</th><th>Absences récurrentes</th><th></th></tr>";
|
||
table.appendChild(thead);
|
||
const tbody = document.createElement("tbody");
|
||
table.appendChild(tbody);
|
||
|
||
const days = ["Dim","Lun","Mar","Mer","Jeu","Ven","Sam"];
|
||
rows.forEach((r, idx) => {
|
||
const tr = document.createElement("tr");
|
||
if (!r.included) tr.classList.add("admin-row-excluded");
|
||
|
||
// Checkbox inclure
|
||
const tdInc = document.createElement("td");
|
||
const cb = document.createElement("input");
|
||
cb.type = "checkbox";
|
||
cb.checked = r.included;
|
||
cb.addEventListener("change", () => {
|
||
r.included = cb.checked;
|
||
tr.classList.toggle("admin-row-excluded", !r.included);
|
||
stats.textContent = `${rows.filter(x => x.included).length} personne(s) incluse(s) sur ${rows.length} connue(s).`;
|
||
});
|
||
tdInc.appendChild(cb);
|
||
tr.appendChild(tdInc);
|
||
|
||
// ID
|
||
const tdId = document.createElement("td");
|
||
const inpId = document.createElement("input");
|
||
inpId.type = "text";
|
||
inpId.value = r.id;
|
||
inpId.placeholder = "76272";
|
||
inpId.className = "admin-input admin-input-id";
|
||
inpId.addEventListener("input", () => { r.id = inpId.value.trim(); });
|
||
tdId.appendChild(inpId);
|
||
tr.appendChild(tdId);
|
||
|
||
// Nom
|
||
const tdName = document.createElement("td");
|
||
const inpName = document.createElement("input");
|
||
inpName.type = "text";
|
||
inpName.value = r.name;
|
||
inpName.placeholder = "Dupont, Jean";
|
||
inpName.className = "admin-input";
|
||
inpName.addEventListener("input", () => { r.name = inpName.value.trim(); });
|
||
tdName.appendChild(inpName);
|
||
tr.appendChild(tdName);
|
||
|
||
// Jours d'absence récurrente
|
||
const tdAbs = document.createElement("td");
|
||
for (let d = 0; d < 7; d++) {
|
||
const lbl = document.createElement("label");
|
||
lbl.className = "admin-day-cb";
|
||
const cbd = document.createElement("input");
|
||
cbd.type = "checkbox";
|
||
cbd.checked = r.days.includes(d);
|
||
cbd.addEventListener("change", () => {
|
||
if (cbd.checked && !r.days.includes(d)) r.days.push(d);
|
||
if (!cbd.checked) r.days = r.days.filter(x => x !== d);
|
||
});
|
||
lbl.appendChild(cbd);
|
||
lbl.appendChild(document.createTextNode(days[d]));
|
||
tdAbs.appendChild(lbl);
|
||
}
|
||
tr.appendChild(tdAbs);
|
||
|
||
// Bouton supprimer ligne
|
||
const tdDel = document.createElement("td");
|
||
const delBtn = document.createElement("button");
|
||
delBtn.type = "button";
|
||
delBtn.className = "admin-del-btn";
|
||
delBtn.textContent = "🗑";
|
||
delBtn.title = "Retirer cette ligne";
|
||
delBtn.addEventListener("click", () => {
|
||
rows.splice(idx, 1);
|
||
render();
|
||
});
|
||
tdDel.appendChild(delBtn);
|
||
tr.appendChild(tdDel);
|
||
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
tableWrap.appendChild(table);
|
||
|
||
// v2026.5.26 : bouton "+ Ajouter manuellement" retiré. La liste complète
|
||
// des techniciens du groupe est chargée automatiquement depuis EasyVista
|
||
// à l'ouverture du panel admin (voir auto-détection plus bas).
|
||
|
||
// Bouton Enregistrer
|
||
const saveBtn = document.createElement("button");
|
||
saveBtn.type = "button";
|
||
saveBtn.className = "btn btn-primary";
|
||
saveBtn.textContent = "💾 Enregistrer";
|
||
saveBtn.style.marginTop = "20px";
|
||
saveBtn.style.marginLeft = "10px";
|
||
saveBtn.addEventListener("click", () => {
|
||
// Reconstruire cfg.team et cfg.recurringAbsences à partir de rows
|
||
const newTeam = {};
|
||
const newRecAbs = {};
|
||
for (const r of rows) {
|
||
if (!r.included || !r.id) continue;
|
||
newTeam[r.id] = r.name || ("? (" + r.id + ")");
|
||
if (r.days && r.days.length > 0) newRecAbs[r.id] = r.days.slice();
|
||
}
|
||
cfg.team = newTeam;
|
||
cfg.recurringAbsences = newRecAbs;
|
||
saveFn();
|
||
});
|
||
tableWrap.appendChild(saveBtn);
|
||
}
|
||
|
||
render();
|
||
|
||
// v2026.5.26 : auto-détection EasyVista à l'ouverture du panel admin.
|
||
// On lance en arrière-plan (non-bloquant), et on merge avec cfg.team actuel
|
||
// quand le résultat arrive. Les techs actuellement en cfg.team restent
|
||
// cochés par défaut ; les nouveaux détectés du groupe apparaissent non-cochés.
|
||
(async () => {
|
||
try {
|
||
const resp = await sendMessage({ type: "detectTeam" });
|
||
if (resp && resp.ok && resp.members && resp.members.length) {
|
||
for (const m of resp.members) {
|
||
const existing = rows.find(r => r.id === m.id);
|
||
if (existing) {
|
||
if (m.name && !m.name.startsWith("?") && existing.name.startsWith("?")) {
|
||
existing.name = m.name;
|
||
}
|
||
} else {
|
||
rows.push({
|
||
id: m.id,
|
||
name: m.name || "? (" + m.id + ")",
|
||
included: !!m.alreadyInTeam,
|
||
days: []
|
||
});
|
||
}
|
||
}
|
||
render();
|
||
}
|
||
} catch (err) {
|
||
console.warn("[admin auto-detect]", err);
|
||
}
|
||
})();
|
||
}
|
||
|
||
// v5.0.0 : sections suivantes (placeholders, à enrichir v5.0.1+)
|
||
function renderAdminSectionEV(container, cfg, saveFn) {
|
||
const h = document.createElement("h3");
|
||
h.textContent = "EasyVista";
|
||
h.className = "admin-section-title";
|
||
container.appendChild(h);
|
||
const desc = document.createElement("p");
|
||
desc.className = "admin-section-desc";
|
||
desc.textContent = "Section à venir dans v5.0.1. Origines EasyVista + group_id.";
|
||
container.appendChild(desc);
|
||
// Infos lecture seule pour l'instant
|
||
const pre = document.createElement("pre");
|
||
pre.className = "admin-readonly";
|
||
pre.textContent = JSON.stringify({
|
||
evOrigins: cfg.evOrigins,
|
||
groupId: cfg.groupId
|
||
}, null, 2);
|
||
container.appendChild(pre);
|
||
}
|
||
|
||
function renderAdminSectionAppearance(container, cfg, saveFn) {
|
||
const h = document.createElement("h3");
|
||
h.textContent = "Apparence";
|
||
h.className = "admin-section-title";
|
||
container.appendChild(h);
|
||
const desc = document.createElement("p");
|
||
desc.className = "admin-section-desc";
|
||
desc.textContent = "Section à venir dans v5.0.x. Heures journée, durée cache, thème.";
|
||
container.appendChild(desc);
|
||
const pre = document.createElement("pre");
|
||
pre.className = "admin-readonly";
|
||
pre.textContent = JSON.stringify({
|
||
dayStart: cfg.dayStart,
|
||
dayEnd: cfg.dayEnd,
|
||
cacheDays: cfg.cacheDays
|
||
}, null, 2);
|
||
container.appendChild(pre);
|
||
}
|
||
|
||
function renderAdminSectionStatuses(container, cfg, saveFn) {
|
||
const h = document.createElement("h3");
|
||
h.textContent = "Statuts";
|
||
h.className = "admin-section-title";
|
||
container.appendChild(h);
|
||
const desc = document.createElement("p");
|
||
desc.className = "admin-section-desc";
|
||
desc.textContent = "Section à venir dans v5.0.x. Mots-clés Clôturé / Résolu / Annulé.";
|
||
container.appendChild(desc);
|
||
const pre = document.createElement("pre");
|
||
pre.className = "admin-readonly";
|
||
pre.textContent = JSON.stringify({
|
||
closed: cfg.closedStatus,
|
||
resolved: cfg.resolvedStatus,
|
||
cancelled: cfg.cancelledStatus
|
||
}, null, 2);
|
||
container.appendChild(pre);
|
||
}
|
||
|
||
function renderAdminSectionDiagnostics(container, cfg, saveFn) {
|
||
const h = document.createElement("h3");
|
||
h.textContent = "Diagnostics";
|
||
h.className = "admin-section-title";
|
||
container.appendChild(h);
|
||
|
||
const version = (chrome && chrome.runtime && chrome.runtime.getManifest)
|
||
? chrome.runtime.getManifest().version : "?";
|
||
|
||
const info = document.createElement("div");
|
||
info.className = "admin-diag-grid";
|
||
info.innerHTML = `
|
||
<div><strong>Version</strong></div><div>${escapeHtml(version)}</div>
|
||
<div><strong>Date courante</strong></div><div>${escapeHtml(state.currentDate || "?")}</div>
|
||
<div><strong>Aujourd'hui</strong></div><div>${escapeHtml(todayISO())}</div>
|
||
<div><strong>Session EasyVista</strong></div><div>${state.session ? "✓ connecté (" + (state.session.origin || "?") + ")" : "✗ non détecté"}</div>
|
||
<div><strong>Popups épinglées</strong></div><div>${pinnedPopups.length}</div>
|
||
`;
|
||
container.appendChild(info);
|
||
|
||
// Bouton reset
|
||
const resetBtn = document.createElement("button");
|
||
resetBtn.type = "button";
|
||
resetBtn.className = "btn btn-danger";
|
||
resetBtn.textContent = "⚠ Réinitialiser la configuration (équipe, etc.)";
|
||
resetBtn.style.marginTop = "20px";
|
||
resetBtn.addEventListener("click", () => {
|
||
showAlertModal({
|
||
title: "Confirmer la réinitialisation",
|
||
message: "Remettre TOUTES les configurations aux valeurs par défaut ? (les techniciens ajoutés manuellement seront perdus)",
|
||
buttons: [
|
||
{ label: "Annuler", variant: "secondary", action: () => {} },
|
||
{
|
||
label: "Réinitialiser",
|
||
variant: "danger",
|
||
action: async () => {
|
||
await chrome.storage.local.remove(ADMIN_CONFIG_KEY);
|
||
showToast("Réinitialisé", "Rechargez la page pour voir les défauts");
|
||
}
|
||
}
|
||
]
|
||
});
|
||
});
|
||
container.appendChild(resetBtn);
|
||
}
|
||
|
||
// ============================================================================
|
||
// v4.2.6 : Modals Absence et Douchette
|
||
// ============================================================================
|
||
|
||
// Types d'absence EasyVista (extraits du HTML plan_set_holidays_popup.php)
|
||
const HOLIDAY_TYPES = [
|
||
{ guid: "{EF51F439-441E-4A68-9D1A-A6E0A85F32FE}", label: "Congés" },
|
||
{ guid: "{B5B887A7-DE5D-4CAB-B55E-7D01E5D0DF84}", label: "Déménagement" },
|
||
{ guid: "{8476B26C-DFE4-4256-B2B5-3CE1C9EC3479}", label: "Ecrans" },
|
||
{ guid: "{E7432422-55CB-4DB9-8A26-619D036E2155}", label: "Evènements spéciaux" },
|
||
{ guid: "{F9B8FFC6-5D64-4339-AAAF-166D6D3801DA}", label: "MAC" },
|
||
{ guid: "{0554F45A-9B31-43D7-A1E2-0407D74F3BB5}", label: "Maladie" },
|
||
{ guid: "{E8301A0F-B246-420A-863C-3837F1B581E0}", label: "PC" },
|
||
{ guid: "{60D70502-063D-45AD-9415-25C1C556105F}", label: "Pompier" },
|
||
{ guid: "{B343C590-1446-45BF-9CE6-790C759BA999}", label: "Réunion" },
|
||
{ guid: "{7E63F472-677E-4EFD-B822-1AF4DC163AEC}", label: "Rollout" },
|
||
{ guid: "{D45DEF80-9DDA-46BA-957E-B5B6D7F9D46A}", label: "Téléphones" },
|
||
{ guid: "{06BCAC52-5A8A-4D6D-9BC6-566AAF18666A}", label: "UTP" }
|
||
];
|
||
|
||
/**
|
||
* Formate une date ISO YYYY-MM-DD en DD/MM/YYYY (format EasyVista).
|
||
*/
|
||
function isoToEvDate(iso) {
|
||
if (!iso) return "";
|
||
const parts = iso.split("-");
|
||
if (parts.length !== 3) return iso;
|
||
return `${parts[2]}/${parts[1]}/${parts[0]}`;
|
||
}
|
||
|
||
/**
|
||
* Construit un bloc liste de techniciens avec checkboxes.
|
||
* @param {Object} opts
|
||
* @param {boolean} [opts.selectAll] - Afficher la case "Tout sélectionner"
|
||
* @returns {HTMLElement}
|
||
*/
|
||
function buildTechCheckboxList(opts = {}) {
|
||
const container = document.createElement("div");
|
||
container.className = "modal-tech-list";
|
||
|
||
const techIds = Object.keys(TEAM);
|
||
|
||
if (opts.selectAll) {
|
||
const allRow = document.createElement("label");
|
||
allRow.className = "modal-tech-item tech-selectall";
|
||
const allBox = document.createElement("input");
|
||
allBox.type = "checkbox";
|
||
allBox.className = "tech-select-all";
|
||
const allLabel = document.createElement("span");
|
||
allLabel.textContent = "Tout sélectionner";
|
||
allRow.appendChild(allBox);
|
||
allRow.appendChild(allLabel);
|
||
container.appendChild(allRow);
|
||
|
||
allBox.addEventListener("change", () => {
|
||
container.querySelectorAll(".tech-checkbox").forEach(cb => {
|
||
cb.checked = allBox.checked;
|
||
});
|
||
});
|
||
}
|
||
|
||
for (const id of techIds) {
|
||
const row = document.createElement("label");
|
||
row.className = "modal-tech-item";
|
||
const cb = document.createElement("input");
|
||
cb.type = "checkbox";
|
||
cb.className = "tech-checkbox";
|
||
cb.value = id;
|
||
const label = document.createElement("span");
|
||
label.textContent = TEAM[id];
|
||
row.appendChild(cb);
|
||
row.appendChild(label);
|
||
container.appendChild(row);
|
||
|
||
// Cocher "Tout" si toutes les cases sont cochées (et décocher sinon)
|
||
cb.addEventListener("change", () => {
|
||
const allBox = container.querySelector(".tech-select-all");
|
||
if (!allBox) return;
|
||
const boxes = [...container.querySelectorAll(".tech-checkbox")];
|
||
allBox.checked = boxes.every(b => b.checked);
|
||
allBox.indeterminate = !allBox.checked && boxes.some(b => b.checked);
|
||
});
|
||
}
|
||
|
||
return container;
|
||
}
|
||
|
||
/**
|
||
* Récupère la liste des techIds cochés dans une liste de checkboxes.
|
||
*/
|
||
function getCheckedTechIds(container) {
|
||
return [...container.querySelectorAll(".tech-checkbox:checked")].map(cb => cb.value);
|
||
}
|
||
|
||
/**
|
||
* Ouvre la modal "Créer une absence".
|
||
*/
|
||
function showAbsenceModal() {
|
||
const existing = document.getElementById("absence-modal");
|
||
if (existing) existing.remove();
|
||
|
||
const overlay = document.createElement("div");
|
||
overlay.id = "absence-modal";
|
||
overlay.className = "modal-overlay";
|
||
|
||
const card = document.createElement("div");
|
||
card.className = "modal-card modal-wide";
|
||
card.setAttribute("role", "dialog");
|
||
|
||
// v5.0.0 : on mémorise la date affichée au moment de l'ouverture de la
|
||
// modal. Le reload après création se fait sur cette date précise, pas
|
||
// sur state.currentDate (qui aurait pu changer entre-temps).
|
||
const dateAtOpen = state.currentDate || todayISO();
|
||
|
||
const title = document.createElement("h2");
|
||
title.className = "modal-title";
|
||
title.textContent = "Créer une absence";
|
||
card.appendChild(title);
|
||
|
||
// Liste des techs (sans "Tout sélectionner" : on ne met quasi jamais tout
|
||
// le monde en absence, et c'est trop dangereux par erreur)
|
||
const techGroup = document.createElement("div");
|
||
techGroup.className = "modal-form-group";
|
||
const techLabel = document.createElement("label");
|
||
techLabel.className = "modal-form-label";
|
||
techLabel.textContent = "Technicien(s)";
|
||
techGroup.appendChild(techLabel);
|
||
const techList = buildTechCheckboxList({ selectAll: false });
|
||
techGroup.appendChild(techList);
|
||
card.appendChild(techGroup);
|
||
|
||
// Dates et heures : aujourd'hui ou le jour affiché, 08:00-18:00
|
||
const today = state.currentDate || todayISO();
|
||
|
||
const dateGroup = document.createElement("div");
|
||
dateGroup.className = "modal-form-group";
|
||
const dateLabel = document.createElement("label");
|
||
dateLabel.className = "modal-form-label";
|
||
dateLabel.textContent = "Date et heure de début";
|
||
dateGroup.appendChild(dateLabel);
|
||
const dateRow1 = document.createElement("div");
|
||
dateRow1.className = "modal-form-row";
|
||
const startDate = document.createElement("input");
|
||
startDate.type = "date";
|
||
startDate.className = "modal-form-input";
|
||
startDate.id = "absence-start-date";
|
||
startDate.value = today;
|
||
const startTime = document.createElement("input");
|
||
startTime.type = "time";
|
||
startTime.className = "modal-form-input";
|
||
startTime.id = "absence-start-time";
|
||
startTime.value = "08:00";
|
||
dateRow1.appendChild(startDate);
|
||
dateRow1.appendChild(startTime);
|
||
dateGroup.appendChild(dateRow1);
|
||
card.appendChild(dateGroup);
|
||
|
||
const endGroup = document.createElement("div");
|
||
endGroup.className = "modal-form-group";
|
||
const endLabel = document.createElement("label");
|
||
endLabel.className = "modal-form-label";
|
||
endLabel.textContent = "Date et heure de fin";
|
||
endGroup.appendChild(endLabel);
|
||
const endRow = document.createElement("div");
|
||
endRow.className = "modal-form-row";
|
||
const endDate = document.createElement("input");
|
||
endDate.type = "date";
|
||
endDate.className = "modal-form-input";
|
||
endDate.id = "absence-end-date";
|
||
endDate.value = today;
|
||
const endTime = document.createElement("input");
|
||
endTime.type = "time";
|
||
endTime.className = "modal-form-input";
|
||
endTime.id = "absence-end-time";
|
||
endTime.value = "18:00";
|
||
endRow.appendChild(endDate);
|
||
endRow.appendChild(endTime);
|
||
endGroup.appendChild(endRow);
|
||
card.appendChild(endGroup);
|
||
|
||
// v5.0.4 : presets rapides pour les horaires (matin / après-midi / journée)
|
||
const presetGroup = document.createElement("div");
|
||
presetGroup.className = "modal-form-group";
|
||
const presetLabel = document.createElement("label");
|
||
presetLabel.className = "modal-form-label";
|
||
presetLabel.textContent = "Presets rapides";
|
||
presetGroup.appendChild(presetLabel);
|
||
const presetRow = document.createElement("div");
|
||
presetRow.className = "modal-form-row modal-preset-row";
|
||
const presets = [
|
||
{ label: "Matin", start: "08:00", end: "12:00" },
|
||
{ label: "Après-midi", start: "13:00", end: "18:00" },
|
||
{ label: "Toute la journée", start: "08:00", end: "18:00" }
|
||
];
|
||
for (const p of presets) {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "btn btn-secondary modal-preset-btn";
|
||
btn.textContent = p.label;
|
||
btn.addEventListener("click", () => {
|
||
startTime.value = p.start;
|
||
endTime.value = p.end;
|
||
// Synchroniser visuellement la mise à jour et déclencher
|
||
// endDateTouched si besoin (la date reste inchangée)
|
||
startTime.dispatchEvent(new Event("input", { bubbles: true }));
|
||
endTime.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
presetRow.appendChild(btn);
|
||
}
|
||
presetGroup.appendChild(presetRow);
|
||
card.appendChild(presetGroup);
|
||
|
||
// v5.0.0 : la date de fin suit la date de début tant que l'user ne l'a
|
||
// pas explicitement modifiée. 95% des absences sont d'un seul jour, donc
|
||
// changer juste le start doit mettre à jour le end aussi.
|
||
let endDateTouched = false;
|
||
endDate.addEventListener("input", () => { endDateTouched = true; });
|
||
startDate.addEventListener("input", () => {
|
||
if (!endDateTouched || endDate.value < startDate.value) {
|
||
endDate.value = startDate.value;
|
||
}
|
||
});
|
||
|
||
// Type d'absence
|
||
const typeGroup = document.createElement("div");
|
||
typeGroup.className = "modal-form-group";
|
||
const typeLabel = document.createElement("label");
|
||
typeLabel.className = "modal-form-label";
|
||
typeLabel.textContent = "Type d'absence";
|
||
typeGroup.appendChild(typeLabel);
|
||
const typeSelect = document.createElement("select");
|
||
typeSelect.className = "modal-form-select";
|
||
typeSelect.id = "absence-type-select";
|
||
const emptyOpt = document.createElement("option");
|
||
emptyOpt.value = "";
|
||
emptyOpt.textContent = "— Choisir un type —";
|
||
typeSelect.appendChild(emptyOpt);
|
||
for (const t of HOLIDAY_TYPES) {
|
||
const opt = document.createElement("option");
|
||
opt.value = t.guid;
|
||
opt.textContent = t.label;
|
||
typeSelect.appendChild(opt);
|
||
}
|
||
typeGroup.appendChild(typeSelect);
|
||
card.appendChild(typeGroup);
|
||
|
||
// Boutons Appliquer / Annuler
|
||
const actions = document.createElement("div");
|
||
actions.className = "modal-actions horizontal";
|
||
const cancelBtn = document.createElement("button");
|
||
cancelBtn.type = "button";
|
||
cancelBtn.className = "btn btn-modal-cancel";
|
||
cancelBtn.textContent = "Annuler";
|
||
cancelBtn.addEventListener("click", () => overlay.remove());
|
||
const applyBtn = document.createElement("button");
|
||
applyBtn.type = "button";
|
||
applyBtn.className = "btn btn-modal-primary";
|
||
applyBtn.textContent = "Appliquer";
|
||
applyBtn.addEventListener("click", async () => {
|
||
// Validation
|
||
const techIds = getCheckedTechIds(techList);
|
||
if (techIds.length === 0) {
|
||
showAlertModal({
|
||
title: "Sélection manquante",
|
||
message: "Choisissez au moins un technicien.",
|
||
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
|
||
});
|
||
return;
|
||
}
|
||
if (!typeSelect.value) {
|
||
showAlertModal({
|
||
title: "Sélection manquante",
|
||
message: "Choisissez un type d'absence.",
|
||
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
|
||
});
|
||
return;
|
||
}
|
||
const sd = startDate.value, st = startTime.value;
|
||
const ed = endDate.value, et = endTime.value;
|
||
if (!sd || !st || !ed || !et) {
|
||
showAlertModal({
|
||
title: "Dates/heures manquantes",
|
||
message: "Remplissez toutes les dates et heures.",
|
||
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
|
||
});
|
||
return;
|
||
}
|
||
// v5.0.0 : validation fin >= début pour ne pas envoyer des absences
|
||
// inversées à EasyVista (il les accepte mais elles n'apparaissent jamais
|
||
// dans le planning, cf bug constaté).
|
||
if (ed < sd || (ed === sd && et <= st)) {
|
||
showAlertModal({
|
||
title: "Dates incohérentes",
|
||
message: "La date/heure de fin doit être après la date/heure de début.",
|
||
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
|
||
});
|
||
return;
|
||
}
|
||
// Désactiver le bouton pendant l'envoi
|
||
applyBtn.disabled = true;
|
||
applyBtn.textContent = "Envoi…";
|
||
try {
|
||
await submitAbsence({
|
||
techIds: techIds,
|
||
startDate: sd,
|
||
startTime: st,
|
||
endDate: ed,
|
||
endTime: et,
|
||
typeGuid: typeSelect.value
|
||
});
|
||
overlay.remove();
|
||
showToast("Absence créée", techIds.length + " tech" + (techIds.length > 1 ? "s" : ""));
|
||
// v5.0.0 : reload le planning DE LA DATE AFFICHÉE AVANT (dateAtOpen),
|
||
// pas de state.currentDate qui a pu être modifié entre-temps (bug
|
||
// où le planning sautait à la date de début de l'absence).
|
||
if (state.session) {
|
||
await loadForDate(dateAtOpen, { forceRefetch: true });
|
||
}
|
||
} catch (err) {
|
||
applyBtn.disabled = false;
|
||
applyBtn.textContent = "Appliquer";
|
||
showAlertModal({
|
||
title: "Erreur lors de la création",
|
||
message: "Impossible de créer l'absence : " + (err.message || err),
|
||
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
|
||
});
|
||
}
|
||
});
|
||
actions.appendChild(cancelBtn);
|
||
actions.appendChild(applyBtn);
|
||
card.appendChild(actions);
|
||
|
||
overlay.appendChild(card);
|
||
document.body.appendChild(overlay);
|
||
|
||
overlay.addEventListener("click", (e) => {
|
||
if (e.target === overlay) overlay.remove();
|
||
});
|
||
const escHandler = (e) => {
|
||
if (e.key === "Escape") {
|
||
overlay.remove();
|
||
document.removeEventListener("keydown", escHandler);
|
||
}
|
||
};
|
||
document.addEventListener("keydown", escHandler);
|
||
}
|
||
|
||
/**
|
||
* Envoie la requête de création d'absence à EasyVista.
|
||
* Appelle le background script qui fait le POST avec la bonne session.
|
||
*/
|
||
async function submitAbsence(opts) {
|
||
const resp = await sendMessage({
|
||
type: "submitAbsence",
|
||
techIds: opts.techIds,
|
||
startDate: isoToEvDate(opts.startDate),
|
||
startTime: opts.startTime + ":00", // HH:MM:SS
|
||
endDate: isoToEvDate(opts.endDate),
|
||
endTime: opts.endTime + ":00",
|
||
typeGuid: opts.typeGuid,
|
||
currentDate: isoToEvDate(opts.startDate)
|
||
});
|
||
if (!resp || !resp.ok) {
|
||
throw new Error(resp && resp.error ? resp.error : "erreur inconnue");
|
||
}
|
||
return resp;
|
||
}
|
||
|
||
/**
|
||
* Ouvre la modal "Envoyer la planification sur la douchette".
|
||
*/
|
||
function showDouchetteModal() {
|
||
const existing = document.getElementById("douchette-modal");
|
||
if (existing) existing.remove();
|
||
|
||
const overlay = document.createElement("div");
|
||
overlay.id = "douchette-modal";
|
||
overlay.className = "modal-overlay";
|
||
|
||
const card = document.createElement("div");
|
||
card.className = "modal-card";
|
||
card.setAttribute("role", "dialog");
|
||
|
||
const title = document.createElement("h2");
|
||
title.className = "modal-title";
|
||
title.textContent = "Envoyer la planification sur la douchette";
|
||
card.appendChild(title);
|
||
|
||
const msg = document.createElement("p");
|
||
msg.className = "modal-message";
|
||
msg.textContent = "Choisissez le ou les techniciens qui recevront la planification du jour sur leur douchette.";
|
||
card.appendChild(msg);
|
||
|
||
const techGroup = document.createElement("div");
|
||
techGroup.className = "modal-form-group";
|
||
const techList = buildTechCheckboxList({ selectAll: true });
|
||
techGroup.appendChild(techList);
|
||
card.appendChild(techGroup);
|
||
|
||
// Boutons
|
||
const actions = document.createElement("div");
|
||
actions.className = "modal-actions horizontal";
|
||
const cancelBtn = document.createElement("button");
|
||
cancelBtn.type = "button";
|
||
cancelBtn.className = "btn btn-modal-cancel";
|
||
cancelBtn.textContent = "Annuler";
|
||
cancelBtn.addEventListener("click", () => overlay.remove());
|
||
const sendBtn = document.createElement("button");
|
||
sendBtn.type = "button";
|
||
sendBtn.className = "btn btn-modal-primary";
|
||
sendBtn.textContent = "Envoyer";
|
||
sendBtn.addEventListener("click", async () => {
|
||
const techIds = getCheckedTechIds(techList);
|
||
if (techIds.length === 0) {
|
||
showAlertModal({
|
||
title: "Sélection manquante",
|
||
message: "Choisissez au moins un technicien.",
|
||
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
|
||
});
|
||
return;
|
||
}
|
||
sendBtn.disabled = true;
|
||
sendBtn.textContent = "Envoi…";
|
||
try {
|
||
const result = await submitDouchette(techIds);
|
||
overlay.remove();
|
||
if (result && result.okCount > 0) {
|
||
showToast(
|
||
"Envoyé sur douchette",
|
||
result.okCount + "/" + techIds.length + " tech" + (techIds.length > 1 ? "s" : "")
|
||
);
|
||
}
|
||
if (result && result.errors && result.errors.length > 0) {
|
||
showAlertModal({
|
||
title: "Envoi partiellement échoué",
|
||
message: result.errors.length + " tech(s) n'ont pas pu recevoir : "
|
||
+ result.errors.map(e => TEAM[e.techId] || e.techId).join(", "),
|
||
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
|
||
});
|
||
}
|
||
} catch (err) {
|
||
sendBtn.disabled = false;
|
||
sendBtn.textContent = "Envoyer";
|
||
showAlertModal({
|
||
title: "Erreur lors de l'envoi",
|
||
message: "Impossible d'envoyer sur la douchette : " + (err.message || err),
|
||
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
|
||
});
|
||
}
|
||
});
|
||
actions.appendChild(cancelBtn);
|
||
actions.appendChild(sendBtn);
|
||
card.appendChild(actions);
|
||
|
||
overlay.appendChild(card);
|
||
document.body.appendChild(overlay);
|
||
|
||
overlay.addEventListener("click", (e) => {
|
||
if (e.target === overlay) overlay.remove();
|
||
});
|
||
const escHandler = (e) => {
|
||
if (e.key === "Escape") {
|
||
overlay.remove();
|
||
document.removeEventListener("keydown", escHandler);
|
||
}
|
||
};
|
||
document.addEventListener("keydown", escHandler);
|
||
}
|
||
|
||
/**
|
||
* Envoie la planification sur la douchette de plusieurs techniciens.
|
||
* Retourne { okCount, errors: [{techId, error}] }.
|
||
*/
|
||
async function submitDouchette(techIds) {
|
||
const resp = await sendMessage({
|
||
type: "submitDouchette",
|
||
techIds: techIds,
|
||
currentDate: isoToEvDate(state.currentDate || todayISO())
|
||
});
|
||
if (!resp || !resp.ok) {
|
||
throw new Error(resp && resp.error ? resp.error : "erreur inconnue");
|
||
}
|
||
return resp;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Date helpers
|
||
// ============================================================================
|
||
|
||
function todayISO() {
|
||
const d = new Date();
|
||
return dateToISO(d);
|
||
}
|
||
|
||
function dateToISO(d) {
|
||
const yyyy = d.getFullYear();
|
||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||
const dd = String(d.getDate()).padStart(2, "0");
|
||
return `${yyyy}-${mm}-${dd}`;
|
||
}
|
||
|
||
function isoToDate(iso) {
|
||
const [y, m, d] = iso.split("-").map(n => parseInt(n, 10));
|
||
return new Date(y, m - 1, d);
|
||
}
|
||
|
||
function isoToDDMMYYYY(iso) {
|
||
const [y, m, d] = iso.split("-");
|
||
return `${d}/${m}/${y}`;
|
||
}
|
||
|
||
function formatDateDM(iso) {
|
||
const [, m, d] = iso.split("-");
|
||
return `${d}/${m}`;
|
||
}
|
||
|
||
function isoToUnixDate(iso) {
|
||
// Renvoie le timestamp Unix à midi local du jour (pour que le serveur comprenne bien le jour demandé)
|
||
const d = isoToDate(iso);
|
||
d.setHours(12, 0, 0, 0);
|
||
return Math.floor(d.getTime() / 1000);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Messages → background
|
||
// ============================================================================
|
||
|
||
function sendMessage(msg) {
|
||
return new Promise((resolve, reject) => {
|
||
chrome.runtime.sendMessage(msg, (response) => {
|
||
if (chrome.runtime.lastError) {
|
||
reject(new Error(chrome.runtime.lastError.message));
|
||
return;
|
||
}
|
||
resolve(response || {});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Cache (chrome.storage.local)
|
||
// ============================================================================
|
||
|
||
async function readCache(isoDate) {
|
||
const key = CACHE_PREFIX + isoDate;
|
||
const obj = await chrome.storage.local.get(key);
|
||
return obj[key] || null;
|
||
}
|
||
|
||
async function writeCache(isoDate, data) {
|
||
const key = CACHE_PREFIX + isoDate;
|
||
await chrome.storage.local.set({ [key]: { ...data, savedAt: Date.now() } });
|
||
}
|
||
|
||
// ============================================================================
|
||
// Flux principal : charger une date
|
||
// ============================================================================
|
||
|
||
async function loadForDate(isoDate, opts = {}) {
|
||
// v4.3.1 : changer de date fermait tous les popups épinglés.
|
||
// v2026.5.17 : les popups épinglés restent maintenant ouverts entre dates,
|
||
// avec les données qu'ils avaient au moment de l'épinglage.
|
||
// v2026.5.18 : au changement de date, on réduit tous les popups épinglés
|
||
// dans la taskbar du bas (l'user peut les re-agrandir au clic).
|
||
const previousDate = state.currentDate;
|
||
if (previousDate && previousDate !== isoDate) {
|
||
_reduceAllPinnedPopups();
|
||
}
|
||
|
||
state.currentDate = isoDate;
|
||
document.getElementById("date-picker").value = isoDate;
|
||
updateDatePickerDayLabel(isoDate); // v2026.5.16 : label "Mardi" à côté
|
||
|
||
if (!state.session) {
|
||
// v4.2.5 : afficher le cache + bannière, plutôt que l'écran "session"
|
||
const cached = await readCache(isoDate);
|
||
if (cached) {
|
||
renderFromData({
|
||
techs: cached.techs,
|
||
targetDate: isoDate,
|
||
captureTime: cached.savedAt || null,
|
||
source: "cache"
|
||
});
|
||
showSessionExpiredBanner();
|
||
} else {
|
||
showSessionNeeded();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// (v3.1.1) Tout chargement = un nouveau jeton d'annulation. Le bouton
|
||
// "Arrêter" apparaît pour TOUT refresh (clic manuel, navigation date,
|
||
// ouverture vue claire), pas juste refreshPlanning(). Le bouton disparaît
|
||
// quand le chargement est vraiment fini (finally).
|
||
const myToken = startNewRefresh();
|
||
showAbortButton(true);
|
||
const t0 = performance.now();
|
||
console.log(`[load] début pour ${isoDate} (token=${myToken})`);
|
||
|
||
// v4.1.14 : choix du bouton qui tourne
|
||
// - Clic explicite "Actualiser" → _fromPartialBtn → "partial"
|
||
// - Clic explicite "Tout recharger" → doStatusRefresh → "total"
|
||
// - Sinon (nav date / chargement auto) :
|
||
// - cache présent → "partial" (c'est juste un diff XML)
|
||
// - cache absent → "total" (on charge tout pour la 1re fois)
|
||
// La détermination se fait APRÈS readCache.
|
||
|
||
try {
|
||
// 1. Afficher immédiatement depuis le cache si disponible
|
||
const cached = await readCache(isoDate);
|
||
|
||
if (!opts._fromPartialBtn) {
|
||
if (opts.doStatusRefresh) {
|
||
setActiveRefreshButton("total");
|
||
} else {
|
||
setActiveRefreshButton(cached ? "partial" : "total");
|
||
}
|
||
}
|
||
if (cached && !opts.forceRefetch) {
|
||
renderFromData({
|
||
techs: cached.techs,
|
||
targetDate: isoDate,
|
||
captureTime: cached.savedAt || null,
|
||
source: "cache"
|
||
});
|
||
// v4.1.9 : on NE retourne PAS ici. On continue pour refetch le XML
|
||
// du planning afin de détecter les nouvelles iv et celles disparues
|
||
// (diff avec le cache). Les iv déjà présentes dans le cache gardent
|
||
// leur enrichissement (ficheActionText, statut) → pas de re-fetch
|
||
// inutile, seules les nouvelles passent par refreshStatuses.
|
||
} else {
|
||
showLoading();
|
||
}
|
||
|
||
if (isRefreshAborted(myToken)) return;
|
||
|
||
// 2. Fetch frais du planning (XML calendar_block — rapide, ~40 ko)
|
||
const tXml = performance.now();
|
||
const fresh = await fetchPlanningForDate(isoDate);
|
||
console.log(`[load] XML planning récupéré en ${Math.round(performance.now() - tXml)} ms`);
|
||
if (!fresh) return;
|
||
if (isRefreshAborted(myToken)) return;
|
||
|
||
// 3. Fusionner cache + frais
|
||
const merged = mergeCacheAndFresh(cached, fresh);
|
||
|
||
// v4.2.5 : AVANT de retirer les ghosts, on lance une analyse de chaque
|
||
// ghost pour déterminer si c'est :
|
||
// - un ticket TERMINÉ par le tech (→ garder en vert ✓ simple)
|
||
// - un ticket CLÔTURÉ/RÉSOLU dans EasyVista (→ garder en vert ✓✓ double)
|
||
// - un ticket DÉPLACÉ (action ouverte au même tech autre jour) → retirer
|
||
// - un ticket ANNULÉ / autre → retirer
|
||
// L'analyse est asynchrone (re-fetch de chaque fiche) : on la lance en
|
||
// arrière-plan APRÈS le rendu initial pour ne pas bloquer l'UI.
|
||
// En attendant, les ghosts restent visibles avec un indicateur "en cours
|
||
// d'analyse" (petit spinner / opacité réduite).
|
||
const ghostsToAnalyze = [];
|
||
for (const tech of merged.techs) {
|
||
for (const iv of tech.interventions) {
|
||
if (iv.ghost) {
|
||
iv._disappearChecking = true; // marquer "en cours d'analyse"
|
||
ghostsToAnalyze.push({ tech, iv });
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. Afficher immédiatement (v4 : tout est déjà rempli depuis le XML !)
|
||
// Le calendar_block contient attr1/attr2/attr3 = contact/lieu/catégorie,
|
||
// et textContent = ref. Donc ce 1er rendu est DÉJÀ complet visuellement
|
||
// (manquent juste : statut clos/résolu, et détails dans le tooltip au
|
||
// survol). Plus d'étapes 5a et 5b successives comme en v3.
|
||
renderFromData({
|
||
techs: merged.techs,
|
||
targetDate: isoDate,
|
||
captureTime: Date.now(),
|
||
source: "fresh",
|
||
lastRefreshKind: activeRefreshButton // v4.1.20
|
||
});
|
||
console.log(`[load] 1er rendu complet à ${Math.round(performance.now() - t0)} ms`);
|
||
|
||
// v4.2.5 : analyser les ghosts (tickets disparus du planning) pour décider
|
||
// s'il faut les garder en vert (terminés par tech / clôturés) ou les
|
||
// retirer définitivement (déplacés / annulés). Asynchrone en arrière-plan.
|
||
if (ghostsToAnalyze.length > 0 && !isRefreshAborted(myToken)) {
|
||
console.log(`[load] analyse de ${ghostsToAnalyze.length} ticket(s) disparu(s)…`);
|
||
analyzeDisappearedInterventions(merged.techs, ghostsToAnalyze, myToken)
|
||
.then(() => {
|
||
if (!isRefreshAborted(myToken)) {
|
||
renderFromData({
|
||
techs: merged.techs,
|
||
targetDate: isoDate,
|
||
captureTime: Date.now(),
|
||
source: "fresh",
|
||
lastRefreshKind: activeRefreshButton
|
||
});
|
||
writeCache(isoDate, { techs: merged.techs }).catch(() => {});
|
||
}
|
||
})
|
||
.catch(err => console.error("[disappear-analysis]", err));
|
||
}
|
||
|
||
// 5. Fetch des fiches en arrière-plan UNIQUEMENT pour obtenir :
|
||
// - le statut Clôturé/Résolu (pour le ✓ vert et le fond vert)
|
||
// - le commentaire technicien (affiché dans le tooltip)
|
||
// - le checksum pour ouvrir la fiche (en vrai déjà dans formLink, mais
|
||
// on garde la fiche comme source de vérité pour le statut)
|
||
//
|
||
// v4.1 : fetch séquentiel (1 à la fois) avec cache écrit tous les 5 fiches.
|
||
// Voir refreshStatuses() pour les détails.
|
||
const needFetch = merged.techs.some(tech =>
|
||
tech.interventions.some(iv =>
|
||
iv.type === "AL-Intervention" && !iv.ficheFetched
|
||
)
|
||
);
|
||
|
||
// v5.0.6 : logs détaillés pour diagnostiquer pourquoi le fetch ne se
|
||
// lance pas.
|
||
const totalIv = merged.techs.reduce((s, t) => s + (t.interventions || []).length, 0);
|
||
const totalInterIv = merged.techs.reduce((s, t) =>
|
||
s + (t.interventions || []).filter(i => i.type === "AL-Intervention").length, 0);
|
||
const notFetched = merged.techs.reduce((s, t) =>
|
||
s + (t.interventions || []).filter(i => i.type === "AL-Intervention" && !i.ficheFetched).length, 0);
|
||
console.log(`[load] merged : ${merged.techs.length} techs, ${totalIv} iv totales, ${totalInterIv} interventions réelles, ${notFetched} sans fiche`);
|
||
console.log(`[load] needFetch = ${needFetch} | doStatusRefresh = ${!!opts.doStatusRefresh} | forceRefetch = ${!!opts.forceRefetch} | aborted = ${isRefreshAborted(myToken)}`);
|
||
|
||
// v2026.5.34 : si le user n'est pas encore connu (badge "?"), on tente
|
||
// un fetch immédiatement puisque le planning a réussi = session valide.
|
||
// Évite d'attendre le retry de 60s quand on vient juste de se reconnecter.
|
||
_maybeRetryFetchUser("after_load_success");
|
||
|
||
// v4.3.2 : avant de lancer le fetch des fiches (lourd, ~460 KB chacune),
|
||
// on fait d'abord un batch xhr2 (léger, ~2-5 KB chacun) pour récupérer
|
||
// les vraies infos contact/lieu de toutes les interventions en parallèle.
|
||
if (!isRefreshAborted(myToken)) {
|
||
await prefetchAllXhr2(merged.techs, myToken, opts.doStatusRefresh);
|
||
}
|
||
|
||
if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted(myToken)) {
|
||
const tFiches = performance.now();
|
||
const nFiches = merged.techs.flatMap(t=>t.interventions).filter(i=>i.type==="AL-Intervention").length;
|
||
console.log(`[load] début fetch des ${nFiches} fiches (statuts)…`);
|
||
await refreshStatuses(merged.techs, isoDate, { forceAll: !!opts.doStatusRefresh, myToken });
|
||
console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`);
|
||
} else {
|
||
console.log(`[load] PAS DE FETCH : needFetch=${needFetch}, doStatusRefresh=${!!opts.doStatusRefresh}, aborted=${isRefreshAborted(myToken)}`);
|
||
}
|
||
|
||
// 6. Sauvegarder dans le cache (une seule fois, après que tout soit enrichi)
|
||
if (!isRefreshAborted(myToken)) {
|
||
await writeCache(isoDate, { techs: merged.techs });
|
||
}
|
||
|
||
if (!isRefreshAborted(myToken)) {
|
||
showRefreshDone();
|
||
console.log(`[load] TOTAL: ${Math.round(performance.now() - t0)} ms`);
|
||
} else {
|
||
// v4.1.9 : toast "annulé" uniquement si c'était un vrai clic "Arrêter",
|
||
// pas un simple changement de date (qui abort l'ancien silencieusement).
|
||
const wasExplicitAbort = (abortedToken === myToken);
|
||
console.log(`[load] annulé à ${Math.round(performance.now() - t0)} ms (explicite=${wasExplicitAbort})`);
|
||
if (wasExplicitAbort) showAbortToast();
|
||
}
|
||
} finally {
|
||
// Masquer le bouton "Arrêter" uniquement si c'est NOTRE chargement qui
|
||
// se termine (pas un chargement postérieur que l'utilisateur aurait lancé
|
||
// entre-temps en naviguant ailleurs).
|
||
if (currentRefreshToken === myToken) {
|
||
showAbortButton(false);
|
||
}
|
||
cleanupAbortResolver(myToken);
|
||
}
|
||
}
|
||
|
||
async function refreshPlanning(opts = {}) {
|
||
if (!state.session) {
|
||
await refreshSessionAndLoad();
|
||
return;
|
||
}
|
||
if (opts.partial) {
|
||
// v4.1.13 : _fromPartialBtn empêche loadForDate de reset activeRefreshButton à "total"
|
||
await loadForDate(state.currentDate, { doStatusRefresh: false, _fromPartialBtn: true });
|
||
} else {
|
||
await loadForDate(state.currentDate, { doStatusRefresh: true });
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Fetch du planning (via background)
|
||
// ============================================================================
|
||
|
||
async function fetchPlanningForDate(isoDate) {
|
||
setRefreshing(true);
|
||
try {
|
||
const unixDate = isoToUnixDate(isoDate);
|
||
const resp = await sendMessage({
|
||
type: "fetchPlanning",
|
||
session: state.session,
|
||
unixDate: unixDate
|
||
});
|
||
if (!resp.ok) {
|
||
// v4.2.5 : si le planning du jour est DÉJÀ rendu (cache), on affiche
|
||
// une bannière non bloquante en haut, le cache reste visible.
|
||
// Si rien n'est rendu (1er chargement, pas de cache), on affiche
|
||
// l'écran plein comme avant.
|
||
const hasCacheRendered =
|
||
document.getElementById("cards") &&
|
||
document.getElementById("cards").children.length > 0;
|
||
if (resp.error === "no_session" || resp.error === "session_expired") {
|
||
state.session = null;
|
||
if (hasCacheRendered) {
|
||
showSessionExpiredBanner();
|
||
} else {
|
||
showSessionNeeded();
|
||
}
|
||
} else if (resp.error === "ev_unreachable") {
|
||
if (hasCacheRendered) {
|
||
showEvUnreachableBanner();
|
||
} else {
|
||
showEvUnreachable();
|
||
}
|
||
} else {
|
||
showError("Erreur de fetch : " + (resp.error || "inconnue"));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Safeguard (v3.1) : le serveur EasyVista répond parfois 200 avec un
|
||
// corps vide — typiquement quand la sesson vient d'être invalidée, ou
|
||
// quand il soupçonne du scraping (trop de requêtes parallèles). Dans
|
||
// les deux cas, on traite ça comme une sesson expirée : inutile de
|
||
// parser (ça ferait "Document is empty") ni de retry en boucle.
|
||
if (!resp.xml || resp.xml.length < 20) {
|
||
console.warn("[viewer] XML planning vide — session probablement invalide");
|
||
state.session = null;
|
||
const hasCacheRendered =
|
||
document.getElementById("cards") &&
|
||
document.getElementById("cards").children.length > 0;
|
||
if (hasCacheRendered) {
|
||
showSessionExpiredBanner();
|
||
} else {
|
||
showSessionNeeded();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Parser le HTML complet du planning (contient TOUT : ref, catégorie,
|
||
// contact, lieu, description, formLinks, request_id + checksum)
|
||
const techs = parsePlanningXml(resp.xml, isoDate);
|
||
|
||
return { techs };
|
||
} catch (err) {
|
||
showError("Erreur inattendue : " + (err.message || err));
|
||
return null;
|
||
} finally {
|
||
setRefreshing(false);
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Parsing du XML du planning
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Parse le XML retourné par planning_xhr.php?div=calendar_block.
|
||
* Contient les interventions (actions) par technicien, avec :
|
||
* - action_id, done_by_id, action_label (parfois juste "AL-Intervention"),
|
||
* - start_time / end_time, start_date / end_date,
|
||
* - formLink (eventName=formEvent&target=ACTIONID&checksum=...) pour ouvrir l'action,
|
||
* - request_id (ID de la fiche SD_REQUEST, utilisé pour ouvrir la fiche).
|
||
*/
|
||
function parsePlanningXml(xml, isoDate) {
|
||
const doc = new DOMParser().parseFromString(xml, "text/xml");
|
||
|
||
const parserError = doc.querySelector("parsererror");
|
||
if (parserError) {
|
||
console.warn("Parser error:", parserError.textContent);
|
||
}
|
||
|
||
const actionNodes = doc.querySelectorAll("action");
|
||
const byTechId = new Map();
|
||
for (const id of Object.keys(TEAM)) {
|
||
byTechId.set(id, { id, name: TEAM[id], interventions: [] });
|
||
}
|
||
|
||
for (const node of actionNodes) {
|
||
const iv = actionNodeToIntervention(node);
|
||
if (!iv) continue;
|
||
if (!byTechId.has(iv.techId)) continue;
|
||
if (!actionCoversDate(iv, isoDate)) continue;
|
||
byTechId.get(iv.techId).interventions.push(iv);
|
||
}
|
||
|
||
for (const tech of byTechId.values()) {
|
||
tech.interventions.sort((a, b) =>
|
||
(a.startTime || "").localeCompare(b.startTime || "")
|
||
);
|
||
}
|
||
|
||
return [...byTechId.values()];
|
||
}
|
||
|
||
function actionNodeToIntervention(node) {
|
||
const get = name => node.getAttribute(name) || "";
|
||
|
||
const actionId = get("action_id");
|
||
if (!actionId) return null;
|
||
|
||
const actionType = get("action_type");
|
||
const techId = get("done_by_id");
|
||
const label = get("action_label");
|
||
const cssClass = get("Css_Class");
|
||
const startDate = get("start_date");
|
||
const endDate = get("end_date");
|
||
const startTime = get("start_time");
|
||
const endTime = get("end_time");
|
||
const currentDate = get("current_date");
|
||
const formLink = get("formLink");
|
||
const deadline = get("max_resolution_date") || get("max_intervention_date");
|
||
const requestId = get("request_id");
|
||
|
||
// ─── v4 : infos enrichies disponibles directement dans le XML ──────────────
|
||
// EasyVista envoie déjà contact/lieu/catégorie dans attr1/attr2/attr3.
|
||
// La ref est dans le textContent du nœud (format "SYYMMDD_NNNNN (CM)" ou
|
||
// "IYYMMDD_NNNNN (SD)"). Plus besoin de fetcher xhr2 ni la fiche pour ça.
|
||
const attr1 = get("attr1"); // contact
|
||
const attr2 = get("attr2"); // lieu
|
||
const attr3 = get("attr3"); // catégorie complète
|
||
const nodeText = (node.textContent || "").trim();
|
||
|
||
// Extraire la ref en priorité du textContent (où elle est complète), sinon
|
||
// fallback sur le label. v4.1.9 : pattern générique [SI]\d+_\d+ (plus
|
||
// hardcodé sur "2..." qui était pour 2020-2029).
|
||
let ref = null;
|
||
const refFromText = nodeText.match(/\b([SI]\d{5,8}_\d{4,6})\b/);
|
||
if (refFromText) {
|
||
ref = refFromText[1];
|
||
} else {
|
||
const refFromLabel = label.match(/\b([SI]\d{5,8}_\d{4,6})\b/);
|
||
if (refFromLabel) ref = refFromLabel[1];
|
||
}
|
||
|
||
// Détection du type "Réservation" vs "Absence".
|
||
//
|
||
// v5.0.3 (simplifiée) : le label suit le pattern "Nom / Créé par : X Y".
|
||
//
|
||
// - Congés / Maladie / Pompier → AL-Absence (tech réellement absent)
|
||
// - TOUT LE RESTE (Ecrans, PC, MAC, Rollout, Téléphones, UTP, Réunion,
|
||
// Déménagement, Evènements spéciaux, Formation, ...)
|
||
// → AL-Reservation (créneau bloqué, tech pas absent)
|
||
//
|
||
// Cette règle simple évite les cas "absence toute la journée" déclenchés
|
||
// par erreur pour des réservations de type événement / réunion.
|
||
const ABSENCE_LABELS = /^(cong[ée]s|maladie|pompier)$/i;
|
||
let effectiveType = actionType;
|
||
let reservationLabel = null;
|
||
let reservationCreator = null;
|
||
const reservationMatch = label.match(/^([^/]+?)\s*\/\s*Créé par\s*:\s*(.+)$/i);
|
||
if (reservationMatch) {
|
||
const label1 = reservationMatch[1].trim();
|
||
const creator = reservationMatch[2].trim();
|
||
if (ABSENCE_LABELS.test(label1)) {
|
||
// Vraie absence du tech
|
||
effectiveType = "AL-Absence";
|
||
} else {
|
||
// Réservation : créneau bloqué (matériel ou activité), tech pas absent
|
||
effectiveType = "AL-Reservation";
|
||
reservationLabel = label1;
|
||
reservationCreator = creator;
|
||
}
|
||
}
|
||
|
||
// ─── v4 : pré-remplissage immédiat depuis les attributs XML ─────────────────
|
||
// On renseigne bulleContact/bulleLieu/categoryLine DÈS la création de l'objet.
|
||
// Plus besoin d'attendre xhr2 ou la fiche pour avoir l'affichage de base.
|
||
// Seuls restent à fetcher (en arrière-plan, sur fiche) : status.
|
||
// Et sur hover (lazy, seulement si l'user survole) : bulleDescription complet.
|
||
const isIntervention = effectiveType === "AL-Intervention";
|
||
const bulleContact = isIntervention && attr1 ? attr1 : null;
|
||
const bulleLieu = isIntervention && attr2 ? attr2 : null;
|
||
const categoryLine = isIntervention && attr3 ? attr3 : null;
|
||
|
||
return {
|
||
actionId: actionId,
|
||
requestId: requestId,
|
||
techId: techId,
|
||
label: label,
|
||
type: effectiveType, // "AL-Intervention" | "AL-Absence" | "AL-Reservation"
|
||
originalType: actionType, // type brut (pour debug)
|
||
reservationLabel: reservationLabel, // "Ecrans", "Rollout", etc.
|
||
reservationCreator: reservationCreator, // "Nom, Prénom" du coordinateur
|
||
cssClass: cssClass,
|
||
isPompier: /pompier/i.test(label) || /pompier/i.test(actionType),
|
||
// v2026.5.27 : catégorie d'absence pour classification visuelle
|
||
// "maladie" | "conge" | "pompier" | null
|
||
absenceCategory: (function() {
|
||
if (effectiveType !== "AL-Absence") return null;
|
||
const lblTest = reservationMatch ? reservationMatch[1].trim() : label;
|
||
if (/pompier/i.test(lblTest) || /pompier/i.test(actionType)) return "pompier";
|
||
if (/maladie/i.test(lblTest)) return "maladie";
|
||
if (/cong[ée]s?/i.test(lblTest)) return "conge";
|
||
return null;
|
||
})(),
|
||
ref: ref,
|
||
startDate: startDate,
|
||
endDate: endDate,
|
||
startTime: startTime,
|
||
endTime: endTime,
|
||
currentDate: currentDate,
|
||
formLink: formLink,
|
||
deadline: deadline,
|
||
// v4 : renseignés directement depuis le XML (plus d'attente de xhr2)
|
||
bulleContact: bulleContact,
|
||
bulleLieu: bulleLieu,
|
||
categoryLine: categoryLine,
|
||
bulleDescription: null, // reste null, rempli lazy au premier hover (xhr2)
|
||
infobulle: null, // reste null, rempli lazy aussi
|
||
status: null, // toujours rempli par fetch fiche (en arrière-plan)
|
||
// v4 : ficheTarget/Checksum déjà présents dans formLink (extraits à la demande)
|
||
ficheTarget: null,
|
||
ficheChecksum: null,
|
||
ficheFetched: false,
|
||
ficheFetchError: null,
|
||
xhr2Fetched: false, // lazy : passe à true après le 1er hover
|
||
xhr2Fetching: false, // évite les doubles fetchs simultanés
|
||
ghost: false
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Parse le body de planning_xhr_2.php?id=ACTIONID (ou similaire).
|
||
* Format observé :
|
||
* @@DESCRIPTION_S@@...texte complet de l'action...@@DESCRIPTION_E@@
|
||
* @@LABEL_S@@AL-Intervention@@LABEL_E@@
|
||
* @@LAST_S@@Nom, Prénom@@LAST_E@@
|
||
* @@PLANNED_TIME_S@@@@PLANNED_TIME_E@@
|
||
* @@PLANNED_CHANGE_S@@@@PLANNED_CHANGE_E@@
|
||
*/
|
||
function parseXhr2Body(body) {
|
||
if (!body || typeof body !== "string") return null;
|
||
const out = { description: null, label: null, last: null };
|
||
const rxD = /@@DESCRIPTION_S@@([\s\S]*?)@@DESCRIPTION_E@@/;
|
||
const rxL = /@@LABEL_S@@([\s\S]*?)@@LABEL_E@@/;
|
||
const rxLa = /@@LAST_S@@([\s\S]*?)@@LAST_E@@/;
|
||
const md = body.match(rxD);
|
||
const ml = body.match(rxL);
|
||
const mla = body.match(rxLa);
|
||
if (md) out.description = md[1].trim();
|
||
if (ml) out.label = ml[1].trim();
|
||
if (mla) out.last = mla[1].trim();
|
||
return out;
|
||
}
|
||
|
||
// v4 : fetchBullesForInterventions (fetch xhr2 en masse au chargement) a été
|
||
// supprimée. Le contact/lieu/catégorie viennent maintenant directement des
|
||
// attributs attr1/attr2/attr3 du calendar_block. Pour le TEXTE complet de
|
||
// l'action (Problème/À faire/Matériel/TFS/...), voir ensureBulleDescription()
|
||
// qui lazy-load UNIQUEMENT au premier hover de l'intervention.
|
||
|
||
function actionCoversDate(iv, isoDate) {
|
||
if (!iv.startDate || !iv.endDate) return true; // manque info → on garde
|
||
const target = isoToDDMMYYYY(isoDate);
|
||
return ddmmyyyyLE(iv.startDate, target) && ddmmyyyyLE(target, iv.endDate);
|
||
}
|
||
|
||
function ddmmyyyyLE(a, b) {
|
||
// Compare deux dates JJ/MM/AAAA
|
||
const toNum = s => {
|
||
const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
||
return m ? parseInt(m[3] + m[2] + m[1], 10) : 0;
|
||
};
|
||
return toNum(a) <= toNum(b);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Fusion cache ↔ fresh
|
||
// ============================================================================
|
||
|
||
function mergeCacheAndFresh(cached, fresh) {
|
||
// fresh.techs : liste des techs avec interventions d'aujourd'hui (depuis EasyVista)
|
||
// cached.techs : dernière liste sauvegardée pour ce jour (avec statuts)
|
||
//
|
||
// Règles v4 :
|
||
// - Le fresh APPORTE (depuis le XML calendar_block) : actionId, type,
|
||
// startTime/endTime, formLink, ref (textContent), bulleContact (attr1),
|
||
// bulleLieu (attr2), categoryLine (attr3), deadline.
|
||
// - Le cache APPORTE : status (clôturé/résolu),
|
||
// bulleDescription (lazy-load xhr2 au hover) + infobulle, ficheFetched,
|
||
// xhr2Fetched.
|
||
// - Règle générale : fresh wins sur les champs live, cache wins sur les
|
||
// champs enrichis qui ne sont pas dans le fresh.
|
||
// - Une interventoin en cache mais plus en fresh → marquée "ghost"
|
||
|
||
if (!cached || !cached.techs) {
|
||
return { techs: fresh.techs };
|
||
}
|
||
|
||
// Indexer le cache par actionId
|
||
const cachedByAction = new Map();
|
||
for (const tech of cached.techs) {
|
||
for (const iv of tech.interventions || []) {
|
||
cachedByAction.set(iv.actionId, iv);
|
||
}
|
||
}
|
||
|
||
const resultTechs = fresh.techs.map(t => ({ ...t, interventions: [] }));
|
||
const freshActionIds = new Set();
|
||
|
||
for (const tech of fresh.techs) {
|
||
const outTech = resultTechs.find(t => t.id === tech.id);
|
||
for (const iv of tech.interventions) {
|
||
freshActionIds.add(iv.actionId);
|
||
const cachedIv = cachedByAction.get(iv.actionId);
|
||
if (cachedIv) {
|
||
// On part du cache (qui a les champs enrichis), puis on remplace
|
||
// les champs "live" depuis le fresh (horaires, type, formLink).
|
||
const merged = {
|
||
...cachedIv,
|
||
// Champs live venant du fresh (le planning peut avoir bougé)
|
||
techId: iv.techId || cachedIv.techId,
|
||
type: iv.type || cachedIv.type,
|
||
label: iv.label || cachedIv.label,
|
||
cssClass: iv.cssClass || cachedIv.cssClass,
|
||
isPompier: iv.isPompier,
|
||
startDate: iv.startDate || cachedIv.startDate,
|
||
endDate: iv.endDate || cachedIv.endDate,
|
||
startTime: iv.startTime || cachedIv.startTime,
|
||
endTime: iv.endTime || cachedIv.endTime,
|
||
currentDate: iv.currentDate || cachedIv.currentDate,
|
||
formLink: iv.formLink || cachedIv.formLink,
|
||
deadline: iv.deadline || cachedIv.deadline,
|
||
requestId: iv.requestId || cachedIv.requestId,
|
||
// v4 : la ref du fresh est maintenant FIABLE (textContent XML),
|
||
// on la privilégie sur le cache (inversé vs v3).
|
||
ref: iv.ref || cachedIv.ref,
|
||
// v4 : categoryLine vient désormais du XML (attr3), on la privilégie.
|
||
categoryLine: iv.categoryLine || cachedIv.categoryLine,
|
||
// Contact/lieu : fresh est plus à jour (attr1/attr2 du XML)
|
||
bulleContact: iv.bulleContact || cachedIv.bulleContact,
|
||
bulleLieu: iv.bulleLieu || cachedIv.bulleLieu,
|
||
// bulleDescription : on privilégie le cache, qui contient le texte
|
||
// lazy-load au hover. Le fresh n'a pas ce texte (null au chargement).
|
||
bulleDescription: cachedIv.bulleDescription || iv.bulleDescription,
|
||
infobulle: cachedIv.infobulle || iv.infobulle,
|
||
xhr2Fetched: cachedIv.xhr2Fetched || iv.xhr2Fetched,
|
||
// ghost : on retire (cette intervention est bien là dans le fresh)
|
||
ghost: false
|
||
};
|
||
outTech.interventions.push(merged);
|
||
} else {
|
||
outTech.interventions.push(iv);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ajouter les interventions qui sont en cache mais plus en fresh
|
||
for (const tech of cached.techs) {
|
||
const outTech = resultTechs.find(t => t.id === tech.id);
|
||
if (!outTech) continue;
|
||
for (const iv of tech.interventions || []) {
|
||
if (!freshActionIds.has(iv.actionId)) {
|
||
// v5.0.1 : les absences et réservations supprimées côté EasyVista
|
||
// sont définitivement retirées (pas ghost). La logique ghost est
|
||
// conçue pour les interventions dont on veut garder trace en attendant
|
||
// la vérification du statut (clos/annulé). Absences/réservations n'ont
|
||
// pas de notion de statut, une disparition = suppression pure.
|
||
if (iv.type === "AL-Absence" || iv.type === "AL-Reservation") {
|
||
continue; // ne pas rajouter
|
||
}
|
||
const ghost = { ...iv, ghost: true };
|
||
outTech.interventions.push(ghost);
|
||
}
|
||
}
|
||
// Retrier
|
||
outTech.interventions.sort((a, b) =>
|
||
(a.startTime || "").localeCompare(b.startTime || "")
|
||
);
|
||
}
|
||
|
||
return { techs: resultTechs };
|
||
}
|
||
|
||
// ============================================================================
|
||
// v4.2.5 : analyse des tickets disparus du planning
|
||
// ============================================================================
|
||
//
|
||
// Pour chaque ticket qui était dans le cache mais n'est plus dans le XML
|
||
// fresh, on doit décider s'il faut :
|
||
// 1. Le GARDER en vert double ✓✓ → clôturé / résolu dans EasyVista
|
||
// 2. Le GARDER en vert simple ✓ → terminé par le tech (commentaire LOGIN:)
|
||
// 3. Le RETIRER → déplacé sur un autre jour / annulé / autre
|
||
//
|
||
// Logique (validée avec l'utilisateur) :
|
||
// a) Re-fetch la fiche
|
||
// b) Si statut global = CLOS ou RÉSOLU → garder, vert ✓✓
|
||
// c) Sinon parcourir les actions OUVERTES de la fiche :
|
||
// - Si action ouverte au nom du tech sur JOUR DIFFÉRENT → retirer (déplacée)
|
||
// - Sinon passer à l'étape d
|
||
// d) Parcourir les actions FERMÉES au nom du tech :
|
||
// - Si une action fermée contient un commentaire tech (pattern `LOGIN:
|
||
// commentaire` où LOGIN = alphanumérique 3-12 chars minuscule) → garder, vert ✓
|
||
// - Sinon → retirer
|
||
//
|
||
// Distinction action ouverte/fermée :
|
||
// Observation sur les HTML fournis : dans le JSON timeline de la fiche,
|
||
// l'action "AL-Intervention" apparaît SEULEMENT si elle a été complétée
|
||
// (fermée). Si elle est toujours ouverte, elle n'est pas dans le timeline.
|
||
// Les autres types d'actions ("Ajout d'informations", "Envoi de mail", etc.)
|
||
// apparaissent dès leur création.
|
||
|
||
// Regex pour détecter un commentaire tech dans le texte d'une action.
|
||
// Pattern : début de ligne OU <br> suivi d'un login court (3-12 caractères
|
||
// alphanumériques MINUSCULES) + ":" + espace + texte.
|
||
// Exemples qui matchent : "vyjuva: Casque remplacé", "awr: ok".
|
||
// Exemples qui NE matchent PAS :
|
||
// - "Service : X" (majuscule + pas un login)
|
||
// - "Nom2, Prénom2" (contient une virgule, pas un login)
|
||
// - "AWR 16/04/26" (pas de deux-points)
|
||
// - "Date : vendredi 17.04" (majuscule au début, c'est un champ)
|
||
const RX_LOGIN_COMMENTAIRE = /(?:^|\n|<br\s*\/?>)\s*([a-z0-9_]{3,12})\s*:\s+(\S[^\n<]{2,})/im;
|
||
|
||
/**
|
||
* Extrait toutes les actions d'une fiche en parsant les blocs "rows" du HTML.
|
||
* Chaque action a 14 values :
|
||
* [2] = Intervenant (ex: "Nom, Prénom" ou "EZV_WS_REST_USER")
|
||
* [4] = Type d'action (ex: "AL-Intervention", "Ajout d'informations")
|
||
* [8] = Date de création (JJ/MM/AAAA HH:MM:SS)
|
||
* [9] = Date de fin
|
||
* [11] = Description HTML (contient le texte de l'action + commentaire tech)
|
||
*
|
||
* Retourne : [ { intervenant, type, dateCreation, dateFin, description }, ... ]
|
||
*/
|
||
function parseAllActionsFromFicheHtml(html) {
|
||
if (!html) return [];
|
||
// Décoder : dans le HTML, les JSON imbriqués ont \u0022 pour " et \/ pour /
|
||
const decoded = html
|
||
.replace(/\\u0022/g, '"')
|
||
.replace(/\\\//g, '/');
|
||
|
||
const actions = [];
|
||
// Chercher chaque bloc "rows":[...]
|
||
const rowsRegex = /"rows":\[/g;
|
||
let m;
|
||
while ((m = rowsRegex.exec(decoded)) !== null) {
|
||
const start = m.index + m[0].length;
|
||
// Trouver la fin du array [...] correspondant (balance des crochets)
|
||
let j = start;
|
||
let depth = 1;
|
||
while (j < decoded.length && depth > 0) {
|
||
const c = decoded[j];
|
||
if (c === '[') depth++;
|
||
else if (c === ']') depth--;
|
||
j++;
|
||
}
|
||
const block = decoded.substring(start, j - 1);
|
||
const values = extractValuesFromRowBlock(block);
|
||
if (values.length < 12) continue;
|
||
// Une "vraie" action a 14 valeurs. On se contente de 12 minimum
|
||
// pour avoir au moins la description.
|
||
actions.push({
|
||
intervenant: decodeUnicodeEscapes(values[2] || ""),
|
||
type: decodeUnicodeEscapes(values[4] || ""),
|
||
dateCreation: values[8] || "",
|
||
dateFin: values[9] || "",
|
||
description: values[11] || ""
|
||
});
|
||
}
|
||
return actions;
|
||
}
|
||
|
||
/**
|
||
* Extrait les valeurs "value":"..." d'un bloc JSON row, gère les guillemets
|
||
* échappés (\").
|
||
*/
|
||
function extractValuesFromRowBlock(block) {
|
||
const values = [];
|
||
let i = 0;
|
||
while (i < block.length) {
|
||
const mIdx = block.indexOf('"value":"', i);
|
||
if (mIdx < 0) break;
|
||
const start = mIdx + '"value":"'.length;
|
||
let j = start;
|
||
while (j < block.length) {
|
||
if (block[j] === '\\') { j += 2; continue; }
|
||
if (block[j] === '"') break;
|
||
j++;
|
||
}
|
||
values.push(block.substring(start, j));
|
||
i = j + 1;
|
||
}
|
||
return values;
|
||
}
|
||
|
||
/**
|
||
* Décode les échappements Unicode \u00XX présents dans les valeurs extraites.
|
||
*/
|
||
function decodeUnicodeEscapes(s) {
|
||
if (!s) return s;
|
||
return s.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
||
}
|
||
|
||
/**
|
||
* Détermine si une action est "fermée" ou "ouverte".
|
||
* - Pour AL-Intervention : on cherche sa présence dans le JSON timeline de
|
||
* la fiche (via la valeur [13] qui contient un JSON avec "NAME".) Si cette
|
||
* action existe dans le JSON, elle est considérée fermée.
|
||
* - Pour les autres types : on considère fermée si dateFin est remplie et
|
||
* différente de dateCreation (approximation raisonnable observée sur les
|
||
* HTML fournis).
|
||
* - Actions système (Intervenant = "EZV_WS_REST_USER" ou vide) : ignorées
|
||
* dans le matching "action au nom du tech".
|
||
*
|
||
* Pour notre logique, ce qui compte vraiment :
|
||
* - Actions "AL-Intervention" fermées = présentes dans le bloc JSON
|
||
* "timeline" de la fiche (pas dans les "rows" HTML, qui les listent toutes)
|
||
*
|
||
* Plus simplement, je détecte la présence de AL-Intervention dans le HTML
|
||
* comme indicateur : si `"NAME":"AL-Intervention"` figure dans le JSON
|
||
* timeline, alors l'AL-Intervention est fermée.
|
||
*/
|
||
function hasClosedAlInterventionInHtml(html) {
|
||
if (!html) return false;
|
||
// Chercher dans le HTML brut (non décodé) le pattern de timeline
|
||
// `\u0022NAME\u0022:\u0022AL-Intervention\u0022`
|
||
return /\\u0022NAME\\u0022:\\u0022AL-Intervention\\u0022/.test(html);
|
||
}
|
||
|
||
/**
|
||
* Vérifie si le texte d'une action contient un commentaire tech au format
|
||
* `LOGIN: commentaire`. Nettoie d'abord le HTML de la description.
|
||
*/
|
||
function hasTechCommentInDescription(description) {
|
||
if (!description) return false;
|
||
// Décoder unicode puis remplacer les <br> par \n pour faciliter le regex
|
||
const txt = decodeUnicodeEscapes(description)
|
||
.replace(/<br\s*\/?>/gi, '\n')
|
||
.replace(/<\/?p[^>]*>/gi, '\n')
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(/ /g, ' ')
|
||
.replace(/&/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 &, &, 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 ( > 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(/ /g, " ")
|
||
.replace(/>/g, ">")
|
||
.replace(/</g, "<")
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/'/g, "'")
|
||
.replace(/&/g, "&")
|
||
.replace(/\u200b/g, ""); // zero-width space
|
||
// Tags HTML restants
|
||
s = s.replace(/<[^>]+>/g, "");
|
||
// Espaces compactés, lignes trimmed, lignes vides retirées
|
||
s = s.split("\n").map(l => l.trim().replace(/[ \t]+/g, " ")).filter(Boolean).join("\n");
|
||
return s;
|
||
}
|
||
|
||
function decodeJsonString(s) {
|
||
return s
|
||
.replace(/\\r/g, "")
|
||
.replace(/\\n/g, "\n")
|
||
.replace(/\\t/g, "\t")
|
||
.replace(/\\\//g, "/")
|
||
.replace(/\\"/g, '"')
|
||
.replace(/\\\\/g, "\\")
|
||
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
|
||
try { return String.fromCharCode(parseInt(hex, 16)); }
|
||
catch { return _; }
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Parse le texte d'une action au format :
|
||
* Date : lundi 20.04 Heure : matin
|
||
* Lieu : Ville1/Rue1 1
|
||
* Service : Service1/...
|
||
* Contact : Nom1, Prénom1 +41000000001
|
||
* ...
|
||
*
|
||
* → renvoie un objet { date, heure, lieu, service, contact, etage, bureau,
|
||
* probleme, aFaire, tfsAncien, tfsNouveau, materiel, dateProposee, autres }
|
||
*/
|
||
function parseActionText(text) {
|
||
if (!text) return null;
|
||
const out = { _raw: text };
|
||
// v4.2 : on track toutes les occurrences de "Contact" / "Personne de contact"
|
||
// pour détecter l'anomalie (les 2 présents = situation suspecte).
|
||
const contactOccurrences = []; // { kind: "contact"|"personne", value: string }
|
||
// Pré-filtrer les lignes "Date proposée par ..." : on NE prend PAS ce champ
|
||
// nulle part (ni en infobulle.dateProposee, ni dans autres).
|
||
const lines = text.split(/\n+/)
|
||
.map(l => l.trim())
|
||
.filter(Boolean)
|
||
.filter(l => !/^\s*date\s+propos[ée]e\s+par\b/i.test(l));
|
||
const labelMap = {
|
||
"date": "date",
|
||
"heure": "heure",
|
||
"lieu": "lieu",
|
||
"service": "service",
|
||
"contact": "contact",
|
||
"bénéficiaire": "beneficiaire",
|
||
"beneficiaire": "beneficiaire",
|
||
"étage": "etage",
|
||
"etage": "etage",
|
||
"bureau": "bureau",
|
||
"problème": "probleme",
|
||
"probleme": "probleme",
|
||
"a faire": "aFaire",
|
||
"à faire": "aFaire",
|
||
"matériel": "materiel",
|
||
"materiel": "materiel",
|
||
"tfs ancien poste": "tfsAncien",
|
||
"tfs nouveau poste": "tfsNouveau"
|
||
};
|
||
|
||
const autres = [];
|
||
for (const line of lines) {
|
||
// Si la ligne CONTIENT "Date proposée par ..." à l'intérieur (pas juste au
|
||
// début), on coupe cette partie-là avant de parser le reste.
|
||
let cleanLine = line.replace(/\bdate\s+propos[ée]e\s+par\s+(?:le\s+|la\s+)?contact\s*[:?]\s*\S+.*$/i, "").trim();
|
||
if (!cleanLine) continue;
|
||
|
||
// v4.2 : on détecte aussi "Personne de contact..." (spécifique à la demande
|
||
// / sur site / de l'entité quittée / interne / etc.). On la marque comme
|
||
// un 2e candidat possible pour le contact affiché.
|
||
const rxPersonne = /Personne\s+de\s+contact(?:\s+(?:sur\s+site|sp[ée]cifique[^:]*|de\s+l[''`]?entit[ée][^:]*|interne[^:]*))?\s*:\s*/gi;
|
||
let pm;
|
||
while ((pm = rxPersonne.exec(cleanLine)) !== null) {
|
||
// Valeur = jusqu'au prochain label connu OU fin de ligne
|
||
const after = cleanLine.substring(pm.index + pm[0].length);
|
||
const stop = after.search(/\b(Date|Heure|Lieu|Service|Contact|B[ée]n[ée]ficiaire|[ÉE]tage|Bureau|Probl[èe]me|A\s*faire|À\s*faire|Mat[ée]riel|TFS|Personne\s+de\s+contact|Num[ée]ro\s+de\s+t[ée]l[ée]phone)\s*:/i);
|
||
const val = (stop >= 0 ? after.substring(0, stop) : after).trim()
|
||
.replace(/[,;]+$/, "").trim();
|
||
if (val) {
|
||
contactOccurrences.push({ kind: "personne", value: val });
|
||
}
|
||
}
|
||
|
||
// "Date : lundi 20.04 Heure : matin" → split en plusieurs paires
|
||
const markers = [];
|
||
// v4.2 : on ajoute un lookbehind négatif (?<!Personne\s+de\s+) pour ne
|
||
// PAS matcher "Contact" à l'intérieur de "Personne de Contact".
|
||
// Sans ça on aurait un double match.
|
||
const rx = /(?<!Personne\s+de\s+)(Date|Heure|Lieu|Service|Contact|B[ée]n[ée]ficiaire|[ÉE]tage|Bureau|Probl[èe]me|A\s*faire|À\s*faire|Mat[ée]riel|TFS\s+ancien\s+poste|TFS\s+nouveau\s+poste)\s*:\s*/gi;
|
||
let m;
|
||
while ((m = rx.exec(cleanLine)) !== null) {
|
||
markers.push({ label: m[1], valueStart: m.index + m[0].length });
|
||
}
|
||
if (markers.length === 0) {
|
||
autres.push(cleanLine);
|
||
continue;
|
||
}
|
||
for (let i = 0; i < markers.length; i++) {
|
||
const mk = markers[i];
|
||
let val;
|
||
if (i + 1 < markers.length) {
|
||
const nextStart = cleanLine.indexOf(markers[i + 1].label, mk.valueStart);
|
||
val = cleanLine.substring(mk.valueStart, nextStart).trim();
|
||
} else {
|
||
val = cleanLine.substring(mk.valueStart).trim();
|
||
}
|
||
const keyNorm = mk.label.toLowerCase().replace(/\s+/g, " ");
|
||
const outKey = labelMap[keyNorm];
|
||
if (outKey && val) {
|
||
// v4.2 : on track aussi les "Contact" rencontrés dans contactOccurrences
|
||
if (outKey === "contact") {
|
||
contactOccurrences.push({ kind: "contact", value: val });
|
||
} else {
|
||
out[outKey] = out[outKey] ? out[outKey] + " / " + val : val;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// v4.2 : logique de sélection du contact + détection d'anomalie
|
||
// - 0 occurrence → rien
|
||
// - 1 "contact" → OK
|
||
// - 1 "personne" → OK (fallback)
|
||
// - ≥ 2 occurrences → anomalie : on garde la 1re mais on marque anomalie
|
||
// pour que l'UI affiche en rouge et que le caller sache
|
||
// qu'il vaut mieux garder l'ancien contact (xhr2).
|
||
if (contactOccurrences.length === 1) {
|
||
out.contact = contactOccurrences[0].value;
|
||
} else if (contactOccurrences.length >= 2) {
|
||
out.contactAnomalie = true;
|
||
// On prend quand même le 1er "contact" pur (pas "personne") si possible
|
||
const firstReal = contactOccurrences.find(x => x.kind === "contact");
|
||
out.contact = (firstReal || contactOccurrences[0]).value;
|
||
}
|
||
|
||
if (autres.length) out.autres = autres.join("\n");
|
||
return out;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Rendu général
|
||
// ============================================================================
|
||
|
||
// Compteur de fetches en cours. La flèche tourne tant que ce compteur > 0.
|
||
// On le maintient manuellement au lieu d'un booléen pour gérer correctement
|
||
// les appels imbriqués (loadForDate + refreshStatuses en parallèle).
|
||
let refreshCounter = 0;
|
||
// Timer pour effacer le ✓ vert après 5 s
|
||
let refreshDoneTimer = null;
|
||
|
||
// v4.1.13 : quel bouton doit tourner pendant le refresh en cours.
|
||
// Valeurs : "total" (par défaut / chargement auto), "partial", ou "xml_only".
|
||
let activeRefreshButton = "total";
|
||
|
||
function setActiveRefreshButton(kind) {
|
||
activeRefreshButton = kind || "total";
|
||
// v4.1.20 : si le bouton Arrêter est affiché, le repositionner selon
|
||
// le nouveau type de refresh actif. Sinon rien à faire (il prendra sa
|
||
// position au prochain showAbortButton(true)).
|
||
positionAbortButton();
|
||
}
|
||
|
||
// v4.1.20 : place le bouton Arrêter à sa position correcte selon
|
||
// activeRefreshButton. Fonction idempotente, sûre à appeler plusieurs fois.
|
||
function positionAbortButton() {
|
||
const btn = document.getElementById("abort-btn");
|
||
if (!btn) return;
|
||
const partialBtn = document.getElementById("refresh-partial-btn");
|
||
const totalBtn = document.getElementById("refresh-btn");
|
||
if (!partialBtn || !totalBtn) return;
|
||
if (activeRefreshButton === "partial") {
|
||
// Entre Actualiser (partial) et Tout recharger (total)
|
||
if (btn.previousElementSibling !== partialBtn) {
|
||
totalBtn.parentNode.insertBefore(btn, totalBtn);
|
||
}
|
||
} else {
|
||
// Après Tout recharger
|
||
if (totalBtn.nextSibling !== btn) {
|
||
totalBtn.parentNode.insertBefore(btn, totalBtn.nextSibling);
|
||
}
|
||
}
|
||
}
|
||
|
||
function setRefreshing(on) {
|
||
const iconTotal = document.getElementById("refresh-icon");
|
||
const iconPartial = document.getElementById("refresh-partial-icon");
|
||
// Quel icône doit tourner ? Seulement celui correspondant au bouton
|
||
// qui a lancé le refresh (ou "total" par défaut).
|
||
const targetIcon = (activeRefreshButton === "partial") ? iconPartial : iconTotal;
|
||
if (on) {
|
||
refreshCounter++;
|
||
if (targetIcon) targetIcon.classList.add("spinning");
|
||
clearCheckMark();
|
||
// Afficher "rafraichissement en cours…" si on n'a pas déjà les données
|
||
updateCaptureInfoText();
|
||
} else {
|
||
refreshCounter = Math.max(0, refreshCounter - 1);
|
||
if (refreshCounter === 0) {
|
||
// Arrêt : stopper les deux icônes au cas où
|
||
if (iconTotal) iconTotal.classList.remove("spinning");
|
||
if (iconPartial) iconPartial.classList.remove("spinning");
|
||
}
|
||
updateCaptureInfoText();
|
||
}
|
||
}
|
||
|
||
// Force le rafraichissement du texte "MAJ HH:MM" ou "rafraichissement en cours…"
|
||
// selon refreshCounter.
|
||
function updateCaptureInfoText() {
|
||
if (state.currentData) {
|
||
renderCaptureInfo(state.currentData);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Appelé quand TOUS les fetches (y compris les fetches fiches en
|
||
* arrière-plan) sont terminés. Affiche un ✓ vert à côté de l'heure MAJ
|
||
* pendant 5 secondes.
|
||
*/
|
||
function showRefreshDone() {
|
||
const check = document.getElementById("refresh-check");
|
||
if (!check) return;
|
||
check.classList.remove("hidden");
|
||
check.classList.add("visible");
|
||
if (refreshDoneTimer) clearTimeout(refreshDoneTimer);
|
||
refreshDoneTimer = setTimeout(() => {
|
||
check.classList.remove("visible");
|
||
setTimeout(() => check.classList.add("hidden"), 300); // après transition
|
||
}, 5000);
|
||
}
|
||
|
||
function clearCheckMark() {
|
||
const check = document.getElementById("refresh-check");
|
||
if (check) {
|
||
check.classList.remove("visible");
|
||
check.classList.add("hidden");
|
||
}
|
||
if (refreshDoneTimer) {
|
||
clearTimeout(refreshDoneTimer);
|
||
refreshDoneTimer = null;
|
||
}
|
||
}
|
||
|
||
// ─── Barre de progression (v4.1.7) ─────────────────────────────────────
|
||
// État global : on affiche la progression du fetch en cours, uniquement si
|
||
// c'est le fetch de la page actuellement visible. Si l'utilisateur change
|
||
// de date, la barre suit la nouvelle date (son propre état).
|
||
function showProgressBar() {
|
||
const bar = document.getElementById("progress-bar");
|
||
if (bar) bar.classList.remove("hidden");
|
||
}
|
||
|
||
function hideProgressBar() {
|
||
const bar = document.getElementById("progress-bar");
|
||
if (bar) bar.classList.add("hidden");
|
||
updateProgressBar(0, 0);
|
||
}
|
||
|
||
function updateProgressBar(done, total) {
|
||
const fill = document.getElementById("progress-bar-fill");
|
||
const label = document.getElementById("progress-bar-label");
|
||
if (!fill || !label) return;
|
||
if (total <= 0) {
|
||
fill.style.width = "0%";
|
||
label.textContent = "";
|
||
return;
|
||
}
|
||
const pct = Math.min(100, Math.round((done / total) * 100));
|
||
fill.style.width = pct + "%";
|
||
// v4.1.20 : message différencié selon le type de refresh actif
|
||
const prefix = (activeRefreshButton === "partial") ? "Actualisation" : "Rafraîchissement";
|
||
label.textContent = `${prefix}… ${done} / ${total}`;
|
||
}
|
||
|
||
// Affiche/masque le bouton "Arrêter". N'est montré que pdt un refresh
|
||
// manuel (clic utilisateur), pas pendant les chargements normaux ni les
|
||
// refresh auto 12h/15h.
|
||
function showAbortButton(on) {
|
||
const btn = document.getElementById("abort-btn");
|
||
if (!btn) return;
|
||
if (on) {
|
||
positionAbortButton();
|
||
btn.classList.remove("hidden");
|
||
} else {
|
||
btn.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toast de feedback quand l'user clique Arrêter. Les fetches en cours peuvent
|
||
* encore prendre 1-2 secondes avant de se terminer (on ne peut pas vriament
|
||
* annuler un fetch() en cours), mais du point de vue de l'interface tout
|
||
* est arrêté : plus de mise à jour, plus de cache, plus rien.
|
||
*/
|
||
function showAbortToast() {
|
||
showToast("Rafraîchissement", "arrêté");
|
||
}
|
||
|
||
function renderFromData(data) {
|
||
state.currentData = data;
|
||
document.getElementById("loading").classList.add("hidden");
|
||
document.getElementById("error-box").classList.add("hidden");
|
||
document.getElementById("session-needed").classList.add("hidden");
|
||
document.getElementById("cards").classList.remove("hidden");
|
||
|
||
// v4.3.0 : détecter les conflits d'horaire entre interventions d'un même
|
||
// tech (même heure de début OU chevauchement).
|
||
detectOverlaps(data.techs);
|
||
|
||
// Calculer les stats
|
||
const stats = computeStats(data.techs, data.targetDate);
|
||
renderCaptureInfo(data, stats);
|
||
renderStats(stats);
|
||
renderCards(data);
|
||
}
|
||
|
||
// v4.3.0 : détection des conflits d'horaire entre interventions d'un même tech.
|
||
// Marque iv._hasOverlap = true pour chaque intervention en conflit avec une
|
||
// autre (même heure de début OU chevauchement de créneaux).
|
||
// Les absences récurrentes, tickets fantômes à retirer, et réservations
|
||
// sont ignorés (pas de conflit pertinent pour eux).
|
||
function detectOverlaps(techs) {
|
||
if (!techs) return;
|
||
for (const tech of techs) {
|
||
const ivs = (tech.interventions || []).filter(iv =>
|
||
iv && iv.startTime && iv.endTime &&
|
||
!iv._disappearRemove &&
|
||
iv.type !== "AL-Reservation" &&
|
||
// v4.3.2 : le pompier est une absence "tolérée" qui chevauche par
|
||
// nature les heures de travail (garde volontaire) — on l'exclut des
|
||
// conflits. En revanche les congés/maladies/formations restent
|
||
// détectés car une inter planifiée pdt une absence, c'est un vrai pb.
|
||
!iv.isPompier
|
||
);
|
||
// Reset flag sur toutes les inters du tech (y compris celles ignorées)
|
||
for (const iv of (tech.interventions || [])) {
|
||
iv._hasOverlap = false;
|
||
}
|
||
// Convertir HH:MM en minutes pour comparaison rapide
|
||
const toMin = (hhmm) => {
|
||
if (!hhmm) return null;
|
||
const parts = hhmm.split(":");
|
||
if (parts.length < 2) return null;
|
||
const h = parseInt(parts[0], 10);
|
||
const m = parseInt(parts[1], 10);
|
||
if (isNaN(h) || isNaN(m)) return null;
|
||
return h * 60 + m;
|
||
};
|
||
// Comparer chaque paire
|
||
for (let i = 0; i < ivs.length; i++) {
|
||
for (let j = i + 1; j < ivs.length; j++) {
|
||
const a = ivs[i], b = ivs[j];
|
||
const aStart = toMin(a.startTime), aEnd = toMin(a.endTime);
|
||
const bStart = toMin(b.startTime), bEnd = toMin(b.endTime);
|
||
if (aStart === null || aEnd === null || bStart === null || bEnd === null) continue;
|
||
// Chevauchement = a commence avant que b finisse ET b commence avant que a finisse.
|
||
// Inclut aussi le cas "même heure de début" (aStart === bStart).
|
||
if (aStart < bEnd && bStart < aEnd) {
|
||
a._hasOverlap = true;
|
||
b._hasOverlap = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderCaptureInfo(data, stats) {
|
||
const info = document.getElementById("capture-info");
|
||
if (refreshCounter > 0) {
|
||
// v4.1.20 : message différencié selon le type de refresh actif
|
||
// - partial (Actualiser) → "Actualisation en cours…"
|
||
// - total (Tout recharger) → "rafraichissement en cours…"
|
||
if (activeRefreshButton === "partial") {
|
||
info.textContent = "Actualisation en cours…";
|
||
} else {
|
||
info.textContent = "Rafraîchissement en cours…";
|
||
}
|
||
info.classList.add("refreshing");
|
||
return;
|
||
}
|
||
info.classList.remove("refreshing");
|
||
const parts = [];
|
||
if (data.captureTime) {
|
||
const d = new Date(data.captureTime);
|
||
const hh = String(d.getHours()).padStart(2, "0");
|
||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||
const today = new Date();
|
||
const isSameDay = d.getFullYear() === today.getFullYear() &&
|
||
d.getMonth() === today.getMonth() &&
|
||
d.getDate() === today.getDate();
|
||
// v4.1.20 : préfixe selon le type de refresh qui a généré cette capture
|
||
// - lastRefreshKind === "partial" → "Actualisé à HH:MM"
|
||
// - lastRefreshKind === "total" → "Synchronisé à HH:MM"
|
||
// - data.source === "cache" → "Cache de HH:MM"
|
||
let prefix;
|
||
if (data.source === "cache") {
|
||
prefix = "Cache de ";
|
||
} else if (data.lastRefreshKind === "partial") {
|
||
prefix = "Actualisé à ";
|
||
} else {
|
||
prefix = "Synchronisé à ";
|
||
}
|
||
if (isSameDay) {
|
||
parts.push(`${prefix}${hh}:${mm}`);
|
||
} else {
|
||
const dd = String(d.getDate()).padStart(2, "0");
|
||
const mo = String(d.getMonth() + 1).padStart(2, "0");
|
||
let prefixDate;
|
||
if (data.source === "cache") {
|
||
prefixDate = "Cache du ";
|
||
} else if (data.lastRefreshKind === "partial") {
|
||
prefixDate = "Actualisé le ";
|
||
} else {
|
||
prefixDate = "Synchronisé le ";
|
||
}
|
||
parts.push(`${prefixDate}${dd}.${mo} ${hh}:${mm}`);
|
||
}
|
||
}
|
||
info.textContent = parts.join(" · ");
|
||
}
|
||
|
||
function computeStats(techs, targetDate) {
|
||
let pompiers = 0, absents = 0;
|
||
let totalInterventions = 0, morning = 0, afternoon = 0;
|
||
let closed = 0, resolved = 0;
|
||
for (const tech of techs) {
|
||
const isPompier = tech.interventions.some(iv => iv.isPompier);
|
||
const isAbsent = isTechAbsent(tech, targetDate);
|
||
if (isPompier) pompiers++;
|
||
if (isAbsent) absents++;
|
||
const real = tech.interventions.filter(iv =>
|
||
iv.type !== "AL-Absence" && !iv.isPompier
|
||
);
|
||
for (const iv of real) {
|
||
totalInterventions++;
|
||
const s = timeToMinutes(iv.startTime);
|
||
if (s !== null && s < 12 * 60) morning++;
|
||
else if (s !== null) afternoon++;
|
||
if (isClosedStatus(iv.status)) closed++;
|
||
else if (isResolvedStatus(iv.status)) resolved++;
|
||
}
|
||
}
|
||
return { totalTechs: techs.length, pompiers, absents, totalInterventions, morning, afternoon, closed, resolved };
|
||
}
|
||
|
||
function renderStats(s) {
|
||
const el = document.getElementById("stats");
|
||
el.innerHTML = `
|
||
<span class="global-stat global-stat-main"><b>${s.totalInterventions}</b> intervention${s.totalInterventions > 1 ? "s" : ""}</span>
|
||
<span class="global-stat global-stat-sub">(${s.morning} matin · ${s.afternoon} après-midi)</span>
|
||
${(s.closed + s.resolved > 0) ? `<span class="global-stat-sep">·</span><span class="global-stat"><b>${s.closed + s.resolved}</b> clos</span>` : ""}
|
||
<span class="global-stat-sep">·</span>
|
||
<span class="global-stat"><b>${s.totalTechs}</b> techs</span>
|
||
<span class="global-stat-sep">·</span>
|
||
<span class="global-stat"><b>${s.pompiers}</b> pompier${s.pompiers > 1 ? "s" : ""}</span>
|
||
<span class="global-stat-sep">·</span>
|
||
<span class="global-stat"><b>${s.absents}</b> absent${s.absents > 1 ? "s" : ""}</span>
|
||
`;
|
||
el.classList.remove("hidden");
|
||
}
|
||
|
||
function renderCards(data) {
|
||
const container = document.getElementById("cards");
|
||
container.innerHTML = "";
|
||
|
||
// Tri : pompier(s) > actifs alphabétique nom de famille > absents alphabétique
|
||
const sorted = [...data.techs].sort((a, b) => compareTechs(a, b, data.targetDate));
|
||
for (const tech of sorted) {
|
||
container.appendChild(buildCard(tech, data.targetDate));
|
||
}
|
||
}
|
||
|
||
function compareTechs(a, b, targetDate) {
|
||
const aP = a.interventions.some(iv => iv.isPompier);
|
||
const bP = b.interventions.some(iv => iv.isPompier);
|
||
if (aP && !bP) return -1;
|
||
if (bP && !aP) return 1;
|
||
|
||
const aAbs = isTechAbsent(a, targetDate);
|
||
const bAbs = isTechAbsent(b, targetDate);
|
||
if (aAbs && !bAbs) return 1;
|
||
if (bAbs && !aAbs) return -1;
|
||
|
||
// Sinon : alphabétique sur le nom de famille
|
||
// Les noms sont stockés au format "Nom, Prénom"
|
||
const aLast = (a.name || "").split(",")[0].trim();
|
||
const bLast = (b.name || "").split(",")[0].trim();
|
||
return aLast.localeCompare(bLast, "fr");
|
||
}
|
||
|
||
// v5.0.13 : un tech est considéré "absent toute la journée" uniquement si une
|
||
// absence couvre RÉELLEMENT du matin au soir (ou quasi), pas juste s'il a des
|
||
// absences (éventuellement partielles). Avant, une absence matin 08-12 seule
|
||
// faisait passer le tech en "absent toute la journée" car il n'avait QUE des
|
||
// absences. Maintenant on check explicitement que l'absence couvre ≥ 90% de
|
||
// la plage 08:00-18:00.
|
||
function isTechAbsent(tech, isoDate) {
|
||
const recurring = RECURRING_ABSENCES[tech.id];
|
||
if (recurring) {
|
||
const day = isoToDate(isoDate).getDay();
|
||
if (recurring.includes(day)) return true;
|
||
}
|
||
if (tech.interventions.length === 0) return false;
|
||
// Parmi les absences (hors pompier), est-ce qu'une seule couvre la journée ?
|
||
const fullDayAbsences = tech.interventions.filter(iv => {
|
||
if (iv.type !== "AL-Absence" || iv.isPompier) return false;
|
||
const startMin = timeToMinutes(iv.startTime);
|
||
const endMin = timeToMinutes(iv.endTime);
|
||
if (startMin == null || endMin == null) {
|
||
// Si on n'a pas d'horaires, on considère que c'est toute la journée
|
||
// (cas des absences multi-jours sans horaires précis)
|
||
return true;
|
||
}
|
||
// Absence couvre toute la journée si son créneau déborde largement
|
||
// la plage affichée (≥ 90%). Une demi-journée (4h) sur 10h = 40% → ne
|
||
// passera pas, donc on ne marquera pas le tech comme absent toute la journée.
|
||
const DAY_LEN_MIN = 10 * 60; // 08:00 → 18:00 = 10h
|
||
const clampedStart = Math.max(startMin, 8 * 60);
|
||
const clampedEnd = Math.min(endMin, 18 * 60);
|
||
const coveredMin = Math.max(0, clampedEnd - clampedStart);
|
||
return coveredMin >= 0.9 * DAY_LEN_MIN;
|
||
});
|
||
return fullDayAbsences.length > 0;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Construction d'une carte
|
||
// ============================================================================
|
||
|
||
// v4.1.20 : détecte si tech = Pillonel Olivier ET jour = vendredi.
|
||
// Hardcodé car c'est une absence récurrente connue spécifique à lui.
|
||
function isPillonelAbsentFriday(tech, isoDate) {
|
||
if (!tech || !tech.name) return false;
|
||
// Normaliser le nom (tolère "Pillonel, Olivier", "Pillonel Olivier", etc.)
|
||
const name = tech.name.toLowerCase();
|
||
if (!name.includes("pillonel")) return false;
|
||
if (!name.includes("olivier")) return false;
|
||
// Jour de la semaine : 5 = vendredi (en JS, 0=dim, 1=lun, ..., 5=ven)
|
||
const d = isoToDate(isoDate);
|
||
return d.getDay() === 5;
|
||
}
|
||
|
||
function buildCard(tech, isoDate) {
|
||
const card = document.createElement("section");
|
||
card.className = "card";
|
||
card.dataset.techId = tech.id;
|
||
|
||
const isPompier = tech.interventions.some(iv => iv.isPompier);
|
||
const isAbsent = isTechAbsent(tech, isoDate);
|
||
// v2026.5.30 : détecter aussi les absences récurrentes hardcodées (Pillonel vendredi)
|
||
// pour leur appliquer le code couleur cyan (comme Congé) au lieu du rouge Pompier.
|
||
const isRecurring = isPillonelAbsentFriday(tech, isoDate);
|
||
|
||
if (isPompier) card.classList.add("is-pompier");
|
||
if (isAbsent) card.classList.add("is-absent");
|
||
|
||
// v2026.5.27 : déterminer la catégorie d'absence principale (maladie/conge/pompier)
|
||
// pour appliquer le bon code couleur sur la carte entière.
|
||
// v2026.5.30 : les absences récurrentes hardcodées prennent la catégorie
|
||
// "recurring" (même cyan que Congé, texte distinct).
|
||
let absenceCategory = null; // "maladie" | "conge" | "pompier" | "recurring" | null
|
||
if (isRecurring) {
|
||
absenceCategory = "recurring";
|
||
} else if (isPompier) {
|
||
absenceCategory = "pompier";
|
||
} else if (isAbsent) {
|
||
const catBlock = tech.interventions.find(iv => iv.type === "AL-Absence" && iv.absenceCategory);
|
||
if (catBlock) absenceCategory = catBlock.absenceCategory;
|
||
}
|
||
if (absenceCategory) {
|
||
card.classList.add("absence-cat-" + absenceCategory);
|
||
}
|
||
|
||
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";
|
||
|
||
// v2026.5.27 : pastille colorée supprimée (v2026.5.28) — la barre gauche de la
|
||
// carte + le badge à droite suffisent pour indiquer la catégorie d'absence.
|
||
|
||
const nameEl = document.createElement("div");
|
||
nameEl.className = "card-tech-name";
|
||
nameEl.textContent = tech.name;
|
||
header.appendChild(nameEl);
|
||
|
||
if (isPompier || isAbsent || isRecurring) {
|
||
const badge = document.createElement("div");
|
||
badge.className = "card-tech-badge";
|
||
if (isRecurring) {
|
||
// v2026.5.30 : absence récurrente (Pillonel vendredi) → badge "Absent" cyan
|
||
badge.classList.add("badge-recurring");
|
||
badge.textContent = "Absent";
|
||
} else if (isPompier) {
|
||
badge.classList.add("badge-pompier");
|
||
badge.textContent = "Pompier";
|
||
} else if (absenceCategory === "maladie") {
|
||
badge.classList.add("badge-maladie");
|
||
badge.textContent = "Maladie/Accident";
|
||
} else if (absenceCategory === "conge") {
|
||
// Déterminer singulier/pluriel selon la durée
|
||
const ab = absenceBlocks.find(a => a.absenceCategory === "conge") || absenceBlocks[0];
|
||
const multiDay = ab && ab.startDate && ab.endDate && ab.startDate !== ab.endDate;
|
||
badge.classList.add("badge-conge");
|
||
badge.textContent = multiDay ? "Congés" : "Congé";
|
||
} else {
|
||
badge.classList.add("badge-absent");
|
||
badge.textContent = "Absent";
|
||
}
|
||
header.appendChild(badge);
|
||
}
|
||
|
||
// v2026.5.32 : stats rapides pour la vue horizontale (cachées en classique)
|
||
const rowStats = document.createElement("div");
|
||
rowStats.className = "tech-row-stats";
|
||
const totalInterv = realInterventions.length;
|
||
const pill1 = document.createElement("span");
|
||
pill1.className = "stat-pill";
|
||
pill1.textContent = totalInterv + " interv.";
|
||
rowStats.appendChild(pill1);
|
||
if (morning > 0 || afternoon > 0) {
|
||
const pill2 = document.createElement("span");
|
||
pill2.className = "stat-pill";
|
||
pill2.textContent = morning + "m · " + afternoon + "a";
|
||
rowStats.appendChild(pill2);
|
||
}
|
||
header.appendChild(rowStats);
|
||
|
||
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";
|
||
if (absenceCategory) {
|
||
note.classList.add("absent-" + absenceCategory);
|
||
}
|
||
const ab = absenceBlocks[0];
|
||
// v2026.5.27 : libellé enrichi "Absent du XX au YY — Maladie" ou "Absent — Congé"
|
||
const multiDay = ab.startDate && ab.endDate && ab.startDate !== ab.endDate;
|
||
const catLabel = absenceCategory === "maladie" ? "Maladie/Accident"
|
||
: absenceCategory === "conge" ? (multiDay ? "Congés" : "Congé")
|
||
: null;
|
||
let txt;
|
||
if (multiDay) {
|
||
txt = `Absent du ${ab.startDate.substring(0, 5)} au ${ab.endDate.substring(0, 5)}`;
|
||
} else {
|
||
txt = "Absent toute la journée";
|
||
}
|
||
if (catLabel) txt += ` — ${catLabel}`;
|
||
note.textContent = txt;
|
||
body.appendChild(note);
|
||
|
||
// v5.0.4 : tooltip au hover sur toute la carte absent (pas juste un
|
||
// bouton visible). Contient : détail période + bouton supprimer si
|
||
// c'est une absence supprimable (actionId réel, pas pompier récurrent).
|
||
// v2026.5.33 : en vue horizontale, attacher le hover SEULEMENT au badge
|
||
// (Congé / Maladie/Accident / Absent) pour éviter que la popup s'affiche
|
||
// dès qu'on approche de la ligne du tech. En vue classique, on garde la
|
||
// carte entière comme zone de hover (comportement d'origine).
|
||
if (ab.actionId && !ab.isPompier && !ab._recurring) {
|
||
const ivCopy = {
|
||
...ab,
|
||
type: "AL-Absence"
|
||
};
|
||
const _isHorizontalView = () => document.documentElement.classList.contains("view-horizontal");
|
||
// Hover sur la carte entière : actif SEULEMENT en vue classique
|
||
card.addEventListener("mouseenter", (e) => {
|
||
if (_isHorizontalView()) return; // bloqué en horizontal
|
||
showTooltip(e, ivCopy, card);
|
||
});
|
||
card.addEventListener("mouseleave", () => {
|
||
if (_isHorizontalView()) return;
|
||
hideTooltip();
|
||
});
|
||
// Hover sur le badge (Congé / Maladie/Accident / Absent) : actif
|
||
// SEULEMENT en vue horizontale.
|
||
const badgeEl = card.querySelector(".card-tech-badge");
|
||
if (badgeEl) {
|
||
badgeEl.addEventListener("mouseenter", (e) => {
|
||
if (!_isHorizontalView()) return; // bloqué en classique
|
||
showTooltip(e, ivCopy, badgeEl);
|
||
});
|
||
badgeEl.addEventListener("mouseleave", () => {
|
||
if (!_isHorizontalView()) return;
|
||
hideTooltip();
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// v4.1.20 : cas spécifique Pillonel Olivier, absent tous les vendredis.
|
||
// Affichage d'un message explicite au lieu de "Pas d'intervention planifiée".
|
||
// v4.2 : prioritaire même si un bloc AL-Absence couvre le vendredi (ce qui
|
||
// est le cas normal), pour TOUJOURS afficher "Absent le vendredi".
|
||
const isPillonelFriday = isPillonelAbsentFriday(tech, isoDate);
|
||
|
||
// Absent sans interv → on stop là (après avoir posé le message Pillonel
|
||
// si vendredi).
|
||
if (isAbsent && realInterventions.length === 0) {
|
||
if (isPillonelFriday) {
|
||
const note = document.createElement("div");
|
||
note.className = "tech-absence-recurring";
|
||
note.textContent = "Absent le vendredi";
|
||
body.appendChild(note);
|
||
}
|
||
card.appendChild(body);
|
||
return card;
|
||
}
|
||
|
||
// v5.0.14 : si le tech n'a aucune intervention mais a des absences
|
||
// partielles (demi-journée) ou pompier, on veut quand même afficher la
|
||
// timeline avec les blocs absence visibles. Sans ça, une absence 08-12
|
||
// seule n'apparaissait jamais sur la carte (affichait juste "Pas
|
||
// d'intervention planifiée").
|
||
const hasPartialAbsences = absenceBlocks.some(ab => {
|
||
if (ab.isPompier) return false;
|
||
const s = timeToMinutes(ab.startTime);
|
||
const e = timeToMinutes(ab.endTime);
|
||
if (s === null || e === null) return false;
|
||
// Absence qui couvre PAS toute la journée → c'est partiel
|
||
return !(s <= DAY_START && e >= DAY_END);
|
||
});
|
||
|
||
if (realInterventions.length === 0 && !isPompier && !hasPartialAbsences) {
|
||
if (isPillonelFriday) {
|
||
const note = document.createElement("div");
|
||
note.className = "tech-absence-recurring";
|
||
note.textContent = "Absent le vendredi";
|
||
body.appendChild(note);
|
||
} else {
|
||
const empty = document.createElement("div");
|
||
empty.className = "card-empty";
|
||
empty.textContent = "Pas d'intervention planifiée";
|
||
body.appendChild(empty);
|
||
}
|
||
card.appendChild(body);
|
||
return card;
|
||
}
|
||
|
||
// Pillonel vendredi avec quand même des interv planifiées ? Rare mais possible.
|
||
if (isPillonelFriday && realInterventions.length > 0) {
|
||
const note = document.createElement("div");
|
||
note.className = "tech-absence-recurring";
|
||
note.textContent = "Absent le vendredi";
|
||
body.appendChild(note);
|
||
}
|
||
|
||
// Timeline
|
||
body.appendChild(buildTimeline(realInterventions, pompierBlocks, absenceBlocks, card, isPompier, isAbsent));
|
||
|
||
// Stats de carte
|
||
if (realInterventions.length > 0) {
|
||
const stats = document.createElement("div");
|
||
stats.className = "card-stats";
|
||
stats.innerHTML = `
|
||
<div class="stat-total">
|
||
<span class="stat-total-num">${realInterventions.length}</span>
|
||
<span class="stat-total-lbl">intervention${realInterventions.length > 1 ? "s" : ""}</span>
|
||
</div>
|
||
<div class="stat-split">
|
||
<span class="stat-split-item"><b>${morning}</b> matin</span>
|
||
<span class="stat-split-sep">·</span>
|
||
<span class="stat-split-item"><b>${afternoon}</b> après-midi</span>
|
||
</div>
|
||
`;
|
||
body.appendChild(stats);
|
||
}
|
||
|
||
// Liste interventions
|
||
for (const iv of realInterventions) {
|
||
body.appendChild(buildInterventionRow(iv, card));
|
||
}
|
||
|
||
// v5.0.15 : afficher aussi les absences partielles (demi-journée) comme
|
||
// des rows, avec le même style que les réservations mais en gris foncé.
|
||
// Les absences qui couvrent toute la journée sont déjà traitées plus haut
|
||
// (carte "Absent toute la journée") et ne doivent pas être dupliquées ici.
|
||
if (!isAbsent) {
|
||
const partialAbsences = absenceBlocks.filter(ab => {
|
||
if (ab.isPompier) return false;
|
||
const s = timeToMinutes(ab.startTime);
|
||
const e = timeToMinutes(ab.endTime);
|
||
if (s === null || e === null) return false;
|
||
return !(s <= DAY_START && e >= DAY_END);
|
||
});
|
||
// Trier par heure de début
|
||
partialAbsences.sort((a, b) => (a.startTime || "").localeCompare(b.startTime || ""));
|
||
for (const ab of partialAbsences) {
|
||
body.appendChild(buildInterventionRow(ab, card));
|
||
}
|
||
}
|
||
|
||
card.appendChild(body);
|
||
return card;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Timeline
|
||
// ============================================================================
|
||
|
||
// v5.0.0 : constantes timeline globales (avant : locales à buildTimeline),
|
||
// pour que updateNowLine puisse les utiliser aussi.
|
||
const DAY_START = 8 * 60; // 08:00 en minutes
|
||
const DAY_END = 18 * 60; // 18:00 en minutes
|
||
const DAY_LEN = DAY_END - DAY_START;
|
||
|
||
function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) {
|
||
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "timeline";
|
||
if (isPompier) wrap.classList.add("timeline-pompier");
|
||
|
||
const bar = document.createElement("div");
|
||
bar.className = "timeline-bar";
|
||
|
||
const segments = [];
|
||
for (let i = 0; i < realInterventions.length; i++) {
|
||
const iv = realInterventions[i];
|
||
const s = timeToMinutes(iv.startTime);
|
||
const e = timeToMinutes(iv.endTime);
|
||
if (s === null || e === null) continue;
|
||
const cs = Math.max(s, DAY_START);
|
||
const ce = Math.min(e, DAY_END);
|
||
if (ce <= cs) continue;
|
||
segments.push({
|
||
kind: "intervention",
|
||
colorKey: deriveColorKey(iv),
|
||
iv, ivIdx: i,
|
||
start: cs, end: ce,
|
||
statusClass: getStatusClass(iv)
|
||
});
|
||
}
|
||
|
||
for (const ab of absenceBlocks || []) {
|
||
const s = timeToMinutes(ab.startTime);
|
||
const e = timeToMinutes(ab.endTime);
|
||
if (s === null || e === null) continue;
|
||
const cs = Math.max(s, DAY_START);
|
||
const ce = Math.min(e, DAY_END);
|
||
if (cs <= DAY_START && ce >= DAY_END) continue;
|
||
if (ce <= cs) continue;
|
||
segments.push({ kind: "absence", start: cs, end: ce, iv: ab });
|
||
}
|
||
|
||
// Calcul des trous (que si pas absent complet)
|
||
const occupiedRanges = segments.map(s => [s.start, s.end]).sort((a, b) => a[0] - b[0]);
|
||
const merged = [];
|
||
for (const [s, e] of occupiedRanges) {
|
||
if (merged.length && s <= merged[merged.length - 1][1]) {
|
||
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], e);
|
||
} else {
|
||
merged.push([s, e]);
|
||
}
|
||
}
|
||
const holes = [];
|
||
let cursor = DAY_START;
|
||
for (const [s, e] of merged) {
|
||
if (s > cursor) holes.push([cursor, s]);
|
||
cursor = Math.max(cursor, e);
|
||
}
|
||
if (cursor < DAY_END) holes.push([cursor, DAY_END]);
|
||
|
||
if (!isAbsent) {
|
||
for (const [s, e] of holes) {
|
||
if (e - s < 15) continue;
|
||
const h = document.createElement("div");
|
||
h.className = "timeline-hole";
|
||
h.style.left = ((s - DAY_START) / DAY_LEN) * 100 + "%";
|
||
h.style.width = ((e - s) / DAY_LEN) * 100 + "%";
|
||
h.dataset.startMin = s;
|
||
h.dataset.endMin = e;
|
||
h.dataset.kind = "hole";
|
||
bindTimelinePopover(h);
|
||
bar.appendChild(h);
|
||
}
|
||
}
|
||
|
||
for (const seg of segments) {
|
||
const el = document.createElement("div");
|
||
el.className = "timeline-slot kind-" + seg.kind;
|
||
if (seg.colorKey) el.classList.add("color-" + seg.colorKey);
|
||
if (seg.statusClass) el.classList.add(seg.statusClass);
|
||
el.style.left = ((seg.start - DAY_START) / DAY_LEN) * 100 + "%";
|
||
el.style.width = ((seg.end - seg.start) / DAY_LEN) * 100 + "%";
|
||
el.dataset.startMin = seg.start;
|
||
el.dataset.endMin = seg.end;
|
||
el.dataset.kind = seg.kind;
|
||
if (seg.iv) {
|
||
el.dataset.title = deriveShortTitle(seg.iv);
|
||
if (seg.iv.ref) el.dataset.ref = seg.iv.ref;
|
||
}
|
||
if (seg.ivIdx !== undefined) {
|
||
el.dataset.ivIdx = seg.ivIdx;
|
||
el.addEventListener("mouseenter", () => highlightIntervention(cardEl, seg.ivIdx, true));
|
||
el.addEventListener("mouseleave", () => highlightIntervention(cardEl, seg.ivIdx, false));
|
||
}
|
||
bindTimelinePopover(el);
|
||
bar.appendChild(el);
|
||
}
|
||
|
||
const noon = document.createElement("div");
|
||
noon.className = "timeline-noon";
|
||
noon.style.left = (((12 * 60) - DAY_START) / DAY_LEN) * 100 + "%";
|
||
bar.appendChild(noon);
|
||
|
||
wrap.appendChild(bar);
|
||
|
||
const scale = document.createElement("div");
|
||
scale.className = "timeline-scale";
|
||
for (const h of [8, 10, 12, 14, 16, 18]) {
|
||
const t = document.createElement("span");
|
||
t.className = "timeline-tick";
|
||
t.style.left = (((h * 60) - DAY_START) / DAY_LEN * 100) + "%";
|
||
t.textContent = h + "h";
|
||
scale.appendChild(t);
|
||
}
|
||
wrap.appendChild(scale);
|
||
|
||
return wrap;
|
||
}
|
||
|
||
function getStatusClass(iv) {
|
||
// v4.2.5 : priorité aux statuts de disparition analysés
|
||
if (iv._disappearStatus === "closed") return "status-closed";
|
||
if (iv._disappearStatus === "terminated") return "status-terminated";
|
||
if (iv._disappearStatus === "error") return null;
|
||
if (isClosedStatus(iv.status)) return "status-closed";
|
||
if (isResolvedStatus(iv.status)) return "status-resolved";
|
||
return null;
|
||
}
|
||
|
||
function bindTimelinePopover(el) {
|
||
// v2026.5.33 : en vue horizontale, les interactions sont différentes :
|
||
// - hover : ouvre directement la GRANDE popup (pas la petite)
|
||
// - clic : ouvre la fiche EasyVista dans un nouvel onglet
|
||
// En vue classique, le comportement existant est conservé :
|
||
// - hover : petite popup qui suit la souris
|
||
// - clic simple : grande popup persistante
|
||
// - double-clic : fiche EasyVista nouvel onglet
|
||
// - Ctrl+clic : fiche EasyVista en arrière-plan
|
||
const _isHorizontalView = () => document.documentElement.classList.contains("view-horizontal");
|
||
|
||
el.addEventListener("mouseenter", (e) => {
|
||
if (_isHorizontalView()) {
|
||
// Grande popup directement en vue horizontale
|
||
if (el.dataset.ivIdx !== undefined) {
|
||
openPersistentTimelinePopup(el);
|
||
}
|
||
} else {
|
||
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) => {
|
||
if (!_isHorizontalView()) {
|
||
moveTimelineTooltip(e);
|
||
}
|
||
});
|
||
el.addEventListener("mouseleave", () => {
|
||
if (!_isHorizontalView()) {
|
||
hideTooltip();
|
||
}
|
||
// En vue horizontale, la grande popup est persistante donc on ne ferme
|
||
// pas au mouseleave (l'user peut interagir avec).
|
||
});
|
||
|
||
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) => {
|
||
// v2026.5.33 : en vue horizontale, tout clic simple = ouvrir la fiche EV
|
||
if (_isHorizontalView()) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
// Ctrl/Cmd/middle → arrière-plan, sinon nouvel onglet actif
|
||
const background = !!(e.ctrlKey || e.metaKey || e.button === 1);
|
||
openInterventionFromTimeline(el, { background });
|
||
return;
|
||
}
|
||
|
||
// Vue classique (inchangé) :
|
||
// 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) => {
|
||
// En vue horizontale le clic simple fait déjà l'ouverture, le dblclick
|
||
// devient inutile — on le laisse par sécurité (comportement idem).
|
||
if (singleClickTimer) { clearTimeout(singleClickTimer); singleClickTimer = null; }
|
||
if (_isHorizontalView()) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
openInterventionFromTimeline(el, { background: false });
|
||
});
|
||
}
|
||
|
||
/**
|
||
* v4.2.3 : positionne la petite popup timeline à côté du curseur.
|
||
* Utilisée UNIQUEMENT en vue classique pour la petite popup qui suit la souris
|
||
* quand on survole un segment timeline (informations courtes : durée, ref).
|
||
*
|
||
* v2026.5.34 : documentation + logs. Le clamp dans le viewport reste local
|
||
* (pas unifié avec positionTooltipAnchored car la logique "suit-souris" est
|
||
* fondamentalement différente d'un ancrage fixe à une source).
|
||
*
|
||
* @param {MouseEvent} e - événement souris pour position courante
|
||
*/
|
||
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 (mode anchored),
|
||
// 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 (logique simple : flip autour du curseur)
|
||
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;
|
||
|
||
// setTooltipViewportPosition gère la détection auto fixed vs abs.
|
||
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).
|
||
// v4.2.3/4 : ouvre la GRANDE popup au clic sur un segment timeline (vue
|
||
// classique) ou au hover (vue horizontale). Ancrée à côté du segment, pas
|
||
// sur. Se ferme au clic ailleurs.
|
||
//
|
||
// v2026.5.34 : utilise positionTooltipAnchored() unifié au lieu de recalculer
|
||
// sa propre position. Plus de code dupliqué.
|
||
function openPersistentTimelinePopup(el) {
|
||
if (!el) {
|
||
console.warn("[persistentTimeline] segment el null — abandon");
|
||
return;
|
||
}
|
||
const ivIdx = el.dataset.ivIdx;
|
||
if (ivIdx === undefined) {
|
||
console.log("[persistentTimeline] segment sans ivIdx (hole/absence vide) — abandon");
|
||
return;
|
||
}
|
||
const cardEl = el.closest(".card");
|
||
if (!cardEl) {
|
||
console.warn("[persistentTimeline] pas de .card parent trouvée");
|
||
return;
|
||
}
|
||
const row = cardEl.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`);
|
||
if (!row) {
|
||
console.warn(`[persistentTimeline] row intervention-v2[data-iv-idx="${ivIdx}"] introuvable`);
|
||
return;
|
||
}
|
||
const actionId = row.dataset.actionId;
|
||
const iv = findIvByActionId(actionId);
|
||
if (!iv) {
|
||
console.warn(`[persistentTimeline] iv pour actionId=${actionId} introuvable`);
|
||
return;
|
||
}
|
||
|
||
const tip = tooltipEl();
|
||
if (!tip) {
|
||
console.warn("[persistentTimeline] tooltipEl() null");
|
||
return;
|
||
}
|
||
|
||
console.log(`[persistentTimeline] ouverture grande popup pour iv actionId=${actionId}`);
|
||
|
||
// 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 timeline ne doit pas la remplacer par la petite popup
|
||
tip.dataset.mode = "anchored";
|
||
state.currentTooltipIv = iv;
|
||
|
||
// v2026.5.34 : utiliser positionTooltipAnchored() unifié, en préférant
|
||
// dessous (sous le segment timeline) via opts.anchorBelow = true.
|
||
//
|
||
// D'abord on reset les coords pour que le tipRect soit correctement mesuré
|
||
// avec le nouveau contenu.
|
||
tip.style.left = "-9999px";
|
||
tip.style.top = "0px";
|
||
// Force reflow
|
||
void tip.offsetWidth;
|
||
positionTooltipAnchored(el, { anchorBelow: true });
|
||
}
|
||
|
||
function showTimelinePopover(e, el) {
|
||
const s = parseInt(el.dataset.startMin, 10);
|
||
const eMin = parseInt(el.dataset.endMin, 10);
|
||
const kind = el.dataset.kind;
|
||
const dur = eMin - s;
|
||
let html;
|
||
if (kind === "hole") {
|
||
const h = Math.floor(dur / 60);
|
||
const min = dur % 60;
|
||
let d;
|
||
if (h === 0) d = `${min} min`;
|
||
else if (min === 0) d = `${h} h`;
|
||
else d = `${h} h ${min} min`;
|
||
html = `<dl>
|
||
<dt>Libre</dt><dd>${minutesToTime(s)}–${minutesToTime(eMin)}</dd>
|
||
<dt>Durée</dt><dd>${d} disponible</dd>
|
||
</dl>`;
|
||
} else {
|
||
const t = el.dataset.title || "";
|
||
const ref = el.dataset.ref || "";
|
||
const k = kind === "absence" ? "Absence" : "Intervention";
|
||
html = `<dl>
|
||
<dt>${k}</dt><dd>${minutesToTime(s)}–${minutesToTime(eMin)}</dd>
|
||
${t ? `<dt>Type</dt><dd>${escapeHtml(t)}</dd>` : ""}
|
||
${ref ? `<dt>Réf</dt><dd>${escapeHtml(ref)}</dd>` : ""}
|
||
</dl>`;
|
||
}
|
||
const tip = tooltipEl();
|
||
// v4.2.3 : si une grande bulle est déjà ancrée (clic timeline), on ne
|
||
// la remplace pas par la petite popup hover.
|
||
if (tip.dataset.mode === "anchored") return;
|
||
// v4.2.4 : annuler tout hideTimer en cours pour éviter que la popup
|
||
// précédente, en train d'être masquée, masque AUSSI celle-ci juste après.
|
||
// Problème typique quand on passe rapidement d'un segment à un autre.
|
||
if (bulleState.hideTimer) {
|
||
clearTimeout(bulleState.hideTimer);
|
||
bulleState.hideTimer = null;
|
||
}
|
||
tip.innerHTML = html;
|
||
tip.classList.remove("hidden", "pinned");
|
||
tip.classList.add("visible");
|
||
// v4.2.3 : mode "hover" = petite popup qui suit la souris
|
||
tip.dataset.mode = "hover";
|
||
moveTimelineTooltip(e);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Ligne d'interventoin
|
||
// ============================================================================
|
||
|
||
function buildInterventionRow(iv, cardEl) {
|
||
const row = document.createElement("div");
|
||
row.className = "intervention-v2";
|
||
row.dataset.actionId = iv.actionId;
|
||
if (iv.isPompier) row.classList.add("is-pompier-line");
|
||
// v4.3.3 : on ne marque plus les ghosts visuellement (classe is-ghost
|
||
// retirée). Les tickets disparus sont soit retirés (_disappearRemove),
|
||
// soit affichés en vert (_disappearStatus). Plus de barrage.
|
||
// v4.2.5 : indicateur "en cours d'analyse" (ticket disparu, on re-fetch
|
||
// la fiche pour décider de le garder en vert ou le retirer).
|
||
if (iv._disappearChecking) row.classList.add("_checking");
|
||
|
||
const colorKey = deriveColorKey(iv);
|
||
row.classList.add("color-" + colorKey);
|
||
|
||
const statusClass = getStatusClass(iv);
|
||
if (statusClass) row.classList.add(statusClass);
|
||
|
||
const ivIdx = cardEl._rowIdxCounter || 0;
|
||
cardEl._rowIdxCounter = ivIdx + 1;
|
||
row.dataset.ivIdx = ivIdx;
|
||
|
||
if (iv.formLink && !iv.ghost && iv.type !== "AL-Absence") {
|
||
row.classList.add("clickable");
|
||
// v4.1.8 : plus de title au survol (info déjà dans le tooltip en bas)
|
||
|
||
// Clic normal : ouvre l'onglet et change de page
|
||
// Ctrl/Cmd+Clic : ouvre en arrière-plan (reste sur le planning)
|
||
row.addEventListener("click", (e) => {
|
||
if (e.target.closest(".intervention-copy")) return;
|
||
const background = !!(e.ctrlKey || e.metaKey);
|
||
openInterventionInNewTab(iv, { background });
|
||
});
|
||
|
||
// Clic molette (button === 1) : ouvre en arrière-plan
|
||
// On utilise 'auxclick' pour les boutons du milieu/droite (standard W3C).
|
||
row.addEventListener("auxclick", (e) => {
|
||
if (e.button !== 1) return; // que la molette
|
||
if (e.target.closest(".intervention-copy")) return;
|
||
e.preventDefault();
|
||
openInterventionInNewTab(iv, { background: true });
|
||
});
|
||
|
||
// Empêcher le scroll auto quand on clique la molette sur la ligne
|
||
row.addEventListener("mousedown", (e) => {
|
||
if (e.button === 1) e.preventDefault();
|
||
});
|
||
}
|
||
|
||
// Pastille colorée à gauche (barre verticale, toute la hauteur)
|
||
const dot = document.createElement("div");
|
||
dot.className = "intervention-dot";
|
||
row.appendChild(dot);
|
||
|
||
// ─── Ligne 1 : Ref centrée (TITRE en gros + gras) ────────────────────────
|
||
const refHeader = document.createElement("div");
|
||
refHeader.className = "iv-ref-header";
|
||
if (iv.type === "AL-Reservation") {
|
||
refHeader.textContent = "Réservation";
|
||
refHeader.classList.add("is-reservation-title");
|
||
} else if (iv.type === "AL-Absence") {
|
||
// v5.0.15 : absence partielle (demi-journée) affichée comme une row
|
||
refHeader.textContent = "Absence";
|
||
refHeader.classList.add("is-absence-title");
|
||
} else if (iv.ref) {
|
||
refHeader.textContent = iv.ref;
|
||
} else {
|
||
refHeader.textContent = "—";
|
||
refHeader.classList.add("no-ref");
|
||
}
|
||
row.appendChild(refHeader);
|
||
|
||
// Check ✓ + bouton copier à droite de la ref (pas pour réservation / absence)
|
||
if (statusClass && iv.type !== "AL-Reservation" && iv.type !== "AL-Absence") {
|
||
const statusEl = document.createElement("div");
|
||
statusEl.className = "iv-status-check";
|
||
// v4.2.5 : ✓✓ double pour clôturé/résolu (statut officiel EasyVista)
|
||
// ✓ simple pour "terminé par tech" (commentaire LOGIN: détecté)
|
||
if (statusClass === "status-closed" || statusClass === "status-resolved") {
|
||
statusEl.textContent = "✓✓";
|
||
statusEl.classList.add("double");
|
||
} else {
|
||
statusEl.textContent = "✓";
|
||
}
|
||
row.appendChild(statusEl);
|
||
}
|
||
if (iv.ref && iv.type !== "AL-Reservation" && iv.type !== "AL-Absence") {
|
||
const copyBtn = document.createElement("button");
|
||
copyBtn.className = "intervention-copy";
|
||
copyBtn.type = "button";
|
||
copyBtn.title = "Copier la référence";
|
||
copyBtn.innerHTML = "📋";
|
||
copyBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
copyRef(iv.ref, copyBtn);
|
||
});
|
||
row.appendChild(copyBtn);
|
||
}
|
||
|
||
// ─── Ligne 2 gauche : heures VERTICALES (début / ↓ / fin) ─────────────────
|
||
const timeEl = document.createElement("div");
|
||
timeEl.className = "iv-time-vertical";
|
||
// v4.3.0 : marquer rouge + icône ⚠ si conflit horaire détecté
|
||
if (iv._hasOverlap) {
|
||
timeEl.classList.add("iv-time-overlap");
|
||
}
|
||
if (iv.startTime && iv.endTime) {
|
||
const s = document.createElement("div");
|
||
s.className = "iv-time-start";
|
||
s.textContent = iv.startTime;
|
||
const sep = document.createElement("div");
|
||
sep.className = "iv-time-arrow";
|
||
sep.textContent = "↓";
|
||
const e = document.createElement("div");
|
||
e.className = "iv-time-end";
|
||
e.textContent = iv.endTime;
|
||
timeEl.appendChild(s);
|
||
timeEl.appendChild(sep);
|
||
timeEl.appendChild(e);
|
||
// v4.3.0 : icône d'alerte à côté des heures si conflit
|
||
if (iv._hasOverlap) {
|
||
const warn = document.createElement("div");
|
||
warn.className = "iv-time-overlap-warn";
|
||
warn.textContent = "⚠";
|
||
warn.title = "Conflit d'horaire avec une autre intervention";
|
||
timeEl.appendChild(warn);
|
||
}
|
||
} else {
|
||
timeEl.textContent = "—";
|
||
}
|
||
row.appendChild(timeEl);
|
||
|
||
// ─── Ligne 2 droite : lieu / contact+tél / catégorie+signature ───────────
|
||
// Pour une RÉSERVATION : affichage différent (par + sujet)
|
||
const rightCol = document.createElement("div");
|
||
rightCol.className = "iv-right";
|
||
|
||
if (iv.type === "AL-Reservation") {
|
||
// Bloc "Par Nom, Prénom" (en gras)
|
||
if (iv.reservationCreator) {
|
||
const parEl = document.createElement("div");
|
||
parEl.className = "iv-reservation-par";
|
||
parEl.textContent = "Par " + iv.reservationCreator;
|
||
rightCol.appendChild(parEl);
|
||
}
|
||
// Sujet (ex: "Ecrans", "Rollout")
|
||
if (iv.reservationLabel) {
|
||
const sujetEl = document.createElement("div");
|
||
sujetEl.className = "iv-reservation-sujet";
|
||
sujetEl.textContent = "Sujet : " + iv.reservationLabel;
|
||
rightCol.appendChild(sujetEl);
|
||
}
|
||
row.appendChild(rightCol);
|
||
|
||
// Tooltip (fixe, ne suit pas la souris — v4.1.12)
|
||
row.addEventListener("mouseenter", (e) => {
|
||
showTooltip(e, iv, row);
|
||
highlightIntervention(cardEl, ivIdx, true);
|
||
});
|
||
row.addEventListener("mouseleave", () => {
|
||
hideTooltip();
|
||
highlightIntervention(cardEl, ivIdx, false);
|
||
});
|
||
return row;
|
||
}
|
||
|
||
// v5.0.15 : absence partielle (demi-journée) affichée comme une row au
|
||
// même style que les réservations mais en gris foncé, avec le type d'absence
|
||
// (Congés, Maladie, Pompier) comme sujet.
|
||
if (iv.type === "AL-Absence") {
|
||
// Bloc "Par Nom, Prénom" si on a un créateur
|
||
if (iv.reservationCreator) {
|
||
const parEl = document.createElement("div");
|
||
parEl.className = "iv-reservation-par";
|
||
parEl.textContent = "Par " + iv.reservationCreator;
|
||
rightCol.appendChild(parEl);
|
||
}
|
||
// Type d'absence (Congés, Maladie, Pompier) si dispo dans label
|
||
const absenceTypeMatch = (iv.label || "").match(/^([^/]+?)\s*(?:\/|$)/);
|
||
const absenceType = absenceTypeMatch ? absenceTypeMatch[1].trim() : null;
|
||
if (absenceType) {
|
||
const sujetEl = document.createElement("div");
|
||
sujetEl.className = "iv-reservation-sujet";
|
||
sujetEl.textContent = "Type : " + absenceType;
|
||
rightCol.appendChild(sujetEl);
|
||
}
|
||
row.appendChild(rightCol);
|
||
|
||
// Tooltip au hover (avec bouton supprimer)
|
||
row.addEventListener("mouseenter", (e) => {
|
||
showTooltip(e, iv, row);
|
||
highlightIntervention(cardEl, ivIdx, true);
|
||
});
|
||
row.addEventListener("mouseleave", () => {
|
||
hideTooltip();
|
||
highlightIntervention(cardEl, ivIdx, false);
|
||
});
|
||
return row;
|
||
}
|
||
|
||
// v4.1.2 : priorité à iv.infobulle (venant du xhr2 = données réelles vérifiées
|
||
// par le tech sur place) puis fallback sur iv.bulleContact/iv.bulleLieu
|
||
// (venant de attr1/attr2 = planification initiale, parfois incorrecte).
|
||
const info = iv.infobulle || {};
|
||
const contactRaw = info.contact || iv.bulleContact || null;
|
||
const lieuRaw = info.lieu || iv.bulleLieu || null;
|
||
|
||
// Rendu initial de lieu + contacts dans rightCol
|
||
renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, info.contactAnomalie);
|
||
|
||
// ── Bas : Catégorie (à gauche) + Signature planificateur (à droite) ──────
|
||
const bottomEl = document.createElement("div");
|
||
bottomEl.className = "iv-bottom-line";
|
||
|
||
const categoryEl = document.createElement("span");
|
||
categoryEl.className = "iv-category";
|
||
categoryEl.textContent = deriveShortTitle(iv);
|
||
bottomEl.appendChild(categoryEl);
|
||
|
||
// v4.1.8 : extraire la signature depuis le texte COMPLET (fiche) en
|
||
// priorité, sinon depuis le xhr2 tronqué. Le xhr2 tronqué peut couper la
|
||
// signature, la fiche a toujours le texte complet.
|
||
const signature = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription);
|
||
if (signature) {
|
||
const sigEl = document.createElement("span");
|
||
sigEl.className = "iv-signature";
|
||
sigEl.textContent = signature;
|
||
bottomEl.appendChild(sigEl);
|
||
}
|
||
|
||
rightCol.appendChild(bottomEl);
|
||
row.appendChild(rightCol);
|
||
|
||
// Tooltip (fixe, ne suit pas la souris — v4.1.12)
|
||
row.addEventListener("mouseenter", (e) => {
|
||
showTooltip(e, iv, row);
|
||
highlightIntervention(cardEl, ivIdx, true);
|
||
});
|
||
row.addEventListener("mouseleave", () => {
|
||
hideTooltip();
|
||
highlightIntervention(cardEl, ivIdx, false);
|
||
});
|
||
|
||
return row;
|
||
}
|
||
|
||
// Sender correct pour ouvrir une fiche EasyVista (vu dans les URLs qui marchent)
|
||
const FICHE_SENDER = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D";
|
||
|
||
async function openInterventionInNewTab(iv, opts = {}) {
|
||
if (!iv.formLink) return;
|
||
|
||
// Toast de feedback visuel dès le clic
|
||
showToast("Ouverture", iv.ref || iv.actionId);
|
||
|
||
// Récupérer la session actuelle pour construire une URL valide
|
||
let session = state.session;
|
||
if (!session) {
|
||
const resp = await sendMessage({ type: "getSession" });
|
||
session = resp && resp.session;
|
||
}
|
||
if (!session) {
|
||
// v4.2.5 : popup modale propre au lieu d'alert natif
|
||
showAlertModal({
|
||
title: "Impossible d'ouvrir la fiche",
|
||
message: "Votre session EasyVista a expiré. Reconnectez-vous à EasyVista puis réessayez.",
|
||
buttons: [
|
||
{ label: "Ouvrir EasyVista", variant: "primary", action: () => openEasyVista() },
|
||
{ label: "Annuler", variant: "secondary", action: () => {} }
|
||
]
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!iv.requestId) {
|
||
showAlertModal({
|
||
title: "Impossible d'ouvrir la fiche",
|
||
message: "L'identifiant de la fiche est manquant. Essayez d'actualiser le planning (bouton Actualiser).",
|
||
buttons: [
|
||
{ label: "OK", variant: "secondary", action: () => {} }
|
||
]
|
||
});
|
||
return;
|
||
}
|
||
|
||
let target = null;
|
||
let checksum = null;
|
||
|
||
// v4.1.4 : on fetch TOUJOURS la fiche à la volée au clic pour extraire un
|
||
// checksum FRAIS. Ne pas utiliser iv.ficheChecksum du cache : les checksums
|
||
// EasyVista peuvent expirer entre le fetch arrière-plan et le clic utilisateur.
|
||
//
|
||
// Retry automatique en cas d'échec du pattern checksum.
|
||
{
|
||
console.log("[click] fetch fiche fraîche pour iv", iv.actionId, "requestId=", iv.requestId);
|
||
let attempts = 0;
|
||
const maxAttempts = 2;
|
||
while (attempts < maxAttempts && (!target || !checksum)) {
|
||
attempts++;
|
||
try {
|
||
const ficheResp = await sendMessage({
|
||
type: "fetchFiche",
|
||
formLink: iv.formLink
|
||
});
|
||
if (!ficheResp.ok) {
|
||
if (attempts >= maxAttempts) {
|
||
// v4.2.5 : popup modale selon le type d'erreur
|
||
if (ficheResp.error === "no_session" || ficheResp.error === "session_expired") {
|
||
showAlertModal({
|
||
title: "Session EasyVista expirée",
|
||
message: "Votre session a expiré pendant l'ouverture de la fiche. Reconnectez-vous à EasyVista puis réessayez.",
|
||
buttons: [
|
||
{ label: "Ouvrir EasyVista", variant: "primary", action: () => openEasyVista() },
|
||
{ label: "Annuler", variant: "secondary", action: () => {} }
|
||
]
|
||
});
|
||
} else if (ficheResp.error === "ev_unreachable") {
|
||
showAlertModal({
|
||
title: "EasyVista inaccessible",
|
||
message: "EasyVista est inaccessible pour le moment. Réessayez dans quelques instants.",
|
||
buttons: [
|
||
{ label: "Réessayer", variant: "primary", action: () => openInterventionInNewTab(iv, opts) },
|
||
{ label: "Ouvrir EasyVista", variant: "secondary", action: () => openEasyVista() },
|
||
{ label: "Annuler", variant: "secondary", action: () => {} }
|
||
]
|
||
});
|
||
} else {
|
||
showAlertModal({
|
||
title: "Impossible d'ouvrir la fiche",
|
||
message: "Une erreur est survenue : " + (ficheResp.error || "inconnue"),
|
||
buttons: [
|
||
{ label: "OK", variant: "secondary", action: () => {} }
|
||
]
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
continue; // retry
|
||
}
|
||
// Extraire le checksum lié au requestId précis
|
||
const rx = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`, 'g');
|
||
const allMatches = [...ficheResp.html.matchAll(rx)];
|
||
console.log(`[click] Trouvé ${allMatches.length} occurrence(s) de target=${iv.requestId}&checksum=... dans HTML de la fiche (taille ${ficheResp.html.length})`);
|
||
allMatches.forEach((m, idx) => console.log(` [${idx}] checksum = ${m[1]}`));
|
||
|
||
if (allMatches.length === 0) {
|
||
// v4.2.5 : le warning précédent était alarmiste pour rien.
|
||
// Tentative 1 peut légitimement échouer (cache stale côté EV).
|
||
// On log en info, on retry, et en dernier recours on ouvre quand
|
||
// même la fiche (avec un target de fallback) plutôt que de bloquer.
|
||
console.info(`[click] tentative ${attempts}/${maxAttempts}: pattern target=${iv.requestId}&checksum=... introuvable dans HTML de la fiche (taille ${ficheResp.html.length})`);
|
||
if (attempts >= maxAttempts) {
|
||
// Fallback : tenter avec le requestId seul, sans checksum précis.
|
||
// Ça ouvre une URL EasyVista valide qui redirige vers la fiche.
|
||
console.info(`[click] fallback sans checksum précis pour ${iv.requestId}`);
|
||
target = iv.requestId;
|
||
checksum = null;
|
||
break;
|
||
}
|
||
await new Promise(r => setTimeout(r, 300));
|
||
continue;
|
||
}
|
||
// On prend le PREMIER checksum trouvé (comme avant, comportement v4)
|
||
target = iv.requestId;
|
||
checksum = allMatches[0][1];
|
||
console.log(`[click] checksum retenu: ${checksum}`);
|
||
// On stocke aussi en cache pour accélérer le prochain clic (au cas où)
|
||
iv.ficheTarget = target;
|
||
iv.ficheChecksum = checksum;
|
||
} catch (err) {
|
||
if (attempts >= maxAttempts) {
|
||
// v4.2.5 : popup modale au lieu d'alert
|
||
showAlertModal({
|
||
title: "Erreur lors de l'ouverture de la fiche",
|
||
message: "Une erreur s'est produite : " + (err && err.message ? err.message : String(err)),
|
||
buttons: [
|
||
{ label: "Réessayer", variant: "primary", action: () => openInterventionInNewTab(iv, opts) },
|
||
{ label: "Annuler", variant: "secondary", action: () => {} }
|
||
]
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// v4.1.18 : sender à utiliser dépend du type de fiche :
|
||
// - demande S... → {C99ECD05-...}
|
||
// - incident I... → {07ED9C68-...}
|
||
// On préfère le formSenderGuid extrait du HTML de la fiche si connu, sinon
|
||
// fallback sur préfixe de la ref.
|
||
let sender = FICHE_SENDER;
|
||
if (iv.formSenderGuid) {
|
||
sender = iv.formSenderGuid;
|
||
} else if (iv.ref && /^I/i.test(iv.ref)) {
|
||
sender = "%7B07ED9C68-6172-48EA-8A58-90912B0A283E%7D";
|
||
}
|
||
|
||
// Construire l'URL qui fonctionne (format identique à l'URL manuelle qui
|
||
// marche dans le navigateur quand on ouvre une fiche depuis l'UI EasyVista).
|
||
const internalurltime = Math.floor(Date.now() / 1000);
|
||
// v4.2.5 : si on n'a pas pu extraire le checksum précis (fallback après
|
||
// retry), on omet le paramètre checksum. EasyVista acceptera l'URL et
|
||
// redirigera vers la fiche correspondant au target.
|
||
const urlParts = [
|
||
`${session.origin}/index.php`,
|
||
`?PHPSESSID=${encodeURIComponent(session.phpsessid)}`,
|
||
`&internalurltime=${internalurltime}`,
|
||
`&eventName=formEvent`,
|
||
`&target=${encodeURIComponent(target)}`,
|
||
];
|
||
if (checksum) {
|
||
urlParts.push(`&checksum=${encodeURIComponent(checksum)}`);
|
||
}
|
||
urlParts.push(`&sender=${sender}`);
|
||
const url = urlParts.join("");
|
||
|
||
console.log("[click] ouverture fiche iv=", iv.actionId, "ref=", iv.ref, "target=", target, "bg=", !!opts.background);
|
||
// Si background (Ctrl+Clic ou clic molette) : onglet ouvert mais pas actif,
|
||
// on reste sur la page du planning.
|
||
await chrome.tabs.create({ url, active: !opts.background });
|
||
}
|
||
|
||
const TOAST_MAX = 3;
|
||
const TOAST_DURATION_MS = 2400;
|
||
|
||
/**
|
||
* Affiche un toast en bas à droite. S'empile, max 3, animations in/out.
|
||
*/
|
||
function showToast(label, ref) {
|
||
const stack = document.getElementById("toast-stack");
|
||
if (!stack) return;
|
||
|
||
// Si on dépasse le max, supprimer le plus ancien (= premier enfant)
|
||
while (stack.children.length >= TOAST_MAX) {
|
||
const oldest = stack.firstChild;
|
||
if (oldest) stack.removeChild(oldest);
|
||
}
|
||
|
||
const toast = document.createElement("div");
|
||
toast.className = "toast";
|
||
const labelEl = document.createElement("span");
|
||
labelEl.className = "toast-label";
|
||
labelEl.textContent = label;
|
||
const refEl = document.createElement("span");
|
||
refEl.className = "toast-ref";
|
||
refEl.textContent = ref || "…";
|
||
toast.appendChild(labelEl);
|
||
toast.appendChild(refEl);
|
||
|
||
stack.appendChild(toast);
|
||
|
||
// Forcer reflow puis animer en entrée
|
||
void toast.offsetWidth;
|
||
toast.classList.add("visible");
|
||
|
||
// Auto-disparition après TOAST_DURATION_MS
|
||
setTimeout(() => {
|
||
toast.classList.remove("visible");
|
||
toast.classList.add("leaving");
|
||
setTimeout(() => {
|
||
if (toast.parentNode === stack) stack.removeChild(toast);
|
||
}, 220);
|
||
}, TOAST_DURATION_MS);
|
||
}
|
||
|
||
/**
|
||
* Formate un numéro de téléphone suisse / français.
|
||
* 079 123 45 67 (mobile CH)
|
||
* 021 123 45 67 (fixe CH)
|
||
* +41 79 123 45 67
|
||
* +33 1 23 45 67 89
|
||
* Si le format n'est pas reconnu, renvoie le numéro tel quel (avec les chiffres seuls).
|
||
*/
|
||
function formatPhone(raw) {
|
||
if (!raw) return null;
|
||
const digits = String(raw).replace(/[^\d+]/g, "");
|
||
if (!digits) return null;
|
||
|
||
// +41 (Suisse international, 9 chiffres après +41)
|
||
let m = digits.match(/^\+41(\d{9})$/);
|
||
if (m) {
|
||
const d = m[1];
|
||
return `+41 ${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
|
||
}
|
||
// v4.2 : 41XXXXXXXXX sans + (format EasyVista qui colle parfois le préfixe)
|
||
m = digits.match(/^41(\d{9})$/);
|
||
if (m) {
|
||
const d = m[1];
|
||
return `+41 ${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
|
||
}
|
||
// +33 (France)
|
||
m = digits.match(/^\+33(\d{9})$/);
|
||
if (m) {
|
||
const d = m[1];
|
||
return `+33 ${d.slice(0, 1)} ${d.slice(1, 3)} ${d.slice(3, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
|
||
}
|
||
// v4.2 : 33XXXXXXXXX sans +
|
||
m = digits.match(/^33(\d{9})$/);
|
||
if (m) {
|
||
const d = m[1];
|
||
return `+33 ${d.slice(0, 1)} ${d.slice(1, 3)} ${d.slice(3, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
|
||
}
|
||
// 0XX XXX XX XX (fixe ou mobile CH, 10 chiffres commençant par 0)
|
||
m = digits.match(/^0(\d{9})$/);
|
||
if (m) {
|
||
const d = m[1];
|
||
return `0${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
|
||
}
|
||
// Numéro court interne (5 chiffres) : 78999, 68999, 88999, etc.
|
||
m = digits.match(/^(\d{5})$/);
|
||
if (m) {
|
||
return m[1]; // tel quel (déjà court et lisible)
|
||
}
|
||
// Fallback : retour brut
|
||
return digits;
|
||
}
|
||
|
||
/**
|
||
* Extrait le numéro de téléphone d'une chaîne contact.
|
||
* Accepte les préfixes : +41, +33, 07x, 02x, 03x (CH), 01-09 FR.
|
||
* Retourne un objet { name, phone } où phone est déjà formaté.
|
||
*/
|
||
function extractContactNameAndPhone(raw) {
|
||
if (!raw) return { name: null, phone: null };
|
||
const contacts = extractContacts(raw);
|
||
if (contacts.length === 0) return { name: null, phone: null };
|
||
// Pour compat avec l'ancien usage qui ne prend qu'1 contact
|
||
return contacts[0];
|
||
}
|
||
|
||
/**
|
||
* Extrait TOUS les contacts d'une chaîne (potentiellement plusieurs séparés
|
||
* par "ou", "/", des retours à la ligne, etc.).
|
||
* Retourne un tableau [{ name, phone }, { name, phone }, ...]
|
||
* Format d'entrée typique :
|
||
* "Nom1, Prénom1 +41000000001"
|
||
* "Nom1, Prénom1 +41000000001 ou Nom2, Prénom2 +41000000002"
|
||
* "Nom1, Prénom1 +41...\nNom2, Prénom2 +41..."
|
||
*/
|
||
function extractContacts(raw) {
|
||
if (!raw) return [];
|
||
let s = String(raw).trim();
|
||
// Virer les labels parasites (Nom utilisateur, etc.) qui traînent
|
||
s = s.replace(/\b(Nom utilisateur|Utilisateur)\s*:\s*[^\n]+/gi, "");
|
||
|
||
// v4.2.3 : séparer sur plus de délimiteurs pour gérer les cas type
|
||
// "Nom1 Prénom1 +41XXXXXXXXX et Nom2 Prénom2 0XXXXXXXXX"
|
||
// Délimiteurs acceptés :
|
||
// - " ou " / " et " / " and " (mots de liaison)
|
||
// - " / " suivi d'une majuscule (nouveau contact)
|
||
// - " ; " (point-virgule)
|
||
// - saut de ligne
|
||
// IMPORTANT : on ne touche PAS aux virgules (car "Nom, Prénom" en contient).
|
||
const parts = s.split(/\s+ou\s+|\s+et\s+|\s+and\s+|\s*;\s*|\n+|\s*\/\s*(?=[A-ZÉÈÀÂÎÔÛÇ])/i)
|
||
.map(p => p.trim())
|
||
.filter(Boolean);
|
||
|
||
const results = [];
|
||
for (const part of parts) {
|
||
const { name, phone } = splitOneContact(part);
|
||
if (name || phone) results.push({ name, phone });
|
||
}
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* Split UN seul bloc "Nom Prénom +41... [autres tels] [commentaires]" en
|
||
* { name, phone }.
|
||
*
|
||
* Stratégie robuste (v4.1.8) :
|
||
* - On cherche TOUS les numéros de téléphone (long ou court).
|
||
* - Le nom = ce qui précède le PREMIER numéro.
|
||
* - Le champ phone concatène les numéros trouvés (séparés par " / ").
|
||
* - Ce qui suit les numéros (commentaires "S'annoncer à la réception...",
|
||
* "téléphone à l'utilisateur") est JETÉ : ça ne fait pas partie du contact.
|
||
*
|
||
* Pattern numéro (inchangé, connu pour marcher) :
|
||
* Long : +41 / +33 / 0X suivi de 8+ caractères de [chiffres espaces . -]
|
||
* Court: 5 chiffres isolés (entre espaces, parenthèses, ou début/fin)
|
||
*/
|
||
function splitOneContact(raw) {
|
||
if (!raw) return { name: null, phone: null };
|
||
|
||
// v2026.5.25 : avant d'extraire les numéros, on REMPLACE les séquences qui
|
||
// sont des identifiants de matériel (LETTRES_CHIFFRES) par des espaces.
|
||
// Exemples : XXXX_NNNNNNNNNNN, XNNNNNN, XNNNNNN, XNNNNNN.
|
||
// Sans ça, XXXX_NNNNNNNNNNN laisse des "NNNN NNN NN NN" qui se font prendre
|
||
// pour un numéro de téléphone par le regex qui greedy sur [0-9\s.\-].
|
||
// On remplace par des espaces de même longueur pour préserver les offsets
|
||
// (important pour le calcul de position du nom avant le 1er numéro).
|
||
raw = String(raw);
|
||
raw = raw.replace(/\b[A-Z]{1,6}_\d+/g, (m) => " ".repeat(m.length));
|
||
// Idem pour les identifiants sans underscore style XNNNNNN, XNNNNNN, XNNNNNN
|
||
// (1-2 lettres majuscules suivies de 5+ chiffres collés). On garde assez
|
||
// permissif pour matcher les variantes sans enlever des vrais mots.
|
||
raw = raw.replace(/\b[A-Z]{1,3}\d{5,}\b/g, (m) => " ".repeat(m.length));
|
||
|
||
// v4.1.20 : regex plus permissives pour tolérer les erreurs humaines :
|
||
// - pas d'espace après le numéro (ex: "021555555Textecoller")
|
||
// - pas d'espace/parenthèse avant un court numéro
|
||
// LONG : +41 / +33 / 0X suivis de chiffres/espaces/points/tirets
|
||
// On ne limite plus par séparateur après — on laisse le moteur
|
||
// consommer le numéro le plus long possible (greedy) puis on
|
||
// s'arrête dès qu'on tombe sur un caractère non numérique.
|
||
// v4.2 : on accepte aussi le format "41XXXXXXXXX" sans + devant (fréquent
|
||
// quand EasyVista concatène "prefixe+tel" sans espace : Nom,
|
||
// Prénom 41XXXXXXXXX → extraire 41XXXXXXXXX puis reformater en
|
||
// +41 XX XXX XX XX). On exige exactement 11 chiffres collés pour
|
||
// éviter de matcher des codes postaux ou autres nombres.
|
||
// v2026.5.16 : ne PAS matcher si le numéro est précédé d'une lettre ou
|
||
// d'un underscore (identifiants style XXXX_NNNNNNNN, ABC123456,
|
||
// SERIAL_0123456789). On ajoute un lookbehind négatif (?<![A-Za-z_]).
|
||
const rxLong = /(?<![A-Za-z_])(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(?<!\d)41\d{9}(?!\d)|(?<!\d)33\d{9}(?!\d))/g;
|
||
// SHORT : numéro interne court (5 chiffres).
|
||
// - v4.1.20 : accepte "12345Texte" (pas de séparateur après)
|
||
// - v4.2.3 : accepte aussi les formats AVEC ESPACES au sein du numéro,
|
||
// typique du Canton de Vaud : "7 68 43", "6 12 34", "8 90 12".
|
||
// Doit commencer par 6, 7 ou 8 (plan de numérotation interne VD).
|
||
// Pattern : [678] + (4 autres chiffres, avec ou sans espaces/points
|
||
// intercalés, mais pas plus d'un séparateur à la fois entre 2 chiffres).
|
||
// - v4.2.3 : la version "collée" classique (5 chiffres sans espace, tout
|
||
// chiffre de début) reste acceptée comme fallback.
|
||
const rxShort = /(?:^|[\s(\/])([678](?:[\s.\-]?\d){4})(?!\d)|(?:^|[\s(\/])(\d{5})(?!\d)/g;
|
||
|
||
// Trouver toutes les positions de match pour LONG et SHORT
|
||
const matches = [];
|
||
let mm;
|
||
while ((mm = rxLong.exec(raw)) !== null) {
|
||
// v4.1.20 : on ne garde que si au moins 8 chiffres pour un long
|
||
// (élimine les fausses captures "0 1" ou "01 2")
|
||
const digitsOnly = mm[1].replace(/\D/g, "");
|
||
if (digitsOnly.length >= 9) {
|
||
matches.push({ start: mm.index, end: mm.index + mm[1].length, tel: mm[1] });
|
||
}
|
||
}
|
||
while ((mm = rxShort.exec(raw)) !== null) {
|
||
// v4.2.3 : soit le 1er groupe (format avec espaces "7 68 43"), soit le
|
||
// 2e groupe (format collé "12345") a matché.
|
||
const rawTel = mm[1] || mm[2];
|
||
if (!rawTel) continue;
|
||
// On normalise en 5 chiffres sans séparateur
|
||
const shortTel = rawTel.replace(/[\s.\-]/g, "");
|
||
if (!/^\d{5}$/.test(shortTel)) continue;
|
||
const rawStart = mm.index + mm[0].indexOf(rawTel);
|
||
const rawEnd = rawStart + rawTel.length;
|
||
const overlaps = matches.some(x => rawStart < x.end && rawEnd > x.start);
|
||
if (!overlaps) {
|
||
matches.push({ start: rawStart, end: rawEnd, tel: shortTel });
|
||
}
|
||
}
|
||
matches.sort((a, b) => a.start - b.start);
|
||
|
||
let name = raw;
|
||
let phone = null;
|
||
if (matches.length > 0) {
|
||
name = raw.substring(0, matches[0].start).trim();
|
||
const tels = matches.map(x => formatPhone(x.tel)).filter(Boolean);
|
||
phone = tels.length > 0 ? tels.join(" / ") : null;
|
||
}
|
||
|
||
name = cleanContactName(name);
|
||
|
||
// v2026.5.16 : dernier garde-fou — rejeter les "noms" qui ressemblent
|
||
// à des fragments de description technique plutôt qu'à des vrais contacts.
|
||
// Exemples rejetés :
|
||
// - "1x" (quantité isolée)
|
||
// - "1x pc" (quantité + type matériel)
|
||
// - "pc XNNNNNN" (type + numéro de série)
|
||
// - "XXXX_NNNNNNNN" (identifiant matériel)
|
||
// Critères d'un vrai nom : contient au moins un mot qui commence par une
|
||
// majuscule ET n'est pas juste un identifiant technique.
|
||
if (name) {
|
||
const looksLikeIdentifier = /^[A-Z]{2,}[_\-]\d+$/.test(name); // XXXX_NNNNNNNN
|
||
const startsWithQuantity = /^\d+x(\s|$)/i.test(name); // "1x" ou "1x pc"
|
||
const noCapitalWord = !/\b[A-ZÉÈÀÂÎÔÛÇ][a-zéèàâîôûç]+/.test(name); // aucun mot "Xxxxx"
|
||
const hasOnlyTechTokens = /^(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout)(\s+(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout|[A-Z]\d+))*\s*$/i.test(name);
|
||
if (looksLikeIdentifier || startsWithQuantity || hasOnlyTechTokens || (noCapitalWord && !phone)) {
|
||
name = null;
|
||
}
|
||
}
|
||
|
||
return { name, phone };
|
||
}
|
||
|
||
/**
|
||
* Nettoie le nom du contact :
|
||
* - retire tout ce qui est dans des parenthèses (...)
|
||
* - retire les éventuels "Nom utilisateur :" ou libellés
|
||
* - retire les virgules en trop en fin
|
||
* - v4.1.8 : tronque les commentaires parasites après le nom
|
||
* (ex: "Dupont, Jean S'annoncer à la réception" → "Dupont, Jean")
|
||
* - Conserve juste "Nom, Prénom" (ou "Nom Prénom" si pas de virgule)
|
||
*/
|
||
function cleanContactName(raw) {
|
||
if (!raw) return null;
|
||
let s = String(raw);
|
||
// Retirer parenthèses COMPLÈTES et leur contenu : (RH), (support)...
|
||
s = s.replace(/\s*\([^)]*\)\s*/g, " ");
|
||
// Retirer parenthèses non fermées en fin : "Bento, Joao (" → "Bento, Joao"
|
||
s = s.replace(/\s*\([^)]*$/g, " ");
|
||
// Retirer parenthèses non ouvertes en début : ")Bento" → "Bento"
|
||
s = s.replace(/^[^(]*\)\s*/g, "");
|
||
// Retirer tout caractère parenthèse isolé restant
|
||
s = s.replace(/[()]/g, " ");
|
||
// Retirer labels type "Nom utilisateur :", "Utilisateur :", "Bénéficiaire :"
|
||
s = s.replace(/\b(Nom utilisateur|Utilisateur|B[ée]n[ée]ficiaire)\s*:\s*[^\n,]*/gi, "");
|
||
|
||
// v4.1.20 : virer les commentaires parasites fréquents AVANT la logique
|
||
// des 4-mots (ils peuvent apparaître au tout début quand EasyVista n'a
|
||
// pas de nom saisi et commence directement par un commentaire).
|
||
// On détecte et coupe DÈS que ces expressions apparaissent.
|
||
// NOTE: on évite \b avant/après les caractères accentués (à, é) car
|
||
// \b est basé sur [a-zA-Z0-9_] et donne de faux négatifs.
|
||
const parasitePhrases = [
|
||
// Instructions d'appel (avec "à" ou "a")
|
||
/t[ée]l[ée]phone(?:r)?\s*[àa]\s*l[''`]?utilisateur/gi,
|
||
/t[ée]l[ée]phone(?:r)?\s*[àa](?:\s|$)/gi,
|
||
/t[ée]l[ée]phone(?:r)?\s*[àa]$/gi,
|
||
/\bappeler?\s+l[''`]?utilisateur\b/gi,
|
||
/\bappeler?\s+(?:le\s+)?b[ée]n[ée]ficiaire\b/gi,
|
||
/\bappeler?\s+la\s+personne\b/gi,
|
||
/\bappeler?\s+[àa]\s+/gi,
|
||
/\brappeler?\s+l[''`]?utilisateur\b/gi,
|
||
/\brappeler?\s+(?:le\s+)?b[ée]n[ée]ficiaire\b/gi,
|
||
// Instructions de présentation
|
||
/s[''`]annoncer?\s+[àa]\s+(?:la\s+r[ée]ception|l[''`]?accueil|.+?)(?=\.|,|$)/gi,
|
||
/\bse\s+pr[ée]senter\s+[àa]\s+.+?(?=\.|,|$)/gi,
|
||
// Autres
|
||
/\bbonjour\b/gi,
|
||
/\bmerci\b/gi,
|
||
// v4.1.20 : mots isolés qui restent parfois après les nettoyages ci-dessus
|
||
/\butilisateur\b/gi,
|
||
/\bb[ée]n[ée]ficiaire\b/gi
|
||
];
|
||
for (const rx of parasitePhrases) {
|
||
s = s.replace(rx, " ");
|
||
}
|
||
|
||
// Espaces multiples → un seul
|
||
s = s.replace(/\s{2,}/g, " ").trim();
|
||
// Ponctuation en bord
|
||
s = s.replace(/^[\s,;:.\-]+|[\s,;:.\-]+$/g, "").trim();
|
||
if (!s) return null;
|
||
|
||
// v4.1.8 : tronquer les commentaires parasites qui suivent le nom.
|
||
const words = s.split(/\s+/);
|
||
const keep = [];
|
||
for (let i = 0; i < words.length; i++) {
|
||
const w = words[i];
|
||
if (i === 0) { keep.push(w); continue; }
|
||
if (/^(de|da|du|van|von|le|la|del|di|der)$/i.test(w)) { keep.push(w); continue; }
|
||
if (keep.length >= 2 && /^[a-zéèêàâîôûç]/.test(w)) break;
|
||
if (keep.length >= 4) break;
|
||
keep.push(w);
|
||
}
|
||
s = keep.join(" ");
|
||
s = s.replace(/[\s,;:.\-]+$/, "").trim();
|
||
|
||
// v4.1.20 : dernier garde-fou : si le résultat final est juste un mot
|
||
// parasite (ex: "téléphone" tout seul, "appeler" tout seul), on retourne
|
||
// null plutôt qu'afficher un faux nom.
|
||
if (/^(t[ée]l[ée]phone|t[ée]l|appeler?|rappeler?|s[''`]?annoncer|bonjour|merci)$/i.test(s)) {
|
||
return null;
|
||
}
|
||
|
||
return s || null;
|
||
}
|
||
|
||
/**
|
||
* Split un lieu du type "Lausanne/Rue Caroline 9 bis" en
|
||
* { ville: "Lausanne", adresse: "Rue Caroline 9 bis" }
|
||
* Si format inconnu, retourne { ville: null, adresse: raw }.
|
||
*/
|
||
function splitLieu(raw) {
|
||
if (!raw) return { ville: null, adresse: null };
|
||
let s = String(raw).trim();
|
||
// Retirer un / final (avec ou sans espaces)
|
||
s = s.replace(/\s*\/\s*$/, "").trim();
|
||
if (!s) return { ville: null, adresse: null };
|
||
|
||
// v2026.5.16 : le format EasyVista peut avoir jusqu'à 3 parties séparées
|
||
// par "/" : VILLE / ADRESSE / PRÉCISIONS (étage, bureau, indications).
|
||
// Exemple : "LAUSANNE / Av. de Beaulieu 19 / 4eme en face de l'ascenseur"
|
||
// On ne garde que VILLE + ADRESSE. Les précisions (3e partie et suivantes)
|
||
// sont strippées — elles alourdissent la carte et sont disponibles dans
|
||
// le tooltip détaillé.
|
||
const parts = s.split("/").map(p => p.trim()).filter(Boolean);
|
||
|
||
let ville, adresse;
|
||
if (parts.length === 0) {
|
||
return { ville: null, adresse: null };
|
||
} else if (parts.length === 1) {
|
||
// Pas de slash : tout est l'adresse
|
||
ville = null;
|
||
adresse = parts[0];
|
||
} else {
|
||
// 2+ parties : ville = 1ère, adresse = 2e, on ignore le reste
|
||
ville = parts[0];
|
||
adresse = parts[1];
|
||
}
|
||
|
||
// Capitaliser la 1ère lettre des mots de voie (Rue, Chemin, Route, Avenue,
|
||
// Boulevard, Place, Quai, Impasse + abréviations Av., Ch., Rte, Bd)
|
||
if (adresse) {
|
||
adresse = adresse.replace(
|
||
/\b(rue|chemin|route|avenue|boulevard|place|quai|impasse|ruelle|allée|allee|passage|sentier|av\.?|ch\.?|rte\.?|bd\.?)\b/gi,
|
||
(match) => {
|
||
if (/^[A-ZÉÈÀÂÎÔÛÇ]/.test(match)) return match;
|
||
return match.charAt(0).toUpperCase() + match.slice(1).toLowerCase();
|
||
}
|
||
);
|
||
}
|
||
return { ville: ville || null, adresse: adresse || null };
|
||
}
|
||
|
||
/**
|
||
* Extrait la "signature planificateur" de la description d'action.
|
||
* Formats acceptés : "ECM 16.04", "JKF 17.04", "AWR 13/04/26", "ECM 16.04.2026".
|
||
* Parcourt d'abord les lignes depuis la fin (si la signature est sur sa ligne),
|
||
* sinon cherche à la fin de la description entière.
|
||
* Retourne null si rien trouvé.
|
||
*/
|
||
/**
|
||
* Normalise une date trouvée dans une signature :
|
||
* - "27/03" → "27.03"
|
||
* - "27.03" → "27.03"
|
||
* - "10/04/26" → "10.04" (on retire l'année)
|
||
* - "13/04/2026" → "13.04"
|
||
*/
|
||
function normalizeSignatureDate(date) {
|
||
if (!date) return "";
|
||
// Prendre les 2 premiers blocs de chiffres (JJ et MM) et les joindre avec "."
|
||
const parts = String(date).split(/[./]/);
|
||
if (parts.length < 2) return date;
|
||
const dd = parts[0].padStart(2, "0");
|
||
const mm = parts[1].padStart(2, "0");
|
||
return `${dd}.${mm}`;
|
||
}
|
||
|
||
function extractPlanifSignature(actionText) {
|
||
if (!actionText) return null;
|
||
// Formater le texte d'abord pour avoir des lignes séparées
|
||
const text = formatActionTextMultiline(String(actionText)).trim();
|
||
|
||
// 1. Dernière ligne non vide : regarder si c'est une signature (avec ou sans date)
|
||
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||
if (lines.length > 0) {
|
||
const last = lines[lines.length - 1];
|
||
|
||
// 1a. Lettres (majuscules OU minuscules) + date
|
||
// Ex: "FRD 07/04", "csh 27.03", "AWR 13/04/26", "JKF 17.04"
|
||
const mFull = last.match(/^([A-Za-z]{2,4})\s+(\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)$/);
|
||
if (mFull) {
|
||
return `${mFull[1].toUpperCase()} ${normalizeSignatureDate(mFull[2])}`;
|
||
}
|
||
|
||
// 1b. Juste les lettres seules (JKF, NDV) sur leur propre ligne
|
||
const mSolo = last.match(/^([A-Za-z]{2,4})$/);
|
||
if (mSolo) return mSolo[1].toUpperCase();
|
||
}
|
||
|
||
// 2. Sinon chercher la dernière signature "lettres + date" collée en fin
|
||
let lastMatch = null;
|
||
let m;
|
||
const rxGlobal = /([A-Za-z]{2,4})\s+(\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)/g;
|
||
while ((m = rxGlobal.exec(text)) !== null) {
|
||
lastMatch = { sigs: m[1], date: m[2], pos: m.index };
|
||
}
|
||
if (lastMatch && lastMatch.pos >= text.length - 100) {
|
||
return `${lastMatch.sigs.toUpperCase()} ${normalizeSignatureDate(lastMatch.date)}`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// v4.1.1 : shortMeta() et buildMetaDom() supprimées (code mort, héritage v1).
|
||
// Le rendu actuel utilise renderLieuContactBlocks() + buildInterventionRow().
|
||
|
||
async function copyRef(ref, btn) {
|
||
if (!ref) return;
|
||
try {
|
||
await navigator.clipboard.writeText(ref);
|
||
btn.classList.add("copied");
|
||
btn.textContent = "✓";
|
||
setTimeout(() => { btn.classList.remove("copied"); btn.textContent = "📋"; }, 1200);
|
||
} catch {
|
||
alert("Référence : " + ref);
|
||
}
|
||
}
|
||
|
||
// ─── Rendu incrémental (v3.1) ───────────────────────────────────────────────
|
||
// Met à jour UNE ligne d'interventoin dans le DOM (après qu'un fetch fiche
|
||
// ait enrichi l'objet iv avec sa ref, son statut, etc.). Appelée par
|
||
// fetchAndUpdateIntervention pour afficher la ref dès qu'elle arrive, sans
|
||
// attendre que tous les workers aient fini ni re-rendre toute la vue.
|
||
//
|
||
// Doit rester en phase avec la structure DOM construite par
|
||
// buildInterventionRow (classes iv-ref-header, iv-status-check,
|
||
// intervention-copy, intervention-dot, timeline-slot...).
|
||
const ALL_COLOR_CLASSES = [
|
||
"color-livraison", "color-installation", "color-recup",
|
||
"color-remplacement", "color-incident", "color-rollout",
|
||
"color-reservation", "color-autre"
|
||
];
|
||
|
||
/**
|
||
* (Re)génère les blocs Lieu et Contact(s) dans le conteneur .iv-right.
|
||
* Supprime d'abord les anciens blocs (.iv-lieu-block + .iv-contact-line),
|
||
* puis insère les nouveaux AVANT le bloc .iv-bottom-line (si présent) pour
|
||
* conserver l'ordre d'affichage. Utilisé à la création ET lors de la
|
||
* mise à jour après fetch de la fiche.
|
||
*/
|
||
function renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, contactAnomalie) {
|
||
// Supprime les anciens blocs lieu/contact
|
||
rightCol.querySelectorAll(".iv-lieu-block, .iv-contact-line").forEach(el => el.remove());
|
||
|
||
const contacts = extractContacts(contactRaw);
|
||
const { ville, adresse } = splitLieu(lieuRaw);
|
||
|
||
// Point d'insertion : avant .iv-bottom-line (catégorie + signature), sinon à la fin
|
||
const anchor = rightCol.querySelector(".iv-bottom-line");
|
||
const insert = (el) => {
|
||
if (anchor) rightCol.insertBefore(el, anchor);
|
||
else rightCol.appendChild(el);
|
||
};
|
||
|
||
// ── Lieu : ville (MAJUSCULES GRAS) puis adresse (italique noir) ──────────
|
||
if (ville || adresse) {
|
||
const lieuBlock = document.createElement("div");
|
||
lieuBlock.className = "iv-lieu-block";
|
||
if (ville) {
|
||
const villeEl = document.createElement("div");
|
||
villeEl.className = "iv-lieu-ville";
|
||
villeEl.textContent = ville.toUpperCase();
|
||
lieuBlock.appendChild(villeEl);
|
||
}
|
||
if (adresse) {
|
||
const addrEl = document.createElement("div");
|
||
addrEl.className = "iv-lieu-adresse";
|
||
addrEl.textContent = adresse;
|
||
lieuBlock.appendChild(addrEl);
|
||
}
|
||
insert(lieuBlock);
|
||
}
|
||
|
||
// ── Contact(s) + téléphone — un par ligne si plusieurs ──────────────────
|
||
for (const c of contacts) {
|
||
if (!c.name && !c.phone) continue;
|
||
const contactEl = document.createElement("div");
|
||
contactEl.className = "iv-contact-line";
|
||
// v4.2 : si anomalie (les 2 champs Contact + Personne de contact existent
|
||
// dans l'action), afficher en rouge pour signaler à l'user de vérifier.
|
||
if (contactAnomalie) contactEl.classList.add("iv-contact-anomalie");
|
||
if (c.name) {
|
||
const nameSpan = document.createElement("span");
|
||
nameSpan.className = "iv-contact";
|
||
nameSpan.textContent = c.name;
|
||
contactEl.appendChild(nameSpan);
|
||
}
|
||
if (c.phone) {
|
||
if (c.name) {
|
||
const sep = document.createElement("span");
|
||
sep.className = "iv-sep";
|
||
sep.textContent = " | ";
|
||
contactEl.appendChild(sep);
|
||
}
|
||
const phoneSpan = document.createElement("span");
|
||
phoneSpan.className = "iv-phone";
|
||
phoneSpan.textContent = c.phone;
|
||
contactEl.appendChild(phoneSpan);
|
||
}
|
||
insert(contactEl);
|
||
}
|
||
}
|
||
|
||
function updateInterventionRow(iv) {
|
||
// Réservations : pas concerné (pas de fetch fiche pour elles)
|
||
if (iv.type === "AL-Reservation") return;
|
||
|
||
const row = document.querySelector(
|
||
`.intervention-v2[data-action-id="${iv.actionId}"]`
|
||
);
|
||
if (!row) return;
|
||
|
||
// Classes de statut sur la ligne
|
||
const sc = getStatusClass(iv);
|
||
row.classList.remove("status-closed", "status-resolved", "status-terminated");
|
||
if (sc) row.classList.add(sc);
|
||
|
||
// Classe de couleur sur la ligne (la pastille hérite via CSS)
|
||
const colorKey = deriveColorKey(iv);
|
||
row.classList.remove(...ALL_COLOR_CLASSES);
|
||
row.classList.add("color-" + colorKey);
|
||
|
||
// Ref (le titre gros en haut de la ligne)
|
||
const refEl = row.querySelector(".iv-ref-header");
|
||
if (refEl) {
|
||
if (iv.ref) {
|
||
refEl.textContent = iv.ref;
|
||
refEl.classList.remove("no-ref");
|
||
} else {
|
||
refEl.textContent = "—";
|
||
refEl.classList.add("no-ref");
|
||
}
|
||
}
|
||
|
||
// Check ✓ : ajouter/retirer/mettre à jour selon statut
|
||
let checkEl = row.querySelector(".iv-status-check");
|
||
if (sc) {
|
||
// v4.2.5 : ✓✓ pour clos/résolu, ✓ pour terminé tech
|
||
const isDouble = (sc === "status-closed" || sc === "status-resolved");
|
||
const desiredText = isDouble ? "✓✓" : "✓";
|
||
if (!checkEl) {
|
||
checkEl = document.createElement("div");
|
||
checkEl.className = "iv-status-check";
|
||
// Insérer après la ref (avant le bouton copier s'il existe)
|
||
const copy = row.querySelector(".intervention-copy");
|
||
if (copy) row.insertBefore(checkEl, copy);
|
||
else if (refEl && refEl.nextSibling) row.insertBefore(checkEl, refEl.nextSibling);
|
||
else row.appendChild(checkEl);
|
||
}
|
||
checkEl.textContent = desiredText;
|
||
checkEl.classList.toggle("double", isDouble);
|
||
} else if (checkEl) {
|
||
checkEl.remove();
|
||
}
|
||
|
||
// Bouton 📋 copier : ajouter si on a maintenant une ref et qu'il n'existe pas
|
||
let copyBtn = row.querySelector(".intervention-copy");
|
||
if (iv.ref && !copyBtn) {
|
||
copyBtn = document.createElement("button");
|
||
copyBtn.className = "intervention-copy";
|
||
copyBtn.type = "button";
|
||
copyBtn.title = "Copier la référence";
|
||
copyBtn.innerHTML = "📋";
|
||
copyBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
copyRef(iv.ref, copyBtn);
|
||
});
|
||
row.appendChild(copyBtn);
|
||
}
|
||
|
||
// Catégorie affichée en bas (dépend de la ref pour Incident, etc.)
|
||
const catEl = row.querySelector(".iv-category");
|
||
if (catEl) catEl.textContent = deriveShortTitle(iv);
|
||
|
||
// v4.1.8 : signature planificateur (XXX JJ.MM). Si le texte fiche (complet)
|
||
// est arrivé, il peut maintenant fournir une signature que le xhr2 tronqué
|
||
// n'avait pas. On met à jour le span .iv-signature en conséquence.
|
||
const bottomEl = row.querySelector(".iv-bottom-line");
|
||
if (bottomEl) {
|
||
let sigEl = bottomEl.querySelector(".iv-signature");
|
||
const sig = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription);
|
||
if (sig) {
|
||
if (!sigEl) {
|
||
sigEl = document.createElement("span");
|
||
sigEl.className = "iv-signature";
|
||
bottomEl.appendChild(sigEl);
|
||
}
|
||
sigEl.textContent = sig;
|
||
} else if (sigEl) {
|
||
sigEl.remove();
|
||
}
|
||
}
|
||
|
||
// v4.1.2 : régénérer les blocs lieu/contact depuis les valeurs actuelles.
|
||
// Priorité à iv.infobulle (xhr2 lazy, vraies infos) puis attr1/attr2 (planif).
|
||
const rightCol = row.querySelector(".iv-right");
|
||
if (rightCol) {
|
||
const info = iv.infobulle || {};
|
||
const contactRaw = info.contact || iv.bulleContact || null;
|
||
const lieuRaw = info.lieu || iv.bulleLieu || null;
|
||
renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, info.contactAnomalie);
|
||
}
|
||
|
||
// Segment timeline correspondant : même couleur + même classe statut
|
||
const card = row.closest(".card");
|
||
if (card && row.dataset.ivIdx !== undefined) {
|
||
const slot = card.querySelector(
|
||
`.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]`
|
||
);
|
||
if (slot) {
|
||
slot.classList.remove("status-closed", "status-resolved", "status-terminated", ...ALL_COLOR_CLASSES);
|
||
slot.classList.add("color-" + colorKey);
|
||
if (sc) slot.classList.add(sc);
|
||
// Maj du dataset pour le popover (titre + ref)
|
||
slot.dataset.title = deriveShortTitle(iv);
|
||
if (iv.ref) slot.dataset.ref = iv.ref;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Tooltip
|
||
// ============================================================================
|
||
|
||
const tooltipEl = () => document.getElementById("tooltip");
|
||
|
||
// v4.1.10 : état persistant de la bulle
|
||
// - pinned : une fois épinglée (double Ctrl), la bulle reste à sa position,
|
||
// ne suit plus la souris, et ne se ferme ni au mouseleave ni au
|
||
// mouseleave suivant. On peut sélectionner le texte dedans.
|
||
// Clic hors bulle (ailleurs que sur une autre intervention) ou
|
||
// nouveau double-Ctrl → désépingle.
|
||
// - hoveredInBulle : si la souris entre DANS la bulle elle-même, la bulle
|
||
// reste visible même si elle n'est pas épinglée. Elle ne
|
||
// disparaît que quand la souris sort à la fois de la carte ET
|
||
// de la bulle.
|
||
let bulleState = {
|
||
pinned: false,
|
||
hoveredInBulle: false,
|
||
hoveredInRow: false,
|
||
hideTimer: null
|
||
};
|
||
|
||
function showTooltip(e, iv, rowEl) {
|
||
// v2026.5.19 : pendant qu'un popup épinglé est en cours de drag, on ignore
|
||
// les mouseenter sur les cartes — sinon en survolant une carte on déclenche
|
||
// l'ouverture d'un nouveau tooltip par-dessus ce qu'on est en train de bouger.
|
||
if (state._popupDragging) {
|
||
console.log("[showTooltip] ignoré : popup drag en cours");
|
||
return;
|
||
}
|
||
|
||
// v2026.5.27 : fermer tout popup "soft-unpinned" encore visible (il traîne
|
||
// parce que la souris était dessus). Dès qu'on survole autre chose, on veut
|
||
// que seul le popup actuel ou les popups épinglés restent.
|
||
// v2026.5.34 : le softUnpin ne supprime plus le popup au mouseleave, donc
|
||
// ici on les supprime explicitement quand un nouveau tooltip démarre.
|
||
const softUnpinned = document.querySelectorAll(".soft-unpinned");
|
||
if (softUnpinned.length) {
|
||
console.log(`[showTooltip] suppression de ${softUnpinned.length} popup(s) soft-unpinned`);
|
||
softUnpinned.forEach(el => {
|
||
try { el.remove(); } catch (err) {}
|
||
});
|
||
}
|
||
|
||
// 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) {
|
||
console.log("[showTooltip] ignoré : tooltip épinglé sur une autre iv");
|
||
return;
|
||
}
|
||
|
||
const el = tooltipEl();
|
||
el.innerHTML = buildTooltipHTML(iv);
|
||
el.classList.remove("hidden");
|
||
el.classList.add("visible");
|
||
// Conserver le pinned si on revient sur la même iv
|
||
if (bulleState.pinned && state.currentTooltipIv === iv) {
|
||
el.classList.add("pinned");
|
||
} else {
|
||
el.classList.remove("pinned");
|
||
}
|
||
if (bulleState.hideTimer) {
|
||
clearTimeout(bulleState.hideTimer);
|
||
bulleState.hideTimer = null;
|
||
}
|
||
bulleState.hoveredInRow = true;
|
||
// v4.1.12 : positionner la bulle UNE SEULE FOIS au mouseenter, près de la
|
||
// carte (row) et pas du curseur. Elle ne bouge plus pdt le survol.
|
||
// v4.1.15 : si pinned, NE PAS repositionner (la bulle doit rester fixe).
|
||
if (!bulleState.pinned) {
|
||
positionTooltipAnchored(rowEl || (e && e.currentTarget));
|
||
}
|
||
|
||
// v4 : lazy-load du texte complet de l'action au premier hover.
|
||
// Sans await : on affiche le tooltip IMMÉDIATEMENT avec ce qu'on a (lieu,
|
||
// contact, catégorie, ref venant du XML) ; quand le xhr2 arrive (50-200 ms
|
||
// plus tard typiquement), on régénère le tooltip s'il est encore visible.
|
||
if (iv && iv.type === "AL-Intervention" && !iv.xhr2Fetched && !iv.xhr2Fetching) {
|
||
ensureBulleDescription(iv).then(ok => {
|
||
// Si ça a marché ET que le tooltip est toujours visible sur CETTE iv,
|
||
// on régénère le HTML pour afficher les détails Problème/À faire/Matériel.
|
||
if (!ok) return;
|
||
const tip = tooltipEl();
|
||
if (!tip.classList.contains("visible")) return;
|
||
// Vérifie qu'on affiche toujours la même interventoin (pas un autre hover
|
||
// intervenu entretemps)
|
||
if (state.currentTooltipIv === iv) {
|
||
tip.innerHTML = buildTooltipHTML(iv);
|
||
}
|
||
});
|
||
}
|
||
// Mémoriser quelle iv est actuellement affichée (utilisé pour éviter
|
||
// d'écraser un tooltip différent si un autre hover s'est produit entretemps)
|
||
state.currentTooltipIv = iv;
|
||
}
|
||
|
||
function hideTooltip(opts = {}) {
|
||
// Si la bulle est épinglée, on ignore (sauf force: true = unpin explicite)
|
||
if (bulleState.pinned && !opts.force) return;
|
||
bulleState.hoveredInRow = false;
|
||
// Petit délai : laisse le temps à la souris d'ENTRER dans la bulle elle-même
|
||
// (si l'user veut sélectionner du texte). On annule la fermeture si
|
||
// hoveredInBulle passe à true entre-temps.
|
||
if (bulleState.hideTimer) clearTimeout(bulleState.hideTimer);
|
||
bulleState.hideTimer = setTimeout(() => {
|
||
if (bulleState.hoveredInBulle || bulleState.hoveredInRow) return;
|
||
if (bulleState.pinned) return;
|
||
// v4.2 : si l'utilisateur a une sélection de texte ACTIVE dans la bulle,
|
||
// on ne ferme pas (sinon la sélection disparaît avant d'avoir pu copier).
|
||
if (!opts.force && hasTextSelectionInTooltip()) return;
|
||
const el = tooltipEl();
|
||
el.classList.remove("visible", "pinned");
|
||
el.classList.add("hidden");
|
||
// v4.2.4 : reset du mode d'ancrage et de la détection de position
|
||
if (el.dataset) {
|
||
delete el.dataset.mode;
|
||
}
|
||
state.currentTooltipIv = null;
|
||
currentTooltipPos = null;
|
||
tooltipPositionMode = null; // re-détecter à la prochaine ouverture
|
||
}, 1000); // v2026.5.17 : délai 1s au lieu de 120ms pour laisser le temps
|
||
// à l'user d'atteindre le popup depuis la carte
|
||
}
|
||
|
||
// v4.2 : détecte si l'utilisateur a une sélection de texte active dans la bulle.
|
||
// Utilisé pour empêcher la fermeture automatique tant qu'on n'a pas fini de
|
||
// sélectionner/copier.
|
||
function hasTextSelectionInTooltip() {
|
||
try {
|
||
const sel = window.getSelection();
|
||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return false;
|
||
const tip = tooltipEl();
|
||
if (!tip) return false;
|
||
const range = sel.getRangeAt(0);
|
||
// La sélection est dans la bulle si au moins un endpoint y est
|
||
return tip.contains(range.startContainer) || tip.contains(range.endContainer);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function moveTooltip(e) {
|
||
// Historique : avant on suivait la souris. Maintenant la bulle est fixe
|
||
// (placée une seule fois au mouseenter). Cette fonction est là juste pour
|
||
// pas casser les appels existants.
|
||
}
|
||
|
||
// ============================================================================
|
||
// Positionnement du tooltip
|
||
// ============================================================================
|
||
// On positionne avec style.left/top en coords VIEWPORT (comme position:fixed).
|
||
// Si un ancêtre casse position:fixed (transform, filter, backdrop-filter ou
|
||
// contain), on détecte ça empiriquement au 1er placement via
|
||
// getBoundingClientRect — et on bascule en "abs" : mêmes coords mais on
|
||
// compense le scroll manuellement pour garder la bulle stable à l'écran.
|
||
// ============================================================================
|
||
|
||
// Position stockée : targetLeft / targetTop = coordonnées VIEWPORT désirées
|
||
// (où la popup doit apparaître à l'écran, peu importe le scroll).
|
||
let currentTooltipPos = null;
|
||
|
||
// Mode de positionnement, détecté empiriquement :
|
||
// null : pas encore détecté
|
||
// "fixed" : position:fixed marche → on laisse le navigateur gérer au scroll
|
||
// "abs" : position:fixed cassée → on compense manuellement au scroll
|
||
let tooltipPositionMode = null;
|
||
|
||
function setTooltipViewportPosition(viewportX, viewportY) {
|
||
const el = tooltipEl();
|
||
if (!el) return;
|
||
currentTooltipPos = { x: viewportX, y: viewportY };
|
||
|
||
// Appliquer la position en supposant que position:fixed marche
|
||
el.style.left = viewportX + "px";
|
||
el.style.top = viewportY + "px";
|
||
|
||
// Détection empirique au 1er positionnement : on compare la position
|
||
// réelle du tooltip (getBoundingClientRect) à la position demandée.
|
||
// Si ça correspond (à 1px près), position:fixed fonctionne. Sinon
|
||
// c'est qu'un ancêtre a cassé le containing block.
|
||
if (tooltipPositionMode === null) {
|
||
const r = el.getBoundingClientRect();
|
||
const deltaX = Math.abs(r.left - viewportX);
|
||
const deltaY = Math.abs(r.top - viewportY);
|
||
if (deltaX <= 1 && deltaY <= 1) {
|
||
tooltipPositionMode = "fixed";
|
||
} else {
|
||
tooltipPositionMode = "abs";
|
||
console.info(
|
||
"[tooltip] position:fixed cassée par un ancêtre, passage en mode compensé au scroll. " +
|
||
`delta=(${deltaX.toFixed(1)}, ${deltaY.toFixed(1)})`
|
||
);
|
||
}
|
||
}
|
||
|
||
// Si mode "abs" : le top/left qu'on vient de poser est en réalité interprété
|
||
// par rapport au containing block (pas le viewport). On doit compenser
|
||
// immédiatement pour placer la popup au bon endroit visuellement.
|
||
if (tooltipPositionMode === "abs") {
|
||
const r = el.getBoundingClientRect();
|
||
const offsetX = viewportX - r.left; // écart à corriger
|
||
const offsetY = viewportY - r.top;
|
||
// Nouvelle valeur absolute qui produit la position viewport voulue
|
||
const absLeft = parseFloat(el.style.left) + offsetX;
|
||
const absTop = parseFloat(el.style.top) + offsetY;
|
||
el.style.left = absLeft + "px";
|
||
el.style.top = absTop + "px";
|
||
// Mémoriser pour compenser au scroll
|
||
el._absBasisLeft = absLeft;
|
||
el._absBasisTop = absTop;
|
||
el._absBasisScrollX = window.scrollX || window.pageXOffset || 0;
|
||
el._absBasisScrollY = window.scrollY || window.pageYOffset || 0;
|
||
}
|
||
}
|
||
|
||
// Listener global scroll : si on est en mode "abs", on compense pour que la
|
||
// popup reste visuellement au même endroit pendant le scroll.
|
||
function reapplyTooltipPosition() {
|
||
if (!currentTooltipPos) return;
|
||
const el = tooltipEl();
|
||
if (!el || !el.classList.contains("visible")) return;
|
||
if (tooltipPositionMode !== "abs") return; // fixed marche, rien à faire
|
||
|
||
// Compenser le scroll : la popup doit rester à currentTooltipPos dans le
|
||
// viewport. Pour ça, on ajoute l'écart entre le scroll actuel et le
|
||
// scroll au moment de l'ancrage.
|
||
const scrollX = window.scrollX || window.pageXOffset || 0;
|
||
const scrollY = window.scrollY || window.pageYOffset || 0;
|
||
const dx = scrollX - (el._absBasisScrollX || 0);
|
||
const dy = scrollY - (el._absBasisScrollY || 0);
|
||
el.style.left = ((el._absBasisLeft || 0) + dx) + "px";
|
||
el.style.top = ((el._absBasisTop || 0) + dy) + "px";
|
||
}
|
||
|
||
/**
|
||
* v2026.5.34 : fonction UNIQUE et unifiée de positionnement ancré du tooltip.
|
||
*
|
||
* Utilisée par :
|
||
* - showTooltip() — hover d'une row intervention en vue classique
|
||
* - openPersistentTimelinePopup() — clic (classique) ou hover (horizontal)
|
||
* d'un segment timeline
|
||
* - showTooltip() pour les cartes/badges absence
|
||
*
|
||
* Algorithme :
|
||
* 1. Essaie 4 positions dans l'ordre : droite, gauche, dessous, dessus
|
||
* 2. Chaque position : padding de 8px min par rapport à la source
|
||
* 3. Chaque position : doit tenir dans la safe area (pas sous topbar/dock)
|
||
* 4. Chaque position : ne doit pas chevaucher les popups épinglés existants
|
||
* 5. Première position qui satisfait tout → on la prend
|
||
* 6. Fallback si aucune ne marche : droite clampée (la moins pire)
|
||
*
|
||
* La popup ne couvre JAMAIS la source (pad >= 8px en distance euclidienne).
|
||
*
|
||
* @param {HTMLElement} sourceEl - l'élément déclencheur (row, card, segment)
|
||
* @param {object} opts - options { anchorBelow: true pour préférer dessous }
|
||
*/
|
||
function positionTooltipAnchored(sourceEl, opts) {
|
||
opts = opts || {};
|
||
const el = tooltipEl();
|
||
if (!el) {
|
||
console.warn("[positionTooltip] tooltip DOM introuvable");
|
||
return;
|
||
}
|
||
if (!sourceEl) {
|
||
console.warn("[positionTooltip] sourceEl null — pas de positionnement");
|
||
return;
|
||
}
|
||
|
||
const pad = 10; // padding entre source et popup
|
||
const viewportMargin = 8; // marge par rapport aux bords
|
||
const srcRect = sourceEl.getBoundingClientRect();
|
||
const tipRect = el.getBoundingClientRect();
|
||
const tipW = tipRect.width || 320; // fallback taille si pas encore rendu
|
||
const tipH = tipRect.height || 200;
|
||
|
||
// Safe area : respecter topbar en haut, dock en bas
|
||
const safe = (typeof _getPopupSafeArea === "function")
|
||
? _getPopupSafeArea()
|
||
: { left: viewportMargin, top: viewportMargin,
|
||
right: window.innerWidth - viewportMargin,
|
||
bottom: window.innerHeight - viewportMargin };
|
||
|
||
// 4 candidats (ordre : droite → gauche → dessous → dessus)
|
||
// Préférence opts.anchorBelow = true : dessous en premier (ex: clic timeline)
|
||
const rightCandidate = { x: srcRect.right + pad, y: srcRect.top, label: "droite" };
|
||
const leftCandidate = { x: srcRect.left - tipW - pad, y: srcRect.top, label: "gauche" };
|
||
const belowCandidate = { x: srcRect.left, y: srcRect.bottom + pad, label: "dessous" };
|
||
const aboveCandidate = { x: srcRect.left, y: srcRect.top - tipH - pad, label: "dessus" };
|
||
|
||
const candidates = opts.anchorBelow
|
||
? [belowCandidate, aboveCandidate, rightCandidate, leftCandidate]
|
||
: [rightCandidate, leftCandidate, belowCandidate, aboveCandidate];
|
||
|
||
const pinnedRects = (typeof _getPinnedPopupsViewportRects === "function")
|
||
? _getPinnedPopupsViewportRects()
|
||
: [];
|
||
|
||
let chosen = null;
|
||
for (const c of candidates) {
|
||
// Clamp dans la safe area
|
||
let cx = c.x, cy = c.y;
|
||
if (cx < safe.left) cx = safe.left;
|
||
if (cx + tipW > safe.right) cx = safe.right - tipW;
|
||
if (cx < safe.left) continue; // popup plus large que safe area — skip
|
||
if (cy < safe.top) cy = safe.top;
|
||
if (cy + tipH > safe.bottom) cy = safe.bottom - tipH;
|
||
if (cy < safe.top) continue;
|
||
|
||
// Ne chevauche PAS la source (garantit qu'on ne la cache pas)
|
||
const candRect = { left: cx, top: cy, right: cx + tipW, bottom: cy + tipH };
|
||
if (_rectsOverlap(candRect, srcRect)) continue;
|
||
|
||
// Ne chevauche pas les popups épinglés existants
|
||
const hitsPinned = pinnedRects.some(pr => _rectsOverlap(candRect, pr));
|
||
if (hitsPinned) continue;
|
||
|
||
chosen = { x: cx, y: cy, label: c.label };
|
||
break;
|
||
}
|
||
|
||
if (!chosen) {
|
||
// Fallback : droite clampée à tout prix, même si ça chevauche (cas rare
|
||
// avec écran minuscule ou beaucoup de popups épinglés)
|
||
let fx = srcRect.right + pad;
|
||
let fy = srcRect.top;
|
||
if (fx + tipW > safe.right) fx = safe.right - tipW;
|
||
if (fx < safe.left) fx = safe.left;
|
||
if (fy + tipH > safe.bottom) fy = safe.bottom - tipH;
|
||
if (fy < safe.top) fy = safe.top;
|
||
chosen = { x: fx, y: fy, label: "fallback" };
|
||
console.log("[positionTooltip] fallback utilisé (aucun candidat optimal trouvé)");
|
||
} else {
|
||
console.log(`[positionTooltip] position choisie : ${chosen.label} (${Math.round(chosen.x)}, ${Math.round(chosen.y)})`);
|
||
}
|
||
|
||
setTooltipViewportPosition(chosen.x, chosen.y);
|
||
}
|
||
|
||
/**
|
||
* v2026.5.17 : retourne les rectangles (en coords viewport) de tous les popups
|
||
* actuellement épinglés et visibles (non réduits). Utilisé pour anti-chevauchement.
|
||
*/
|
||
function _getPinnedPopupsViewportRects() {
|
||
const rects = [];
|
||
document.querySelectorAll(".pinned-popup").forEach(p => {
|
||
if (p.classList.contains("pinned-popup-reduced")) return; // docké, pas à l'écran
|
||
const r = p.getBoundingClientRect();
|
||
if (r.width > 0 && r.height > 0) rects.push(r);
|
||
});
|
||
return rects;
|
||
}
|
||
|
||
// ============================================================================
|
||
// v4.3.0 : système de popups épinglés détachés
|
||
// ============================================================================
|
||
//
|
||
// Au lieu d'épingler le tooltip unique (qui empêchait d'afficher d'autres
|
||
// infos au survol), on clone son contenu en un popup indépendant :
|
||
// - Ancré DANS le contenu de la page (position: absolute + coordonnées
|
||
// document) → scrolle avec le contenu, pas avec le viewport.
|
||
// - Peut coexister avec d'autres popups épinglés (jusqu'à ce qu'il n'y
|
||
// ait plus de place disponible).
|
||
// - Persiste jusqu'à fermeture explicite (bouton ×, Échap, ou Ctrl×2 si 1 seul).
|
||
//
|
||
// Le tooltip live (#tooltip) garde son rôle initial : il se ferme au mouseleave.
|
||
|
||
const pinnedPopups = []; // [{el, iv, rect}]
|
||
|
||
/**
|
||
* Ancre la popup au contenu : ajoute le scrollY actuel au top viewport pour
|
||
* obtenir une position absolute document, qui scrolle avec le contenu.
|
||
*/
|
||
function _viewportToDocumentY(y) {
|
||
return y + (window.scrollY || window.pageYOffset || 0);
|
||
}
|
||
function _viewportToDocumentX(x) {
|
||
return x + (window.scrollX || window.pageXOffset || 0);
|
||
}
|
||
|
||
/**
|
||
* Teste si un rectangle {left, top, right, bottom} (en coords document)
|
||
* chevauche avec un popup déjà épinglé.
|
||
*/
|
||
function _rectsOverlap(a, b) {
|
||
return !(a.right <= b.left || a.left >= b.right ||
|
||
a.bottom <= b.top || a.top >= b.bottom);
|
||
}
|
||
|
||
/**
|
||
* Cherche une position libre pour un popup de dimensions {w, h} près de la
|
||
* ligne source `rowEl`. Essaie dans l'ordre : droite, gauche, dessous, dessus.
|
||
* Retourne {x, y} en coordonnées document, ou null si aucune position libre.
|
||
*/
|
||
function _findFreePopupPosition(rowEl, w, h) {
|
||
const pad = 14;
|
||
const rowRect = rowEl.getBoundingClientRect();
|
||
// v2026.5.20 : utiliser la safe area (en dessous topbar, au-dessus dock)
|
||
const safe = _getPopupSafeArea();
|
||
|
||
// 4 candidats d'abord, autour de la row source (en coords viewport)
|
||
const candidates = [
|
||
{ x: rowRect.right + pad, y: rowRect.top, name: "droite" },
|
||
{ x: rowRect.left - w - pad, y: rowRect.top, name: "gauche" },
|
||
{ x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" },
|
||
{ x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" }
|
||
];
|
||
|
||
// v2026.5.20 : ajouter une grille de positions de fallback couvrant toute
|
||
// la safe area (pas de 60px × 60px) — garantit qu'on trouve ~toujours une
|
||
// place, sauf si vraiment trop de popups actifs.
|
||
const availW = safe.right - safe.left;
|
||
const availH = safe.bottom - safe.top;
|
||
if (availW > w + 20 && availH > h + 20) {
|
||
for (let y = safe.top; y + h <= safe.bottom; y += 60) {
|
||
for (let x = safe.left; x + w <= safe.right; x += 60) {
|
||
candidates.push({ x, y, name: "grid" });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Tester chaque candidat dans l'ordre
|
||
for (const c of candidates) {
|
||
let x = c.x, y = c.y;
|
||
// Clamp dans la safe area
|
||
if (x < safe.left) x = safe.left;
|
||
if (x + w > safe.right) x = safe.right - w;
|
||
if (x < safe.left) continue; // popup plus large que safe area
|
||
if (y < safe.top) y = safe.top;
|
||
if (y + h > safe.bottom) y = safe.bottom - h;
|
||
if (y < safe.top) continue;
|
||
|
||
// Si, après clamp, la popup chevaucherait la ligne source elle-même,
|
||
// on ignore ce candidat (on préfère une direction qui la laisse visible).
|
||
const rowRectClamped = {
|
||
left: rowRect.left, top: rowRect.top,
|
||
right: rowRect.right, bottom: rowRect.bottom
|
||
};
|
||
const candRect = { left: x, top: y, right: x + w, bottom: y + h };
|
||
// v2026.5.20 : pour les 4 candidats principaux, on refuse de chevaucher
|
||
// la row ; pour les candidats "grid" de fallback, on l'accepte
|
||
// (on veut une place à tout prix).
|
||
if (c.name !== "grid" && _rectsOverlap(candRect, rowRectClamped)) continue;
|
||
|
||
// Test chevauchement avec les popups déjà épinglés (coords document)
|
||
const docRect = {
|
||
left: _viewportToDocumentX(x),
|
||
top: _viewportToDocumentY(y),
|
||
right: _viewportToDocumentX(x + w),
|
||
bottom: _viewportToDocumentY(y + h)
|
||
};
|
||
let overlapsOther = false;
|
||
for (const p of pinnedPopups) {
|
||
// Ne pas comparer avec un popup qui est dans le dock (réduit)
|
||
if (p.el && p.el.classList && p.el.classList.contains("pinned-popup-reduced")) continue;
|
||
if (_rectsOverlap(docRect, p.rect)) {
|
||
overlapsOther = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!overlapsOther) {
|
||
return {
|
||
viewportX: x, viewportY: y,
|
||
docX: docRect.left, docY: docRect.top,
|
||
rect: docRect
|
||
};
|
||
}
|
||
}
|
||
|
||
// v2026.5.20 : ultime fallback — accepter de chevaucher mais décaler
|
||
// un peu par rapport au 1er popup épinglé existant. Évite complètement
|
||
// le "Pas de place" injuste.
|
||
if (pinnedPopups.length > 0) {
|
||
const last = pinnedPopups[pinnedPopups.length - 1];
|
||
let x = (last.rect.left - (window.scrollX || 0)) + 30;
|
||
let y = (last.rect.top - (window.scrollY || 0)) + 30;
|
||
if (x + w > safe.right) x = safe.right - w;
|
||
if (y + h > safe.bottom) y = safe.bottom - h;
|
||
if (x < safe.left) x = safe.left;
|
||
if (y < safe.top) y = safe.top;
|
||
const docRect = {
|
||
left: _viewportToDocumentX(x),
|
||
top: _viewportToDocumentY(y),
|
||
right: _viewportToDocumentX(x + w),
|
||
bottom: _viewportToDocumentY(y + h)
|
||
};
|
||
return {
|
||
viewportX: x, viewportY: y,
|
||
docX: docRect.left, docY: docRect.top,
|
||
rect: docRect
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* v4.3.0 : épingle la bulle courante en la clonant dans un popup détaché
|
||
* ancré au contenu. Le tooltip live redevient disponible.
|
||
*/
|
||
function pinTooltip() {
|
||
if (!state.currentTooltipIv) return;
|
||
const srcEl = tooltipEl();
|
||
if (!srcEl) return;
|
||
const iv = state.currentTooltipIv;
|
||
|
||
// v2026.5.21 : unicité actionId + date. Si un popup pour la même ref
|
||
// ET la même date est déjà épinglé, on le supprime et on re-crée un nouveau
|
||
// (user a choisi ce comportement : "tu supprime le popup actuellement
|
||
// épinglé et tu répingle la nouvelle fenêtre").
|
||
const currentDate = state.currentDate || "";
|
||
const existingKey = (iv.actionId || "") + "|" + currentDate;
|
||
for (let i = pinnedPopups.length - 1; i >= 0; i--) {
|
||
const p = pinnedPopups[i];
|
||
if (!p || !p.el) continue;
|
||
const aid = p.el.dataset.actionId || "";
|
||
const d = p.el.dataset.originDate || "";
|
||
if (aid + "|" + d === existingKey) {
|
||
// Retirer l'ancien (popup + pastille dock éventuelle)
|
||
if (p.el._linkedPill) {
|
||
try { p.el._linkedPill.remove(); } catch (e) {}
|
||
}
|
||
try { p.el.remove(); } catch (e) {}
|
||
pinnedPopups.splice(i, 1);
|
||
}
|
||
}
|
||
// Nettoyer un éventuel dock devenu vide
|
||
const dockEl = document.getElementById("pinned-popups-dock");
|
||
if (dockEl && dockEl.querySelectorAll(".pinned-popup-dock-pill").length === 0) {
|
||
dockEl.classList.remove("visible");
|
||
const closeAllBtn = document.getElementById("pinned-popups-close-all");
|
||
if (closeAllBtn) closeAllBtn.remove();
|
||
}
|
||
|
||
// Chercher la ligne source (row iv-v2)
|
||
let rowEl = null;
|
||
if (iv.actionId) {
|
||
rowEl = document.querySelector(`.intervention-v2[data-action-id="${iv.actionId}"]`);
|
||
}
|
||
if (!rowEl) {
|
||
// Fallback : utiliser la position actuelle du tooltip live
|
||
rowEl = srcEl;
|
||
}
|
||
|
||
// Cloner le contenu du tooltip actuel en popup détaché
|
||
const popup = document.createElement("div");
|
||
popup.className = "tooltip pinned-popup visible";
|
||
popup.dataset.actionId = iv.actionId || "";
|
||
popup.innerHTML = srcEl.innerHTML;
|
||
|
||
// v2026.5.18 : mémoriser la ref et la couleur pour le dock (pastille avec
|
||
// couleur de catégorie + texte ref)
|
||
popup.dataset.ref = iv.ref || "";
|
||
popup.dataset.colorKey = (typeof deriveColorKey === "function" ? deriveColorKey(iv) : "autre") || "autre";
|
||
|
||
// v2026.5.19 : mémoriser aussi la date pour l'afficher sur la pastille dock
|
||
popup.dataset.originDate = state.currentDate || "";
|
||
|
||
// v2026.5.25 : mémoriser aussi lieu + service pour la pastille enrichie
|
||
try {
|
||
const info = iv.infobulle || {};
|
||
const lieuRaw = info.lieu || iv.bulleLieu || "";
|
||
// Format pour pastille : "VILLE, Adresse" (on extrait les 2 premières parties)
|
||
const { ville, adresse } = (typeof splitLieu === "function")
|
||
? splitLieu(lieuRaw)
|
||
: { ville: null, adresse: null };
|
||
let lieuFmt = "";
|
||
if (ville && adresse) lieuFmt = ville.toUpperCase() + ", " + adresse;
|
||
else if (adresse) lieuFmt = adresse;
|
||
else if (ville) lieuFmt = ville.toUpperCase();
|
||
popup.dataset.lieu = lieuFmt;
|
||
|
||
// Service : prendre les 2 dernières parties séparées par "/"
|
||
// Ex: "AdmCV/DJES/SSCM/SSCM - Admin/TEO - OS" → "SSCM - Admin/TEO - OS"
|
||
// "AdmCV/OJV/JPX/JPX Lavaux-Oron" → "JPX/JPX Lavaux-Oron"
|
||
// v2026.5.26 : dans le DERNIER segment seulement, couper au " - " (espace
|
||
// tiret espace) pour retirer les descriptions longues. Les
|
||
// noms propres comme "Lavaux-Oron" (sans espaces) sont préservés.
|
||
// Ex: "AdmCV/DJES/SSCM/SPOP - Centre administratif" → "SSCM/SPOP"
|
||
// "AdmCV/OJV/JPX/JPX Lavaux-Oron" → "JPX/JPX Lavaux-Oron" (inchangé)
|
||
const serviceRaw = (info.service) || iv.categoryLine || "";
|
||
if (serviceRaw) {
|
||
const parts = serviceRaw.split("/").map(s => s.trim()).filter(Boolean);
|
||
let last2 = parts.slice(-2);
|
||
// Couper au " - " dans le DERNIER élément de last2
|
||
if (last2.length > 0) {
|
||
const lastIdx = last2.length - 1;
|
||
const lastSeg = last2[lastIdx];
|
||
const dashIdx = lastSeg.indexOf(" - ");
|
||
if (dashIdx > 0) {
|
||
last2[lastIdx] = lastSeg.substring(0, dashIdx).trim();
|
||
}
|
||
}
|
||
popup.dataset.service = last2.join("/");
|
||
} else {
|
||
popup.dataset.service = "";
|
||
}
|
||
} catch (err) {
|
||
console.warn("[pin] lieu/service build failed", err);
|
||
}
|
||
|
||
// v2026.5.17 : masquer l'icône 📌 du contenu cloné (redondante car le
|
||
// popup a sa propre topbar avec le bouton "désépingler" 📍 explicite)
|
||
const oldPin = popup.querySelector('.tooltip-pinbtn[data-action="pin"]');
|
||
if (oldPin) oldPin.remove();
|
||
|
||
// v2026.5.17 : topbar avec 3 boutons pour un popup épinglé :
|
||
// v2026.5.18 : swap des actions — _ réduit dans le dock, ▭ minimise flottant
|
||
// _ = Réduire (docké dans la taskbar du bas)
|
||
// ▭ = Minimiser (popup reste flottant mais compact, juste la ref)
|
||
// 📍 = Désépingler (l'icône d'épingle "plantée" ; clic = retire l'épingle)
|
||
const topbar = document.createElement("div");
|
||
topbar.className = "pinned-popup-topbar";
|
||
|
||
// Bouton Réduire (icône _ )
|
||
const reduceBtn = document.createElement("button");
|
||
reduceBtn.type = "button";
|
||
reduceBtn.className = "pinned-popup-btn pinned-popup-reduce";
|
||
reduceBtn.innerHTML = "_";
|
||
reduceBtn.title = "Réduire (docké en bas de l'écran)";
|
||
reduceBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
_reducePinnedPopup(popup);
|
||
});
|
||
topbar.appendChild(reduceBtn);
|
||
|
||
// Bouton Minimiser (icône ▭ )
|
||
const minBtn = document.createElement("button");
|
||
minBtn.type = "button";
|
||
minBtn.className = "pinned-popup-btn pinned-popup-minimize";
|
||
minBtn.innerHTML = "▭";
|
||
minBtn.title = "Minimiser (reste flottant mais compact)";
|
||
minBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
_minimizePinnedPopup(popup);
|
||
});
|
||
topbar.appendChild(minBtn);
|
||
|
||
// v2026.5.19 : Bouton Actualiser (même icône SVG que le tooltip standard)
|
||
// Re-fetch la fiche de l'intervention pour mettre à jour les infos (statut,
|
||
// commentaires, action text) sans recharger le planning entier.
|
||
const refreshBtn = document.createElement("button");
|
||
refreshBtn.type = "button";
|
||
refreshBtn.className = "pinned-popup-btn pinned-popup-refresh";
|
||
refreshBtn.innerHTML = '<svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8a6 6 0 1 0 1.76-4.24M2 3v3h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||
refreshBtn.title = "Actualiser les informations de cette intervention";
|
||
refreshBtn.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
if (refreshBtn.classList.contains("spinning")) return;
|
||
refreshBtn.classList.add("spinning");
|
||
try {
|
||
await _refreshPinnedPopupIv(popup, iv);
|
||
} finally {
|
||
setTimeout(() => refreshBtn.classList.remove("spinning"), 300);
|
||
}
|
||
});
|
||
topbar.appendChild(refreshBtn);
|
||
|
||
// Bouton Désépingler (icône épingle plantée)
|
||
const unpinBtn = document.createElement("button");
|
||
unpinBtn.type = "button";
|
||
unpinBtn.className = "pinned-popup-btn pinned-popup-unpin";
|
||
unpinBtn.innerHTML = "📍";
|
||
unpinBtn.title = "Désépingler (se ferme quand la souris sort)";
|
||
unpinBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
_softUnpinPopup(popup);
|
||
});
|
||
topbar.appendChild(unpinBtn);
|
||
|
||
popup.appendChild(topbar);
|
||
|
||
// v4.3.3 : barre de drag en haut, pour déplacer la popup à la souris.
|
||
// Ancrée en haut à 22px de haut ; le padding-top de la popup est augmenté
|
||
// côté CSS pour ne pas que le contenu soit caché derrière.
|
||
const dragbar = document.createElement("div");
|
||
dragbar.className = "pinned-popup-dragbar";
|
||
dragbar.title = "Glissez pour déplacer";
|
||
popup.appendChild(dragbar);
|
||
_attachPopupDragHandler(popup, dragbar);
|
||
|
||
// v4.3.0 : le popup contient un clone du tooltip live, qui inclut le
|
||
// bouton 📌. Dans un popup déjà épinglé, ce bouton devient "désépingler".
|
||
// On intercepte le clic ici, avant qu'il remonte.
|
||
popup.addEventListener("click", (e) => {
|
||
const btn = e.target.closest('[data-action]');
|
||
if (!btn) return;
|
||
const action = btn.dataset.action;
|
||
if (action === "pin") {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
_softUnpinPopup(popup);
|
||
}
|
||
// Les autres actions (reload, copy-ref, etc.) ne sont pas gérées ici ;
|
||
// on pourrait les ajouter plus tard si besoin.
|
||
});
|
||
|
||
// Placer en (0,0) temporairement pour mesurer la taille
|
||
popup.style.position = "absolute";
|
||
popup.style.left = "-9999px";
|
||
popup.style.top = "-9999px";
|
||
popup.style.visibility = "hidden";
|
||
document.body.appendChild(popup);
|
||
|
||
// Mesurer après rendu
|
||
const pRect = popup.getBoundingClientRect();
|
||
const w = pRect.width;
|
||
const h = pRect.height;
|
||
|
||
// Chercher une position libre
|
||
const pos = _findFreePopupPosition(rowEl, w, h);
|
||
|
||
if (!pos) {
|
||
// Pas de place : retirer et afficher un toast
|
||
popup.remove();
|
||
showToast("Pas de place", "Fermez une popup épinglée");
|
||
return;
|
||
}
|
||
|
||
// Appliquer la position (coords document = position: absolute)
|
||
popup.style.left = pos.docX + "px";
|
||
popup.style.top = pos.docY + "px";
|
||
popup.style.visibility = "visible";
|
||
|
||
// v2026.5.20 : clamper dans la safe area (topbar + dock)
|
||
_clampPopupInSafeArea(popup);
|
||
|
||
// Enregistrer dans la liste
|
||
pinnedPopups.push({ el: popup, iv: iv, rect: pos.rect });
|
||
// v2026.5.34 : stocker une référence à l'iv sur l'élément DOM — utilisée
|
||
// par _softUnpinPopup pour pouvoir ré-épingler via le bouton 📌 restauré.
|
||
popup._linkedIv = iv;
|
||
|
||
// v4.3.0 : libérer le tooltip live (il redevient utilisable pour d'autres survols)
|
||
bulleState.pinned = false;
|
||
bulleState.hoveredInRow = false;
|
||
bulleState.hoveredInBulle = false;
|
||
srcEl.classList.remove("visible", "pinned");
|
||
srcEl.classList.add("hidden");
|
||
if (srcEl.dataset) delete srcEl.dataset.mode;
|
||
state.currentTooltipIv = null;
|
||
currentTooltipPos = null;
|
||
tooltipPositionMode = null;
|
||
if (bulleState.hideTimer) {
|
||
clearTimeout(bulleState.hideTimer);
|
||
bulleState.hideTimer = null;
|
||
}
|
||
}
|
||
|
||
/** Ferme un popup épinglé donné. */
|
||
function _closePinnedPopup(el) {
|
||
const idx = pinnedPopups.findIndex(p => p.el === el);
|
||
if (idx >= 0) pinnedPopups.splice(idx, 1);
|
||
el.remove();
|
||
}
|
||
|
||
/**
|
||
* v2026.5.19 : re-fetch les infos d'une intervention et met à jour le contenu
|
||
* du popup épinglé correspondant. Utilise fetchAndUpdateIntervention qui fait
|
||
* xhr2 + fiche, puis régénère le HTML du tooltip avec buildTooltipHTML.
|
||
*/
|
||
async function _refreshPinnedPopupIv(popup, iv) {
|
||
if (!popup || !iv) return;
|
||
try {
|
||
// Forcer le refetch : on invalide les flags qui disent "déjà fetché"
|
||
iv.xhr2Fetched = false;
|
||
iv.xhr2Fetching = false;
|
||
iv.ficheFetched = false;
|
||
iv.ficheFetching = false;
|
||
|
||
// Token de refresh actuel (pour que fetchAndUpdateIntervention ne soit
|
||
// pas abortée par les checks isRefreshAborted)
|
||
const token = (typeof currentRefreshToken !== "undefined") ? currentRefreshToken : 0;
|
||
|
||
await fetchAndUpdateIntervention(iv, token);
|
||
|
||
// Régénérer le HTML du tooltip avec les nouvelles infos.
|
||
// On doit réinjecter juste le contenu, en gardant la topbar et la dragbar
|
||
// (qui ne sont PAS dans le tooltip source, elles sont propres au popup).
|
||
const topbar = popup.querySelector(".pinned-popup-topbar");
|
||
const dragbar = popup.querySelector(".pinned-popup-dragbar");
|
||
const newHtml = buildTooltipHTML(iv);
|
||
popup.innerHTML = newHtml;
|
||
// Virer aussi la vieille icône 📌 si elle revient dans le rebuild
|
||
const oldPin = popup.querySelector('.tooltip-pinbtn[data-action="pin"]');
|
||
if (oldPin) oldPin.remove();
|
||
// Remettre topbar et dragbar
|
||
if (topbar) popup.appendChild(topbar);
|
||
if (dragbar) popup.appendChild(dragbar);
|
||
} catch (err) {
|
||
console.warn("[refresh-popup]", err);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Désépinglage "mou" (v4.3.3) : transforme un popup épinglé en tooltip simple.
|
||
*
|
||
* v2026.5.34 : refonte — le popup ne disparaît PLUS au mouseleave. À la place,
|
||
* il redevient un tooltip normal avec ses boutons ↻ (Actualiser) et 📌
|
||
* (Épingler) restaurés dans .tooltip-actions, et reste visible à l'écran
|
||
* comme un tooltip classique. L'user peut le re-cliquer sur 📌 pour le
|
||
* ré-épingler, ou cliquer ailleurs pour s'en débarrasser normalement.
|
||
*
|
||
* @param {HTMLElement} el - le popup à désépingler
|
||
*/
|
||
function _softUnpinPopup(el) {
|
||
if (!el) {
|
||
console.warn("[softUnpin] elément null — abandon");
|
||
return;
|
||
}
|
||
console.log("[softUnpin] désépinglage du popup", el.dataset.actionId || "(sans actionId)");
|
||
|
||
// 1. Retirer de la liste des popups épinglés (pour Ctrl×2 etc.)
|
||
const idx = pinnedPopups.findIndex(p => p.el === el);
|
||
if (idx >= 0) {
|
||
pinnedPopups.splice(idx, 1);
|
||
console.log(`[softUnpin] retiré de pinnedPopups (reste ${pinnedPopups.length})`);
|
||
}
|
||
|
||
// 2. Reset le flag global bulleState.pinned si plus aucun popup épinglé.
|
||
// Sans ça, le tooltip live "croit" toujours qu'il est pinned et son
|
||
// handler click (notamment ↻ reload) peut partir en cacahuète.
|
||
if (pinnedPopups.length === 0) {
|
||
bulleState.pinned = false;
|
||
}
|
||
|
||
// 3. Retirer les éléments propres au mode épinglé : topbar, dragbar,
|
||
// close btn, et classes minimized/reduced.
|
||
const dragbar = el.querySelector(".pinned-popup-dragbar");
|
||
if (dragbar) dragbar.remove();
|
||
const closeBtn = el.querySelector(".pinned-popup-close");
|
||
if (closeBtn) closeBtn.remove();
|
||
const topbar = el.querySelector(".pinned-popup-topbar");
|
||
if (topbar) topbar.remove();
|
||
el.classList.remove("pinned-popup-minimized");
|
||
el.classList.remove("pinned-popup-reduced");
|
||
|
||
// 4. Retirer la pastille du dock si elle existe
|
||
if (el._linkedPill) {
|
||
try { el._linkedPill.remove(); } catch (e) {
|
||
console.warn("[softUnpin] erreur remove pill:", e);
|
||
}
|
||
el._linkedPill = null;
|
||
}
|
||
// Si le dock est vide, le cacher ; mettre à jour le bouton "Fermer tous"
|
||
const dock = document.getElementById("pinned-popups-dock");
|
||
if (dock && dock.querySelectorAll(".pinned-popup-dock-pill").length === 0) {
|
||
dock.classList.remove("visible");
|
||
const closeAllBtn = document.getElementById("pinned-popups-close-all");
|
||
if (closeAllBtn) closeAllBtn.remove();
|
||
} else {
|
||
_ensureDockCloseAllBtn();
|
||
}
|
||
|
||
// 5. Restaurer le bouton 📌 dans .tooltip-actions
|
||
// Lors de l'épinglage, ce bouton a été SUPPRIMÉ du DOM (voir pinTooltip
|
||
// v2026.5.17 ligne 6921). Il faut le recréer pour que l'user puisse
|
||
// ré-épingler ou que l'icône 📌 revienne visible.
|
||
const actionsEl = el.querySelector('.tooltip-actions');
|
||
if (actionsEl) {
|
||
const existingPin = actionsEl.querySelector('.tooltip-pinbtn[data-action="pin"]');
|
||
if (!existingPin) {
|
||
console.log("[softUnpin] restauration du bouton 📌 dans .tooltip-actions");
|
||
const pinBtn = document.createElement('div');
|
||
pinBtn.className = 'tooltip-actionbtn tooltip-pinbtn';
|
||
pinBtn.setAttribute('data-action', 'pin');
|
||
pinBtn.title = 'Épingler la bulle (ou double-Ctrl). Cliquer à nouveau pour libérer.';
|
||
pinBtn.textContent = '📌';
|
||
actionsEl.appendChild(pinBtn);
|
||
} else {
|
||
console.log("[softUnpin] bouton 📌 déjà présent, mise à jour visuelle");
|
||
existingPin.textContent = '📌';
|
||
existingPin.classList.remove('tooltip-pinbtn-active');
|
||
}
|
||
} else {
|
||
console.warn("[softUnpin] .tooltip-actions introuvable dans le popup — boutons pas restaurés");
|
||
}
|
||
|
||
// 5bis. v2026.5.34 : attacher un nouveau handler click sur .tooltip-actions
|
||
// du popup soft-unpinned pour ré-épinglement / actualisation.
|
||
// L'ancien handler du popup épinglé (qui faisait softUnpin au clic
|
||
// sur pin) est encore attaché, mais notre nouveau handler utilise
|
||
// stopPropagation avant pour l'empêcher de s'exécuter.
|
||
// On garde une référence à l'iv pour pouvoir ré-épingler proprement.
|
||
const ivForRepin = el._linkedIv || null;
|
||
if (actionsEl && ivForRepin) {
|
||
// Retirer d'éventuels handlers précédents en remplaçant l'element
|
||
const newActions = actionsEl.cloneNode(true);
|
||
actionsEl.parentNode.replaceChild(newActions, actionsEl);
|
||
|
||
newActions.addEventListener("click", (e) => {
|
||
const btn = e.target.closest('[data-action]');
|
||
if (!btn) return;
|
||
const action = btn.dataset.action;
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
|
||
if (action === "pin") {
|
||
console.log("[softUnpin] clic sur 📌 d'un popup soft-unpinned → ré-épinglage");
|
||
// Retirer le popup soft-unpinned actuel
|
||
try { el.remove(); } catch (err) {}
|
||
// Simuler le survol de l'iv pour rendre le tooltip live, puis épingler
|
||
state.currentTooltipIv = ivForRepin;
|
||
pinTooltip();
|
||
} else if (action === "reload") {
|
||
console.log("[softUnpin] clic sur ↻ d'un popup soft-unpinned → recharger intervention");
|
||
if (typeof reloadSingleIntervention === "function") {
|
||
reloadSingleIntervention(ivForRepin, btn);
|
||
} else {
|
||
console.warn("[softUnpin] reloadSingleIntervention indisponible");
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 6. Basculer visuellement : retirer pinned-popup, mettre soft-unpinned.
|
||
// La classe .soft-unpinned applique les styles "tooltip normal"
|
||
// (pas de bordure bleue, pas de padding-top pour topbar disparue, etc.)
|
||
el.classList.remove("pinned-popup");
|
||
el.classList.add("soft-unpinned");
|
||
|
||
// 7. Régénérer le tooltip hover si on est en train de survoler la même iv
|
||
// (pour synchroniser l'icône 📌/📍 dans le tooltip live).
|
||
// Aussi, reset iv._reloading qui pourrait rester bloqué à true.
|
||
const tip = tooltipEl();
|
||
if (tip && tip.classList.contains("visible") && state.currentTooltipIv) {
|
||
try {
|
||
state.currentTooltipIv._reloading = false;
|
||
tip.innerHTML = buildTooltipHTML(state.currentTooltipIv);
|
||
} catch (err) {
|
||
console.warn("[softUnpin] buildTooltipHTML failed:", err);
|
||
}
|
||
}
|
||
|
||
// 8. v2026.5.34 : ne PAS programmer de suppression automatique au mouseleave.
|
||
// Le popup reste visible comme un tooltip normal. L'user le ferme en
|
||
// cliquant ailleurs ou en appuyant sur Échap. Il peut aussi re-cliquer
|
||
// sur 📌 pour le ré-épingler.
|
||
//
|
||
// Pour que le popup disparaisse quand l'user clique ailleurs, on ajoute
|
||
// un handler de clic document qui supprime tous les .soft-unpinned si
|
||
// le clic est hors du popup. Ce handler ne s'arme QU'UNE FOIS (au premier
|
||
// désépinglage) et reste globalement.
|
||
_ensureSoftUnpinnedCleanupHandler();
|
||
|
||
console.log("[softUnpin] terminé — popup reste visible en mode tooltip normal");
|
||
}
|
||
|
||
/**
|
||
* v2026.5.34 : pose un handler global (une seule fois) qui ferme les popups
|
||
* en état .soft-unpinned quand l'user clique ailleurs, ou appuie sur Échap.
|
||
*/
|
||
let _softUnpinnedCleanupHandlerInstalled = false;
|
||
function _ensureSoftUnpinnedCleanupHandler() {
|
||
if (_softUnpinnedCleanupHandlerInstalled) return;
|
||
_softUnpinnedCleanupHandlerInstalled = true;
|
||
|
||
// Clic hors d'un popup soft-unpinned → supprimer
|
||
document.addEventListener("mousedown", (e) => {
|
||
const softPopups = document.querySelectorAll(".soft-unpinned");
|
||
if (softPopups.length === 0) return;
|
||
softPopups.forEach(el => {
|
||
// Ne pas fermer si le clic est DANS le popup lui-même
|
||
if (el.contains(e.target)) return;
|
||
console.log("[softUnpin cleanup] clic hors du popup — suppression");
|
||
el.classList.add("unpinning");
|
||
setTimeout(() => { try { el.remove(); } catch (err) {} }, 180);
|
||
});
|
||
}, true); // phase capture pour attraper avant d'autres handlers
|
||
|
||
console.log("[softUnpin] handler cleanup global installé");
|
||
}
|
||
|
||
// ============================================================================
|
||
// v2026.5.17 : États d'un popup épinglé
|
||
// - Normal (complet, flottant)
|
||
// - Minimisé (compact, flottant, juste la ref + topbar)
|
||
// - Réduit (docké dans la taskbar en bas de l'écran)
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Passe un popup épinglé en mode Minimisé : on ne montre plus que la ref,
|
||
* dans un petit cadre flottant toujours drag-able.
|
||
*
|
||
* v2026.5.19 : au lieu de masquer tout le contenu via CSS et tenter de
|
||
* réafficher la ref (fragile), on crée un élément dédié `.pinned-popup-minref`
|
||
* qui contient juste la ref + la date. Cet élément est ajouté/retiré au besoin.
|
||
*/
|
||
function _minimizePinnedPopup(popup) {
|
||
if (!popup) return;
|
||
popup.classList.add("pinned-popup-minimized");
|
||
|
||
// Adapter les boutons topbar : [▭] devient [⬆] (agrandir)
|
||
const minBtn = popup.querySelector(".pinned-popup-minimize");
|
||
if (minBtn) {
|
||
minBtn.innerHTML = "⬆";
|
||
minBtn.title = "Agrandir";
|
||
// On retire les anciens listeners en clonant l'élément
|
||
const newBtn = minBtn.cloneNode(true);
|
||
minBtn.replaceWith(newBtn);
|
||
newBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
_expandPinnedPopup(popup);
|
||
});
|
||
}
|
||
|
||
// Créer un élément dédié pour afficher la ref en mode minimisé
|
||
let minRef = popup.querySelector(".pinned-popup-minref");
|
||
if (!minRef) {
|
||
minRef = document.createElement("div");
|
||
minRef.className = "pinned-popup-minref";
|
||
const refText = popup.dataset.ref || "(sans ref)";
|
||
minRef.textContent = refText;
|
||
minRef.title = "Cliquer pour agrandir";
|
||
minRef.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
_expandPinnedPopup(popup);
|
||
});
|
||
popup.appendChild(minRef);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Repasse un popup minimisé en mode Normal (complet).
|
||
*/
|
||
function _expandPinnedPopup(popup) {
|
||
if (!popup) return;
|
||
popup.classList.remove("pinned-popup-minimized");
|
||
|
||
// Restaurer bouton Minimiser
|
||
const minBtn = popup.querySelector(".pinned-popup-minimize");
|
||
if (minBtn) {
|
||
minBtn.innerHTML = "▭";
|
||
minBtn.title = "Minimiser (reste flottant mais compact)";
|
||
const newBtn = minBtn.cloneNode(true);
|
||
minBtn.replaceWith(newBtn);
|
||
newBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
_minimizePinnedPopup(popup);
|
||
});
|
||
}
|
||
|
||
// Retirer l'élément ref dédié (s'il existe)
|
||
const minRef = popup.querySelector(".pinned-popup-minref");
|
||
if (minRef) minRef.remove();
|
||
}
|
||
|
||
/**
|
||
* Passe un popup épinglé en mode Réduit : il disparaît de son emplacement
|
||
* flottant et vient s'ajouter dans une taskbar en bas de l'écran sous forme
|
||
* de pastille cliquable.
|
||
*/
|
||
function _reducePinnedPopup(popup) {
|
||
if (!popup) return;
|
||
|
||
// Récupérer la référence pour le label de la pastille
|
||
// v2026.5.18 : préférer le dataset.ref mémorisé à la création plutôt que
|
||
// le textContent (qui peut contenir "—" si la ref n'était pas encore
|
||
// disponible à l'épinglage)
|
||
const refEl = popup.querySelector(".iv-ref-header");
|
||
const label = popup.dataset.ref
|
||
|| (refEl ? (refEl.textContent || "").trim() : "")
|
||
|| "Popup";
|
||
const colorKey = popup.dataset.colorKey || "autre";
|
||
|
||
// S'assurer que la taskbar du bas existe
|
||
let dock = document.getElementById("pinned-popups-dock");
|
||
if (!dock) {
|
||
dock = document.createElement("div");
|
||
dock.id = "pinned-popups-dock";
|
||
dock.className = "pinned-popups-dock";
|
||
document.body.appendChild(dock);
|
||
}
|
||
|
||
// Créer la pastille dock
|
||
// v2026.5.18 : le fond de la pastille prend la couleur de catégorie
|
||
// v2026.5.25 : pastille à 3 lignes — lieu (fort), service (petit), date (petit)
|
||
// La référence est affichée dans le mini-menu au survol.
|
||
const pill = document.createElement("button");
|
||
pill.type = "button";
|
||
pill.className = "pinned-popup-dock-pill color-" + colorKey;
|
||
pill.title = "Cliquer pour agrandir";
|
||
|
||
// Ligne 1 : lieu (ou ref si pas de lieu)
|
||
const pillLieu = document.createElement("span");
|
||
pillLieu.className = "pinned-popup-dock-pill-lieu";
|
||
pillLieu.textContent = popup.dataset.lieu || label || "—";
|
||
pill.appendChild(pillLieu);
|
||
|
||
// Ligne 2 : service (2 dernières parties)
|
||
const serviceTxt = popup.dataset.service || "";
|
||
if (serviceTxt) {
|
||
const pillService = document.createElement("span");
|
||
pillService.className = "pinned-popup-dock-pill-service";
|
||
pillService.textContent = serviceTxt;
|
||
pill.appendChild(pillService);
|
||
}
|
||
|
||
// Ligne 3 : date "Mardi 22.04"
|
||
const originDate = popup.dataset.originDate || "";
|
||
if (originDate) {
|
||
const pillDate = document.createElement("span");
|
||
pillDate.className = "pinned-popup-dock-pill-date";
|
||
pillDate.textContent = _formatDateForPill(originDate);
|
||
pill.appendChild(pillDate);
|
||
}
|
||
|
||
// Mémoriser la position/taille du popup avant de le masquer
|
||
const rect = popup.getBoundingClientRect();
|
||
popup.dataset.prevLeft = popup.style.left || (rect.left + "px");
|
||
popup.dataset.prevTop = popup.style.top || (rect.top + "px");
|
||
popup.dataset.prevWidth = popup.style.width || "";
|
||
|
||
// Cacher le popup (on le garde en DOM pour conserver son état et restaurer
|
||
// instantanément)
|
||
popup.classList.add("pinned-popup-reduced");
|
||
|
||
// Associer pill ↔ popup
|
||
pill._linkedPopup = popup;
|
||
popup._linkedPill = pill;
|
||
|
||
pill.addEventListener("click", (e) => {
|
||
// v2026.5.23 : si on était en train de drag, ne pas déclencher le clic
|
||
if (pill._justDragged) {
|
||
pill._justDragged = false;
|
||
return;
|
||
}
|
||
e.stopPropagation();
|
||
_restorePinnedPopupFromDock(popup);
|
||
});
|
||
|
||
// v2026.5.20 : mini-menu au survol (Agrandir / Fermer)
|
||
pill.addEventListener("mouseenter", () => {
|
||
if (pill._dragging) return; // pas de menu pendant le drag
|
||
_showPillHoverMenu(pill, popup);
|
||
});
|
||
pill.addEventListener("mouseleave", (e) => {
|
||
// Le menu peut être sous la souris — on ne ferme pas si on entre dans le menu
|
||
_schedulePillMenuClose();
|
||
});
|
||
|
||
// v2026.5.23 : drag & drop — réordonne dans le dock, ou restaure hors du dock
|
||
_attachPillDragHandler(pill, popup);
|
||
|
||
dock.appendChild(pill);
|
||
dock.classList.add("visible");
|
||
|
||
// v2026.5.18 : s'assurer qu'il y a un bouton "Fermer tous" si 2+ popups
|
||
_ensureDockCloseAllBtn();
|
||
|
||
// v2026.5.20 : le dock qui apparaît peut chevaucher des popups flottants —
|
||
// les reclamper pour qu'ils restent dans la safe area.
|
||
_reclampAllFloatingPopups();
|
||
}
|
||
|
||
/**
|
||
* v2026.5.20 : affiche un mini-menu au-dessus d'une pastille dock au survol.
|
||
* Contient 2 actions : Agrandir, Fermer.
|
||
*/
|
||
let _pillMenuCloseTimer = null;
|
||
function _showPillHoverMenu(pill, popup) {
|
||
// Annuler une fermeture en cours
|
||
if (_pillMenuCloseTimer) {
|
||
clearTimeout(_pillMenuCloseTimer);
|
||
_pillMenuCloseTimer = null;
|
||
}
|
||
// S'il existe déjà un menu pour un autre pill, le fermer
|
||
const existing = document.getElementById("pill-hover-menu");
|
||
if (existing) {
|
||
if (existing._linkedPill === pill) return; // déjà pour ce pill
|
||
existing.remove();
|
||
}
|
||
|
||
const menu = document.createElement("div");
|
||
menu.id = "pill-hover-menu";
|
||
menu.className = "pill-hover-menu";
|
||
menu._linkedPill = pill;
|
||
menu._linkedPopup = popup;
|
||
|
||
// v2026.5.25 : REF en haut du menu (info seulement, centrée)
|
||
const refText = popup.dataset.ref || "";
|
||
if (refText) {
|
||
const refLabel = document.createElement("div");
|
||
refLabel.className = "pill-hover-menu-ref";
|
||
refLabel.textContent = refText;
|
||
menu.appendChild(refLabel);
|
||
}
|
||
|
||
const restoreBtn = document.createElement("button");
|
||
restoreBtn.type = "button";
|
||
restoreBtn.className = "pill-hover-menu-btn";
|
||
restoreBtn.innerHTML = '<span class="pill-menu-ico">⬆</span> Agrandir';
|
||
restoreBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
_hidePillHoverMenu();
|
||
_restorePinnedPopupFromDock(popup);
|
||
});
|
||
menu.appendChild(restoreBtn);
|
||
|
||
const closeBtn = document.createElement("button");
|
||
closeBtn.type = "button";
|
||
closeBtn.className = "pill-hover-menu-btn pill-hover-menu-close";
|
||
closeBtn.innerHTML = '<span class="pill-menu-ico">✕</span> Fermer';
|
||
closeBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
_hidePillHoverMenu();
|
||
// Retirer le popup de la liste et supprimer le DOM
|
||
const idx = pinnedPopups.findIndex(p => p.el === popup);
|
||
if (idx >= 0) pinnedPopups.splice(idx, 1);
|
||
try { popup.remove(); } catch (err) {}
|
||
try { pill.remove(); } catch (err) {}
|
||
const dock = document.getElementById("pinned-popups-dock");
|
||
if (dock && dock.querySelectorAll(".pinned-popup-dock-pill").length === 0) {
|
||
dock.classList.remove("visible");
|
||
const closeAllBtn = document.getElementById("pinned-popups-close-all");
|
||
if (closeAllBtn) closeAllBtn.remove();
|
||
_reclampAllFloatingPopups();
|
||
} else {
|
||
_ensureDockCloseAllBtn();
|
||
}
|
||
});
|
||
menu.appendChild(closeBtn);
|
||
|
||
document.body.appendChild(menu);
|
||
|
||
// Positionner au-dessus de la pastille
|
||
const r = pill.getBoundingClientRect();
|
||
const menuR = menu.getBoundingClientRect();
|
||
let left = r.left + (r.width / 2) - (menuR.width / 2);
|
||
if (left < 4) left = 4;
|
||
if (left + menuR.width > window.innerWidth - 4) left = window.innerWidth - menuR.width - 4;
|
||
menu.style.left = left + "px";
|
||
menu.style.top = (r.top - menuR.height - 8) + "px";
|
||
|
||
// Garder ouvert si la souris entre dans le menu
|
||
menu.addEventListener("mouseenter", () => {
|
||
if (_pillMenuCloseTimer) {
|
||
clearTimeout(_pillMenuCloseTimer);
|
||
_pillMenuCloseTimer = null;
|
||
}
|
||
});
|
||
menu.addEventListener("mouseleave", () => {
|
||
_schedulePillMenuClose();
|
||
});
|
||
}
|
||
|
||
function _schedulePillMenuClose() {
|
||
if (_pillMenuCloseTimer) clearTimeout(_pillMenuCloseTimer);
|
||
_pillMenuCloseTimer = setTimeout(() => {
|
||
_hidePillHoverMenu();
|
||
_pillMenuCloseTimer = null;
|
||
}, 250);
|
||
}
|
||
|
||
function _hidePillHoverMenu() {
|
||
const existing = document.getElementById("pill-hover-menu");
|
||
if (existing) existing.remove();
|
||
}
|
||
|
||
/**
|
||
* v2026.5.20 : calcule la safe area pour les popups épinglés.
|
||
* Retourne {top, bottom, left, right} en coords viewport.
|
||
* - top : hauteur de la topbar (les popups ne doivent pas passer dessous)
|
||
* - bottom : top du dock si visible, sinon hauteur viewport
|
||
*/
|
||
function _getPopupSafeArea() {
|
||
let topLimit = 4;
|
||
const topbar = document.querySelector("header.topbar");
|
||
if (topbar) {
|
||
const r = topbar.getBoundingClientRect();
|
||
if (r.bottom > topLimit) topLimit = r.bottom + 4;
|
||
}
|
||
let bottomLimit = window.innerHeight - 4;
|
||
const dock = document.getElementById("pinned-popups-dock");
|
||
if (dock && dock.classList.contains("visible")) {
|
||
const r = dock.getBoundingClientRect();
|
||
if (r.top < bottomLimit) bottomLimit = r.top - 4;
|
||
}
|
||
return { top: topLimit, bottom: bottomLimit, left: 4, right: window.innerWidth - 4 };
|
||
}
|
||
|
||
/**
|
||
* v2026.5.20 : contraint un popup flottant (en coords document via style.left/top)
|
||
* dans la safe area. Appelé à l'épinglage, pendant le drag, et quand le dock
|
||
* apparaît/disparaît.
|
||
*/
|
||
function _clampPopupInSafeArea(popup) {
|
||
if (!popup) return;
|
||
if (popup.classList.contains("pinned-popup-reduced")) return; // pas clamp si docké
|
||
const safe = _getPopupSafeArea();
|
||
const rect = popup.getBoundingClientRect();
|
||
const w = rect.width || popup.offsetWidth || 280;
|
||
const h = rect.height || popup.offsetHeight || 200;
|
||
|
||
// Les coords viewport actuelles
|
||
const vLeft = rect.left;
|
||
const vTop = rect.top;
|
||
|
||
// Calcul des coords viewport cibles après clamp
|
||
// v2026.5.28 : si le popup est plus large que la zone dispo (fenêtre rétrécie
|
||
// depuis le côté droit), on le garde à sa position actuelle —
|
||
// l'user redimensionnera ou bougera manuellement. On préfère
|
||
// "déborder" à droite plutôt que rétrécir le popup.
|
||
let newVLeft = vLeft;
|
||
let newVTop = vTop;
|
||
const safeWidth = safe.right - safe.left;
|
||
const safeHeight = safe.bottom - safe.top;
|
||
if (w <= safeWidth) {
|
||
// Popup rentre : on clamp normalement
|
||
if (newVLeft < safe.left) newVLeft = safe.left;
|
||
if (newVLeft + w > safe.right) newVLeft = safe.right - w;
|
||
if (newVLeft < safe.left) newVLeft = safe.left;
|
||
}
|
||
// Sinon : popup plus large que la zone → on laisse où il est, user libre
|
||
if (h <= safeHeight) {
|
||
if (newVTop < safe.top) newVTop = safe.top;
|
||
if (newVTop + h > safe.bottom) newVTop = safe.bottom - h;
|
||
if (newVTop < safe.top) newVTop = safe.top;
|
||
}
|
||
|
||
if (newVLeft === vLeft && newVTop === vTop) return; // rien à faire
|
||
|
||
// Différence = appliquer au style.left / style.top (qui sont en document coords)
|
||
const dx = newVLeft - vLeft;
|
||
const dy = newVTop - vTop;
|
||
const curLeft = parseFloat(popup.style.left) || 0;
|
||
const curTop = parseFloat(popup.style.top) || 0;
|
||
popup.style.left = (curLeft + dx) + "px";
|
||
popup.style.top = (curTop + dy) + "px";
|
||
}
|
||
|
||
/**
|
||
* Réclampe tous les popups flottants (utile après apparition/disparition du dock).
|
||
*/
|
||
function _reclampAllFloatingPopups() {
|
||
document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced)").forEach(p => {
|
||
_clampPopupInSafeArea(p);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* v2026.5.23 : drag & drop d'une pastille du dock.
|
||
* - Drag dans le dock : réordonne les pastilles (drop = insérer à la nouvelle
|
||
* position). Les autres se décalent en live.
|
||
* - Drop HORS du dock : restaure le popup en mode Normal à la position de la
|
||
* souris.
|
||
* Semi-transparent pendant le drag.
|
||
*/
|
||
function _attachPillDragHandler(pill, popup) {
|
||
const DRAG_THRESHOLD = 4; // px avant de considérer un vrai drag
|
||
let isDown = false;
|
||
let isDragging = false;
|
||
let startX = 0, startY = 0;
|
||
let ghostEl = null;
|
||
let pillOriginalNext = null; // voisin d'origine pour restaurer l'ordre
|
||
|
||
const onMouseMove = (e) => {
|
||
if (!isDown) return;
|
||
const dx = e.clientX - startX;
|
||
const dy = e.clientY - startY;
|
||
if (!isDragging) {
|
||
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
|
||
// Début du drag
|
||
isDragging = true;
|
||
pill._dragging = true;
|
||
pill._justDragged = true;
|
||
_hidePillHoverMenu();
|
||
pill.classList.add("pill-dragging");
|
||
|
||
// Créer un "ghost" flottant qui suit la souris
|
||
const r = pill.getBoundingClientRect();
|
||
ghostEl = pill.cloneNode(true);
|
||
ghostEl.style.position = "fixed";
|
||
ghostEl.style.left = r.left + "px";
|
||
ghostEl.style.top = r.top + "px";
|
||
ghostEl.style.width = r.width + "px";
|
||
ghostEl.style.height = r.height + "px";
|
||
ghostEl.style.zIndex = "1100";
|
||
ghostEl.style.opacity = "0.85";
|
||
ghostEl.style.pointerEvents = "none";
|
||
ghostEl.classList.add("pill-dragging-ghost");
|
||
document.body.appendChild(ghostEl);
|
||
|
||
// Mémoriser le voisin d'origine pour restaurer si drop hors dock
|
||
pillOriginalNext = pill.nextSibling;
|
||
}
|
||
// Déplacer le ghost avec la souris
|
||
if (ghostEl) {
|
||
ghostEl.style.left = (e.clientX - ghostEl.offsetWidth / 2) + "px";
|
||
ghostEl.style.top = (e.clientY - ghostEl.offsetHeight / 2) + "px";
|
||
}
|
||
|
||
// Détecter si on est au-dessus du dock
|
||
const dock = document.getElementById("pinned-popups-dock");
|
||
if (!dock) return;
|
||
const dockRect = dock.getBoundingClientRect();
|
||
const insideDock = (
|
||
e.clientX >= dockRect.left && e.clientX <= dockRect.right &&
|
||
e.clientY >= dockRect.top && e.clientY <= dockRect.bottom
|
||
);
|
||
|
||
if (insideDock) {
|
||
// Trouver où insérer parmi les autres pastilles
|
||
const pills = Array.from(dock.querySelectorAll(".pinned-popup-dock-pill"))
|
||
.filter(p => p !== pill);
|
||
let inserted = false;
|
||
for (const other of pills) {
|
||
const or = other.getBoundingClientRect();
|
||
const midX = or.left + or.width / 2;
|
||
if (e.clientX < midX) {
|
||
dock.insertBefore(pill, other);
|
||
inserted = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!inserted) {
|
||
// Insérer juste avant le bouton "Fermer tous" s'il existe, sinon en fin
|
||
const closeAllBtn = document.getElementById("pinned-popups-close-all");
|
||
if (closeAllBtn) {
|
||
dock.insertBefore(pill, closeAllBtn);
|
||
} else {
|
||
dock.appendChild(pill);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const onMouseUp = (e) => {
|
||
if (!isDown) return;
|
||
isDown = false;
|
||
document.removeEventListener("mousemove", onMouseMove);
|
||
document.removeEventListener("mouseup", onMouseUp);
|
||
|
||
if (!isDragging) return; // juste un clic simple, pas un drag
|
||
|
||
isDragging = false;
|
||
setTimeout(() => { pill._dragging = false; }, 10);
|
||
pill.classList.remove("pill-dragging");
|
||
if (ghostEl) {
|
||
try { ghostEl.remove(); } catch (err) {}
|
||
ghostEl = null;
|
||
}
|
||
|
||
// Vérifier si drop dans le dock ou hors
|
||
const dock = document.getElementById("pinned-popups-dock");
|
||
let insideDock = false;
|
||
if (dock) {
|
||
const dockRect = dock.getBoundingClientRect();
|
||
insideDock = (
|
||
e.clientX >= dockRect.left && e.clientX <= dockRect.right &&
|
||
e.clientY >= dockRect.top && e.clientY <= dockRect.bottom
|
||
);
|
||
}
|
||
|
||
if (insideDock) {
|
||
// Drop dans le dock = réordonnage déjà fait pendant onMouseMove. OK.
|
||
return;
|
||
}
|
||
|
||
// Drop HORS du dock : restaurer le popup en mode Normal à la position souris
|
||
// Le popup est actuellement en état "réduit" — on le réaffiche.
|
||
popup.classList.remove("pinned-popup-reduced");
|
||
popup.classList.remove("pinned-popup-minimized");
|
||
// Positionner à la souris (coords document)
|
||
const popupW = popup.offsetWidth || 320;
|
||
const popupH = popup.offsetHeight || 200;
|
||
const docX = _viewportToDocumentX(e.clientX - popupW / 2);
|
||
const docY = _viewportToDocumentY(e.clientY - 20);
|
||
popup.style.left = docX + "px";
|
||
popup.style.top = docY + "px";
|
||
// Clamper dans la safe area
|
||
_clampPopupInSafeArea(popup);
|
||
|
||
// Retirer la pastille et nettoyer le dock si vide
|
||
try { pill.remove(); } catch (err) {}
|
||
popup._linkedPill = null;
|
||
if (dock && dock.querySelectorAll(".pinned-popup-dock-pill").length === 0) {
|
||
dock.classList.remove("visible");
|
||
const closeAllBtn = document.getElementById("pinned-popups-close-all");
|
||
if (closeAllBtn) closeAllBtn.remove();
|
||
} else {
|
||
_ensureDockCloseAllBtn();
|
||
}
|
||
// Restaurer le bouton Minimiser (si c'était en mode minimisé avant réduction)
|
||
const minBtn = popup.querySelector(".pinned-popup-minimize");
|
||
if (minBtn) {
|
||
minBtn.innerHTML = "▭";
|
||
minBtn.title = "Minimiser (reste flottant mais compact)";
|
||
const newBtn = minBtn.cloneNode(true);
|
||
minBtn.replaceWith(newBtn);
|
||
newBtn.addEventListener("click", (ev) => {
|
||
ev.stopPropagation();
|
||
_minimizePinnedPopup(popup);
|
||
});
|
||
}
|
||
|
||
// v2026.5.23 : mettre à jour le rect dans pinnedPopups pour que l'anti-
|
||
// chevauchement tienne compte de la nouvelle position
|
||
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 };
|
||
}
|
||
};
|
||
|
||
pill.addEventListener("mousedown", (e) => {
|
||
if (e.button !== 0) return;
|
||
isDown = true;
|
||
isDragging = false;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
document.addEventListener("mousemove", onMouseMove);
|
||
document.addEventListener("mouseup", onMouseUp);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* v2026.5.19 : réduit TOUS les popups épinglés actuellement ouverts (en mode
|
||
* normal ou minimisé) dans la taskbar du bas. Appelé au changement de date.
|
||
*/
|
||
function _reduceAllPinnedPopups() {
|
||
const popups = document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced)");
|
||
popups.forEach(popup => {
|
||
try { _reducePinnedPopup(popup); } catch (e) {}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* v2026.5.19 : ISO date (YYYY-MM-DD) → format court "DD.MM" pour le dock.
|
||
*/
|
||
function _formatDateShort(iso) {
|
||
if (!iso) return "";
|
||
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (!m) return iso;
|
||
return `${m[3]}.${m[2]}`;
|
||
}
|
||
|
||
/**
|
||
* v2026.5.25 : formatte une date ISO en "Mardi 22.04" pour la pastille dock.
|
||
*/
|
||
function _formatDateForPill(iso) {
|
||
if (!iso) return "";
|
||
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (!m) return iso;
|
||
try {
|
||
const d = new Date(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3]));
|
||
const dayName = (typeof DAY_NAMES_FULL !== "undefined" && DAY_NAMES_FULL[d.getDay()])
|
||
? DAY_NAMES_FULL[d.getDay()]
|
||
: "";
|
||
return (dayName ? dayName + " " : "") + m[3] + "." + m[2];
|
||
} catch (e) {
|
||
return m[3] + "." + m[2];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* v2026.5.18 : ajoute (ou met à jour) le bouton "Fermer tous" dans le dock
|
||
* quand au moins 2 popups épinglés existent (réduits OU affichés).
|
||
* Le bouton est placé à droite du dock.
|
||
*/
|
||
function _ensureDockCloseAllBtn() {
|
||
const dock = document.getElementById("pinned-popups-dock");
|
||
if (!dock) return;
|
||
const allPinned = document.querySelectorAll(".pinned-popup");
|
||
let closeAllBtn = document.getElementById("pinned-popups-close-all");
|
||
if (allPinned.length >= 2) {
|
||
if (!closeAllBtn) {
|
||
closeAllBtn = document.createElement("button");
|
||
closeAllBtn.type = "button";
|
||
closeAllBtn.id = "pinned-popups-close-all";
|
||
closeAllBtn.className = "pinned-popups-close-all";
|
||
closeAllBtn.textContent = "✕ Fermer tous";
|
||
closeAllBtn.title = "Fermer tous les popups épinglés";
|
||
closeAllBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
closeAllPinnedPopups();
|
||
});
|
||
dock.appendChild(closeAllBtn);
|
||
} else {
|
||
// Remettre à la fin (après les pastilles éventuellement ajoutées)
|
||
dock.appendChild(closeAllBtn);
|
||
}
|
||
dock.classList.add("visible");
|
||
} else if (closeAllBtn) {
|
||
closeAllBtn.remove();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ramène un popup réduit en mode Normal : retire la pastille du dock et
|
||
* réaffiche le popup flottant à sa position d'avant réduction.
|
||
*/
|
||
function _restorePinnedPopupFromDock(popup) {
|
||
if (!popup) return;
|
||
popup.classList.remove("pinned-popup-reduced");
|
||
// Si le popup était minimisé avant d'être réduit, on l'agrandit direct
|
||
// (la demande était : "Si la reduit et rappeller s'affiche en grand direct")
|
||
popup.classList.remove("pinned-popup-minimized");
|
||
const minBtn = popup.querySelector(".pinned-popup-minimize");
|
||
if (minBtn) {
|
||
minBtn.innerHTML = "▭";
|
||
minBtn.title = "Minimiser (reste flottant mais compact)";
|
||
const newBtn = minBtn.cloneNode(true);
|
||
minBtn.replaceWith(newBtn);
|
||
newBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
_minimizePinnedPopup(popup);
|
||
});
|
||
}
|
||
|
||
// Supprimer la pastille associée
|
||
if (popup._linkedPill) {
|
||
popup._linkedPill.remove();
|
||
popup._linkedPill = null;
|
||
}
|
||
|
||
// Si le dock est vide (sauf le bouton "Fermer tous"), le masquer
|
||
const dock = document.getElementById("pinned-popups-dock");
|
||
if (dock) {
|
||
const remainingPills = dock.querySelectorAll(".pinned-popup-dock-pill").length;
|
||
if (remainingPills === 0) {
|
||
dock.classList.remove("visible");
|
||
const closeAllBtn = document.getElementById("pinned-popups-close-all");
|
||
if (closeAllBtn) closeAllBtn.remove();
|
||
} else {
|
||
_ensureDockCloseAllBtn();
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Ferme toutes les popups épinglées (appelé par Échap ou changement de date). */
|
||
/**
|
||
* v5.0.1 : helper pour déclencher la suppression d'une absence ou réservation.
|
||
* Affiche la modal de confirmation, puis appelle le background.
|
||
*/
|
||
function _triggerDeleteItem(actionId, kind, triggerBtn) {
|
||
if (!actionId) return;
|
||
const label = kind === "reservation" ? "cette réservation" : "cette absence";
|
||
showAlertModal({
|
||
title: "Confirmer la suppression",
|
||
message: `Voulez-vous vraiment supprimer ${label} ? Cette action est irréversible.`,
|
||
buttons: [
|
||
{ label: "Annuler", variant: "secondary", action: () => {} },
|
||
{
|
||
label: "Supprimer",
|
||
variant: "danger",
|
||
action: async () => {
|
||
if (triggerBtn) {
|
||
triggerBtn.disabled = true;
|
||
triggerBtn.textContent = "Suppression…";
|
||
}
|
||
try {
|
||
const resp = await sendMessage({
|
||
type: "deletePlanningItem",
|
||
actionId: actionId,
|
||
kind: kind
|
||
});
|
||
if (!resp || !resp.ok) {
|
||
throw new Error(resp && resp.error ? resp.error : "erreur inconnue");
|
||
}
|
||
showToast("Supprimé", "L'élément a été retiré du planning.");
|
||
unpinTooltip();
|
||
closeAllPinnedPopups();
|
||
if (state.session) {
|
||
await loadForDate(state.currentDate, { forceRefetch: true });
|
||
}
|
||
} catch (err) {
|
||
showAlertModal({
|
||
title: "Erreur lors de la suppression",
|
||
message: "Impossible de supprimer : " + (err.message || err),
|
||
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
|
||
});
|
||
if (triggerBtn) {
|
||
triggerBtn.disabled = false;
|
||
triggerBtn.textContent = "🗑 Supprimer l'absence";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
function closeAllPinnedPopups() {
|
||
for (const p of pinnedPopups.slice()) {
|
||
p.el.remove();
|
||
}
|
||
pinnedPopups.length = 0;
|
||
// Fermer aussi les popups en état soft-unpinned qui trainent encore
|
||
document.querySelectorAll(".pinned-popup.soft-unpinned").forEach(el => el.remove());
|
||
// v2026.5.18 : supprimer aussi les éléments du dock
|
||
document.querySelectorAll(".pinned-popup").forEach(el => el.remove());
|
||
document.querySelectorAll(".pinned-popup-dock-pill").forEach(el => el.remove());
|
||
const closeAllBtn = document.getElementById("pinned-popups-close-all");
|
||
if (closeAllBtn) closeAllBtn.remove();
|
||
const dock = document.getElementById("pinned-popups-dock");
|
||
if (dock) dock.classList.remove("visible");
|
||
}
|
||
|
||
/**
|
||
* v4.3.3 : permet de déplacer une popup épinglée à la souris via sa barre
|
||
* de drag. Met à jour les coords document (position absolute) et le rect
|
||
* mémorisé dans pinnedPopups pour que les nouvelles popups évitent bien
|
||
* la nouvelle position.
|
||
*/
|
||
function _attachPopupDragHandler(popup, dragbar) {
|
||
let dragging = false;
|
||
let startMouseX = 0, startMouseY = 0;
|
||
let startLeft = 0, startTop = 0;
|
||
|
||
const onMouseMove = (e) => {
|
||
if (!dragging) return;
|
||
const dx = e.clientX - startMouseX;
|
||
const dy = e.clientY - startMouseY;
|
||
let newLeft = startLeft + dx;
|
||
let newTop = startTop + dy;
|
||
|
||
// v2026.5.20 : clamper dans la safe area (topbar en haut, dock en bas,
|
||
// bordures viewport gauche/droite). On calcule en coords viewport puis
|
||
// on applique en coords document.
|
||
popup.style.left = newLeft + "px";
|
||
popup.style.top = newTop + "px";
|
||
_clampPopupInSafeArea(popup);
|
||
};
|
||
|
||
const onMouseUp = () => {
|
||
if (!dragging) return;
|
||
dragging = false;
|
||
popup.classList.remove("dragging");
|
||
document.removeEventListener("mousemove", onMouseMove);
|
||
document.removeEventListener("mouseup", onMouseUp);
|
||
// v2026.5.19 : retirer la classe body et le flag global après un petit
|
||
// délai pour laisser le temps au mouseleave de la carte de se propager
|
||
// sans déclencher de tooltip parasite.
|
||
document.body.classList.remove("popup-dragging");
|
||
setTimeout(() => { state._popupDragging = false; }, 50);
|
||
|
||
// Mettre à jour le rect mémorisé pour la détection de chevauchement
|
||
const entry = pinnedPopups.find(p => p.el === popup);
|
||
if (entry) {
|
||
const l = parseFloat(popup.style.left) || 0;
|
||
const t = parseFloat(popup.style.top) || 0;
|
||
const w = popup.offsetWidth;
|
||
const h = popup.offsetHeight;
|
||
entry.rect = { left: l, top: t, right: l + w, bottom: t + h };
|
||
}
|
||
};
|
||
|
||
dragbar.addEventListener("mousedown", (e) => {
|
||
// Seulement bouton gauche
|
||
if (e.button !== 0) return;
|
||
e.preventDefault();
|
||
dragging = true;
|
||
startMouseX = e.clientX;
|
||
startMouseY = e.clientY;
|
||
startLeft = parseFloat(popup.style.left) || 0;
|
||
startTop = parseFloat(popup.style.top) || 0;
|
||
popup.classList.add("dragging");
|
||
// v2026.5.19 : flag global pour que showTooltip ignore les mouseenter
|
||
// pendant le drag. Ajout d'une classe sur <body> qui désactive les
|
||
// pointer-events sur les cartes.
|
||
state._popupDragging = true;
|
||
document.body.classList.add("popup-dragging");
|
||
document.addEventListener("mousemove", onMouseMove);
|
||
document.addEventListener("mouseup", onMouseUp);
|
||
});
|
||
}
|
||
|
||
// v4.1.14/19 : recharger UNIQUEMENT cette intervention. Fetch DIRECT sans
|
||
// passer par isRefreshAborted (pour ne pas être bloqué par un abort global
|
||
// ou un refresh précédent). Animation sur le bouton ↻ de la bulle.
|
||
async function reloadSingleIntervention(iv, btnEl) {
|
||
if (!iv || iv.type === "AL-Reservation") return;
|
||
// Empêcher double-clic en cours
|
||
if (iv._reloading) return;
|
||
iv._reloading = true;
|
||
|
||
// v4.1.19 : NE PAS reset les champs AVANT le fetch (sinon si le fetch
|
||
// échoue ou est interrompu, on perd les données précédentes). On les
|
||
// mettra à jour uniquement si le fetch réussit.
|
||
const previousState = {
|
||
xhr2Fetched: iv.xhr2Fetched,
|
||
ficheFetched: iv.ficheFetched,
|
||
ficheActionText: iv.ficheActionText,
|
||
ficheFetchError: iv.ficheFetchError,
|
||
bulleDescription: iv.bulleDescription,
|
||
infobulle: iv.infobulle,
|
||
status: iv.status,
|
||
label: iv.label,
|
||
ficheChecksum: iv.ficheChecksum,
|
||
ficheTarget: iv.ficheTarget,
|
||
formSenderGuid: iv.formSenderGuid
|
||
};
|
||
|
||
// Marquer le bouton ↻ comme en cours (visuel immédiat)
|
||
if (btnEl) btnEl.classList.add("spinning");
|
||
// v4.1.19 : toast de feedback en bas à droite
|
||
showToast("Rafraîchissement", iv.ref || iv.actionId);
|
||
|
||
try {
|
||
// ─── xhr2 (rapide) ─────────────────────────────────────────────────
|
||
try {
|
||
const xhr2Resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId });
|
||
if (xhr2Resp && xhr2Resp.ok) {
|
||
const parsed = parseXhr2Body(xhr2Resp.body);
|
||
if (parsed) {
|
||
if (parsed.description) {
|
||
iv.bulleDescription = parsed.description;
|
||
const infob = parseActionText(parsed.description);
|
||
if (infob) iv.infobulle = infob;
|
||
}
|
||
if (parsed.label) iv.label = parsed.label;
|
||
iv.xhr2Fetched = true;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn("[reloadSingle/xhr2] iv", iv.actionId, err);
|
||
}
|
||
|
||
// ─── fiche HTML ────────────────────────────────────────────────────
|
||
const ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
|
||
if (ficheResp.ok) {
|
||
const fiche = parseFicheHtml(ficheResp.html);
|
||
iv.status = fiche.status;
|
||
if (fiche.rfc && !iv.ref) iv.ref = fiche.rfc;
|
||
if (fiche.formSenderGuid) iv.formSenderGuid = fiche.formSenderGuid;
|
||
|
||
// ─── timeline API : texte complet ──────────────────────────────
|
||
if (fiche.formId && fiche.formChecksum && fiche.formSenderGuid && iv.actionId) {
|
||
try {
|
||
const tlResp = await sendMessage({
|
||
type: "fetchTimelineApi",
|
||
guid: fiche.formSenderGuid,
|
||
formId: fiche.formId,
|
||
formChecksum: fiche.formChecksum
|
||
});
|
||
if (tlResp && tlResp.ok) {
|
||
const fullText = parseTimelineJsonForAction(tlResp.body, iv.actionId);
|
||
if (fullText) iv.ficheActionText = fullText;
|
||
}
|
||
} catch (err) {
|
||
console.warn("[reloadSingle/timeline] iv", iv.actionId, err);
|
||
}
|
||
}
|
||
|
||
// ─── Extraire checksum pour ouverture ───────────────────────────
|
||
if (iv.requestId && !iv.ficheChecksum) {
|
||
const rx1 = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`);
|
||
const m1 = ficheResp.html.match(rx1);
|
||
if (m1) {
|
||
iv.ficheTarget = iv.requestId;
|
||
iv.ficheChecksum = m1[1];
|
||
}
|
||
}
|
||
iv.ficheFetched = true;
|
||
iv.ficheFetchError = null;
|
||
} else {
|
||
iv.ficheFetchError = ficheResp.error || "fetch_failed";
|
||
if (ficheResp.error === "session_expired") {
|
||
state.session = null;
|
||
showSessionExpiredBanner();
|
||
}
|
||
}
|
||
|
||
// Mettre à jour la carte (statut clos → ✓ vert, catégorie, etc.)
|
||
updateInterventionRow(iv);
|
||
|
||
// Si la bulle est toujours ouverte sur cette iv, régénérer son HTML
|
||
const tip = tooltipEl();
|
||
if (tip.classList.contains("visible") && state.currentTooltipIv === iv) {
|
||
tip.innerHTML = buildTooltipHTML(iv);
|
||
}
|
||
|
||
// Sauvegarder le cache
|
||
try {
|
||
const cached = await readCache(state.currentDate);
|
||
if (cached && cached.techs) {
|
||
for (const tech of cached.techs) {
|
||
for (let i = 0; i < (tech.interventions || []).length; i++) {
|
||
if (tech.interventions[i].actionId === iv.actionId) {
|
||
tech.interventions[i] = iv;
|
||
}
|
||
}
|
||
}
|
||
await writeCache(state.currentDate, { techs: cached.techs });
|
||
}
|
||
} catch (err) {
|
||
console.warn("[reloadSingle/cache]", err);
|
||
}
|
||
|
||
// v4.1.19 : toast de succès
|
||
showToast("Mis à jour", iv.ref || iv.actionId);
|
||
} catch (err) {
|
||
console.warn("[reloadSingle] erreur iv", iv.actionId, err);
|
||
// Restaurer l'état précédent en cas d'erreur globale
|
||
Object.assign(iv, previousState);
|
||
} finally {
|
||
iv._reloading = false;
|
||
if (btnEl) btnEl.classList.remove("spinning");
|
||
}
|
||
}
|
||
function unpinTooltip() {
|
||
bulleState.pinned = false;
|
||
const el = tooltipEl();
|
||
el.classList.remove("pinned");
|
||
// v4.1.13 : test immédiat si la souris est toujours dans la bulle ou sur
|
||
// la ligne. Si ni l'un ni l'autre, on ferme tout de suite (sans timer).
|
||
if (!bulleState.hoveredInBulle && !bulleState.hoveredInRow) {
|
||
el.classList.remove("visible");
|
||
el.classList.add("hidden");
|
||
if (el.dataset) delete el.dataset.mode;
|
||
state.currentTooltipIv = null;
|
||
currentTooltipPos = null;
|
||
tooltipPositionMode = null;
|
||
if (bulleState.hideTimer) {
|
||
clearTimeout(bulleState.hideTimer);
|
||
bulleState.hideTimer = null;
|
||
}
|
||
}
|
||
// Sinon : la bulle reste visible, et c'est le mouseleave qui la fermera
|
||
// normalement quand la souris sortira.
|
||
}
|
||
|
||
// v4.1.10 : interactions bulle (double-Ctrl pour pin/unpin, hover dans la
|
||
// bulle pour persistance, clic hors pour unpin).
|
||
function bindTooltipInteractions() {
|
||
const el = tooltipEl();
|
||
if (!el) return;
|
||
|
||
// v2026.5.27 : quand la souris entre dans un popup épinglé, fermer tout
|
||
// popup non-épinglé (tooltip live ou soft-unpinned) pour garder l'écran clair.
|
||
document.addEventListener("mouseover", (e) => {
|
||
const target = e.target;
|
||
if (!target || !target.closest) return;
|
||
const pinned = target.closest(".pinned-popup");
|
||
if (!pinned) return;
|
||
// On survole un popup épinglé → fermer tooltip live s'il n'est pas pinned
|
||
if (!bulleState.pinned) {
|
||
const tip = tooltipEl();
|
||
if (tip && tip.classList.contains("visible")) {
|
||
tip.classList.remove("visible");
|
||
tip.classList.add("hidden");
|
||
if (tip.dataset) delete tip.dataset.mode;
|
||
state.currentTooltipIv = null;
|
||
currentTooltipPos = null;
|
||
tooltipPositionMode = null;
|
||
}
|
||
}
|
||
// Fermer les soft-unpinned qui traînent
|
||
document.querySelectorAll(".soft-unpinned").forEach(el => {
|
||
try { el.remove(); } catch (err) {}
|
||
});
|
||
});
|
||
|
||
// v4.1.17 : ré-applique la position au scroll de la page (safety net
|
||
// contre un ancêtre qui casserait position:fixed silencieusement).
|
||
window.addEventListener("scroll", reapplyTooltipPosition, { passive: true });
|
||
window.addEventListener("resize", () => {
|
||
// Au resize, on laisse fermer la bulle (position probablement invalidée)
|
||
if (bulleState.pinned) return;
|
||
hideTooltip({ force: true });
|
||
});
|
||
|
||
// v4.1.17 : bloquer le scroll de la page quand la souris est DANS la
|
||
// bulle. Le scroll interne de la bulle (overflow-y auto) reste OK.
|
||
// On utilise "wheel" non-passif pour pouvoir preventDefault.
|
||
el.addEventListener("wheel", (e) => {
|
||
// Si la bulle a un scroll interne et n'est pas à la limite, laisser
|
||
// le scroll naturel se faire. Sinon, bloquer le scroll global.
|
||
const canScrollDown = el.scrollTop + el.clientHeight < el.scrollHeight;
|
||
const canScrollUp = el.scrollTop > 0;
|
||
if ((e.deltaY > 0 && !canScrollDown) || (e.deltaY < 0 && !canScrollUp)) {
|
||
e.preventDefault();
|
||
}
|
||
// Ne pas laisser le scroll se propager au body
|
||
e.stopPropagation();
|
||
}, { passive: false });
|
||
|
||
// Hover sur la bulle elle-même : empêche la fermeture
|
||
el.addEventListener("mouseenter", () => {
|
||
bulleState.hoveredInBulle = true;
|
||
if (bulleState.hideTimer) {
|
||
clearTimeout(bulleState.hideTimer);
|
||
bulleState.hideTimer = null;
|
||
}
|
||
});
|
||
el.addEventListener("mouseleave", () => {
|
||
bulleState.hoveredInBulle = false;
|
||
if (!bulleState.hoveredInRow && !bulleState.pinned) {
|
||
hideTooltip();
|
||
}
|
||
});
|
||
|
||
// Double-Ctrl : v2026.5.21 — toggle sur l'intervention survolée
|
||
// - Si tooltip visible et iv pas encore épinglée pour cette date : épingle
|
||
// - Si tooltip visible et iv déjà épinglée pour cette date : désépingle
|
||
// - Sinon (pas de tooltip visible) : rien
|
||
// On détecte 2 keydown Control dans une fenêtre de 400 ms.
|
||
let lastCtrlTs = 0;
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key !== "Control") return;
|
||
if (e.repeat) return;
|
||
const now = performance.now();
|
||
if (now - lastCtrlTs < 400) {
|
||
lastCtrlTs = 0;
|
||
const iv = state.currentTooltipIv;
|
||
if (!iv) return;
|
||
const pinState = _getPinStateForIv(iv);
|
||
if (pinState.isPinned && pinState.popup) {
|
||
// Déjà épinglée : désépingle (ferme le popup correspondant)
|
||
const idx = pinnedPopups.findIndex(p => p.el === pinState.popup);
|
||
if (idx >= 0) pinnedPopups.splice(idx, 1);
|
||
// Fermer aussi la pastille dock si elle existe
|
||
if (pinState.popup._linkedPill) {
|
||
try { pinState.popup._linkedPill.remove(); } catch (e) {}
|
||
}
|
||
try { pinState.popup.remove(); } catch (e) {}
|
||
// Nettoyer dock si vide
|
||
const dock = document.getElementById("pinned-popups-dock");
|
||
if (dock && dock.querySelectorAll(".pinned-popup-dock-pill").length === 0) {
|
||
dock.classList.remove("visible");
|
||
const closeAllBtn = document.getElementById("pinned-popups-close-all");
|
||
if (closeAllBtn) closeAllBtn.remove();
|
||
} else {
|
||
_ensureDockCloseAllBtn();
|
||
}
|
||
// Mettre à jour le tooltip hover (📍 → 📌)
|
||
const tip = tooltipEl();
|
||
if (tip && tip.classList.contains("visible")) {
|
||
tip.innerHTML = buildTooltipHTML(iv);
|
||
}
|
||
} else {
|
||
// Pas encore épinglée : épingle
|
||
pinTooltip();
|
||
}
|
||
} else {
|
||
lastCtrlTs = now;
|
||
}
|
||
});
|
||
|
||
// v4.1.13 : clic sur le bouton 📌 ou ↻ (bouton d'action de la bulle)
|
||
el.addEventListener("click", (e) => {
|
||
const btn = e.target.closest('[data-action]');
|
||
if (!btn) return;
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
const action = btn.dataset.action;
|
||
if (action === "pin") {
|
||
// v2026.5.22 : clic sur 📌/📍 dans le tooltip hover = TOUJOURS réépingler
|
||
// à la position actuelle. Si un popup existe déjà pour cette iv+date,
|
||
// il est supprimé avant d'en créer un nouveau à côté de la carte survolée.
|
||
// (La suppression de l'ancien est faite dans pinTooltip() qui gère
|
||
// l'unicité actionId+date — v2026.5.21.)
|
||
// Pour désépingler : bouton 📍 dans la topbar du popup, double-Ctrl, ou Échap.
|
||
if (state.currentTooltipIv) {
|
||
pinTooltip();
|
||
}
|
||
} else if (action === "reload") {
|
||
// v4.1.14 : recharger uniquement l'intervention actuellement affichée
|
||
if (state.currentTooltipIv) {
|
||
reloadSingleIntervention(state.currentTooltipIv, btn);
|
||
}
|
||
} else if (action === "copy-ref") {
|
||
// v4.1.15 : copier la référence depuis la bulle
|
||
const ref = btn.dataset.ref;
|
||
if (ref) {
|
||
navigator.clipboard.writeText(ref).then(() => {
|
||
btn.classList.add("copied");
|
||
const original = btn.textContent;
|
||
btn.textContent = "✓";
|
||
setTimeout(() => {
|
||
btn.classList.remove("copied");
|
||
btn.textContent = original;
|
||
}, 1200);
|
||
}).catch(() => {});
|
||
}
|
||
} else if (action === "delete-item") {
|
||
// v5.0.0 : supprimer absence/réservation (depuis tooltip)
|
||
const actionId = btn.dataset.actionId;
|
||
const kind = btn.dataset.kind || "absence";
|
||
_triggerDeleteItem(actionId, kind, btn);
|
||
}
|
||
});
|
||
|
||
// Clic hors bulle : unpin si épinglé.
|
||
// Attention : ne pas déclencher sur clic DANS la bulle (elle contient du
|
||
// texte sélectionnable), ni sur clic sur une interventoin (qui ouvre la
|
||
// fiche — le user n'attend pas que la bulle reste épinglée dans ce cas
|
||
// mais le comportement "ouvrir la fiche" reste prioritaire).
|
||
document.addEventListener("mousedown", (e) => {
|
||
if (!bulleState.pinned) return;
|
||
// Clic dans la bulle → on laisse (sélection de texte)
|
||
if (el.contains(e.target)) return;
|
||
// Dans tous les autres cas (y compris clic sur une autre interventoin),
|
||
// on désépingle. Si c'était un clic sur intervention, le handler
|
||
// d'ouverture de la fiche s'exécutera ensuite normalement.
|
||
unpinTooltip();
|
||
});
|
||
}
|
||
|
||
function buildTooltipHTML(iv) {
|
||
if (!iv) return '<dl><dt>Info</dt><dd>—</dd></dl>';
|
||
const i = iv.infobulle || {};
|
||
const rows = [];
|
||
|
||
// Cas spécial : réservation (créneau bloqué par un coordinateur)
|
||
if (iv.type === "AL-Reservation") {
|
||
rows.push(`<dt>Type</dt><dd><span class="status-pill other" style="background:var(--c-reservation);color:#fff">Réservation</span></dd>`);
|
||
if (iv.startTime && iv.endTime) {
|
||
rows.push(row("Horaire", `${iv.startTime}–${iv.endTime}`));
|
||
}
|
||
if (iv.reservationLabel) rows.push(row("Sujet", iv.reservationLabel));
|
||
if (iv.reservationCreator) rows.push(row("Par", iv.reservationCreator));
|
||
// v5.0.0 : bouton supprimer pour les réservations (avec confirmation)
|
||
rows.push(`<dt></dt><dd><button type="button" class="tooltip-delete-btn" data-action="delete-item" data-action-id="${escapeHtml(iv.actionId || "")}" data-kind="reservation">🗑 Supprimer cette réservation</button></dd>`);
|
||
return `<dl>${rows.join("")}</dl>`;
|
||
}
|
||
|
||
// v5.0.0 : cas spécial absence (congé, maladie, formation, pompier, ...)
|
||
if (iv.type === "AL-Absence") {
|
||
const label = iv.label || "Absence";
|
||
rows.push(`<dt>Type</dt><dd><span class="status-pill other">${escapeHtml(label)}</span></dd>`);
|
||
if (iv.startTime && iv.endTime) {
|
||
rows.push(row("Horaire", `${iv.startTime}–${iv.endTime}`));
|
||
}
|
||
// Pour les absences récurrentes (Pillonel vendredi), pas d'actionId réel
|
||
// → pas de bouton supprimer. Pour les autres → oui.
|
||
if (iv.actionId) {
|
||
rows.push(`<dt></dt><dd><button type="button" class="tooltip-delete-btn" data-action="delete-item" data-action-id="${escapeHtml(iv.actionId)}" data-kind="absence">🗑 Supprimer cette absence</button></dd>`);
|
||
}
|
||
return `<dl>${rows.join("")}</dl>`;
|
||
}
|
||
|
||
// Statut en premier (si connu)
|
||
if (iv.status) {
|
||
let cls = "other";
|
||
if (isClosedStatus(iv.status)) cls = "closed";
|
||
else if (isResolvedStatus(iv.status)) cls = "resolved";
|
||
else if (/en cours|ex[ée]cution/i.test(iv.status)) cls = "ongoing";
|
||
rows.push(`<dt>Statut</dt><dd><span class="status-pill ${cls}">${escapeHtml(iv.status)}</span></dd>`);
|
||
}
|
||
|
||
if (iv.startTime && iv.endTime) {
|
||
rows.push(row("Horaire", `${iv.startTime}–${iv.endTime}`));
|
||
}
|
||
|
||
// ─── Texte d'action : fiche (complet) en priorité, sinon xhr2 (tronqué) ──
|
||
// v4.1.8 : un seul bloc "Action" qui s'enrichit automatiquement. Au début,
|
||
// le xhr2 tronqué s'affiche ; dès que le fetch timeline est revenu,
|
||
// iv.ficheActionText remplace le texte dans le même bloc.
|
||
const actionText = iv.ficheActionText ||
|
||
(iv.bulleDescription ? formatActionTextMultiline(iv.bulleDescription) : null);
|
||
if (actionText) {
|
||
const htmlAction = escapeHtml(actionText).replace(/\n/g, "<br>");
|
||
rows.push(`<dt>Action</dt><dd class="description">${htmlAction}</dd>`);
|
||
} else {
|
||
// Si pas de description (même pas de xhr2), afficher les infos structurées qu'on a
|
||
const hasAction = !!(i.date || i.heure || i.lieu || i.contact || i.service ||
|
||
i.probleme || i.aFaire || i.materiel);
|
||
if (i.date || i.heure) {
|
||
const dh = [i.date, i.heure].filter(Boolean).join(" · ");
|
||
if (dh) rows.push(row("Quand", dh));
|
||
}
|
||
const contact = i.contact || iv.bulleContact;
|
||
if (contact) rows.push(row("Contact", contact));
|
||
const lieu = i.lieu || iv.bulleLieu;
|
||
if (lieu) rows.push(row("Lieu", lieu));
|
||
if (i.service) rows.push(row("Service", i.service));
|
||
if (i.probleme) rows.push(row("Problème", i.probleme));
|
||
if (i.aFaire) rows.push(row("À faire", i.aFaire));
|
||
if (!hasAction && !contact && !lieu) {
|
||
if (iv.ficheFetched) {
|
||
rows.push(`<dt>Info</dt><dd style="color:var(--text-faint)">Aucun détail pour cette intervention.</dd>`);
|
||
} else {
|
||
rows.push(`<dt>Info</dt><dd style="color:var(--text-faint)">Chargement des détails…</dd>`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Deadline (si connue et différente)
|
||
if (iv.deadline && !i.date) rows.push(row("Deadline", iv.deadline));
|
||
|
||
if (iv.ref) {
|
||
rows.push(`<hr>`);
|
||
// v4.1.15 : ref avec bouton copier inline
|
||
const refSafe = escapeHtml(iv.ref);
|
||
rows.push(`<dt>Référence</dt><dd class="tt-ref-cell"><span class="tt-ref-val">${refSafe}</span><button class="tt-copy-btn" data-action="copy-ref" data-ref="${refSafe}" title="Copier la référence">📋</button></dd>`);
|
||
}
|
||
|
||
if (iv.ghost) {
|
||
rows.push(`<hr>`);
|
||
rows.push(`<dt>⚠</dt><dd>Intervention disparue d'EasyVista (clôturée, déplacée ou annulée)</dd>`);
|
||
} else if (iv.formLink) {
|
||
rows.push(`<hr>`);
|
||
rows.push(`<dt></dt><dd style="color:var(--text-faint);font-size:11px">Cliquer pour ouvrir la fiche</dd>`);
|
||
}
|
||
|
||
// v2026.5.21 : icône épingle adaptative
|
||
// 📌 (épingle couchée) = pas encore épinglée, clic pour épingler
|
||
// 📍 (épingle plantée, rouge) = déjà épinglée pour cette (ref + date)
|
||
// — clic pour désépingler. NB : le popup a sa propre topbar avec un
|
||
// bouton 📍 explicite ; celui-ci dans le tooltip hover sert surtout
|
||
// à montrer visuellement l'état à l'user au survol.
|
||
const _pinState = _getPinStateForIv(iv);
|
||
const _pinIcon = _pinState.isPinned ? "📍" : "📌";
|
||
const _pinTitle = _pinState.isPinned
|
||
? "Cette intervention est déjà épinglée pour ce jour. Cliquer pour désépingler."
|
||
: "Épingler la bulle (ou double-Ctrl). Cliquer à nouveau pour libérer.";
|
||
const _pinClass = _pinState.isPinned ? " tooltip-pinbtn-active" : "";
|
||
|
||
if (rows.length === 0) {
|
||
return `<div class="tooltip-actions">
|
||
<div class="tooltip-actionbtn" data-action="reload" title="Recharger uniquement cette intervention">
|
||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8a6 6 0 1 0 1.76-4.24M2 3v3h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||
</div>
|
||
<div class="tooltip-actionbtn tooltip-pinbtn${_pinClass}" data-action="pin" title="${_pinTitle}">${_pinIcon}</div>
|
||
</div><dl><dt>Info</dt><dd>Aucun détail disponible</dd></dl>`;
|
||
}
|
||
// v4.1.13/14 : boutons d'action en haut à droite (recharger + épingler)
|
||
return `<div class="tooltip-actions">
|
||
<div class="tooltip-actionbtn" data-action="reload" title="Recharger uniquement cette intervention">
|
||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8a6 6 0 1 0 1.76-4.24M2 3v3h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||
</div>
|
||
<div class="tooltip-actionbtn tooltip-pinbtn${_pinClass}" data-action="pin" title="${_pinTitle}">${_pinIcon}</div>
|
||
</div><dl>${rows.join("")}</dl>`;
|
||
}
|
||
|
||
/**
|
||
* v2026.5.21 : retourne l'état d'épinglage pour une intervention donnée
|
||
* (pour la date actuellement affichée). Utilisé pour afficher 📌 vs 📍
|
||
* dans le tooltip hover.
|
||
*/
|
||
function _getPinStateForIv(iv) {
|
||
if (!iv || !iv.actionId) return { isPinned: false };
|
||
const date = state.currentDate || "";
|
||
const key = iv.actionId + "|" + date;
|
||
for (const p of pinnedPopups) {
|
||
if (!p || !p.el) continue;
|
||
const aid = p.el.dataset.actionId || "";
|
||
const d = p.el.dataset.originDate || "";
|
||
if (aid + "|" + d === key) {
|
||
return { isPinned: true, popup: p.el };
|
||
}
|
||
}
|
||
return { isPinned: false };
|
||
}
|
||
|
||
/**
|
||
* Met en forme un texte d'action EasyVista en ajoutant des retours à la ligne
|
||
* avant chaque étiquette connue ("Date :", "Lieu :", "Contact :", etc.).
|
||
* Transforme :
|
||
* "Date : 20.04 Heure : MatinLieu : Ville1/Rue1 1 bisContact : Nom..."
|
||
* En :
|
||
* "Date : 20.04 Heure : Matin
|
||
* Lieu : Ville1/Rue1 1 bis
|
||
* Contact : Nom..."
|
||
*/
|
||
function formatActionTextMultiline(text) {
|
||
if (!text) return "";
|
||
const newlineLabels = [
|
||
"Lieu", "Contact",
|
||
"Service", "Étage", "Bureau",
|
||
"Nom utilisateur",
|
||
"Problème", "A faire", "À faire",
|
||
"Matériel", "Materiel",
|
||
"Bénéficiaire", "Beneficiaire"
|
||
];
|
||
let result = String(text);
|
||
for (const label of newlineLabels) {
|
||
const rx = new RegExp(`([^\\n])(${escapeRegex(label)}\\s*:\\s*)`, "g");
|
||
result = result.replace(rx, "$1\n$2");
|
||
}
|
||
// Isoler la signature planificateur finale ("ECM 16.04", "csh 27.03", etc.)
|
||
// qui se trouve typiquement en fin sans préfixe de label.
|
||
// On utilise un look-behind pour ne PAS manger la lettre précédente
|
||
// (et donc ne pas couper le "F" de "FRD 07/04").
|
||
result = result.replace(/(?<=[^\n])(\s*)([A-Za-z]{2,4}\s+\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)\s*$/, "\n$2");
|
||
// Nettoyer
|
||
result = result.replace(/\n{2,}/g, "\n").trim();
|
||
return result;
|
||
}
|
||
|
||
function escapeRegex(s) {
|
||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
function row(label, value) {
|
||
return `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function highlightIntervention(cardEl, ivIdx, on) {
|
||
// v2026.5.29 : chercher .intervention-v2 (nouveau nom) et fallback .intervention
|
||
const row = cardEl.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`)
|
||
|| 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);
|
||
|
||
// v2026.5.29 : quand on active le highlight (typiquement depuis un slot
|
||
// timeline), faire scroller la row dans la vue pour que l'user voie la
|
||
// carte correspondante sans avoir à chercher. On évite de scroller le
|
||
// body, on scroll juste la row à l'intérieur de la carte si elle déborde.
|
||
if (on && row && typeof row.scrollIntoView === "function") {
|
||
try {
|
||
row.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Helpers temps
|
||
// ============================================================================
|
||
|
||
function timeToMinutes(hhmm) {
|
||
if (!hhmm) return null;
|
||
const m = hhmm.match(/^(\d{1,2}):(\d{2})$/);
|
||
if (!m) return null;
|
||
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
|
||
}
|
||
|
||
function minutesToTime(mins) {
|
||
const h = Math.floor(mins / 60);
|
||
const m = mins % 60;
|
||
return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
|
||
}
|
||
|
||
// ============================================================================
|
||
// Écrans d'erreur
|
||
// ============================================================================
|
||
|
||
function showLoading() {
|
||
document.getElementById("loading").classList.remove("hidden");
|
||
document.getElementById("error-box").classList.add("hidden");
|
||
document.getElementById("session-needed").classList.add("hidden");
|
||
document.getElementById("stats").classList.add("hidden");
|
||
document.getElementById("cards").innerHTML = "";
|
||
}
|
||
|
||
function showError(msg) {
|
||
document.getElementById("loading").classList.add("hidden");
|
||
document.getElementById("stats").classList.add("hidden");
|
||
document.getElementById("session-needed").classList.add("hidden");
|
||
const evUnr = document.getElementById("ev-unreachable");
|
||
if (evUnr) evUnr.classList.add("hidden");
|
||
document.getElementById("cards").innerHTML = "";
|
||
const box = document.getElementById("error-box");
|
||
box.textContent = msg;
|
||
box.classList.remove("hidden");
|
||
}
|
||
|
||
function showSessionNeeded() {
|
||
document.getElementById("loading").classList.add("hidden");
|
||
document.getElementById("error-box").classList.add("hidden");
|
||
document.getElementById("stats").classList.add("hidden");
|
||
const evUnr = document.getElementById("ev-unreachable");
|
||
if (evUnr) evUnr.classList.add("hidden");
|
||
document.getElementById("cards").innerHTML = "";
|
||
document.getElementById("session-needed").classList.remove("hidden");
|
||
}
|
||
|
||
function hideSessionNeeded() {
|
||
document.getElementById("session-needed").classList.add("hidden");
|
||
}
|
||
|
||
// v4.2 : écran plein "EasyVista inaccessible" (différent de session expirée).
|
||
function showEvUnreachable() {
|
||
document.getElementById("loading").classList.add("hidden");
|
||
document.getElementById("error-box").classList.add("hidden");
|
||
document.getElementById("stats").classList.add("hidden");
|
||
document.getElementById("session-needed").classList.add("hidden");
|
||
document.getElementById("cards").innerHTML = "";
|
||
const el = document.getElementById("ev-unreachable");
|
||
if (el) el.classList.remove("hidden");
|
||
}
|
||
|
||
function hideEvUnreachable() {
|
||
const el = document.getElementById("ev-unreachable");
|
||
if (el) el.classList.add("hidden");
|
||
}
|
||
|
||
// v4.1.12 : bannière non bloquante "session expirée". Affichée quand le
|
||
// fetch détecte une session morte EN COURS DE ROUTE (pas au démarrage).
|
||
// L'utilisateur voit toujours les données déjà chargées, mais est prévenu
|
||
// que les mises à jour sont arrêtées.
|
||
function showSessionExpiredBanner() {
|
||
const b = document.getElementById("session-expired-banner");
|
||
if (b) {
|
||
b.classList.remove("hidden");
|
||
// v5.0.10 : rebrancher le bouton "Ouvrir EasyVista" natif pour qu'il
|
||
// appelle triggerReconnect() au lieu de juste ouvrir un onglet. Ça
|
||
// déclenche la reconnexion SSO ET l'auto-reload du viewer quand la
|
||
// nouvelle session est détectée.
|
||
// On renomme aussi le bouton pour être explicite.
|
||
const btn = b.querySelector("#session-banner-reconnect");
|
||
if (btn && !btn.dataset.boundReconnect) {
|
||
btn.dataset.boundReconnect = "1";
|
||
btn.textContent = "🔄 Me reconnecter";
|
||
// Retirer d'éventuels anciens listeners en clonant le bouton
|
||
const clone = btn.cloneNode(true);
|
||
btn.parentNode.replaceChild(clone, btn);
|
||
clone.addEventListener("click", () => triggerReconnect());
|
||
}
|
||
}
|
||
hideEvUnreachableBanner();
|
||
hideReconnectingBanner();
|
||
}
|
||
function hideSessionExpiredBanner() {
|
||
const b = document.getElementById("session-expired-banner");
|
||
if (b) b.classList.add("hidden");
|
||
}
|
||
|
||
// v5.0.9 : bannière affichée pendant la reconnexion (remplace la bannière
|
||
// expirée après clic sur "Me reconnecter")
|
||
// v5.0.11 : ajoute un bouton "Annuler" pour interrompre le processus.
|
||
function showReconnectingBanner() {
|
||
let b = document.getElementById("session-reconnecting-banner");
|
||
if (!b) {
|
||
b = document.createElement("div");
|
||
b.id = "session-reconnecting-banner";
|
||
b.className = "banner-reconnecting";
|
||
const topbar = document.querySelector(".topbar") || document.querySelector("header") || document.body;
|
||
if (topbar.nextSibling) {
|
||
topbar.parentNode.insertBefore(b, topbar.nextSibling);
|
||
} else {
|
||
document.body.insertBefore(b, document.body.firstChild);
|
||
}
|
||
}
|
||
b.innerHTML = `
|
||
<span class="banner-spinner">⏳</span>
|
||
<span class="banner-text">Reconnexion à EasyVista en cours… Connectez-vous dans l'onglet qui vient de s'ouvrir.</span>
|
||
<button type="button" class="banner-cancel-btn">Annuler</button>
|
||
`;
|
||
const cancelBtn = b.querySelector(".banner-cancel-btn");
|
||
if (cancelBtn) cancelBtn.addEventListener("click", () => cancelReconnect());
|
||
b.classList.remove("hidden");
|
||
hideSessionExpiredBanner();
|
||
hideReconnectFailedBanner();
|
||
}
|
||
function hideReconnectingBanner() {
|
||
const b = document.getElementById("session-reconnecting-banner");
|
||
if (b) b.classList.add("hidden");
|
||
}
|
||
|
||
// v5.0.11 : bannière "Reconnexion échouée" avec choix manuel du réseau
|
||
// (Bureau/Télétravail). Affichée après timeout 90s de reconnexion.
|
||
function showReconnectFailedBanner() {
|
||
let b = document.getElementById("session-reconnect-failed-banner");
|
||
if (!b) {
|
||
b = document.createElement("div");
|
||
b.id = "session-reconnect-failed-banner";
|
||
b.className = "banner-reconnect-failed";
|
||
const topbar = document.querySelector(".topbar") || document.querySelector("header") || document.body;
|
||
if (topbar.nextSibling) {
|
||
topbar.parentNode.insertBefore(b, topbar.nextSibling);
|
||
} else {
|
||
document.body.insertBefore(b, document.body.firstChild);
|
||
}
|
||
}
|
||
b.innerHTML = `
|
||
<span class="banner-icon">⚠</span>
|
||
<span class="banner-text">
|
||
<strong>Reconnexion échouée.</strong>
|
||
Indiquez où vous êtes pour réessayer :
|
||
</span>
|
||
<button type="button" class="banner-btn-primary" data-origin="https://itsma.etat-de-vaud.ch">🏢 Au bureau</button>
|
||
<button type="button" class="banner-btn-primary" data-origin="https://itsma.vd.ch">🏠 En télétravail</button>
|
||
<button type="button" class="banner-cancel-btn">Annuler</button>
|
||
`;
|
||
// Boutons de choix de réseau : retry avec origine forcée
|
||
b.querySelectorAll(".banner-btn-primary").forEach(btn => {
|
||
btn.addEventListener("click", () => {
|
||
const origin = btn.dataset.origin;
|
||
hideReconnectFailedBanner();
|
||
triggerReconnect(origin);
|
||
});
|
||
});
|
||
// Bouton Annuler : retour à la bannière "Session expirée" simple
|
||
const cancelBtn = b.querySelector(".banner-cancel-btn");
|
||
if (cancelBtn) cancelBtn.addEventListener("click", () => {
|
||
hideReconnectFailedBanner();
|
||
showSessionExpiredBanner();
|
||
});
|
||
b.classList.remove("hidden");
|
||
hideSessionExpiredBanner();
|
||
hideReconnectingBanner();
|
||
}
|
||
function hideReconnectFailedBanner() {
|
||
const b = document.getElementById("session-reconnect-failed-banner");
|
||
if (b) b.classList.add("hidden");
|
||
}
|
||
|
||
// v4.2.5 : bannière non bloquante "EasyVista inaccessible"
|
||
function showEvUnreachableBanner() {
|
||
const b = document.getElementById("ev-unreachable-banner");
|
||
if (b) b.classList.remove("hidden");
|
||
// On masque la bannière session expirée (1 seule bannière à la fois)
|
||
hideSessionExpiredBanner();
|
||
}
|
||
function hideEvUnreachableBanner() {
|
||
const b = document.getElementById("ev-unreachable-banner");
|
||
if (b) b.classList.add("hidden");
|
||
}
|