From 193b3252d4f47a6b9689f6cdc42027d452e36523 Mon Sep 17 00:00:00 2001 From: Quentin Rouiller Date: Fri, 24 Apr 2026 12:12:32 +0200 Subject: [PATCH] =?UTF-8?q?v2026.5.33=20=E2=80=94=20Vue=20horizontale=20:?= =?UTF-8?q?=20interactions=20diff=C3=A9renci=C3=A9es=20(hover/clic)=20[cod?= =?UTF-8?q?e=20interpol=C3=A9]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.json | 2 +- viewer.css | 504 +++++++++++++++++++++++++++++++++++++++++++++++--- viewer.js | 307 ++++++++++++++++++++++++++---- 3 files changed, 745 insertions(+), 68 deletions(-) diff --git a/manifest.json b/manifest.json index 5a26957..2ecf0a4 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planification", - "version": "2026.5.26", + "version": "2026.5.33", "description": "Vue claire et rapide du planning des techniciens EasyVista. Regroupe interventions et réservations par tech, affiche horaires, contact, lieu, catégorie et statut en un coup d'œil.", "permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"], "host_permissions": [ diff --git a/viewer.css b/viewer.css index ac13374..47f3f0a 100644 --- a/viewer.css +++ b/viewer.css @@ -9,8 +9,8 @@ --border: #e2e4e8; --border-strong: #cfd3da; --text: #1a1f2b; - --text-muted: #5b6573; - --text-faint: #8892a0; + --text-muted: #2e3642; /* v2026.5.29 : +contraste (était #4a5260) */ + --text-faint: #50596a; /* v2026.5.29 : +contraste (était #6c7583) */ --accent: #0f4f8b; --accent-soft: #e1ecf7; --danger: #b03030; @@ -59,8 +59,8 @@ --border: #2e333c; --border-strong: #414754; --text: #e6e8ec; - --text-muted: #9ba2ad; - --text-faint: #6a727e; + --text-muted: #d0d5de; /* v2026.5.29 : +contraste (était #b8c0cc) — quasi blanc */ + --text-faint: #a8b0bc; /* v2026.5.29 : +contraste (était #8b93a0) */ --accent: #5ea8e8; --accent-soft: #223348; --danger: #e87878; @@ -842,6 +842,17 @@ html, body { background: var(--bg-hover); } +/* v2026.5.29 : highlight visible sur les rows .intervention-v2 quand on + survole le segment timeline correspondant (ou que l'user survole la row) */ +.intervention-v2.highlight { + background: var(--accent-soft); + outline: 2px solid var(--accent); + outline-offset: -2px; + box-shadow: 0 0 0 3px var(--accent-soft); + z-index: 2; + position: relative; +} + /* ========================================================================== Interventions — layout v2 (heures verticales) ========================================================================== */ @@ -1832,22 +1843,28 @@ body.modal-open { overflow: hidden; } -/* v4.2.9 : pied de page discret en bas à droite — affiche auteur + date + version */ +/* v4.2.9 : pied de page discret en bas à droite — affiche auteur + date + version + v2026.5.29 : agrandi + plus contrasté */ .app-footer { position: fixed; - right: 8px; - bottom: 4px; - font-size: 10px; - color: var(--text-faint, #8892a0); - opacity: 0.55; - pointer-events: none; /* ne capture pas les clics */ + right: 10px; + bottom: 6px; + font-size: 13px; + font-weight: 500; + color: var(--text-muted); + opacity: 0.85; + pointer-events: none; user-select: none; font-variant-numeric: tabular-nums; - letter-spacing: 0.2px; - z-index: 1; /* sous les modals (qui sont à 10000) */ + letter-spacing: 0.3px; + z-index: 1; + padding: 3px 8px; + background: var(--bg-muted); + border: 1px solid var(--border); + border-radius: 6px; } .app-footer:hover { - opacity: 0.85; + opacity: 1; } /* ───────────────────────────────────────────────────────────────────────── @@ -1992,18 +2009,18 @@ body.modal-open { /* ───────────────────────────────────────────────────────────────────────── v5.0.0 : horloge au milieu de la topbar (HH:MM, pas de secondes) ───────────────────────────────────────────────────────────────────────── */ -/* v2026.5.16 : app-clock contient maintenant 2 lignes empilées : - - app-clock-date : "Mardi 21 avril 2026" (petit) - - app-clock-time : "12:34" (grand) */ +/* v2026.5.27 : app-clock sur UNE seule ligne : "Jeudi 23.04.26 • 21:55" + Même taille pour la date et l'heure, gros point au milieu. */ .app-clock { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); display: flex; - flex-direction: column; + flex-direction: row; align-items: center; justify-content: center; + gap: 12px; line-height: 1.1; color: var(--text); pointer-events: none; @@ -2011,11 +2028,19 @@ body.modal-open { white-space: nowrap; } .app-clock-date { - font-size: 12px; - font-weight: 500; + font-size: 22px; + font-weight: 600; + color: var(--text); + letter-spacing: 0.5px; + font-variant-numeric: tabular-nums; +} +.app-clock-date::after { + content: "•"; + margin-left: 12px; color: var(--text-muted); - letter-spacing: 0.3px; - text-transform: capitalize; + font-size: 26px; + line-height: 0.8; + vertical-align: middle; } .app-clock-time { font-size: 22px; @@ -2487,11 +2512,12 @@ header.topbar::before { /* Breakpoint medium : entre 1000 et 1300px, on compacte un peu */ @media (max-width: 1300px) { - .app-clock-date { font-size: 11px; } - .app-clock-time { font-size: 20px; } + .app-clock-date { font-size: 18px; } + .app-clock-time { font-size: 18px; } + .app-clock-date::after { font-size: 20px; } .topbar-right .btn-action .btn-action-label, .topbar-right .btn-refresh .btn-refresh-label { - font-size: 12px; + font-size: 13px; } } @@ -2499,10 +2525,10 @@ header.topbar::before { et on réduit encore l'horloge. Les icônes restent, titres restent. */ @media (max-width: 1000px) { .topbar { padding: 8px 14px; gap: 8px; } - .topbar h1 { font-size: 16px; } - .app-clock { font-size: smaller; } - .app-clock-date { font-size: 10px; } - .app-clock-time { font-size: 18px; } + .topbar h1 { font-size: 18px; } + .app-clock-date { font-size: 16px; } + .app-clock-time { font-size: 16px; } + .app-clock-date::after { font-size: 18px; } .btn-action .btn-action-label, .btn-refresh .btn-refresh-label { display: none; @@ -3129,3 +3155,423 @@ body.popup-dragging .pinned-popup { .user-badge.user-badge-unknown:hover { opacity: 1; } + +/* ========================================================================== + v2026.5.27 : agrandir +20% les textes topbar + stats bar pour la lisibilité + ========================================================================== */ +/* Labels boutons topbar */ +.btn-action-label, +.btn-refresh-label { + font-size: 14px !important; /* +20% depuis 12px */ +} +.btn-today { + font-size: 14px !important; + padding: 7px 12px !important; +} +.btn-subtle { + font-size: 14px !important; +} +.capture-info { + font-size: 14px !important; /* +20% depuis 12px */ +} +.topbar h1 { + font-size: 21px !important; /* +20% depuis 18px */ +} +/* Date-custom label (Vendredi 24.04.2026) */ +#date-custom-label { + font-size: 14px !important; /* +20% depuis 12px */ +} +.date-custom { + font-size: 14px !important; +} +/* Stats bar */ +.stats-bar, +.stats-bar * { + font-size: 14px !important; +} +.stats-bar strong { + font-size: 16px !important; +} + +/* v2026.5.27 : icône thème plus contrastée avec bordure + fond visible */ +#theme-toggle { + border: 1.5px solid var(--border-strong, rgba(128,128,128,0.5)) !important; + background: var(--bg-muted) !important; + width: 38px; + height: 38px; + padding: 0 !important; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, border-color 0.15s; +} +#theme-toggle:hover { + background: var(--accent-soft, rgba(59, 130, 246, 0.15)) !important; + border-color: var(--accent, #3b82f6) !important; +} +#theme-icon { + font-size: 20px; + line-height: 1; + filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3)); +} + +/* ========================================================================== + v2026.5.27 : classification visuelle des absences (Maladie, Congé, Pompier) + ========================================================================== */ + +/* Variables de couleurs pour les catégories d'absence */ +:root { + --c-maladie: #4338ca; /* Indigo foncé */ + --c-maladie-soft: #e0e7ff; /* Indigo très clair */ + --c-conge: #06b6d4; /* Cyan */ + --c-conge-soft: #cffafe; /* Cyan très clair */ +} +html.theme-dark { + --c-maladie: #818cf8; /* Indigo plus clair pour dark mode */ + --c-maladie-soft: #2a1e66; /* Indigo foncé pour fonds */ + --c-conge: #67e8f9; /* Cyan plus clair pour dark mode */ + --c-conge-soft: #0e3e4a; /* Cyan foncé pour fonds */ +} + +/* Badge "Maladie" à côté du nom */ +.badge-maladie { + background: var(--c-maladie-soft); + color: var(--c-maladie); + font-weight: 600; +} + +/* Badge "Congé" / "Congés" à côté du nom */ +.badge-conge { + background: var(--c-conge-soft); + color: var(--c-conge); + font-weight: 600; +} + +/* Carte entière : couleur de fond + barre gauche épaisse pour absence */ +.card.absence-cat-maladie { + border-left: 4px solid var(--c-maladie); + background: linear-gradient(to bottom, var(--c-maladie-soft) 0%, var(--bg-elevated) 40%); +} +html.theme-dark .card.absence-cat-maladie { + background: linear-gradient(to bottom, rgba(67, 56, 202, 0.12) 0%, var(--bg-elevated) 40%); +} + +.card.absence-cat-conge { + border-left: 4px solid var(--c-conge); + background: linear-gradient(to bottom, var(--c-conge-soft) 0%, var(--bg-elevated) 40%); +} +html.theme-dark .card.absence-cat-conge { + background: linear-gradient(to bottom, rgba(6, 182, 212, 0.12) 0%, var(--bg-elevated) 40%); +} + +/* Pompier — on reprend le style existant mais on accentue le border-left */ +.card.absence-cat-pompier { + border-left: 4px solid var(--danger); +} + +/* Pastille ronde à côté du nom */ +.tech-name-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 8px; + flex-shrink: 0; + vertical-align: middle; +} +.tech-name-dot.absence-dot-maladie { background: var(--c-maladie); } +.tech-name-dot.absence-dot-conge { background: var(--c-conge); } +.tech-name-dot.absence-dot-pompier { background: var(--danger); } + +/* Note statut (banner texte sur la zone interventions) */ +.card-status-note { + padding: 14px 16px; + font-size: 15px; + font-weight: 500; + text-align: center; + border-radius: 6px; + margin: 10px; +} +.card-status-note.absent-maladie { + background: var(--c-maladie-soft); + color: var(--c-maladie); +} +.card-status-note.absent-conge { + background: var(--c-conge-soft); + color: var(--c-conge); +} +.card-status-note.pompier { + background: var(--danger-soft); + color: var(--danger); +} + +/* ========================================================================== + v2026.5.28 : popups épinglés gardent une taille standard au resize de la + fenêtre. Le max-width 620px du tooltip de base les contraignait quand on + réduisait la largeur depuis le côté droit — désormais ils restent à leur + taille créée et sont simplement repositionnés dans la safe area. + ========================================================================== */ +.pinned-popup:not(.pinned-popup-minimized):not(.pinned-popup-reduced) { + max-width: none !important; + width: 520px !important; /* largeur standard fixe */ + min-width: 380px; +} +/* Sur les très petits écrans (< 600px), on laisse le clamp naturel */ +@media (max-width: 600px) { + .pinned-popup:not(.pinned-popup-minimized):not(.pinned-popup-reduced) { + width: calc(100vw - 20px) !important; + min-width: 0; + } +} + +/* ========================================================================== + v2026.5.30 : absences récurrentes (Pillonel vendredi) en cyan + (même couleur que Congé mais texte distinct "Absent le vendredi") + ========================================================================== */ + +/* Badge "Absent" cyan pour récurrent */ +.badge-recurring { + background: var(--c-conge-soft); + color: var(--c-conge); + font-weight: 600; +} + +/* Carte entière : bordure gauche cyan + fond dégradé */ +.card.absence-cat-recurring { + border-left: 4px solid var(--c-conge); + background: linear-gradient(to bottom, var(--c-conge-soft) 0%, var(--bg-elevated) 40%); +} +html.theme-dark .card.absence-cat-recurring { + background: linear-gradient(to bottom, rgba(6, 182, 212, 0.12) 0%, var(--bg-elevated) 40%); +} + +/* Message "Absent le vendredi" (ou autre récurrence) en cyan */ +.tech-absence-recurring { + padding: 14px 12px; + text-align: center; + font-size: 15px; + font-weight: 500; + font-style: normal; + color: var(--c-conge); + background: var(--c-conge-soft); + border-radius: 6px; + margin: 10px; +} +html.theme-dark .tech-absence-recurring { + background: rgba(6, 182, 212, 0.12); +} + +/* ========================================================================== + v2026.5.30 : mode compact pour écrans 24" Full HD (1920×1080) ou plus petits + Objectif : réduire les paddings et tailles sans casser la lisibilité. + ========================================================================== */ +@media (max-width: 1920px) { + /* Topbar légèrement compactée */ + .topbar { + padding: 8px 14px !important; + gap: 10px; + } + .topbar h1 { + font-size: 18px !important; + } + .app-clock-date, + .app-clock-time { + font-size: 19px !important; + } + .app-clock-date::after { + font-size: 22px !important; + } + + /* Stats bar plus dense */ + .stats-bar { + padding: 6px 12px !important; + } + .stats-bar, + .stats-bar * { + font-size: 13px !important; + } + .stats-bar strong { + font-size: 14px !important; + } + + /* Cartes : padding réduit */ + .card-header { + padding: 8px 12px !important; + } + .card-tech-name { + font-size: 14px !important; + } + .card-tech-badge { + font-size: 10px !important; + padding: 2px 6px !important; + } + + /* Interventions : padding vertical réduit */ + .intervention-v2 { + padding: 7px 10px 9px 6px !important; + } + + /* Timeline plus fine */ + .timeline { + padding: 10px 12px 6px 12px !important; + } + .timeline-bar { + height: 18px !important; + } + .timeline-label { + font-size: 10px !important; + } + + /* Boutons topbar un peu plus compacts */ + .btn-action, + .btn-refresh { + padding: 6px 10px !important; + } + .btn-action-label, + .btn-refresh-label { + font-size: 13px !important; + } + .btn-today { + padding: 6px 10px !important; + font-size: 13px !important; + } + .btn-subtle { + padding: 6px 10px !important; + font-size: 13px !important; + } + + /* Grid cartes : colonnes légèrement plus étroites pour en caser 1-2 de + */ + .main-grid { + gap: 10px !important; + } +} + +/* Breakpoint encore plus étroit (tablette / laptop 13-14") */ +@media (max-width: 1400px) { + .topbar { + padding: 6px 10px !important; + } + .topbar h1 { + font-size: 17px !important; + } + .app-clock-date, + .app-clock-time { + font-size: 17px !important; + } + .intervention-v2 { + padding: 6px 8px 7px 5px !important; + } + .card-header { + padding: 7px 10px !important; + } + .stats-bar, + .stats-bar * { + font-size: 12px !important; + } +} + +/* ========================================================================== + v2026.5.32 : Vue horizontale — chaque tech = 1 ligne fine empilée + Active seulement quand . + But : voir les 8 techs d'un coup sur un 24" Full HD. + ========================================================================== */ + +/* Mode horizontal : la grille devient un simple stack vertical */ +html.view-horizontal .cards { + display: flex !important; + flex-direction: column !important; + gap: 6px !important; + padding: 8px 12px 24px 12px !important; +} + +/* Chaque carte devient une ligne horizontale compacte */ +html.view-horizontal .card { + flex-direction: row !important; + align-items: stretch !important; + height: auto !important; + min-height: 0 !important; + max-height: none !important; + overflow: visible !important; +} + +/* Header devient une barre latérale gauche fixe */ +html.view-horizontal .card-header { + flex-direction: column !important; + align-items: flex-start !important; + justify-content: center !important; + min-width: 200px !important; + max-width: 200px !important; + border-bottom: none !important; + border-right: 1px solid var(--border) !important; + padding: 8px 12px !important; + gap: 4px !important; + flex: 0 0 auto; +} +html.view-horizontal .card-tech-name { + font-size: 14px !important; + font-weight: 600; +} +html.view-horizontal .card-tech-badge { + font-size: 10px !important; + padding: 2px 6px !important; + white-space: nowrap; +} + +/* Le body prend le reste de la ligne, scroll horizontal si trop d'interv */ +html.view-horizontal .card-body { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Timeline visible, un peu plus fine */ +html.view-horizontal .timeline { + padding: 6px 10px 4px 10px !important; + background: transparent !important; + border-bottom: none !important; +} +html.view-horizontal .timeline-bar { + height: 22px !important; +} + +/* Liste interventions en mode "chips" (défilement horizontal) */ +html.view-horizontal .card-body > .intervention-v2, +html.view-horizontal .card-body > .intervention { + display: none !important; /* masquer la liste détaillée en vue horiz */ +} + +/* Messages "Pas d'intervention planifiée" / "Absent" tiennent sur la ligne */ +html.view-horizontal .card-empty, +html.view-horizontal .card-status-note, +html.view-horizontal .tech-absence-recurring { + padding: 8px 12px !important; + font-size: 13px !important; + margin: 4px 8px !important; + text-align: left !important; +} + +/* Stats quick pour chaque tech (nb interv etc.) affichées dans le header */ +html.view-horizontal .card-header::after { + content: ""; +} +html.view-horizontal .tech-row-stats { + display: flex; + gap: 8px; + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} +html.view-horizontal .tech-row-stats .stat-pill { + padding: 1px 6px; + background: var(--bg-muted); + border: 1px solid var(--border); + border-radius: 3px; + font-variant-numeric: tabular-nums; +} + +/* En vue classique, on cache les éléments spécifiques horizontal */ +html.view-classic .tech-row-stats { + display: none !important; +} diff --git a/viewer.js b/viewer.js index 616eb12..6c9b62b 100644 --- a/viewer.js +++ b/viewer.js @@ -240,6 +240,7 @@ async function init() { initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal initAppFooter(); // v4.2.9 : pied de page discret bas-droite initAppClock(); // v5.0.0 : horloge HH:MM au milieu topbar + _applyViewMode(); // v2026.5.32 : appliquer la vue sauvegardée initAdminMenu(); // v5.0.0 : menu admin caché (5 clics sur titre) initSessionTimer(); // v5.0.9 : compteur de session EV (tick 1s) initDateCustomPicker(); // v2026.5.17 : faux input date avec jour @@ -459,6 +460,21 @@ function toggleUserNamePopup() { popup.appendChild(sessEl); // v2026.5.25 : bouton Paramètres (remplace les 5 clics sur le titre) + // v2026.5.32 : bouton "Vue" pour basculer Vue classique ↔ Vue horizontale + const viewBtn = document.createElement("button"); + viewBtn.type = "button"; + viewBtn.className = "user-name-popup-settings"; + const currentView = _getCurrentView(); + viewBtn.innerHTML = ' Vue : ' + + (currentView === "horizontal" ? "Horizontale" : "Classique"); + viewBtn.title = "Changer la disposition du planning"; + viewBtn.addEventListener("click", (e) => { + e.stopPropagation(); + hideUserNamePopup(); + _toggleView(); + }); + popup.appendChild(viewBtn); + const settingsBtn = document.createElement("button"); settingsBtn.type = "button"; settingsBtn.className = "user-name-popup-settings"; @@ -946,20 +962,49 @@ function initAppFooter() { document.body.appendChild(el); } +// v2026.5.32 : bascule entre Vue classique (cards) et Vue horizontale (rows) +// Persisté dans localStorage (clé : "view_mode"). Défaut : "classic". +const VIEW_MODE_KEY = "view_mode"; + +function _getCurrentView() { + try { + const v = localStorage.getItem(VIEW_MODE_KEY); + return v === "horizontal" ? "horizontal" : "classic"; + } catch (e) { + return "classic"; + } +} + +function _setCurrentView(mode) { + try { + localStorage.setItem(VIEW_MODE_KEY, mode === "horizontal" ? "horizontal" : "classic"); + } catch (e) {} + _applyViewMode(); +} + +function _toggleView() { + const current = _getCurrentView(); + const next = current === "horizontal" ? "classic" : "horizontal"; + _setCurrentView(next); +} + +function _applyViewMode() { + const mode = _getCurrentView(); + document.documentElement.classList.remove("view-classic", "view-horizontal"); + document.documentElement.classList.add("view-" + mode); +} + // v5.0.0 : horloge HH:MM au milieu de la topbar. Mise à jour toutes les 30s // (les secondes ne sont pas affichées donc pas besoin d'un tick plus rapide). +// v2026.5.27 : date courte "Jeudi 23.04.26" sur la même ligne que l'heure, +// séparées par un gros point "•", même taille que l'heure. function initAppClock() { const el = document.getElementById("app-clock"); if (!el) return; const dateEl = document.getElementById("app-clock-date"); const timeEl = document.getElementById("app-clock-time"); - // v2026.5.16 : format "Mardi 21 avril 2026" const JOURS = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]; - const MOIS = [ - "janvier", "février", "mars", "avril", "mai", "juin", - "juillet", "août", "septembre", "octobre", "novembre", "décembre" - ]; let lastDateStr = ""; const tick = () => { @@ -970,13 +1015,13 @@ function initAppClock() { if (timeEl) timeEl.textContent = timeStr; else el.textContent = timeStr; // fallback si ancien markup - // Date complète : actualisée seulement si elle a changé (évite reflow inutile) + // Date courte : "Jeudi 23.04.26" if (dateEl) { const jour = JOURS[d.getDay()]; - const num = d.getDate(); - const mois = MOIS[d.getMonth()]; - const annee = d.getFullYear(); - const dateStr = `${jour} ${num} ${mois} ${annee}`; + const dd = String(d.getDate()).padStart(2, "0"); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const yy = String(d.getFullYear()).slice(-2); + const dateStr = `${jour} ${dd}.${mm}.${yy}`; if (dateStr !== lastDateStr) { dateEl.textContent = dateStr; lastDateStr = dateStr; @@ -2907,6 +2952,16 @@ function actionNodeToIntervention(node) { reservationCreator: reservationCreator, // "Nom, Prénom" du coordinateur cssClass: cssClass, isPompier: /pompier/i.test(label) || /pompier/i.test(actionType), + // v2026.5.27 : catégorie d'absence pour classification visuelle + // "maladie" | "conge" | "pompier" | null + absenceCategory: (function() { + if (effectiveType !== "AL-Absence") return null; + const lblTest = reservationMatch ? reservationMatch[1].trim() : label; + if (/pompier/i.test(lblTest) || /pompier/i.test(actionType)) return "pompier"; + if (/maladie/i.test(lblTest)) return "maladie"; + if (/cong[ée]s?/i.test(lblTest)) return "conge"; + return null; + })(), ref: ref, startDate: startDate, endDate: endDate, @@ -4585,9 +4640,30 @@ function buildCard(tech, isoDate) { const isPompier = tech.interventions.some(iv => iv.isPompier); const isAbsent = isTechAbsent(tech, isoDate); + // v2026.5.30 : détecter aussi les absences récurrentes hardcodées (Pillonel vendredi) + // pour leur appliquer le code couleur cyan (comme Congé) au lieu du rouge Pompier. + const isRecurring = isPillonelAbsentFriday(tech, isoDate); + if (isPompier) card.classList.add("is-pompier"); if (isAbsent) card.classList.add("is-absent"); + // v2026.5.27 : déterminer la catégorie d'absence principale (maladie/conge/pompier) + // pour appliquer le bon code couleur sur la carte entière. + // v2026.5.30 : les absences récurrentes hardcodées prennent la catégorie + // "recurring" (même cyan que Congé, texte distinct). + let absenceCategory = null; // "maladie" | "conge" | "pompier" | "recurring" | null + if (isRecurring) { + absenceCategory = "recurring"; + } else if (isPompier) { + absenceCategory = "pompier"; + } else if (isAbsent) { + const catBlock = tech.interventions.find(iv => iv.type === "AL-Absence" && iv.absenceCategory); + if (catBlock) absenceCategory = catBlock.absenceCategory; + } + if (absenceCategory) { + card.classList.add("absence-cat-" + absenceCategory); + } + const realInterventions = tech.interventions.filter(iv => iv.type !== "AL-Absence" && !iv.isPompier ); @@ -4603,23 +4679,57 @@ function buildCard(tech, isoDate) { // --- Header --- const header = document.createElement("div"); header.className = "card-header"; + + // v2026.5.27 : pastille colorée supprimée (v2026.5.28) — la barre gauche de la + // carte + le badge à droite suffisent pour indiquer la catégorie d'absence. + const nameEl = document.createElement("div"); nameEl.className = "card-tech-name"; nameEl.textContent = tech.name; header.appendChild(nameEl); - if (isPompier || isAbsent) { + if (isPompier || isAbsent || isRecurring) { const badge = document.createElement("div"); badge.className = "card-tech-badge"; - if (isPompier) { + if (isRecurring) { + // v2026.5.30 : absence récurrente (Pillonel vendredi) → badge "Absent" cyan + badge.classList.add("badge-recurring"); + badge.textContent = "Absent"; + } else if (isPompier) { badge.classList.add("badge-pompier"); badge.textContent = "Pompier"; + } else if (absenceCategory === "maladie") { + badge.classList.add("badge-maladie"); + badge.textContent = "Maladie/Accident"; + } else if (absenceCategory === "conge") { + // Déterminer singulier/pluriel selon la durée + const ab = absenceBlocks.find(a => a.absenceCategory === "conge") || absenceBlocks[0]; + const multiDay = ab && ab.startDate && ab.endDate && ab.startDate !== ab.endDate; + badge.classList.add("badge-conge"); + badge.textContent = multiDay ? "Congés" : "Congé"; } else { badge.classList.add("badge-absent"); badge.textContent = "Absent"; } header.appendChild(badge); } + + // v2026.5.32 : stats rapides pour la vue horizontale (cachées en classique) + const rowStats = document.createElement("div"); + rowStats.className = "tech-row-stats"; + const totalInterv = realInterventions.length; + const pill1 = document.createElement("span"); + pill1.className = "stat-pill"; + pill1.textContent = totalInterv + " interv."; + rowStats.appendChild(pill1); + if (morning > 0 || afternoon > 0) { + const pill2 = document.createElement("span"); + pill2.className = "stat-pill"; + pill2.textContent = morning + "m · " + afternoon + "a"; + rowStats.appendChild(pill2); + } + header.appendChild(rowStats); + card.appendChild(header); // --- Body --- @@ -4640,30 +4750,60 @@ function buildCard(tech, isoDate) { } else if (isAbsent && absenceBlocks.length) { const note = document.createElement("div"); note.className = "card-status-note absent"; - const ab = absenceBlocks[0]; - if (ab.startDate && ab.endDate && ab.startDate !== ab.endDate) { - note.textContent = `Absent du ${ab.startDate.substring(0, 5)} au ${ab.endDate.substring(0, 5)}`; - } else { - note.textContent = "Absent toute la journée"; + if (absenceCategory) { + note.classList.add("absent-" + absenceCategory); } + const ab = absenceBlocks[0]; + // v2026.5.27 : libellé enrichi "Absent du XX au YY — Maladie" ou "Absent — Congé" + const multiDay = ab.startDate && ab.endDate && ab.startDate !== ab.endDate; + const catLabel = absenceCategory === "maladie" ? "Maladie/Accident" + : absenceCategory === "conge" ? (multiDay ? "Congés" : "Congé") + : null; + let txt; + if (multiDay) { + txt = `Absent du ${ab.startDate.substring(0, 5)} au ${ab.endDate.substring(0, 5)}`; + } else { + txt = "Absent toute la journée"; + } + if (catLabel) txt += ` — ${catLabel}`; + note.textContent = txt; body.appendChild(note); // v5.0.4 : tooltip au hover sur toute la carte absent (pas juste un // bouton visible). Contient : détail période + bouton supprimer si // c'est une absence supprimable (actionId réel, pas pompier récurrent). + // v2026.5.33 : en vue horizontale, attacher le hover SEULEMENT au badge + // (Congé / Maladie/Accident / Absent) pour éviter que la popup s'affiche + // dès qu'on approche de la ligne du tech. En vue classique, on garde la + // carte entière comme zone de hover (comportement d'origine). if (ab.actionId && !ab.isPompier && !ab._recurring) { - // On attache le tooltip sur la CARD ENTIÈRE (card) — comme ça - // survoler n'importe où sur la zone grisée "absent" le déclenche. const ivCopy = { ...ab, - type: "AL-Absence" // force pour buildTooltipHTML + type: "AL-Absence" }; + const _isHorizontalView = () => document.documentElement.classList.contains("view-horizontal"); + // Hover sur la carte entière : actif SEULEMENT en vue classique card.addEventListener("mouseenter", (e) => { + if (_isHorizontalView()) return; // bloqué en horizontal showTooltip(e, ivCopy, card); }); card.addEventListener("mouseleave", () => { + if (_isHorizontalView()) return; hideTooltip(); }); + // Hover sur le badge (Congé / Maladie/Accident / Absent) : actif + // SEULEMENT en vue horizontale. + const badgeEl = card.querySelector(".card-tech-badge"); + if (badgeEl) { + badgeEl.addEventListener("mouseenter", (e) => { + if (!_isHorizontalView()) return; // bloqué en classique + showTooltip(e, ivCopy, badgeEl); + }); + badgeEl.addEventListener("mouseleave", () => { + if (!_isHorizontalView()) return; + hideTooltip(); + }); + } } } @@ -4909,19 +5049,42 @@ function getStatusClass(iv) { } function bindTimelinePopover(el) { - el.addEventListener("mouseenter", (e) => showTimelinePopover(e, el)); + // v2026.5.33 : en vue horizontale, les interactions sont différentes : + // - hover : ouvre directement la GRANDE popup (pas la petite) + // - clic : ouvre la fiche EasyVista dans un nouvel onglet + // En vue classique, le comportement existant est conservé : + // - hover : petite popup qui suit la souris + // - clic simple : grande popup persistante + // - double-clic : fiche EasyVista nouvel onglet + // - Ctrl+clic : fiche EasyVista en arrière-plan + const _isHorizontalView = () => document.documentElement.classList.contains("view-horizontal"); + + el.addEventListener("mouseenter", (e) => { + if (_isHorizontalView()) { + // Grande popup directement en vue horizontale + if (el.dataset.ivIdx !== undefined) { + openPersistentTimelinePopup(el); + } + } else { + showTimelinePopover(e, el); + } + }); // v4.2.3 : la petite popup timeline SUIT la souris (différent de la grande // popup des lignes d'intervention qui est ancrée). On n'utilise pas // moveTooltip() (no-op depuis v4.1.12) mais une fonction dédiée. - el.addEventListener("mousemove", (e) => moveTimelineTooltip(e)); - el.addEventListener("mouseleave", hideTooltip); + el.addEventListener("mousemove", (e) => { + if (!_isHorizontalView()) { + moveTimelineTooltip(e); + } + }); + el.addEventListener("mouseleave", () => { + if (!_isHorizontalView()) { + hideTooltip(); + } + // En vue horizontale, la grande popup est persistante donc on ne ferme + // pas au mouseleave (l'user peut interagir avec). + }); - // v4.2.3 : clic / double-clic / Ctrl+clic sur un segment timeline - // - clic simple : ferme la petite popup et ouvre la GRANDE popup - // (ancrée juste en dessous de la timeline, persistante pour permettre - // de sélectionner du texte / copier) - // - double-clic : ouvre la fiche EasyVista dans un nouvel onglet actif - // - Ctrl+clic (Cmd+clic sur Mac) : ouvre dans un onglet en arrière-plan const kind = el.dataset.kind; const ivIdxStr = el.dataset.ivIdx; // Seulement sur les segments avec une interventoin (pas les "hole" libres @@ -4930,6 +5093,17 @@ function bindTimelinePopover(el) { let singleClickTimer = null; el.addEventListener("click", (e) => { + // v2026.5.33 : en vue horizontale, tout clic simple = ouvrir la fiche EV + if (_isHorizontalView()) { + e.preventDefault(); + e.stopPropagation(); + // Ctrl/Cmd/middle → arrière-plan, sinon nouvel onglet actif + const background = !!(e.ctrlKey || e.metaKey || e.button === 1); + openInterventionFromTimeline(el, { background }); + return; + } + + // Vue classique (inchangé) : // Ctrl / Cmd / molette → ouvrir fiche en arrière-plan if (e.ctrlKey || e.metaKey || e.button === 1) { e.preventDefault(); @@ -4947,8 +5121,10 @@ function bindTimelinePopover(el) { }, 250); }); el.addEventListener("dblclick", (e) => { - // Annuler le clic simple en attente + // En vue horizontale le clic simple fait déjà l'ouverture, le dblclick + // devient inutile — on le laisse par sécurité (comportement idem). if (singleClickTimer) { clearTimeout(singleClickTimer); singleClickTimer = null; } + if (_isHorizontalView()) return; e.preventDefault(); e.stopPropagation(); openInterventionFromTimeline(el, { background: false }); @@ -6229,6 +6405,13 @@ function showTooltip(e, iv, rowEl) { // l'ouverture d'un nouveau tooltip par-dessus ce qu'on est en train de bouger. if (state._popupDragging) return; + // v2026.5.27 : fermer tout popup "soft-unpinned" encore visible (il traîne + // parce que la souris était dessus). Dès qu'on survole autre chose, on veut + // que seul le popup actuel ou les popups épinglés restent. + document.querySelectorAll(".soft-unpinned").forEach(el => { + try { el.remove(); } catch (err) {} + }); + // v4.1.15 : si la bulle est épinglée sur une autre iv, on NE REMPLACE PAS // son contenu (l'user veut garder la fiche épinglée même en survolant // d'autres cartes). @@ -7334,15 +7517,26 @@ function _clampPopupInSafeArea(popup) { const vTop = rect.top; // Calcul des coords viewport cibles après clamp + // v2026.5.28 : si le popup est plus large que la zone dispo (fenêtre rétrécie + // depuis le côté droit), on le garde à sa position actuelle — + // l'user redimensionnera ou bougera manuellement. On préfère + // "déborder" à droite plutôt que rétrécir le popup. let newVLeft = vLeft; let newVTop = vTop; - if (newVLeft < safe.left) newVLeft = safe.left; - if (newVLeft + w > safe.right) newVLeft = safe.right - w; - if (newVLeft < safe.left) newVLeft = safe.left; // si popup plus large que viewport - - if (newVTop < safe.top) newVTop = safe.top; - if (newVTop + h > safe.bottom) newVTop = safe.bottom - h; - if (newVTop < safe.top) newVTop = safe.top; + const safeWidth = safe.right - safe.left; + const safeHeight = safe.bottom - safe.top; + if (w <= safeWidth) { + // Popup rentre : on clamp normalement + if (newVLeft < safe.left) newVLeft = safe.left; + if (newVLeft + w > safe.right) newVLeft = safe.right - w; + if (newVLeft < safe.left) newVLeft = safe.left; + } + // Sinon : popup plus large que la zone → on laisse où il est, user libre + if (h <= safeHeight) { + if (newVTop < safe.top) newVTop = safe.top; + if (newVTop + h > safe.bottom) newVTop = safe.bottom - h; + if (newVTop < safe.top) newVTop = safe.top; + } if (newVLeft === vLeft && newVTop === vTop) return; // rien à faire @@ -7956,6 +8150,31 @@ function bindTooltipInteractions() { const el = tooltipEl(); if (!el) return; + // v2026.5.27 : quand la souris entre dans un popup épinglé, fermer tout + // popup non-épinglé (tooltip live ou soft-unpinned) pour garder l'écran clair. + document.addEventListener("mouseover", (e) => { + const target = e.target; + if (!target || !target.closest) return; + const pinned = target.closest(".pinned-popup"); + if (!pinned) return; + // On survole un popup épinglé → fermer tooltip live s'il n'est pas pinned + if (!bulleState.pinned) { + const tip = tooltipEl(); + if (tip && tip.classList.contains("visible")) { + tip.classList.remove("visible"); + tip.classList.add("hidden"); + if (tip.dataset) delete tip.dataset.mode; + state.currentTooltipIv = null; + currentTooltipPos = null; + tooltipPositionMode = null; + } + } + // Fermer les soft-unpinned qui traînent + document.querySelectorAll(".soft-unpinned").forEach(el => { + try { el.remove(); } catch (err) {} + }); + }); + // v4.1.17 : ré-applique la position au scroll de la page (safety net // contre un ancêtre qui casserait position:fixed silencieusement). window.addEventListener("scroll", reapplyTooltipPosition, { passive: true }); @@ -8302,10 +8521,22 @@ function escapeHtml(s) { } function highlightIntervention(cardEl, ivIdx, on) { - const row = cardEl.querySelector(`.intervention[data-iv-idx="${ivIdx}"]`); + // v2026.5.29 : chercher .intervention-v2 (nouveau nom) et fallback .intervention + const row = cardEl.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`) + || cardEl.querySelector(`.intervention[data-iv-idx="${ivIdx}"]`); const slot = cardEl.querySelector(`.timeline-slot[data-iv-idx="${ivIdx}"]`); if (row) row.classList.toggle("highlight", on); if (slot) slot.classList.toggle("highlight", on); + + // v2026.5.29 : quand on active le highlight (typiquement depuis un slot + // timeline), faire scroller la row dans la vue pour que l'user voie la + // carte correspondante sans avoir à chercher. On évite de scroller le + // body, on scroll juste la row à l'intérieur de la carte si elle déborde. + if (on && row && typeof row.scrollIntoView === "function") { + try { + row.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); + } catch (e) {} + } } // ============================================================================