Files
Planification/viewer.js
T

1395 lines
46 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// viewer.js — vue claire du planning techniciens (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 <title> 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 &amp;lt;BR&amp;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(/&amp;/g, "&")
.replace(/&lt;BR\/?&gt;/gi, "\n")
.replace(/&lt;br\/?&gt;/gi, "\n")
.replace(/<BR\/?>/gi, "\n")
.replace(/<br\/?>/gi, "\n")
.replace(/&nbsp;/g, " ")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ============================================================================
// 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");
}