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