forked from FroSteel/Planification
1395 lines
46 KiB
JavaScript
1395 lines
46 KiB
JavaScript
// ============================================================================
|
||
// 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 &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");
|
||
}
|