Files
Planification/viewer.js
T
FroSteel 3b1831a83a Version 1.0.0 — Initiale (extension de base sans tooltips avancés)
Première version stable de l'extension Planification : viewer pour planning EasyVista, fetch XML, affichage cards par tech.
2026-04-16 09:30:00 +02:00

682 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// viewer.js — Logique de la vue claire
//
// Étapes :
// 1. Lire le HTML capturé depuis chrome.storage.local
// 2. Parser les techniciens (emp_XXXXX dans le DOM)
// 3. Parser les événements (g_arr_player[N] dans le JS inline)
// 4. Calculer : pompier du jour, absents, interventions par tech
// 5. Afficher tout ça
// 6. Au survol / clic : charger la fiche détaillée via fetch()
// ==========================================================================
// Configuration
// ==========================================================================
// Règles fixes (techs avec horaires particuliers)
const RULES = {
// Pillonel, Olivier (ID 40944) est absent tous les vendredis
"40944": {
alwaysAbsentOn: [5], // 5 = vendredi (JS: 0=dim, 1=lun, ..., 5=ven, 6=sam)
reason: "Absent fixe le vendredi"
}
};
// Cache des fiches détaillées (persiste pour la session)
const detailsCache = new Map();
// Fetch en cours (pour éviter de lancer 2x la même requête)
const detailsPromises = new Map();
// ==========================================================================
// Init
// ==========================================================================
document.addEventListener("DOMContentLoaded", async () => {
document.getElementById("btn-refresh").addEventListener("click", refresh);
document.getElementById("btn-preload").addEventListener("click", preloadAll);
await loadFromStorage();
});
async function loadFromStorage() {
const data = await chrome.storage.local.get([
"planningHtml", "planningUrl", "planningCapturedAt", "planningError"
]);
if (data.planningError) {
showError(data.planningError);
return;
}
if (!data.planningHtml) {
showError(
"Aucune donnée de planning disponible. " +
"Va sur la page du planning des techniciens sur itsma.vd.ch puis clique sur l'icône de l'extension."
);
return;
}
try {
const parsed = parsePlanning(data.planningHtml, data.planningUrl);
render(parsed, data.planningCapturedAt);
} catch (err) {
console.error(err);
showError("Erreur lors du parsing du planning : " + err.message);
}
}
async function refresh() {
document.getElementById("subtitle").textContent = "Retour sur EasyVista pour actualiser…";
// Inviter l'utilisateur à revenir sur l'onglet EasyVista et re-cliquer sur l'icône
// (on ne peut pas re-capturer automatiquement depuis le viewer)
showError(
"Pour actualiser : va sur l'onglet EasyVista, recharge le planning (F5), " +
"puis clique à nouveau sur l'icône de l'extension."
);
}
// ==========================================================================
// Parsing
// ==========================================================================
function parsePlanning(html, sourceUrl) {
// Parser le HTML dans un DOM isolé pour pouvoir utiliser les sélecteurs CSS
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// --- 1. Extraire les techniciens depuis <div class="support_list" id="emp_XXXXX">
const techs = {}; // id -> { name, id }
for (const el of doc.querySelectorAll('div.support_list[id^="emp_"]')) {
const id = el.id.replace("emp_", "");
// Le nom est le texte direct du div (après le checkbox)
const rawText = el.textContent.trim();
// Nettoyer : enlever les \u00a0 (&nbsp;)
const name = rawText.replace(/\u00a0/g, " ").trim();
if (name && /^\d+$/.test(id)) {
techs[id] = { id, name };
}
}
// --- 2. Extraire les événements depuis le JS inline (g_arr_player[N])
// On parse les scripts
const events = {};
for (const script of doc.querySelectorAll("script")) {
const src = script.textContent;
if (!src.includes("g_arr_player")) continue;
// Pattern 1 : new action_player("event_id", "label", ...)
const pLabel = /g_arr_player\[(\d+)\]\s*=\s*new action_player\("(\d+)",\s*"([^"]*)"/g;
let m;
while ((m = pLabel.exec(src)) !== null) {
const idx = m[1];
events[idx] = events[idx] || {};
events[idx].eventId = m[2];
events[idx].label = decodeText(m[3]);
}
// Pattern 2 : assign_informations(tech_id, "title", "type", ...)
const pInfo = /g_arr_player\[(\d+)\]\.assign_informations\((\d+),\s*"([^"]*)",\s*"([^"]*)"/g;
while ((m = pInfo.exec(src)) !== null) {
const idx = m[1];
events[idx] = events[idx] || {};
events[idx].techId = m[2];
events[idx].title = decodeText(m[3]);
events[idx].type = m[4];
}
// Pattern 3 : assign_date_time_informations (plusieurs arguments)
const pTime = /g_arr_player\[(\d+)\]\.assign_date_time_informations\(([^)]+)\)/g;
while ((m = pTime.exec(src)) !== null) {
const idx = m[1];
const args = m[2];
const parts = [...args.matchAll(/"([^"]*)"/g)].map(x => x[1]);
if (parts.length >= 10) {
events[idx] = events[idx] || {};
events[idx].dateStart = parts[0];
events[idx].dateEnd = parts[4];
events[idx].timeStart = parts[8];
events[idx].timeEnd = parts[9];
}
}
}
// --- 3. Extraire les liens vers les fiches détaillées depuis les AffBulle
// Les <a href="..."> contenant target=XXXXXXXX donnent l'URL de la fiche
const eventLinks = {}; // eventId -> full URL
for (const a of doc.querySelectorAll('a[href*="target="]')) {
const href = a.getAttribute("href");
const m = /target=(\d+)/.exec(href);
if (m) {
// Construire l'URL absolue à partir de sourceUrl
try {
const absoluteUrl = new URL(href, sourceUrl || "https://itsma.vd.ch/").href;
eventLinks[m[1]] = absoluteUrl;
} catch (e) {
// ignore
}
}
}
// --- 4. Extraire les infobulles rapides pour chaque event
// Format : AffBulle(this, '...texte échappé...')
const eventBulles = {}; // eventId -> texte décodé
// Recherche dans le HTML brut pour les AffBulle (ils ne sont pas dans <script>)
const bulleRegex = /AffBulle\(this,\s*'([^']+)'/g;
let bm;
while ((bm = bulleRegex.exec(html)) !== null) {
const raw = bm[1];
// Décoder : &lt; &gt; &amp; &#xx; + \'XX hex (RTF-like)
let text = raw
.replace(/&lt;BR&gt;/gi, "\n")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
// Trouver l'eventId dans ce texte : il commence par "HH:MM RÉFÉRENCE"
// On associe la bulle à l'eventId du lien <a> le plus proche
// Stratégie plus simple : chercher une réf S... ou I... dans le texte
const refMatch = /([SI]\d{6}_\d{5})/.exec(text);
if (refMatch) {
eventBulles[refMatch[1]] = text;
}
}
// --- 5. Calculer le jour cible = date la plus fréquente dans les events
const dateCounts = {};
for (const e of Object.values(events)) {
if (e.dateStart) {
dateCounts[e.dateStart] = (dateCounts[e.dateStart] || 0) + 1;
}
}
let targetDate = null;
let maxCount = 0;
for (const [d, c] of Object.entries(dateCounts)) {
if (c > maxCount) { maxCount = c; targetDate = d; }
}
// Si on n'a rien, fallback sur aujourd'hui
if (!targetDate) {
const t = new Date();
targetDate = `${pad(t.getDate())}/${pad(t.getMonth()+1)}/${t.getFullYear()}`;
}
// --- 6. Filtrer les événements actifs ce jour
const targetDateObj = parseFrDate(targetDate);
const eventsToday = [];
for (const e of Object.values(events)) {
if (!e.techId || !e.dateStart) continue;
const d1 = parseFrDate(e.dateStart);
const d2 = parseFrDate(e.dateEnd || e.dateStart);
if (d1 && d2 && targetDateObj >= d1 && targetDateObj <= d2) {
// Associer la bulle si on a trouvé la réf correspondante
const refMatch = /([SI]\d{6}_\d{5})/.exec(e.label || "");
if (refMatch) {
e.ref = refMatch[1];
e.bulleText = eventBulles[refMatch[1]];
}
e.detailUrl = eventLinks[e.eventId];
eventsToday.push(e);
}
}
// --- 7. Détecter pompier et absents
let pompierId = null;
const absentIds = new Set();
for (const e of eventsToday) {
if (e.type !== "AL-Absence") continue;
const lbl = ((e.label || "") + " " + (e.title || "")).toLowerCase();
if (lbl.includes("pompier")) {
pompierId = e.techId;
} else {
absentIds.add(e.techId);
}
}
// Appliquer les règles fixes (Pillonel absent le vendredi)
const dayOfWeek = targetDateObj ? targetDateObj.getDay() : null;
const fixedAbsences = {}; // techId -> reason
for (const [tid, rule] of Object.entries(RULES)) {
if (rule.alwaysAbsentOn && dayOfWeek !== null && rule.alwaysAbsentOn.includes(dayOfWeek)) {
// On l'ajoute aux absents (si pas déjà là)
if (tid !== pompierId && techs[tid]) {
absentIds.add(tid);
fixedAbsences[tid] = rule.reason;
}
}
}
return {
techs,
eventsToday,
targetDate,
pompierId,
absentIds,
fixedAbsences
};
}
function decodeText(s) {
if (!s) return s;
// Décodage des séquences RTF \'XX en caractères latin-1
// (rare dans un DOM vivant, mais présent si le HTML vient d'un fichier RTF)
return s.replace(/\\'([0-9a-fA-F]{2})/g, (_, hex) => {
return String.fromCharCode(parseInt(hex, 16));
});
}
function parseFrDate(s) {
if (!s) return null;
const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(s);
if (!m) return null;
return new Date(parseInt(m[3]), parseInt(m[2])-1, parseInt(m[1]));
}
function pad(n) { return String(n).padStart(2, "0"); }
// ==========================================================================
// Rendu
// ==========================================================================
function render(data, capturedAt) {
const { techs, eventsToday, targetDate, pompierId, absentIds, fixedAbsences } = data;
// Topbar
document.getElementById("display-date").textContent = formatFrDate(targetDate);
const ago = capturedAt ? ` (capturé il y a ${formatAgo(Date.now() - capturedAt)})` : "";
document.getElementById("subtitle").textContent =
`${Object.keys(techs).length} techniciens · ${eventsToday.length} événements${ago}`;
// Summary cards
const interventions = eventsToday.filter(e => e.type === "AL-Intervention");
document.getElementById("summary").classList.remove("hidden");
document.getElementById("pompier-name").textContent =
pompierId ? techs[pompierId]?.name || "?" : "(aucun)";
document.getElementById("absents-list").innerHTML = [...absentIds]
.map(id => techs[id]?.name || id)
.map(n => `<div>${escape(n)}</div>`).join("") || '<span class="muted">(aucun)</span>';
document.getElementById("stats-interv").innerHTML =
`<span>${interventions.length}</span> <span class="muted">sur ${eventsToday.length} événements</span>`;
// Grid des techniciens
const main = document.getElementById("main-content");
main.innerHTML = "";
const grid = document.createElement("div");
grid.className = "tech-grid";
// Ordonner les techs : pompier d'abord, puis actifs par nb d'interv desc, puis absents
const sortedIds = Object.keys(techs).sort((a, b) => {
if (a === pompierId) return -1;
if (b === pompierId) return 1;
const aAbs = absentIds.has(a);
const bAbs = absentIds.has(b);
if (aAbs !== bAbs) return aAbs ? 1 : -1;
const aInt = eventsToday.filter(e => e.techId === a && e.type === "AL-Intervention").length;
const bInt = eventsToday.filter(e => e.techId === b && e.type === "AL-Intervention").length;
return bInt - aInt;
});
for (const tid of sortedIds) {
const tech = techs[tid];
const techEvents = eventsToday.filter(e => e.techId === tid);
const card = renderTechCard(tech, techEvents, tid === pompierId, absentIds.has(tid), fixedAbsences[tid]);
grid.appendChild(card);
}
main.appendChild(grid);
}
function renderTechCard(tech, events, isPompier, isAbsent, fixedAbsenceReason) {
const card = document.createElement("div");
card.className = "tech-card";
if (isPompier) card.classList.add("tech-pompier");
if (isAbsent && !isPompier) card.classList.add("tech-absent");
// Header
const header = document.createElement("div");
header.className = "tech-header";
const name = document.createElement("div");
name.className = "tech-name";
name.textContent = tech.name;
header.appendChild(name);
const badges = document.createElement("div");
if (isPompier) {
badges.innerHTML += '<span class="tech-badge badge-pompier">🚒 Pompier</span> ';
}
if (isAbsent && !isPompier) {
badges.innerHTML += '<span class="tech-badge badge-absent">❌ Absent</span> ';
}
const interv = events.filter(e => e.type === "AL-Intervention");
if (interv.length > 0) {
badges.innerHTML += `<span class="tech-badge badge-count">${interv.length} interv.</span>`;
}
header.appendChild(badges);
card.appendChild(header);
// Pompier info (bandeau)
if (isPompier) {
const pompierEvent = events.find(e =>
e.type === "AL-Absence" &&
(((e.label || "") + " " + (e.title || "")).toLowerCase().includes("pompier"))
);
if (pompierEvent) {
const info = document.createElement("div");
info.className = "pompier-info";
const period = pompierEvent.dateStart && pompierEvent.dateEnd && pompierEvent.dateStart !== pompierEvent.dateEnd
? `Astreinte du ${pompierEvent.dateStart} au ${pompierEvent.dateEnd}`
: `Astreinte ${pompierEvent.timeStart || ""}-${pompierEvent.timeEnd || ""}`;
info.textContent = "🚒 " + period;
card.appendChild(info);
}
}
// Absence info (bandeau)
if (isAbsent && !isPompier) {
const absenceEvent = events.find(e => e.type === "AL-Absence");
const info = document.createElement("div");
info.className = "absence-info";
if (fixedAbsenceReason) {
info.textContent = "📌 " + fixedAbsenceReason;
} else if (absenceEvent) {
// On n'affiche pas le motif précis (congé/maladie) - juste "Absent"
const period = absenceEvent.dateStart && absenceEvent.dateEnd && absenceEvent.dateStart !== absenceEvent.dateEnd
? `Absent du ${absenceEvent.dateStart} au ${absenceEvent.dateEnd}`
: `Absent`;
info.textContent = period;
} else {
info.textContent = "Absent";
}
card.appendChild(info);
}
// Interventions list
const list = document.createElement("div");
list.className = "tech-interventions";
if (interv.length === 0 && !isAbsent && !isPompier) {
list.innerHTML = '<div class="tech-empty">Aucune intervention planifiée</div>';
} else if (interv.length === 0 && isPompier) {
list.innerHTML = '<div class="tech-empty">Pompier sans intervention planifiée</div>';
} else {
// Trier par heure de début
interv.sort((a, b) => (a.timeStart || "").localeCompare(b.timeStart || ""));
for (const e of interv) {
list.appendChild(renderInterventionItem(e));
}
}
card.appendChild(list);
return card;
}
function renderInterventionItem(e) {
const item = document.createElement("div");
item.className = "intervention";
item.dataset.eventId = e.eventId;
// Header : heure + ref
const header = document.createElement("div");
header.className = "interv-header";
const time = document.createElement("span");
time.className = "interv-time";
time.textContent = `${e.timeStart || "?"} ${e.timeEnd || "?"}`;
const ref = document.createElement("span");
ref.className = "interv-ref";
ref.textContent = e.ref || "";
header.appendChild(time);
header.appendChild(ref);
item.appendChild(header);
// Summary depuis la bulle rapide (contact + lieu si dispo)
if (e.bulleText) {
const lines = e.bulleText.split("\n").map(l => l.trim()).filter(Boolean);
// Format bulle : ligne 0 = "HH:MM ref (type)", 1 = AL-Intervention, 2 = contact, 3 = lieu, 4 = catégorie
const contact = lines[2] || "";
const lieu = lines[3] || "";
const summary = document.createElement("div");
summary.className = "interv-summary";
if (contact) {
const c = document.createElement("div");
c.className = "interv-contact";
c.textContent = "👤 " + contact;
summary.appendChild(c);
}
if (lieu) {
const l = document.createElement("div");
l.className = "interv-location";
l.textContent = "📍 " + lieu;
summary.appendChild(l);
}
item.appendChild(summary);
}
// Zone détails (cachée au départ, s'affiche au clic)
const details = document.createElement("div");
details.className = "interv-details hidden";
item.appendChild(details);
// Click pour déplier/replier
item.addEventListener("click", async (ev) => {
if (ev.target.tagName === "A") return; // ne pas intercepter les liens
if (!details.classList.contains("hidden")) {
details.classList.add("hidden");
return;
}
details.classList.remove("hidden");
await loadIntervDetails(e, details);
});
// Tooltip au survol (quand détails pas chargés : affiche la bulle)
item.addEventListener("mouseenter", (ev) => {
if (!details.classList.contains("hidden")) return;
const cached = detailsCache.get(e.eventId);
const text = cached || e.bulleText || "(pas d'aperçu)";
showTooltip(ev, text);
});
item.addEventListener("mousemove", moveTooltip);
item.addEventListener("mouseleave", hideTooltip);
return item;
}
async function loadIntervDetails(event, container) {
if (detailsCache.has(event.eventId)) {
renderDetails(container, detailsCache.get(event.eventId), event);
return;
}
if (detailsPromises.has(event.eventId)) {
container.classList.add("loading");
container.textContent = "Chargement en cours…";
const text = await detailsPromises.get(event.eventId);
renderDetails(container, text, event);
return;
}
container.classList.add("loading");
container.textContent = "Chargement de la fiche détaillée…";
const promise = fetchInterventionDetails(event.detailUrl);
detailsPromises.set(event.eventId, promise);
try {
const text = await promise;
detailsCache.set(event.eventId, text);
renderDetails(container, text, event);
} catch (err) {
container.classList.add("error");
container.textContent = "Erreur : " + err.message;
} finally {
detailsPromises.delete(event.eventId);
}
}
function renderDetails(container, text, event) {
container.classList.remove("loading", "error");
container.textContent = "";
const pre = document.createElement("div");
pre.style.whiteSpace = "pre-wrap";
pre.textContent = text || "(pas de description disponible)";
container.appendChild(pre);
if (event.detailUrl) {
const link = document.createElement("a");
link.href = event.detailUrl;
link.target = "_blank";
link.rel = "noopener";
link.className = "interv-open-link";
link.textContent = "🔍 Ouvrir la fiche complète dans EasyVista";
container.appendChild(link);
}
}
async function fetchInterventionDetails(url) {
if (!url) throw new Error("Lien de la fiche introuvable");
// Requête avec les cookies de session de l'utilisateur
const resp = await fetch(url, { credentials: "include" });
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} en accédant à la fiche`);
}
const html = await resp.text();
// Extraire la description : chercher la zone Froala Editor
// Elle contient un <div class="fr-element fr-view ..."> ou, si doublement encodé,
// &lt;div class="fr-element fr-view ..."&gt;
return extractDescription(html);
}
function extractDescription(html) {
// Essayer la forme directe
let m = /class="fr-element fr-view[^"]*"[^>]*>([\s\S]*?)<\/div>/.exec(html);
if (m) {
return cleanDescription(m[1]);
}
// Essayer la forme doublement encodée (Copy outerHTML sur Angular)
m = /class="fr-element fr-view[^"]*"[^&]*&gt;([\s\S]*?)&lt;\/div&gt;/.exec(html);
if (m) {
let raw = m[1];
// Décoder les entités HTML
raw = raw
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
return cleanDescription(raw);
}
// Dernier recours : chercher autour du mot "Description"
return "(Description introuvable dans la fiche — il se peut que la structure ait changé)";
}
function cleanDescription(raw) {
// Remplacer <br> par \n
let text = raw.replace(/<br\s*\/?>/gi, "\n");
// Enlever toutes les autres balises
text = text.replace(/<[^>]+>/g, "");
// Décoder les entités HTML résiduelles
text = text
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
// Enlever les espaces multiples mais préserver les retours à la ligne
text = text.split("\n").map(l => l.replace(/\s+/g, " ").trim()).join("\n");
return text.trim();
}
// ==========================================================================
// Pré-chargement (bouton "Charger tous les détails")
// ==========================================================================
async function preloadAll() {
const items = document.querySelectorAll(".intervention");
const btn = document.getElementById("btn-preload");
btn.disabled = true;
const total = items.length;
let done = 0;
// On fait ça en séquentiel avec un petit délai pour être gentil avec le serveur
for (const item of items) {
const eventId = item.dataset.eventId;
if (detailsCache.has(eventId)) { done++; continue; }
// Retrouver l'événement correspondant (via data-attribute on n'a que l'eventId)
// On fetch directement depuis le lien présent dans la page d'origine
// Mais on ne l'a pas en direct ici — on ré-utilise la structure via item interne
// Pour simplifier : on clique programmatiquement pour déclencher loadIntervDetails
// → mais on veut pas afficher. Donc on récupère via une autre voie.
// Solution : on a stocké l'URL dans un data-attribute au rendu ? Non.
// Plus simple : déclencher l'expansion puis la replier.
item.click(); // déplie → déclenche le fetch
await new Promise(r => setTimeout(r, 250)); // 250ms entre chaque
done++;
btn.textContent = `📥 Chargement ${done}/${total}`;
}
btn.textContent = "✅ Tout chargé";
setTimeout(() => {
btn.disabled = false;
btn.textContent = "📥 Charger tous les détails";
}, 2000);
}
// ==========================================================================
// Tooltip
// ==========================================================================
function showTooltip(ev, text) {
const tip = document.getElementById("tooltip");
tip.textContent = text;
tip.classList.remove("hidden");
moveTooltip(ev);
}
function moveTooltip(ev) {
const tip = document.getElementById("tooltip");
if (tip.classList.contains("hidden")) return;
let x = ev.clientX + 16;
let y = ev.clientY + 16;
const rect = tip.getBoundingClientRect();
if (x + rect.width > window.innerWidth - 10) x = ev.clientX - rect.width - 16;
if (y + rect.height > window.innerHeight - 10) y = ev.clientY - rect.height - 16;
tip.style.left = x + "px";
tip.style.top = y + "px";
}
function hideTooltip() {
document.getElementById("tooltip").classList.add("hidden");
}
// ==========================================================================
// Utilitaires
// ==========================================================================
function showError(msg) {
const z = document.getElementById("error-zone");
z.textContent = msg;
z.classList.remove("hidden");
document.getElementById("main-content").innerHTML = "";
}
function formatFrDate(dateStr) {
// "17/04/2026" -> "vendredi 17 avril 2026"
const d = parseFrDate(dateStr);
if (!d) return dateStr;
const jours = ["dimanche","lundi","mardi","mercredi","jeudi","vendredi","samedi"];
const mois = ["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"];
return `${jours[d.getDay()]} ${d.getDate()} ${mois[d.getMonth()]} ${d.getFullYear()}`;
}
function formatAgo(ms) {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60);
return `${h}h${m % 60 ? " " + (m % 60) + "min" : ""}`;
}
function escape(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[c]));
}