From 2d242d26ecb508b9e5eb22b3b76521acedf3fb75 Mon Sep 17 00:00:00 2001 From: FroSteel Date: Fri, 1 May 2026 18:08:11 +0200 Subject: [PATCH] =?UTF-8?q?v2026.5.44=20=E2=80=94=20Refonte=20topbar,=20pe?= =?UTF-8?q?rsonnalisation=20Apparence,=20onboarding=20=C3=A9quipe,=20fix?= =?UTF-8?q?=20#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refresh / cache / verdicts ghost : - Rafraîchissement séquentiel (1 fiche à la fois) avec arrêt instantané via AbortController. - Re-fetch checksum frais (basicAutoComplete + redirectHeader). - Cache merge robuste avec fallback cachedByRef ; cache écrit toutes les 5 fiches (incrémental). - Verdicts ghost unifiés : ✓✓ clos/résolu, ✓ Fait (pending), ✓ jaune Suspendu, retrait silencieux pour cancelled. - Statuts EV configurables depuis Paramètres → EasyVista (matching insensible à la casse, accents, conjugaisons). - Mode diagnostic optionnel (Diagnostics) qui logge tout sans rien retirer. Topbar (vue classique) : - Sélecteur de date du planning ancré au centre absolu (ne se décale plus quand le bouton Arrêter apparaît). - Bouton Aujourd'hui en toutes lettres. - Horloge contextuelle réduite à côté. Personnalisation (Paramètres → Apparence) : - Couleur de la topbar : 12 presets cliquables + picker custom + champ hex. Texte topbar adapté automatiquement (luminance) pour rester lisible. - Police de l'application : 28 choix (Arial, Helvetica, Verdana, Tahoma, Trebuchet, Calibri, Segoe UI, Times New Roman, Georgia, Cambria, Garamond, Palatino, Courier, Consolas, Comic Sans, Impact, …) appliquée à toute la page (cards, popups, panel admin) avec preview live. - Export / import du cache et de admin_config. Vue horizontale : - Bloc Aujourd'hui + horloge empilé verticalement dans la sidebar. - Date sélectionnée mise en avant (taille augmentée, gras), date du jour + heure réduites à la même petite taille. - Barre verticale verte à droite des mini-cards clos/résolu (✓✓), avec décalage du ✓✓ pour ne pas chevaucher. - Sidebar adopte la couleur de topbar custom (titre, horloge, today-block, date sélectionnée, boutons, theme-toggle, séparateurs translucides cohérents via color-mix). Stats globales : - Nouveau compteur 'X faits / Y clos' entre (matin · après-midi) et tech. dispo. - Vue classique : séparateur '//' après clos. - Vue horizontale (sidebar) : barre horizontale 1px de séparation. Onboarding équipe : - Carte centrée propre (icône, titre, description, bouton 'Ouvrir paramètres') quand aucun technicien n'est sélectionné. Bouton ouvre directement la section Équipe du panel admin. Bugfix : - Issue #1 (Pompier + Absence) : les deux badges s'affichent désormais avec '/' au lieu de masquer l'absence. - Absences récurrentes restaurées au switch de groupe (étaient invisibles alors qu'en storage). - Barre de progression / bannière session expirée suivent la hauteur dynamique de la topbar (--topbar-height via ResizeObserver). - STATUS_FR regex limite 30 → 200 chars. - Description action décodée proprement (\u0022,
, HTML strippé) ; préfixe 'login:' retiré du commentaire technicien. - Flèche '↗' retirée des références cliquables. --- CHANGELOG.md | 128 ++ firefox-updates.json | 7 +- src/background.js | 76 +- src/manifest.json | 2 +- src/viewer.css | 800 ++++++++++--- src/viewer.html | 24 +- src/viewer.js | 2656 +++++++++++++++++++++++++++++++++++++----- 7 files changed, 3223 insertions(+), 470 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 351dbdc..dd44776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,134 @@ --- +## v2026.5.44 — Refonte topbar, personnalisation Apparence, onboarding équipe, refresh séquentiel + +> Refonte visuelle de la topbar (vue classique + horizontale), nouveau panneau +> de personnalisation (couleur de la barre du haut + police de l'application +> sur toute la page), nouvelle expérience d'onboarding quand aucun technicien +> n'est sélectionné, refonte du système de verdicts ghost (✓✓ clos / ✓ Fait / +> ✓ Suspendu), refresh strictement séquentiel avec arrêt instantané, et +> plusieurs corrections. + +### Refresh / cache / verdicts ghost + +- Rafraîchissement **séquentiel** (1 fiche à la fois) au lieu de 5 workers + parallèles → arrêt instantané via le bouton « ✕ Arrêter » (AbortController), + plus de races DOM, ordre d'affichage cohérent (pompier d'abord, puis alpha, + puis matin → après-midi). +- Re-fetch du checksum frais via `basicAutoComplete` + `redirectHeader` + (plus de fiche périmée entre sessions). +- Cache merge robuste (fallback `cachedByRef` quand `actionId` change) et + cache écrit toutes les 5 fiches pendant le refresh (incrémental). +- **Système de verdicts ghost unifié** : ✓✓ vert (clos / résolu officiel), + ✓ gris « Fait » (terminated-pending), ✓ jaune « Suspendu » + (terminated-suspended), retrait silencieux pour cancelled / cancelled- + reservation / cancelled-absence. +- Statuts EV (clos / résolu / annulé / suspendu) éditables depuis Paramètres + → EasyVista avec matching insensible à la casse, accents et conjugaisons. +- Mise à jour live du tooltip et du popup épinglé après un verdict (plus + besoin de fermer/réouvrir). +- Clic immédiat sur la carte dès que le verdict tombe (avant la fin du + refresh complet). +- Boutons « Actualiser » (rapide, ne re-télécharge pas les fiches déjà + connues) vs « Tout recharger » (force tout sauf les ✓✓ déjà clos). +- **Mode diagnostic optionnel** (Paramètres → Diagnostics) : aucune + intervention disparue n'est retirée silencieusement, tout est tracé sous + le préfixe `[disparition]` dans la console F12 pour debug. En PROD + (par défaut), les iv `cancelled` sont bien retirées comme avant. + +### Topbar — vue classique + +- Sélecteur de date du planning **ancré au centre absolu** : il ne se décale + plus quand le bouton « ✕ Arrêter » apparaît à droite pendant un + rafraîchissement. +- Bouton **« Aujourd'hui »** affiché en toutes lettres (au lieu de « Auj. »). +- Horloge contextuelle (date du jour + heure) réduite et discrète, à côté + du bouton Aujourd'hui dans un cadre encadré. +- Date du planning agrandie et neutre (couleur stable, plus de bascule + selon la date sélectionnée). + +### Personnalisation — Paramètres → Apparence + +- **Couleur de la barre du haut** : 12 presets cliquables (Défaut, Blanc, + Gris clair, Anthracite, Bleu DGNSI, Marine, Vert sapin, Brique, Violet, + Rouge, Bleu pastel, Vert pastel) + picker custom + champ hex `#rrggbb` + + bouton « Réinitialiser ». +- La couleur s'applique uniquement à la topbar (et à la sidebar quand on + est en vue horizontale). +- Le texte de la topbar (titre, horloge, date, capture-info, badges, + boutons) s'adapte automatiquement (clair/foncé) selon la **luminance** + de la couleur choisie pour rester toujours lisible. +- **Police de l'application** : 28 choix organisés en familles + (sans-serif : Arial, Helvetica, Verdana, Tahoma, Trebuchet, Calibri, + Segoe UI, Gill Sans, Futura, Optima ; serif : Times New Roman, Georgia, + Cambria, Garamond, Palatino, Bookman ; monospace : Courier New, Consolas, + Lucida Console, JetBrains Mono ; display : Comic Sans MS, Impact, + Brush Script, Copperplate ; condensée : Arial Narrow). La police choisie + s'applique à **toute la page** (topbar, cards, popups, tooltips, panel + admin) et chaque option du select s'affiche dans sa propre police pour + prévisualiser le rendu, avec un aperçu live à droite. +- Export / import du cache et de `admin_config` depuis Paramètres → + Diagnostics. + +### Vue horizontale + +- Bloc « Aujourd'hui + horloge » empilé verticalement dans la sidebar, dans + le même cadre encadré que la vue classique. +- Date sélectionnée mise en avant (taille augmentée, en gras), date du + jour et heure réduites à la même petite taille pour rester discrètes. +- **Barre verticale verte** ajoutée à droite des mini-cards quand le + ticket est officiellement clôturé / résolu (✓✓), avec léger décalage du + ✓✓ pour ne pas chevaucher la barre. +- Quand l'utilisateur a choisi une couleur de topbar, la sidebar prend + aussi la couleur : titre, horloge, capture-info, stats, today-block, + date sélectionnée, boutons, theme-toggle et séparateurs adoptent une + teinte translucide cohérente (via `color-mix`) qui contraste correctement + sur n'importe quel fond. + +### Statistiques globales + +- Nouveau compteur **« X faits / Y clos »** entre `(matin · après-midi)` + et `tech. dispo`. Inclut tous les tickets terminés (clos/résolus officiels + + verdicts ghost « Fait » / « Suspendu »). +- En vue classique, séparateur `//` après `clos` (au lieu de `·`). +- En vue horizontale (sidebar), une **barre horizontale 1px** sépare le + bloc interventions/faits/clos du bloc tech. dispo + pompiers / absents. + +### Onboarding équipe (1ʳᵉ install ou config vide) + +- L'erreur générique « Aucun technicien sélectionné » est remplacée par une + **carte d'onboarding centrée** comprenant : + - icône (👥) cerclée en couleur accent du thème ; + - titre « Aucune équipe configurée » ; + - description claire ; + - bouton primary **« Ouvrir paramètres »** qui ouvre directement le panel + admin sur la section Équipe. +- Carte centrée verticalement et horizontalement dans la zone disponible, + identique en vue classique et horizontale. + +### Bugfix + +- **Issue #1 (Pompier + Absence)** : si un tech est à la fois pompier ET + absent, les deux badges s'affichent désormais avec un séparateur `/` au + lieu de masquer l'absence derrière le badge pompier. +- **Absences récurrentes** : quand on changeait de groupe puis revenait au + groupe initial, les jours d'absence cochés pour les techniciens + disparaissaient visuellement (la donnée elle-même restait en storage). + Correction : restauration depuis `cfg.recurringAbsences` à chaque + re-render. +- **Barre de progression / bannière session expirée** : suivent désormais + la hauteur dynamique de la topbar (variable CSS `--topbar-height` mesurée + par un `ResizeObserver`). Plus de chevauchement quand on scrolle. +- **STATUS_FR regex** : limite augmentée de 30 à 200 caractères (battait + sur « Suspendu : Attente info bénéficiaire/demandeur »). +- **Description action** : décodage `" → "`, `
→ \n`, HTML + strippé. Préfixe « login: » retiré du commentaire technicien dans le + tooltip / popup. +- **Tooltip référence** : flèche « ↗ » retirée du lien cliquable. + +--- + ## v2026.5.43 — Fix Firefox : positionnement menu dock + stabilité popup pin/unpin ### Menu hover sur pastille du dock (popup réduit) diff --git a/firefox-updates.json b/firefox-updates.json index 1782feb..2782486 100644 --- a/firefox-updates.json +++ b/firefox-updates.json @@ -2,10 +2,15 @@ "addons": { "planification-dgnsi@netaplaid.ch": { "updates": [ + { + "version": "2026.5.44", + "update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.44/planification-v2026.5.44-firefox.xpi", + "update_hash": "sha256:e56e87d59c465e5df828b18d74376f561bf34e81e21bf4d70989a709e89217e0" + }, { "version": "2026.5.43", "update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.43/planification-v2026.5.43-firefox.xpi", - "update_hash": "sha256:2bdf1b0a781080f4a86600579eb8c2049e060b9e8a0439212f3f29d280d5b93e" + "update_hash": "sha256:7052200fab3c9266d5b809398a00dac768679ab2e96e4e147e4bb86c4ab648e5" }, { "version": "2026.5.42", diff --git a/src/background.js b/src/background.js index fff22be..c8ae706 100644 --- a/src/background.js +++ b/src/background.js @@ -299,14 +299,39 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) { * @param {string} origin - origine EasyVista (pour construire le Referer) * @param {object} [opts] - options fetch (method, body, headers supplémentaires) */ +// registre global des AbortController des fetchs EV en vol. Permet +// au foreground (viewer.js) d'envoyer un message "abortAllFetches" pour +// tuer instantanément les requêtes en cours quand l'user clique "Arrêter". +const _evFetchControllers = new Set(); +function _abortAllEvFetches() { + for (const c of _evFetchControllers) { + try { c.abort(); } catch (e) { /* ignore */ } + } + _evFetchControllers.clear(); +} + async function evFetch(url, origin, opts = {}) { const defaultHeaders = { "Referer": `${origin}/index.php?eventName=HelpDesk_PlanningItem`, "X-Requested-With": "XMLHttpRequest" }; const headers = Object.assign({}, defaultHeaders, opts.headers || {}); - const fetchOpts = Object.assign({ credentials: "include" }, opts, { headers }); - return await fetch(url, fetchOpts); + // on ne remplace pas un signal explicitement passé par l'appelant. + let controller = null; + if (!opts.signal) { + controller = new AbortController(); + _evFetchControllers.add(controller); + } + const fetchOpts = Object.assign( + { credentials: "include" }, + opts, + { headers, signal: opts.signal || (controller && controller.signal) } + ); + try { + return await fetch(url, fetchOpts); + } finally { + if (controller) _evFetchControllers.delete(controller); + } } /** @@ -376,10 +401,10 @@ async function fetchFicheHtml(origin, phpsessid, formLink) { continue; } - // Sinon : on retourne ce qu'on a - return html; + // on signale au foreground si la dernière réponse est tronquée pour + // qu'il puisse afficher un ⚠ et probe la session. + return { html, truncated: html.length < MIN_VALID_SIZE, size: html.length }; } - // Ne devrait pas arriver (la boucle fait return avant) throw new Error("fetchFicheHtml: max retries reached"); } @@ -1225,6 +1250,13 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) { chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { (async () => { try { + // abort de toutes les requêtes EV en vol (clic sur "Arrêter"). + if (msg.type === "abortAllFetches") { + _abortAllEvFetches(); + sendResponse({ ok: true }); + return; + } + if (msg.type === "getSession") { const session = await findEasyVistaSession(); sendResponse({ ok: true, session }); @@ -1282,12 +1314,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return; } try { - const html = await fetchFicheHtml(session.origin, session.phpsessid, msg.formLink); + // fetchFicheHtml renvoie maintenant { html, truncated, size }. + const result = await fetchFicheHtml(session.origin, session.phpsessid, msg.formLink); + const html = result.html; if (looksLikeLoginPage(html)) { sendResponse({ ok: false, error: "session_expired" }); return; } - sendResponse({ ok: true, html, session }); + sendResponse({ ok: true, html, session, truncated: !!result.truncated, size: result.size }); } catch (err) { sendResponse({ ok: false, @@ -1299,6 +1333,34 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return; } + // probe rapide de session — fetch un endpoint léger pour vérifier + // que PHPSESSID est toujours valide. Renvoie ok=false/error=session_expired + // si la session est morte. + if (msg.type === "checkSession") { + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const url = `${session.origin}/index.php?eventName=HelpDesk_PlanningItem&PHPSESSID=${encodeURIComponent(session.phpsessid)}`; + const r = await evFetch(url, session.origin); + if (!r.ok) { + sendResponse({ ok: false, error: classifyHttpStatus(r.status), httpStatus: r.status }); + return; + } + const txt = await r.text(); + if (looksLikeLoginPage(txt) || txt.length < 5000) { + sendResponse({ ok: false, error: "session_expired" }); + return; + } + sendResponse({ ok: true }); + } catch (err) { + sendResponse({ ok: false, error: "fetch_failed", detail: err.message || String(err) }); + } + return; + } + if (msg.type === "fetchTimelineApi") { const session = await findEasyVistaSession(); if (!session) { diff --git a/src/manifest.json b/src/manifest.json index da5852b..1fc7f48 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planification", - "version": "2026.5.43", + "version": "2026.5.44", "description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin Rouiller — DGNSI, Canton de Vaud.", "permissions": [ "activeTab", diff --git a/src/viewer.css b/src/viewer.css index 080b3da..e873963 100644 --- a/src/viewer.css +++ b/src/viewer.css @@ -33,8 +33,9 @@ /* Palette par type d'intervention */ --c-livraison: #2563eb; --c-livraison-soft: #dbeafe; - --c-recup: #16a34a; - --c-recup-soft: #dcfce7; + /* R12e : récupération en TEAL (turquoise) — distinct des verts de statut. */ + --c-recup: #14b8a6; + --c-recup-soft: #ccfbf1; --c-remplacement: #ea580c; --c-remplacement-soft: #fed7aa; --c-incident: #8b5cf6; @@ -48,11 +49,18 @@ --c-autre: #6b7280; --c-autre-soft: #e5e7eb; - /* Statuts clos */ - --c-closed: #15803d; /* vert foncé = Clôturé */ - --c-closed-soft: #bbf7d0; - --c-resolved: #4ade80; /* vert clair = Résolu */ - --c-resolved-soft: #dcfce7; + /* Statuts clos + R12e : + - terminated reprend l'ANCIEN vert de récupération (#16a34a) — vert pur + - closed bascule sur un vert plus FONCÉ/bleuté pour bien le distinguer + visuellement de terminated. Les deux restent verts, mais opposés + sur la teinte. */ + --c-closed: #047857; /* vert sapin foncé = Clôturé */ + --c-closed-soft: #d1fae5; + --c-resolved: #65a30d; /* lime/vert-jaune = Résolu */ + --c-resolved-soft: #ecfccb; + --c-terminated: #16a34a; /* vert pur (= ex-recup) = Terminé par tech */ + --c-terminated-soft:#dcfce7; --shadow: 0 1px 3px rgba(20, 30, 50, 0.06), 0 1px 2px rgba(20, 30, 50, 0.04); --shadow-hover: 0 2px 8px rgba(20, 30, 50, 0.08); @@ -82,8 +90,9 @@ --c-livraison: #60a5fa; --c-livraison-soft: #1e3a5f; - --c-recup: #4ade80; - --c-recup-soft: #14432a; + /* R12e : récupération en teal (dark mode). */ + --c-recup: #2dd4bf; + --c-recup-soft: #134e4a; --c-remplacement: #fb923c; --c-remplacement-soft: #4a2512; --c-incident: #a78bfa; @@ -93,10 +102,13 @@ --c-autre: #9ca3af; --c-autre-soft: #2a2e36; - --c-closed: #22c55e; - --c-closed-soft: #14432a; - --c-resolved: #86efac; - --c-resolved-soft: #0f3320; + /* R12e dark : closed = vert sapin clair, resolved = lime, terminated = vert pur. */ + --c-closed: #34d399; + --c-closed-soft: #064e3b; + --c-resolved: #a3e635; + --c-resolved-soft: #1a2e05; + --c-terminated: #4ade80; + --c-terminated-soft:#14432a; --shadow: 0 1px 3px rgba(0, 0, 0, 0.3); --shadow-hover: 0 2px 10px rgba(0, 0, 0, 0.4); @@ -112,7 +124,7 @@ html, body { background: var(--bg); color: var(--text); font-family: var(--font); - font-size: 14px; + font-size: 12px; line-height: 1.5; } @@ -121,6 +133,154 @@ html, body { /* ========================================================================== Topbar ========================================================================== */ +/* couleur s'applique uniquement à la topbar, MAIS la police s'applique + à TOUTE la page (--app-font sur body, hérité par cards / popups / tooltips). + Le texte de la topbar utilise --topbar-text pour rester lisible quand l'user + choisit un fond foncé (calculé automatiquement par luminance). */ +body { + font-family: var(--app-font, inherit); +} + +/* quand l'utilisateur a choisi une couleur de topbar, les boutons et + le bloc Aujourd'hui+horloge adoptent un look translucide qui s'harmonise + avec le fond (au lieu de rester gris/blancs et devenir illisibles sur un + fond vert/bleu/foncé). Le `color-mix` produit une teinte translucide + dérivée de la couleur de texte calculée — donc cohérente et lisible. */ +html.has-topbar-color .topbar .btn, +html.has-topbar-color .topbar .btn-today, +html.has-topbar-color .topbar .btn-subtle, +html.has-topbar-color .topbar .btn-refresh, +html.has-topbar-color .topbar .btn-action, +html.has-topbar-color .topbar .btn-icon, +html.has-topbar-color .topbar #refresh-btn, +html.has-topbar-color .topbar #refresh-partial-btn, +html.has-topbar-color .topbar #clear-cache-btn, +html.has-topbar-color .topbar #theme-toggle { + color: var(--topbar-text, var(--text)) !important; + background: color-mix(in srgb, var(--topbar-text, var(--text)) 10%, transparent) !important; + border-color: color-mix(in srgb, var(--topbar-text, var(--text)) 28%, transparent) !important; +} +html.has-topbar-color .topbar .btn:hover, +html.has-topbar-color .topbar .btn-today:hover, +html.has-topbar-color .topbar .btn-subtle:hover, +html.has-topbar-color .topbar .btn-refresh:hover, +html.has-topbar-color .topbar .btn-action:hover, +html.has-topbar-color .topbar .btn-icon:hover { + background: color-mix(in srgb, var(--topbar-text, var(--text)) 20%, transparent) !important; + border-color: color-mix(in srgb, var(--topbar-text, var(--text)) 45%, transparent) !important; +} +html.has-topbar-color .today-block { + background: color-mix(in srgb, var(--topbar-text, var(--text)) 10%, transparent) !important; + border-color: color-mix(in srgb, var(--topbar-text, var(--text)) 28%, transparent) !important; +} +/* La date du planning de la TOPBAR seulement — on l'aligne aussi pour qu'elle + ressorte sans se mélanger au fond. ajout du scope `.topbar` pour + ne PAS toucher la date-custom de la sidebar horizontale (qui doit garder + son look accent / vert d'origine). */ +html.has-topbar-color .topbar .date-custom { + background: color-mix(in srgb, var(--topbar-text, var(--text)) 14%, transparent) !important; + border-color: color-mix(in srgb, var(--topbar-text, var(--text)) 40%, transparent) !important; + color: var(--topbar-text, var(--text)) !important; +} +/* Pastille user-badge + autres icônes textuelles : utiliser le texte topbar. */ +html.has-topbar-color .topbar .user-badge { + color: var(--topbar-text, var(--text)) !important; + border-color: color-mix(in srgb, var(--topbar-text, var(--text)) 35%, transparent) !important; +} + +/* la sidebar horizontale (= la "barre à gauche") suit la couleur + choisie pour la topbar — fond, texte, boutons et stats globales adaptés + automatiquement pour rester lisibles. */ +html.has-topbar-color.view-horizontal .horizontal-sidebar { + background: var(--topbar-bg) !important; + color: var(--topbar-text, var(--text)) !important; + border-right-color: color-mix(in srgb, var(--topbar-text) 30%, transparent) !important; +} +html.has-topbar-color.view-horizontal .horizontal-sidebar .app-clock-date, +html.has-topbar-color.view-horizontal .horizontal-sidebar .app-clock-time, +html.has-topbar-color.view-horizontal .horizontal-sidebar .capture-info, +html.has-topbar-color.view-horizontal .horizontal-sidebar .global-stat, +html.has-topbar-color.view-horizontal .horizontal-sidebar .global-stat b, +html.has-topbar-color.view-horizontal .horizontal-sidebar .global-stat-sub, +html.has-topbar-color.view-horizontal .horizontal-sidebar .global-stat-sep, +html.has-topbar-color.view-horizontal .horizontal-sidebar #app-title { + color: var(--topbar-text, var(--text)) !important; +} +/* on duplique le sélecteur exact (".date-nav .date-custom") pour + battre la spécificité de la règle de base qui force un fond muted neutre. */ +html.has-topbar-color.view-horizontal .horizontal-sidebar .today-block, +html.has-topbar-color.view-horizontal .horizontal-sidebar .date-nav .date-custom, +html.has-topbar-color.view-horizontal .horizontal-sidebar .date-custom { + background: color-mix(in srgb, var(--topbar-text) 10%, transparent) !important; + border-color: color-mix(in srgb, var(--topbar-text) 28%, transparent) !important; + color: var(--topbar-text, var(--text)) !important; +} +html.has-topbar-color.view-horizontal .horizontal-sidebar button.btn, +html.has-topbar-color.view-horizontal .horizontal-sidebar .btn-today, +html.has-topbar-color.view-horizontal .horizontal-sidebar .btn-action, +html.has-topbar-color.view-horizontal .horizontal-sidebar .btn-refresh, +html.has-topbar-color.view-horizontal .horizontal-sidebar .btn-subtle, +html.has-topbar-color.view-horizontal .horizontal-sidebar .btn-icon, +html.has-topbar-color.view-horizontal .horizontal-sidebar .btn-nav { + color: var(--topbar-text, var(--text)) !important; + background: color-mix(in srgb, var(--topbar-text) 10%, transparent) !important; + border-color: color-mix(in srgb, var(--topbar-text) 28%, transparent) !important; +} +html.has-topbar-color.view-horizontal .horizontal-sidebar button.btn:hover { + background: color-mix(in srgb, var(--topbar-text) 20%, transparent) !important; + border-color: color-mix(in srgb, var(--topbar-text) 45%, transparent) !important; +} + +/* icône change-thème en sidebar horizontale — même look translucide + que les autres boutons quand la topbar a une couleur custom. Sans cette + règle l'icône restait blanche/grise et tranchait sur le fond coloré. */ +html.has-topbar-color.view-horizontal .horizontal-sidebar #theme-toggle { + color: var(--topbar-text, var(--text)) !important; + background: color-mix(in srgb, var(--topbar-text) 10%, transparent) !important; + border-color: color-mix(in srgb, var(--topbar-text) 28%, transparent) !important; +} +html.has-topbar-color.view-horizontal .horizontal-sidebar #theme-toggle:hover { + background: color-mix(in srgb, var(--topbar-text) 22%, transparent) !important; + border-color: color-mix(in srgb, var(--topbar-text) 50%, transparent) !important; +} +html.has-topbar-color.view-horizontal .horizontal-sidebar #theme-toggle #theme-icon { + filter: none; +} + +/* séparateurs visibles de la sidebar (border-top/bottom du #stats, + trait au-dessus du 1er bouton, separator-after-clos) prennent une teinte + dérivée du --topbar-text quand l'utilisateur a une couleur custom — sinon + ils restaient gris foncé invisibles sur fond coloré. */ +html.has-topbar-color.view-horizontal .horizontal-sidebar #stats { + border-top-color: color-mix(in srgb, var(--topbar-text) 30%, transparent) !important; + border-bottom-color: color-mix(in srgb, var(--topbar-text) 30%, transparent) !important; +} +html.has-topbar-color.view-horizontal .horizontal-sidebar #absence-btn::before { + background: color-mix(in srgb, var(--topbar-text) 30%, transparent) !important; +} + +/* barre verticale VERTE à droite des mini-cards UNIQUEMENT quand le + ticket est officiellement CLOS / RÉSOLU (✓✓). Pas pour "Fait" pending, + pas pour Suspendu. */ +html.view-horizontal .iv-mini-card.status-closed, +html.view-horizontal .iv-mini-card.status-resolved { + position: relative; +} +html.view-horizontal .iv-mini-card.status-closed::before, +html.view-horizontal .iv-mini-card.status-resolved::before { + content: ""; + position: absolute; + right: 0; + top: 4px; + bottom: 4px; + width: 4px; + background: var(--ok, #2e7b4a); + border-radius: 2px; + z-index: 2; +} + +/* .iv-done-separator retiré — l'info "X faits" est dans la + barre stats globale au format "X faits / Y clos". */ .topbar { position: sticky; top: 0; @@ -129,7 +289,8 @@ html, body { justify-content: space-between; align-items: center; padding: 10px 20px; - background: var(--bg-elevated); + background: var(--topbar-bg, var(--bg-elevated)); + color: var(--topbar-text, var(--text)); border-bottom: 1px solid var(--border); box-shadow: var(--shadow); gap: 12px; @@ -149,28 +310,60 @@ html, body { la nav date et enfin la capture-info. Ce verrou évite que des composants restaurés depuis la sidebar (vue horizontale → classique) finissent dans le mauvais ordre. */ -html.view-classic .topbar-left #user-badge { order: 1; } -html.view-classic .topbar-left #app-title { order: 2; } -html.view-classic .topbar-left .date-nav { order: 3; } -html.view-classic .topbar-left .capture-info { order: 4; } +/* ordre topbar — initiales | titre | bloc Auj.+horloge | MAJ | + refresh-check | sélecteur date du planning CENTRÉ via marges auto. */ +html.view-classic .topbar-left #user-badge { order: 1; } +html.view-classic .topbar-left #app-title { order: 2; } +html.view-classic .topbar-left #today-block { order: 3; } +html.view-classic .topbar-left .capture-info { order: 4; } html.view-classic .topbar-left #refresh-check { order: 5; } +/* on positionne la nav-date EN ABSOLU au centre de la topbar (et plus + en flex avec marges auto). Sans ça, l'apparition du bouton "Arrêter" dans + la topbar-right faisait rétrécir topbar-left, et le centrage relatif de la + date glissait. Avec position: absolute, la date est ancrée au centre du + viewport ; les éléments left/right peuvent grandir/rétrécir librement + sans déplacer la date. */ +html.view-classic .topbar-left .date-nav { + order: 6; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + margin: 0 !important; + z-index: 1; + pointer-events: auto; +} + +/* encadré qui regroupe le bouton "Aujourd'hui" + l'horloge actuelle. */ +.today-block { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 4px 10px 4px 4px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-muted); +} +.today-block #nav-today { margin: 0; } +.today-block #app-clock { margin: 0; } .topbar h1 { margin: 0; font-size: 18px; font-weight: 600; - color: var(--text); + color: var(--topbar-text, var(--text)); white-space: nowrap; } .capture-info { font-size: 12px; - color: var(--text-muted); + color: var(--topbar-text, var(--text-muted)); + opacity: 0.85; white-space: nowrap; } .refresh-check { - font-size: 14px; + font-size: 12px; color: var(--c-recup); /* vert */ font-weight: 700; opacity: 0; @@ -192,10 +385,13 @@ html.view-classic .topbar-left #refresh-check { order: 5; } flex-shrink: 0; } -/* Bannière de session expirée (v4.1.12) — sticky sous la topbar, non bloquante */ +/* Bannière de session expirée (v4.1.12) — sticky sous la topbar, non bloquante. + on utilise --topbar-height (calculé en JS via ResizeObserver) au lieu + d'une valeur fixe — la topbar grandit/rétrécit selon la police, la taille + de la date, le zoom, etc. */ .session-banner { position: sticky; - top: 56px; + top: var(--topbar-height, 56px); z-index: 8; display: flex; align-items: center; @@ -206,7 +402,7 @@ html.view-classic .topbar-left #refresh-check { order: 5; } color: #fff; border-top: 2px solid #ff6060; border-bottom: 2px solid #7a1515; - font-size: 14px; + font-size: 12px; font-weight: 500; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); /* petite animation d'apparition pour attirer l'œil */ @@ -289,7 +485,8 @@ html.view-classic .topbar-left #refresh-check { order: 5; } barre verte est dessous). */ .progress-bar { position: sticky; - top: 56px; + /* suit la hauteur réelle de la topbar (calculée en JS). */ + top: var(--topbar-height, 56px); z-index: 9; height: 22px; /* v4.1.17 : backdrop-blur sur toute la barre → ce qui défile derrière @@ -350,22 +547,26 @@ html.view-classic .topbar-left #refresh-check { order: 5; } display: inline-flex; align-items: center; } +/* date du planning vue classique — réduite de 10% (31 → 28px). */ .date-custom { display: inline-flex; align-items: center; - gap: 8px; - padding: 5px 10px 5px 12px; - border: 1px solid var(--border); - border-radius: 6px; + justify-content: center; + gap: 0; + padding: 6px 22px; + line-height: 1; + border: 1px solid var(--border-strong); + border-radius: 18px; background: var(--bg-muted); - color: var(--text); + color: var(--topbar-text, var(--text)); font-family: inherit; - font-size: 13px; - font-weight: 500; + font-size: 28px; + font-weight: 700; cursor: pointer; white-space: nowrap; user-select: none; - transition: border-color 0.15s, background 0.15s; + transition: border-color 0.15s, background 0.15s, transform 0.1s; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); } .date-custom:hover { border-color: var(--border-strong); @@ -375,10 +576,27 @@ html.view-classic .topbar-left #refresh-check { order: 5; } outline: 2px solid var(--accent); outline-offset: -1px; } -.date-custom-icon { - font-size: 13px; - opacity: 0.7; +/* badge rouge "Auj. JJ.MM" qui apparaît quand la date affichée + n'est PAS aujourd'hui. Cliquable → revient à Auj. */ +.today-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + margin-left: 6px; + border: 1px solid var(--danger); + background: var(--danger-soft); + color: var(--danger); + border-radius: 999px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: filter 0.1s; } +.today-badge:hover { filter: brightness(0.95); } +.today-badge.hidden { display: none; } +.today-badge::before { content: "🔴"; margin-right: 2px; } .date-input-hidden { position: absolute; top: 100%; @@ -398,9 +616,15 @@ html.view-classic .topbar-left #refresh-check { order: 5; } min-width: 32px; } +/* bouton "Aujourd'hui" — texte complet, centré, plus visible. + Reste cliquable pour revenir au jour courant à tout moment. */ .btn-today { - padding: 6px 10px; - font-size: 12px; + padding: 7px 16px; + font-size: 13px; + font-weight: 600; + min-width: 110px; + text-align: center; + justify-content: center; } .date-input { @@ -536,18 +760,149 @@ html.view-classic .topbar-left #refresh-check { order: 5; } padding: 40px 20px; text-align: center; color: var(--text-muted); - font-size: 14px; + font-size: 12px; } +/* error-box — bandeau classique pour les vraies erreurs (rouge). */ .error-box { margin: 20px; - padding: 14px 18px; + padding: 16px 20px; background: var(--danger-soft); color: var(--danger); border: 1px solid var(--danger); border-radius: var(--radius); - font-size: 14px; + font-size: 13px; line-height: 1.55; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + max-width: 800px; +} +.error-box .btn-primary { + flex: 0 0 auto; + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + background: var(--danger); + color: #fff; + border: 1px solid var(--danger); + border-radius: 8px; + cursor: pointer; + transition: background 0.15s, transform 0.1s, box-shadow 0.15s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); +} +.error-box .btn-primary:hover { + background: #8a2424; + border-color: #8a2424; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); +} +.error-box .btn-primary:active { + transform: translateY(1px); +} + +/* en vue horizontale, .cards est flex:1 — quand il est vide, il + continue de manger toute la largeur, ce qui empêche margin:auto de + centrer la carte d'onboarding. On collapse .cards quand il est vide. + Couvre aussi la vue classique (pas de regression — .cards vide n'est + visible nulle part). */ +.cards:empty { + display: none !important; +} + +/* positionnement ABSOLU centré pour la carte d'onboarding en + horizontal — approche robuste qui ne dépend pas du flex flow de main et + garantit le centre exact (horizontal + vertical) de la zone main, peu + importe la largeur de la sidebar ou le contenu. */ +html.view-horizontal main#main { + position: relative; + min-height: calc(100vh - var(--topbar-height, 56px)); +} +html.view-horizontal .error-box.error-box-centered { + position: absolute !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; + margin: 0 !important; +} + +/* variante "centered notice" — carte centrée, neutre (pas rouge), + layout vertical icon + titre + description + bouton. Utilisée pour les + états d'onboarding (équipe non configurée). Identique en classique et + horizontale (centrée dans la zone main). */ +.error-box.error-box-centered { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + width: min(480px, calc(100% - 40px)); + max-width: 480px; + /* marge auto horizontale pour centrer en classique (parent main + non-flex). Marge verticale 0 — le centrage vertical est piloté par + align-items: center du parent flex en horizontal (cf. règle :has). */ + margin: 0 auto; + padding: 36px 32px; + background: var(--bg-elevated); + color: var(--text); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); + gap: 0; + flex: 0 0 auto; +} +/* en classique, on garde une marge de 80px en haut pour ne pas + coller la carte sous la topbar (qui n'a pas de min-height en classique). */ +html.view-classic .error-box.error-box-centered { + margin: 80px auto; +} +.error-box.error-box-centered .error-icon { + font-size: 56px; + line-height: 1; + margin-bottom: 18px; + display: inline-flex; + width: 88px; + height: 88px; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent); +} +.error-box.error-box-centered .error-title { + font-size: 20px; + font-weight: 700; + color: var(--text); + margin: 0 0 10px 0; +} +.error-box.error-box-centered .error-description { + font-size: 14px; + font-weight: 400; + color: var(--text-muted); + line-height: 1.6; + margin: 0 0 24px 0; + max-width: 380px; +} +.error-box.error-box-centered .btn-primary { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 22px; + font-size: 14px; + font-weight: 600; + background: var(--accent); + color: #fff; + border: 1px solid var(--accent); + border-radius: 10px; + cursor: pointer; + transition: filter 0.15s, transform 0.1s, box-shadow 0.15s; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12); +} +.error-box.error-box-centered .btn-primary:hover { + filter: brightness(0.92); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18); +} +.error-box.error-box-centered .btn-primary:active { + transform: translateY(1px); } .session-needed { @@ -639,7 +994,7 @@ html.view-classic .topbar-left #refresh-check { order: 5; } .card-tech-name { font-weight: 600; - font-size: 14px; + font-size: 12px; color: var(--text); } @@ -652,6 +1007,29 @@ html.view-classic .topbar-left #refresh-check { order: 5; } font-weight: 600; } +/* (issue #1) : séparateur "/" entre badges Pompier + Absence — visible + et bien centré pour qu'il ne se confonde pas avec l'un des deux libellés. */ +.card-tech-badge-sep { + display: inline-block; + font-size: 16px; + font-weight: 700; + color: var(--text-muted); + margin: 0 6px; + align-self: center; + line-height: 1; + user-select: none; +} + +/* (issue #1) : container des badges collé à droite du header. Le + parent .card-header utilise display:flex + justify-content:space-between + donc ce wrapper se positionne à l'extrémité opposée du nom du tech. */ +.card-tech-badge-wrap { + display: inline-flex; + align-items: center; + margin-left: auto; + flex-shrink: 0; +} + .badge-pompier { background: var(--danger-soft); color: var(--danger); @@ -754,9 +1132,11 @@ html.view-classic .topbar-left #refresh-check { order: 5; } .timeline-slot.color-reservation { background: var(--c-reservation); } .timeline-slot.color-autre { background: var(--c-autre); } -/* Statuts clos sur la timeline */ -.timeline-slot.status-closed { background: var(--c-closed); } -.timeline-slot.status-resolved { background: var(--c-resolved); } +/* R12c : statuts clos/résolu/terminé sur la timeline → on GARDE la couleur + de catégorie en fond et on ajoute un bandeau vert en BAS de la slot pour + signaler que c'est terminé. La couleur de la catégorie reste lisible. */ +.timeline-slot.status-closed { box-shadow: inset 0 -4px 0 var(--c-closed); } +.timeline-slot.status-resolved { box-shadow: inset 0 -4px 0 var(--c-resolved); } .timeline-slot.kind-absence { /* v5.0.15 : uni gris-noir au lieu de rayé, plus lisible */ @@ -817,7 +1197,7 @@ html.view-horizontal .timeline-noon { .timeline-tick { position: absolute; transform: translateX(-50%); - font-size: 10px; + font-size: 12px; color: var(--text-faint); font-family: var(--mono); } @@ -933,6 +1313,34 @@ html.view-horizontal .timeline-noon { background: #c44040 !important; } +/* conflit horaire entre deux interventions du même tech → rouge plein + (carte classique + mini-card timeline horizontale). */ +.intervention-v2.intervention-conflict-overlap, +.iv-mini-card.intervention-conflict-overlap { + background: #b03030 !important; + color: #ffffff !important; + border-color: #7a1f1f !important; +} +.intervention-v2.intervention-conflict-overlap::before { + background: #7a1f1f !important; +} +.intervention-v2.intervention-conflict-overlap .intervention-dot, +.intervention-v2.intervention-conflict-overlap .iv-status-check, +.intervention-v2.intervention-conflict-overlap a, +.iv-mini-card.intervention-conflict-overlap .iv-mini-card-bar, +.iv-mini-card.intervention-conflict-overlap .iv-mini-time-vertical, +.iv-mini-card.intervention-conflict-overlap .iv-mini-card-text { + color: #ffffff !important; + background: transparent !important; +} +.iv-mini-card.intervention-conflict-overlap .iv-mini-card-bar { + background: rgba(255, 255, 255, 0.45) !important; +} +.intervention-v2.intervention-conflict-overlap:hover, +.iv-mini-card.intervention-conflict-overlap:hover { + background: #c44040 !important; +} + /* ========================================================================== Interventions — layout v2 (heures verticales) ========================================================================== */ @@ -977,51 +1385,34 @@ html.view-horizontal .timeline-noon { .intervention-v2.clickable { cursor: pointer; } .intervention-v2.clickable:active { transform: translateY(1px); } +/* R12d : on GARDE la couleur de catégorie visible (.intervention-dot reste + en couleur de catégorie + fond neutre). Le statut clos/résolu/terminé est + signalé par un bandeau vert sur la DROITE de la carte (inset shadow) + + le ✓✓ ou ✓ existant. */ .intervention-v2.status-closed { - background: var(--c-closed-soft); - box-shadow: inset 4px 0 0 var(--c-closed); + box-shadow: inset -4px 0 0 var(--c-closed); } .intervention-v2.status-closed:hover { - background: var(--c-closed-soft); filter: brightness(0.96); } -.intervention-v2.status-closed .intervention-dot { - background: var(--c-closed); - width: 5px; -} .intervention-v2.status-resolved { - background: var(--c-resolved-soft); - box-shadow: inset 4px 0 0 var(--c-resolved); + box-shadow: inset -4px 0 0 var(--c-resolved); } .intervention-v2.status-resolved:hover { - background: var(--c-resolved-soft); filter: brightness(0.96); } -.intervention-v2.status-resolved .intervention-dot { - background: var(--c-resolved); - width: 5px; -} -/* v4.2.5 : statut "terminée par le tech" (commentaire LOGIN: détecté). - Vert PLUS CLAIR que status-closed (distinction visuelle du ✓ simple - vs ✓✓ double). */ .intervention-v2.status-terminated { - background: var(--c-recup-soft, rgba(63, 185, 80, 0.12)); - box-shadow: inset 4px 0 0 var(--c-recup, #3fb950); + box-shadow: inset -4px 0 0 var(--c-terminated, #16a34a); } .intervention-v2.status-terminated:hover { - background: var(--c-recup-soft, rgba(63, 185, 80, 0.12)); filter: brightness(0.96); } -.intervention-v2.status-terminated .intervention-dot { - background: var(--c-recup, #3fb950); - width: 5px; -} .intervention-v2.status-terminated .iv-status-check { - color: var(--c-recup, #3fb950); + color: var(--c-terminated, #16a34a); } -.timeline-slot.status-terminated { background: var(--c-recup, #3fb950); } +.timeline-slot.status-terminated { box-shadow: inset 0 -4px 0 var(--c-terminated, #16a34a); } /* v4.2.5 : carte "en cours d'analyse" (ghost juste disparu, on re-fetch la fiche pour décider du sort). Opacité réduite + petit spinner discret. */ @@ -1081,10 +1472,29 @@ html.view-horizontal .timeline-noon { } .intervention-v2.status-resolved .iv-status-check { color: var(--c-resolved); } +/* ✓ jaune pour les iv "terminated-suspended" (Suspendu côté EV). + Pas de fond vert sur la carte — juste un check coloré jaune ambre. */ +.iv-status-check.suspended { color: #d39c00; } +.intervention-v2.status-suspended .iv-status-check { color: #d39c00; } +.iv-mini-status-check.suspended { color: #d39c00; } + +/* ⚠ jaune en haut-droite quand la fiche est tronquée / session morte. */ +.iv-fetch-warning { + position: absolute; + top: 6px; + right: 8px; + font-size: 12px; + color: #d39c00; + pointer-events: auto; + cursor: help; + z-index: 2; + text-shadow: 0 0 2px rgba(0,0,0,0.4); +} + /* v4.2.5 : ✓✓ double check (clôturé/résolu) — un peu plus petit pour tenir les 2 caractères. Espacement négatif pour les rapprocher. */ .iv-status-check.double { - font-size: 14px; + font-size: 12px; letter-spacing: -3px; padding-right: 3px; /* compenser le letter-spacing côté droit */ } @@ -1244,13 +1654,46 @@ html.view-horizontal .timeline-noon { margin-left: auto; white-space: nowrap; } -/* v4.1.17 : si statut clos/résolu, le ✓ est à droite en absolute → décaler - la signature pour ne pas se chevaucher */ +/* v4.1.17 : si statut clos/résolu, le ✓✓ est à droite en absolute → décaler + la signature. + ✓✓ double prend plus de place → 32px ; ✓ simple → 22px. */ .intervention-v2.status-closed .iv-signature, .intervention-v2.status-resolved .iv-signature { + padding-right: 32px; +} +.intervention-v2.has-pending-check .iv-signature, +.intervention-v2.status-terminated .iv-signature { padding-right: 22px; } +/* ✓ / ✓✓ dans la mini-card de la timeline horizontale */ +.iv-mini-card { + position: relative; /* pour positionner le check absolute */ +} +.iv-mini-status-check { + position: absolute; + top: 4px; + right: 6px; + font-size: 12px; + font-weight: 700; + line-height: 1; + color: var(--c-closed); + pointer-events: none; + z-index: 1; +} +/* en vue horizontale, la barre verte verticale est à right:0 + 4px. + Sans décalage, le ✓✓ chevauche la barre. On le pousse à droite +14px pour + qu'il reste visible et lisible à côté de la barre. */ +html.view-horizontal .iv-mini-card.status-closed .iv-mini-status-check, +html.view-horizontal .iv-mini-card.status-resolved .iv-mini-status-check { + right: 14px; +} +.iv-mini-card.status-resolved .iv-mini-status-check { color: var(--c-resolved); } +.iv-mini-status-check.double { + letter-spacing: -2px; + font-size: 11px; +} + /* Réservation (créneau bloqué par un coordinateur) */ .iv-ref-header.is-reservation-title { color: var(--c-reservation); @@ -1411,13 +1854,7 @@ html.view-horizontal .timeline-noon { color: var(--accent); text-shadow: 0 0 6px rgba(59, 130, 246, 0.4); } -.tt-ref-link::after { - content: " ↗"; - font-family: inherit; - font-size: 0.85em; - opacity: 0.7; - margin-left: 2px; -} +/* flèche " ↗" retirée de la référence cliquable. */ .tt-copy-btn { background: transparent; border: 1px solid var(--border); @@ -1814,7 +2251,7 @@ html.view-horizontal .timeline-noon { flex-shrink: 0; } .btn-action-emoji { - font-size: 14px; + font-size: 12px; line-height: 1; } .btn-action-label { @@ -1960,7 +2397,7 @@ body.modal-open { } .iv-time-overlap-warn { color: var(--danger, #b03030); - font-size: 14px; + font-size: 12px; font-weight: 700; line-height: 1; margin-top: 2px; @@ -2093,44 +2530,40 @@ body.modal-open { /* ───────────────────────────────────────────────────────────────────────── v5.0.0 : horloge au milieu de la topbar (HH:MM, pas de secondes) ───────────────────────────────────────────────────────────────────────── */ -/* 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 +15% (12 → 14px). */ .app-clock { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - display: flex; + display: inline-flex; flex-direction: row; align-items: center; - justify-content: center; - gap: 12px; - line-height: 1.1; - color: var(--text); - pointer-events: none; + gap: 5px; + line-height: 1; + color: var(--topbar-text, var(--text-faint)); user-select: none; white-space: nowrap; + font-size: 14px; + font-weight: 500; + opacity: 0.8; + margin-left: 2px; } .app-clock-date { - font-size: 22px; - font-weight: 600; - color: var(--text); - letter-spacing: 0.5px; + font-size: 14px; + font-weight: 500; + color: var(--topbar-text, var(--text-muted)); + letter-spacing: 0.15px; font-variant-numeric: tabular-nums; } .app-clock-date::after { content: "•"; - margin-left: 12px; - color: var(--text-muted); - font-size: 26px; - line-height: 0.8; - vertical-align: middle; + margin-left: 5px; + color: var(--topbar-text, var(--text-faint)); + font-size: 14px; } .app-clock-time { - font-size: 22px; + font-size: 14px; font-weight: 600; + color: var(--topbar-text, var(--text-muted)); font-variant-numeric: tabular-nums; - letter-spacing: 1px; + letter-spacing: 0.3px; } .topbar { position: sticky; /* déja défini plus haut */ } /* topbar doit être en position: relative parent pour que .app-clock absolute @@ -2241,7 +2674,7 @@ header.topbar::before { background: transparent; border: none; cursor: pointer; - font-size: 14px; + font-size: 12px; color: var(--text); border-left: 3px solid transparent; transition: background 0.12s, border-color 0.12s; @@ -2442,7 +2875,7 @@ header.topbar::before { display: none; } .app-session .session-icon { - font-size: 14px; + font-size: 12px; } .app-session .session-time { font-weight: 600; @@ -2520,7 +2953,7 @@ header.topbar::before { display: flex; align-items: center; gap: 10px; - font-size: 14px; + font-size: 12px; font-weight: 500; border-bottom: 1px solid rgba(0, 0, 0, 0.1); } @@ -2565,7 +2998,7 @@ header.topbar::before { display: flex; align-items: center; gap: 12px; - font-size: 14px; + font-size: 12px; font-weight: 500; border-bottom: 1px solid rgba(0, 0, 0, 0.1); } @@ -2596,9 +3029,10 @@ header.topbar::before { /* Breakpoint medium : entre 1000 et 1300px, on compacte un peu */ @media (max-width: 1300px) { - .app-clock-date { font-size: 18px; } - .app-clock-time { font-size: 18px; } - .app-clock-date::after { font-size: 20px; } + /* horloge garde sa petite taille à tous les breakpoints. */ + .app-clock-date { font-size: 14px; } + .app-clock-time { font-size: 14px; } + .app-clock-date::after { font-size: 14px; } .topbar-right .btn-action .btn-action-label, .topbar-right .btn-refresh .btn-refresh-label { font-size: 13px; @@ -2610,9 +3044,10 @@ header.topbar::before { @media (max-width: 1000px) { .topbar { padding: 8px 14px; gap: 8px; } .topbar h1 { font-size: 18px; } - .app-clock-date { font-size: 16px; } - .app-clock-time { font-size: 16px; } - .app-clock-date::after { font-size: 18px; } + /* horloge ne doit pas grossir aux breakpoints, elle reste minuscule. */ + .app-clock-date { font-size: 14px; } + .app-clock-time { font-size: 14px; } + .app-clock-date::after { font-size: 14px; } .btn-action .btn-action-label, .btn-refresh .btn-refresh-label { display: none; @@ -2644,8 +3079,8 @@ header.topbar::before { /* Breakpoint minuscule : masque aussi les labels de refresh, boutons deviennent vraiment iconifiés */ @media (max-width: 520px) { - .app-clock-time { font-size: 16px; } - .topbar h1 { font-size: 14px; } + .app-clock-time { font-size: 14px; } + .topbar h1 { font-size: 12px; } .btn-today { padding: 4px 6px; font-size: 11px; } .btn-nav { min-width: 26px; padding: 4px 6px; } } @@ -2694,7 +3129,7 @@ header.topbar::before { border-color: var(--border); } .pinned-popup-unpin { - font-size: 14px; + font-size: 12px; } /* ========================================================================== @@ -2784,7 +3219,7 @@ header.topbar::before { min-width: 0; padding: 0 10px; font-family: var(--mono, monospace); - font-size: 14px; + font-size: 12px; font-weight: 700; color: var(--text); cursor: pointer; @@ -2900,10 +3335,9 @@ header.topbar::before { ========================================================================== */ .session-slide-alert { position: fixed; - /* v2026.5.40 r12 : on positionne l'alerte SOUS les initiales (badge user) - pour ne pas chevaucher l'horloge centrale. La topbar fait ~48px donc - top: 56px laisse 8px de respiration sous le badge. */ - top: 56px; + /* suit la hauteur dynamique de la topbar (--topbar-height calculé + en JS). +8px de respiration sous le badge user. */ + top: calc(var(--topbar-height, 56px) + 8px); left: -420px; /* hors écran au départ */ width: 380px; max-width: calc(100vw - 40px); @@ -2937,7 +3371,7 @@ html.view-horizontal .session-slide-alert.visible { 50% { box-shadow: 0 8px 24px rgba(239,68,68,0.5); } } .session-slide-alert-title { - font-size: 14px; + font-size: 12px; font-weight: 600; color: var(--text); margin-bottom: 12px; @@ -2979,7 +3413,7 @@ html.view-horizontal .session-slide-alert.visible { /* Bouton Actualiser (↻) dans la topbar du popup épinglé — animation spin */ .pinned-popup-refresh { - font-size: 14px; + font-size: 12px; line-height: 1; } .pinned-popup-refresh svg { @@ -3025,7 +3459,7 @@ body.popup-dragging .pinned-popup { } .pinned-popup-dock-pill-date { display: block; - font-size: 10px; + font-size: 12px; font-weight: 500; opacity: 0.85; font-family: var(--mono, monospace); @@ -3079,7 +3513,7 @@ body.popup-dragging .pinned-popup { color: #ef4444; } .pill-menu-ico { - font-size: 14px; + font-size: 12px; width: 16px; text-align: center; } @@ -3190,7 +3624,7 @@ body.popup-dragging .pinned-popup { } .pinned-popup-dock-pill-date { display: block; - font-size: 10px; + font-size: 12px; font-weight: 500; color: var(--text-faint); font-family: var(--mono, monospace); @@ -3273,12 +3707,14 @@ body.popup-dragging .pinned-popup { .topbar h1 { font-size: 21px !important; /* +20% depuis 18px */ } -/* Date-custom label (Vendredi 24.04.2026) */ +/* date du planning — 28px en vue classique (-10%). En vue horizontale, + la sidebar a son propre override (12px) plus bas. */ #date-custom-label { - font-size: 14px !important; /* +20% depuis 12px */ + font-size: 28px !important; + line-height: 1 !important; } .date-custom { - font-size: 14px !important; + font-size: 28px !important; } /* Stats bar */ .stats-bar, @@ -3474,12 +3910,13 @@ html.theme-dark .tech-absence-recurring { .topbar h1 { font-size: 18px !important; } + /* horloge minuscule à tous les breakpoints. */ .app-clock-date, .app-clock-time { - font-size: 19px !important; + font-size: 14px !important; } .app-clock-date::after { - font-size: 22px !important; + font-size: 14px !important; } /* Stats bar plus dense */ @@ -3502,7 +3939,7 @@ html.theme-dark .tech-absence-recurring { font-size: 14px !important; } .card-tech-badge { - font-size: 10px !important; + font-size: 12px !important; padding: 2px 6px !important; } @@ -3519,7 +3956,7 @@ html.theme-dark .tech-absence-recurring { height: 18px !important; } .timeline-label { - font-size: 10px !important; + font-size: 12px !important; } /* Boutons topbar un peu plus compacts */ @@ -3554,9 +3991,10 @@ html.theme-dark .tech-absence-recurring { .topbar h1 { font-size: 17px !important; } + /* horloge minuscule à tous les breakpoints. */ .app-clock-date, .app-clock-time { - font-size: 17px !important; + font-size: 14px !important; } .intervention-v2 { padding: 6px 8px 7px 5px !important; @@ -3669,7 +4107,7 @@ html.view-horizontal .timeline-scale { height: 11px !important; } html.view-horizontal .timeline-tick { - font-size: calc(10px * var(--text-scale)) !important; + font-size: calc(14px * var(--text-scale)) !important; } /* Liste interventions en mode "chips" (défilement horizontal) */ @@ -3803,11 +4241,22 @@ html.view-horizontal .horizontal-sidebar .date-nav .btn-nav { (pas censé arriver puisque JS les sort dans #sidebar-arrows). */ padding: 4px 8px; } +/* date sélectionnée en sidebar horizontale — -5% (18 → 17px). */ html.view-horizontal .horizontal-sidebar .date-nav .date-custom { width: 100%; justify-content: flex-start; padding: 6px 8px; - font-size: 12px !important; + font-size: 17px !important; + font-weight: 700 !important; + border: 1px solid var(--border-strong) !important; + background: var(--bg-muted) !important; + color: var(--text) !important; + border-radius: 6px !important; +} +html.view-horizontal .horizontal-sidebar .date-nav .date-custom #date-custom-label { + font-size: 17px !important; + font-weight: 700 !important; + color: inherit; } html.view-horizontal .horizontal-sidebar .date-nav .btn-today { padding: 4px 10px; @@ -3893,7 +4342,7 @@ html.view-horizontal .horizontal-sidebar #stats .global-stat-sep.global-stat-sep width: 100% !important; text-align: center !important; color: var(--text-faint); - font-size: 14px; + font-size: 12px; font-weight: 700; padding: 0; /* Compenser le gap parent (#stats a gap: 4px) pour que le `+` soit serré @@ -3902,9 +4351,26 @@ html.view-horizontal .horizontal-sidebar #stats .global-stat-sep.global-stat-sep line-height: 1; align-self: center; } +/* séparateur après "clos" — en CLASSIQUE on garde le "//" en texte + inline ; en HORIZONTAL on transforme cette span en barre HORIZONTALE + pleine largeur (le texte "//" est masqué). */ +html.view-horizontal .horizontal-sidebar #stats .global-stat-sep-after-clos { + display: block !important; + width: 90% !important; + height: 0 !important; + border-top: 1px solid var(--border-strong, var(--border)) !important; + margin: 6px auto !important; + padding: 0 !important; + font-size: 0 !important; + line-height: 0 !important; + color: transparent !important; +} +html.has-topbar-color.view-horizontal .horizontal-sidebar #stats .global-stat-sep-after-clos { + border-top-color: color-mix(in srgb, var(--topbar-text) 35%, transparent) !important; +} html.view-horizontal .horizontal-sidebar #stats .global-stat-sub { display: block !important; - font-size: 10px; + font-size: 12px; color: var(--text-faint); padding-left: 6px; } @@ -4049,34 +4515,55 @@ html.view-horizontal .horizontal-sidebar #app-title { html.view-horizontal .horizontal-sidebar .date-nav { display: contents; } -/* Bouton Aujourd'hui — v2026.5.39 r5 : MÊME style que Absence/Douchette - en sidebar. Hérite de la règle générique button.btn (padding/font-size). - On force juste centrage du texte. */ +/* today-block (nav-today + app-clock) déplacé en bloc en sidebar, + empilé verticalement comme la vue classique. */ +html.view-horizontal .horizontal-sidebar .today-block { + order: 1; + display: flex !important; + flex-direction: column !important; + align-items: stretch !important; + width: 100%; + gap: 6px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-muted); + margin: 0; +} +/* Bouton Aujourd'hui en haut du today-block en sidebar. */ +html.view-horizontal .horizontal-sidebar .today-block .btn-today, html.view-horizontal .horizontal-sidebar .btn-today { - order: 1; /* tout en haut après titre */ width: 100% !important; - justify-content: center !important; /* centrage du label */ + justify-content: center !important; text-align: center !important; } /* 5. App-clock (date + heure) centré sous le bouton "Aujourd'hui" */ +html.view-horizontal .horizontal-sidebar .today-block .app-clock, html.view-horizontal .horizontal-sidebar .app-clock { - order: 2; align-items: center !important; text-align: center !important; - padding: 6px 4px !important; + padding: 4px 4px !important; background: transparent !important; border: none !important; - margin-bottom: 4px; + margin: 0 !important; + width: 100%; } +/* date du jour et heure même taille (12px) plus petites en sidebar + horizontale, pour que la date sélectionnée (14px gras) reste l'info dominante. */ html.view-horizontal .horizontal-sidebar .app-clock-date { text-align: center !important; width: 100%; + font-size: 12px !important; + font-weight: 500 !important; + color: var(--text-muted) !important; } html.view-horizontal .horizontal-sidebar .app-clock-time { text-align: center !important; width: 100%; - font-size: 22px !important; + font-size: 12px !important; + font-weight: 600 !important; + color: var(--text-muted) !important; } /* 6. Séparateur visuel après date/heure (avant sélecteur date) @@ -4313,7 +4800,7 @@ html.view-horizontal .day-period-sep { min-width: 0; } .admin-row-label strong { - font-size: 14px; + font-size: 12px; color: var(--text); } .admin-row-desc { @@ -4391,8 +4878,9 @@ html.view-horizontal .day-period-sep { --zoom-factor: 1; --zoom-inv: 1; } +/* base minuscule (9px) — l'horloge contextuelle ne doit pas dominer. */ .app-clock-time, -.app-clock-date { font-size: calc(18px * var(--text-scale)) !important; } +.app-clock-date { font-size: calc(14px * var(--text-scale)) !important; } /* v2026.5.39 r12 : le panel admin suit le zoom comme tout le reste. Pas d'effet yo-yo car le zoom n'est plus appliqué pendant le drag du @@ -4617,7 +5105,7 @@ html.view-horizontal .iv-mini-cards { line-height: 1.1; } .iv-mini-time-arrow { - font-size: calc(9px * var(--text-scale)); + font-size: calc(10px * var(--text-scale)); color: var(--text-faint); line-height: 1; margin: 1px 0; @@ -4838,7 +5326,7 @@ html.view-classic .topbar-right #theme-toggle { order: 6; } padding-right: 8px !important; } .admin-team-table th:first-child { - font-size: 10px !important; + font-size: 12px !important; letter-spacing: 0.4px !important; } .admin-team-id-readonly { diff --git a/src/viewer.html b/src/viewer.html index 9ebd0ac..80f7b08 100644 --- a/src/viewer.html +++ b/src/viewer.html @@ -28,26 +28,28 @@ type="button" aria-label="Utilisateur connecté" title="Utilisateur — cliquer pour accéder aux paramètres">?

Planification

+ +
+ +
+
+
+
+
+ +
-
-
+
- 📅
-
- - -
- -
-
-
diff --git a/src/viewer.js b/src/viewer.js index a04562c..66452cb 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -145,6 +145,58 @@ window.addEventListener("unhandledrejection", (event) => { LOG.info("boot", "viewer.js chargé", { version: LOG.version() }); +// v2026.5.45 — Refonte topbar + personnalisation Apparence + onboarding équipe. +// +// Topbar (vue classique) : +// - Sélecteur de date du planning au CENTRE absolu, ne bouge plus quand le +// bouton "Arrêter" apparaît à droite pendant un rafraîchissement. +// - Bouton "Aujourd'hui" en toutes lettres + horloge contextuelle discrète. +// +// Personnalisation (Paramètres → Apparence) : +// - Couleur de la topbar : 12 presets cliquables + picker custom + champ hex. +// La couleur du texte s'adapte automatiquement (clair/foncé) selon la +// luminance choisie pour rester lisible. +// - Police de l'application : 28 choix (Arial, Helvetica, Verdana, Tahoma, +// Trebuchet, Calibri, Segoe UI, Times New Roman, Georgia, Cambria, +// Garamond, Palatino, Courier, Consolas, Comic Sans, Impact, …) appliquée +// à TOUTE la page (topbar, cards, popups, panel admin). +// +// Vue horizontale : +// - Bloc Aujourd'hui + horloge + date sélectionnée empilés en sidebar dans +// le même cadre, comme la vue classique. +// - Barre verte verticale à droite des mini-cards quand le ticket est +// officiellement clôturé / résolu (✓✓), avec décalage du ✓✓ pour ne pas +// chevaucher la barre. +// - Quand l'utilisateur a choisi une couleur de topbar, la sidebar prend +// aussi la même couleur, avec icônes / boutons / séparateurs / theme-toggle +// translucides cohérents (color-mix). +// +// Stats globales : +// - Nouveau compteur "X faits / Y clos" entre "(matin · après-midi)" et +// "tech. dispo". En vue classique, "//" remplace "·" entre clos et dispo. +// En sidebar horizontale, c'est une vraie barre horizontale de séparation. +// +// Onboarding équipe : +// - Quand aucun technicien n'est sélectionné (1ʳᵉ install ou cfg vide), +// une carte centrée propre s'affiche avec icône, titre, description et +// bouton "Ouvrir paramètres" qui pointe directement sur la section Équipe +// du panel admin. Centrée verticalement et horizontalement dans la zone +// disponible (entre la sidebar et le bord droit en horizontal). +// +// Bugfix divers : +// - Absences récurrentes affichées au switch de groupe puis retour (n'étaient +// plus cochées dans la table même si la donnée existait toujours). +// - Barre de progression / bannière session expirée : suivent la hauteur +// dynamique de la topbar via --topbar-height (ne se cachent plus dessous +// en scrollant). +// - Flèche " ↗" retirée des références cliquables dans le tooltip. +LOG.warn("planification", + `🩺 Planification v${LOG.version()} — Mode ${LOG.isDebug() ? "DIAGNOSTIC" : "PROD"}.`); + +// on n'analyse que les ghosts du tech Quentin pour se concentrer. +// Les ghosts des autres techs sont loggés comme "skipped" et restent visibles. +const DIAGNOSTIC_TECH_FILTER_ID = "86874"; // Rouiller, Quentin + // v2026.5.40 r22 : pattern simple et fiable pour fermer le tooltip // - mouseleave row → timer 500ms (démarré dans hideTooltip) // - mouseenter popup → clearTimeout (annule la fermeture en cours) @@ -175,17 +227,34 @@ async function _initTeamFromConfig() { const cfg = await loadAdminConfig(); TEAM = cfg.team || {}; RECURRING_ABSENCES = cfg.recurringAbsences || {}; + // R12v : recharger les statuts personnalisables depuis la config. + if (Array.isArray(cfg.closedStatus) && cfg.closedStatus.length > 0) { + CLOSED_STATUS = cfg.closedStatus.slice(); + } + if (Array.isArray(cfg.resolvedStatus) && cfg.resolvedStatus.length > 0) { + RESOLVED_STATUS = cfg.resolvedStatus.slice(); + } + if (Array.isArray(cfg.cancelledStatus) && cfg.cancelledStatus.length > 0) { + CANCELLED_STATUS = cfg.cancelledStatus.slice(); + } } catch (e) { console.warn("[boot] _initTeamFromConfig err", e); } } -// Statuts EasyVista qui déclenchent l'affichage "clos" -const CLOSED_STATUS = ["Clôturé", "Cloture", "Clôture"]; -const RESOLVED_STATUS = ["Résolu", "Resolu"]; -// Statuts qui indiquent qu'une intervention a été supprimée/annulée -// → si présente dans le cache mais disparue du planning : on retire -const CANCELLED_STATUS = ["Annulé", "Annule", "Supprimé", "Supprime"]; +// R12v : statuts EasyVista éditables depuis admin_config (Paramètres → EV). +// Les valeurs par défaut servent de filet de sécurité au 1er install et +// pour les versions où la config n'aurait pas encore ces clés. +// Clôturé et Terminé par défaut (les 2 statuts qui signifient +// "finalisé officiellement" dans EV). N'affecte QUE les nouveaux installs +// (config vide) — si une config existe déjà avec d'autres valeurs, +// loadAdminConfig la garde intacte (cf. fusion `...stored`). +const DEFAULT_CLOSED_STATUS = ["Clôturé", "Terminé"]; +const DEFAULT_RESOLVED_STATUS = []; +const DEFAULT_CANCELLED_STATUS = ["Annulé", "Supprimé"]; +let CLOSED_STATUS = [...DEFAULT_CLOSED_STATUS]; +let RESOLVED_STATUS = [...DEFAULT_RESOLVED_STATUS]; +let CANCELLED_STATUS = [...DEFAULT_CANCELLED_STATUS]; // Clés de stockage const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD @@ -354,6 +423,22 @@ function abortCurrentRefresh() { abortResolvers.delete(token); } } + // tuer toutes les requêtes EV en vol côté background pour que + // rien ne continue en arrière-plan. fire-and-forget. + try { + sendMessage({ type: "abortAllFetches" }).catch(() => {}); + } catch (e) { /* ignore */ } + // retirer l'animation "en cours" sur toutes les iv en analyse + if (state.currentData && state.currentData.techs) { + for (const tech of state.currentData.techs) { + for (const iv of tech.interventions || []) { + if (iv._disappearChecking) { + iv._disappearChecking = false; + try { updateInterventionRow(iv); } catch (e) { /* ignore */ } + } + } + } + } } // v4.1.9 : isRefreshAborted(myToken) retourne true si : // - un nouveau refresh a été lancé (currentRefreshToken > myToken), OU @@ -379,6 +464,10 @@ async function init() { // v2026.5.39 : appliquer le zoom texte enregistré dès le boot, avant que // le DOM ne soit affiché (sinon "flash" à la taille par défaut). _initTextZoomFromConfig(); + // appliquer la couleur + police de la topbar enregistrées. + _initTopbarStyleFromConfig(); + // exposer la hauteur réelle de la topbar (sticky) en variable CSS. + _initTopbarHeightVar(); // v2026.5.39 : lire les heures de la journée depuis admin_config (8-18 défaut). await _initDayBoundsFromConfig(); // v2026.5.41 : charger l'équipe et les absences récurrentes depuis admin_config. @@ -850,6 +939,11 @@ function bindTopbar() { document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1)); document.getElementById("nav-next").addEventListener("click", () => navigateDate(+1)); document.getElementById("nav-today").addEventListener("click", () => loadForDate(todayISO())); + // clic sur le badge rouge "🔴 Auj. JJ.MM" → revient à aujourd'hui. + const todayBadgeEl = document.getElementById("today-badge"); + if (todayBadgeEl) { + todayBadgeEl.addEventListener("click", () => loadForDate(todayISO())); + } document.getElementById("date-picker").addEventListener("change", (e) => { if (e.target.value) loadForDate(e.target.value); @@ -1279,11 +1373,14 @@ function _applyViewMode() { // Ordre = ordre visuel dans la sidebar en mode horizontal (haut → bas) // v2026.5.37 : user-badge, app-title, theme-toggle déplacés aussi → la topbar // devient vide en vue horizontale. + // on déplace today-block comme un tout (qui contient nav-today + + // app-clock). Ainsi, en sidebar, le bouton "Aujourd'hui" reste au-dessus + // de la date+heure du jour, dans le même cadre, comme en vue classique. const ELEMENTS_TO_RELOCATE = [ "user-badge", // tout en haut de la sidebar "app-title", // juste après user-badge + "today-block", // nav-today + app-clock, en bloc "date-nav", // (pas un id mais une classe, traité à part) - "app-clock", "capture-info", "stats", // conteneur stats globales (généré dynamiquement) "absence-btn", @@ -1301,12 +1398,10 @@ function _applyViewMode() { _restoreElementsToTopbar(ELEMENTS_TO_RELOCATE); } - // v2026.5.39 r2 : label complet "Aujourd'hui" en sidebar (plus large), "Auj." - // en topbar classique (plus compact). On gère ça ici car on n'utilise pas - // de pseudo-element CSS (problèmes avec le reflow). + // libellé complet "Aujourd'hui" partout (vue classique + horizontale). const navToday = document.getElementById("nav-today"); if (navToday) { - navToday.textContent = (mode === "horizontal") ? "Aujourd'hui" : "Auj."; + navToday.textContent = "Aujourd'hui"; } } @@ -1544,15 +1639,24 @@ function initAppClock() { const DAY_NAMES_FULL = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]; function updateDatePickerDayLabel(isoDate) { const el = document.getElementById("date-custom-label"); + const dateCustom = document.getElementById("date-custom"); if (!el) return; - if (!isoDate) { el.textContent = ""; return; } + if (!isoDate) { + el.textContent = ""; + if (dateCustom) dateCustom.classList.remove("is-today"); + return; + } try { const d = isoToDate(isoDate); const day = DAY_NAMES_FULL[d.getDay()]; const dd = String(d.getDate()).padStart(2, "0"); const mm = String(d.getMonth() + 1).padStart(2, "0"); const yyyy = d.getFullYear(); + // libellé "Vendredi 24.04.2026" — couleur fixe désormais (la + // personnalisation se fait sur toute la topbar via les paramètres). el.textContent = `${day} ${dd}.${mm}.${yyyy}`; + const isToday = (isoDate === todayISO()); + if (dateCustom) dateCustom.classList.toggle("is-today", isToday); } catch (e) { el.textContent = ""; } @@ -1996,9 +2100,9 @@ function getDefaultAdminConfig() { recurringAbsences: { ...RECURRING_ABSENCES }, // idem groupId: "191", evOrigins: ["https://itsma.etat-de-vaud.ch", "https://itsma.vd.ch"], - closedStatus: [...CLOSED_STATUS], - resolvedStatus: [...RESOLVED_STATUS], - cancelledStatus: [...CANCELLED_STATUS], + closedStatus: [...DEFAULT_CLOSED_STATUS], + resolvedStatus: [...DEFAULT_RESOLVED_STATUS], + cancelledStatus: [...DEFAULT_CANCELLED_STATUS], dayStart: 8, dayEnd: 18, cacheDays: 7 @@ -2029,7 +2133,9 @@ async function saveAdminConfig(cfg) { } // v5.0.0 : affiche le panel admin plein écran. -async function showAdminPanel() { +// showAdminPanel accepte un id de section initiale ("appearance", +// "team", "easyvista", "diagnostics", "about"). Défaut : "appearance". +async function showAdminPanel(initialSection) { // Ferme un éventuel panel existant const existing = document.getElementById("admin-panel"); if (existing) existing.remove(); @@ -2080,7 +2186,10 @@ async function showAdminPanel() { { id: "about", label: "À propos", render: renderAdminSectionAbout } ]; - let currentSection = "appearance"; + // utilise la section demandée si valide, sinon "appearance" par défaut. + let currentSection = sections.some(s => s.id === initialSection) + ? initialSection + : "appearance"; const navButtons = {}; for (const section of sections) { @@ -2107,8 +2216,9 @@ async function showAdminPanel() { overlay.appendChild(card); document.body.appendChild(overlay); - // Rendu initial : section "Équipe" - sections[0].render(content, cfg, () => saveAndReload(cfg)); + // rendu initial = la section demandée (par défaut "appearance"). + const initialSec = sections.find(s => s.id === currentSection) || sections[0]; + initialSec.render(content, cfg, () => saveAndReload(cfg)); // Échap ferme le panel const escHandler = (e) => { @@ -2415,11 +2525,16 @@ function renderAdminSectionTeam(container, cfg, saveFn) { existing.name = m.name; } } else { + // restaurer les absences récurrentes depuis cfg si elles + // existent — un changement de groupe ne doit jamais les "oublier" + // visuellement (la donnée elle-même n'a pas bougé en storage). rows.push({ id: m.id, name: m.name || "? (" + m.id + ")", included: !!m.alreadyInTeam, - days: [] + days: (cfg.recurringAbsences && cfg.recurringAbsences[m.id]) + ? cfg.recurringAbsences[m.id].slice() + : [] }); } } @@ -2443,11 +2558,16 @@ function renderAdminSectionTeam(container, cfg, saveFn) { const resp = await sendMessage({ type: "detectTeam", groupId: newGroupId }); if (resp && resp.ok && resp.members && resp.members.length) { for (const m of resp.members) { + // restaurer les jours d'absence récurrente depuis cfg. + // Sans ça, revenir au groupe initial réaffichait des cases vides + // alors que les absences étaient toujours en storage. rows.push({ id: m.id, name: m.name || "? (" + m.id + ")", included: !!m.alreadyInTeam, - days: [] + days: (cfg.recurringAbsences && cfg.recurringAbsences[m.id]) + ? cfg.recurringAbsences[m.id].slice() + : [] }); } render(); @@ -2600,6 +2720,129 @@ function renderAdminSectionEV(container, cfg, saveFn) { btnWrap.appendChild(resetBtn); container.appendChild(btnWrap); + + // ─── R12w : sous-section "Statuts EasyVista" — UI liste ─────────────── + // Une seule entrée par statut suffit (ex: "Clôturé") : la détection est + // automatiquement insensible à la casse et aux accents. L'user peut + // ajouter, modifier ou supprimer des entrées avec les boutons. + const SUB_TITLE_STYLE = "margin:24px 0 6px 0; font-size:14px; font-weight:600; color:var(--text); border-bottom:1px solid var(--border); padding-bottom:4px;"; + const SUB_HINT_STYLE = "font-size:12px; color:var(--text-faint); margin:0 0 12px 0;"; + + const stTitle = document.createElement("div"); + stTitle.textContent = "🏷 Statuts EasyVista"; + stTitle.style.cssText = SUB_TITLE_STYLE; + container.appendChild(stTitle); + + const stHint = document.createElement("p"); + stHint.textContent = "Libellés EV qui signifient clos / résolu / annulé. La détection est faite en arrière-plan en ignorant la casse et les accents — pas besoin de saisir toutes les variantes."; + stHint.style.cssText = SUB_HINT_STYLE; + container.appendChild(stHint); + + const _makeStatusListRow = (labelText, descText, defaultArr, currentArr, onSave) => { + const row = _makeAdminRow(labelText, descText); + const ctrl = row.querySelector(".admin-row-control"); + container.appendChild(row); + + // État local (copie défensive) + let items = (currentArr && currentArr.length) ? currentArr.slice() : defaultArr.slice(); + + const wrap = document.createElement("div"); + wrap.style.cssText = "display:flex; flex-direction:column; gap:4px; min-width:320px;"; + ctrl.appendChild(wrap); + + const list = document.createElement("div"); + list.style.cssText = "display:flex; flex-direction:column; gap:4px;"; + wrap.appendChild(list); + + const persist = async () => { + try { + onSave(items.slice()); + await saveAdminConfig(cfg); + LOG.warn("admin", `${labelText} sauvegardés`, { count: items.length, valeurs: items }); + showToast(labelText, `${items.length} entrée(s) — recharge la page pour appliquer`); + } catch (err) { + LOG.error("admin", `échec sauvegarde ${labelText}`, { err: err && err.message }); + showToast("Erreur", (err && err.message) || String(err)); + } + }; + + const renderList = () => { + list.innerHTML = ""; + items.forEach((val, idx) => { + const itemRow = document.createElement("div"); + itemRow.style.cssText = "display:flex; gap:6px; align-items:center;"; + + const inp = document.createElement("input"); + inp.type = "text"; + inp.className = "admin-input"; + inp.value = val; + inp.style.flex = "1"; + inp.addEventListener("change", async () => { + const v = inp.value.trim(); + if (!v) { + // Vide → on supprime l'entrée + items.splice(idx, 1); + renderList(); + await persist(); + return; + } + if (v !== items[idx]) { + items[idx] = v; + await persist(); + } + }); + itemRow.appendChild(inp); + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "btn"; + delBtn.textContent = "🗑"; + delBtn.title = "Supprimer"; + delBtn.style.cssText = "padding:4px 10px;"; + delBtn.addEventListener("click", async () => { + items.splice(idx, 1); + renderList(); + await persist(); + }); + itemRow.appendChild(delBtn); + + list.appendChild(itemRow); + }); + }; + + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "btn"; + addBtn.textContent = "➕ Ajouter une entrée"; + addBtn.style.cssText = "align-self:flex-start; padding:4px 10px;"; + addBtn.addEventListener("click", () => { + items.push(""); + renderList(); + // focus sur la nouvelle ligne + const inputs = list.querySelectorAll("input"); + const last = inputs[inputs.length - 1]; + if (last) last.focus(); + }); + wrap.appendChild(addBtn); + + renderList(); + }; + + _makeStatusListRow( + "Clôturé / Résolu (✓✓ vert)", + "Statuts EV qui signifient « finalisé officiellement » (incluant Clôturé et Résolu).", + DEFAULT_CLOSED_STATUS, + cfg.closedStatus, + (arr) => { cfg.closedStatus = arr; } + ); + + // R12z : entrées "Résolu" et "Annulé/Supprimé" retirées de l'UI. + // - Résolu : fusionné dans la liste Clôturé/Résolu (un seul statut + // "finalisé" couvre les deux côté admin). + // - Annulé/Supprimé : la décision est basée sur le comportement du tech + // (action présente sans commentaire = annulée), pas sur le statut + // global de la fiche. CANCELLED_STATUS reste dans le code pour + // back-compat des configs existantes mais n'est plus exposé. } // v2026.5.39 : section Apparence — thème, taille du texte, cache. @@ -2637,31 +2880,8 @@ function renderAdminSectionAppearance(container, cfg, saveFn) { themeRow.querySelector(".admin-row-control").appendChild(themeSelect); container.appendChild(themeRow); - // ---- Champ Cache (2e) ---- - const cacheRow = _makeAdminRow( - "Durée du cache (jours)", - "Au-delà, les anciens caches sont purgés. Défaut : 7 jours." - ); - // v2026.5.39 : tooltip survol → emplacement physique du cache (selon - // navigateur + OS). Aide les techs DGNSI à inspecter / nettoyer si besoin. - cacheRow.title = _getCacheLocationHint(); - const cacheInput = document.createElement("input"); - cacheInput.type = "number"; - cacheInput.min = "1"; - cacheInput.max = "365"; - cacheInput.step = "1"; - cacheInput.className = "admin-input admin-input-num"; - cacheInput.value = String(cfg.cacheDays || 7); - cacheInput.addEventListener("change", async () => { - let v = parseInt(cacheInput.value, 10); - if (isNaN(v) || v < 1) v = 1; - if (v > 365) v = 365; - cacheInput.value = String(v); - cfg.cacheDays = v; - await saveAdminConfig(cfg); - }); - cacheRow.querySelector(".admin-row-control").appendChild(cacheInput); - container.appendChild(cacheRow); + // R12p : la durée du cache a été déplacée dans Diagnostics → section Cache, + // à côté des boutons Export/Import (regroupement logique). // ---- Champ Taille du texte (3e) — slider horizontal ---- // 5 crans : -2 (-20%), -1 (-10%), 0 (100% défaut), +1 (+10%), +2 (+20%) @@ -2805,6 +3025,164 @@ function renderAdminSectionAppearance(container, cfg, saveFn) { hoursWrap.appendChild(applyBtn); hoursRow.querySelector(".admin-row-control").appendChild(hoursWrap); container.appendChild(hoursRow); + + // couleur de la barre du haut — refonte UI propre, avec presets + // cliquables + picker fin + champ hex. Le texte de la topbar prend + // automatiquement une couleur lisible (blanc ou foncé) selon la luminance. + const colorRow = _makeAdminRow( + "Couleur de la barre du haut", + "S'applique uniquement à la topbar. Le texte s'adapte automatiquement (clair/foncé) pour rester lisible." + ); + const colorWrap = document.createElement("div"); + colorWrap.style.cssText = "display:flex; flex-direction:column; gap:10px;"; + + // Presets : 12 couleurs choisies pour bien rendre en topbar. + const COLOR_PRESETS = [ + { val: "", label: "Défaut" }, + { val: "#ffffff", label: "Blanc" }, + { val: "#f4f5f7", label: "Gris clair" }, + { val: "#1f2937", label: "Anthracite" }, + { val: "#0f4f8b", label: "Bleu DGNSI" }, + { val: "#1e3a8a", label: "Marine" }, + { val: "#065f46", label: "Vert sapin" }, + { val: "#7c2d12", label: "Brique" }, + { val: "#7c3aed", label: "Violet" }, + { val: "#dc2626", label: "Rouge" }, + { val: "#dbeafe", label: "Bleu pastel" }, + { val: "#dcfce7", label: "Vert pastel" } + ]; + const presetWrap = document.createElement("div"); + presetWrap.style.cssText = "display:flex; gap:6px; flex-wrap:wrap;"; + const _refreshPresetSelected = (current) => { + [...presetWrap.children].forEach(c => { + const isSel = (c.dataset.color || "") === (current || ""); + c.style.boxShadow = isSel + ? "0 0 0 3px var(--accent), 0 1px 3px rgba(0,0,0,0.15)" + : "0 1px 3px rgba(0,0,0,0.15)"; + }); + }; + for (const p of COLOR_PRESETS) { + const sw = document.createElement("button"); + sw.type = "button"; + sw.dataset.color = p.val; + sw.title = p.label; + sw.style.cssText = ` + width:30px; height:30px; padding:0; cursor:pointer; + border:1px solid var(--border-strong); border-radius:50%; + background:${p.val || "transparent"}; + ${p.val ? "" : "background:repeating-linear-gradient(45deg,#fff 0 4px,#ddd 4px 8px);"} + `; + sw.addEventListener("click", async () => { + _applyTopbarColor(p.val); + cfg.topbarColor = p.val; + colorInput.value = p.val || "#ffffff"; + colorHex.value = p.val; + _refreshPresetSelected(p.val); + await saveAdminConfig(cfg); + }); + presetWrap.appendChild(sw); + } + colorWrap.appendChild(presetWrap); + + // Picker custom + hex + reset, sur une seconde ligne. + const customLine = document.createElement("div"); + customLine.style.cssText = "display:flex; align-items:center; gap:8px; flex-wrap:wrap;"; + const colorLabel = document.createElement("span"); + colorLabel.textContent = "Couleur perso :"; + colorLabel.style.cssText = "font-size:12px; color:var(--text-muted);"; + const colorInput = document.createElement("input"); + colorInput.type = "color"; + colorInput.style.cssText = "width:42px; height:32px; padding:0; border:1px solid var(--border-strong); border-radius:6px; cursor:pointer;"; + colorInput.value = cfg.topbarColor || "#ffffff"; + const colorHex = document.createElement("input"); + colorHex.type = "text"; + colorHex.className = "admin-input"; + colorHex.style.cssText = "width:100px; font-family: monospace;"; + colorHex.placeholder = "#rrggbb"; + colorHex.value = cfg.topbarColor || ""; + const colorReset = document.createElement("button"); + colorReset.type = "button"; + colorReset.className = "btn btn-subtle"; + colorReset.textContent = "Réinitialiser"; + colorInput.addEventListener("input", async () => { + const v = colorInput.value; + colorHex.value = v; + _applyTopbarColor(v); + cfg.topbarColor = v; + _refreshPresetSelected(v); + await saveAdminConfig(cfg); + }); + colorHex.addEventListener("change", async () => { + const v = colorHex.value.trim(); + if (v && !/^#[0-9a-fA-F]{3,8}$/.test(v)) { + showToast("Format invalide", "Utilise #rrggbb (ex: #1a73e8)"); + colorHex.value = cfg.topbarColor || ""; + return; + } + if (v) colorInput.value = v; + _applyTopbarColor(v); + cfg.topbarColor = v; + _refreshPresetSelected(v); + await saveAdminConfig(cfg); + }); + colorReset.addEventListener("click", async () => { + cfg.topbarColor = ""; + colorHex.value = ""; + colorInput.value = "#ffffff"; + _applyTopbarColor(""); + _refreshPresetSelected(""); + await saveAdminConfig(cfg); + showToast("Couleur réinitialisée", "Topbar revenue au défaut"); + }); + customLine.appendChild(colorLabel); + customLine.appendChild(colorInput); + customLine.appendChild(colorHex); + customLine.appendChild(colorReset); + colorWrap.appendChild(customLine); + colorRow.querySelector(".admin-row-control").appendChild(colorWrap); + container.appendChild(colorRow); + _refreshPresetSelected(cfg.topbarColor || ""); + + // police de l'application — s'applique à TOUTE la page (topbar + + // cards + popups + tooltips + panel admin). Liste de polices système. + const fontRow = _makeAdminRow( + "Police de l'application", + "Style de texte pour toute la page (topbar, cartes, popups). Defaults système si non choisi." + ); + const fontWrap = document.createElement("div"); + fontWrap.style.cssText = "display:flex; align-items:center; gap:8px; flex-wrap:wrap;"; + const fontSelect = document.createElement("select"); + fontSelect.className = "admin-select"; + fontSelect.style.minWidth = "260px"; + for (const opt of APP_FONT_OPTIONS) { + const o = document.createElement("option"); + o.value = opt.val; + o.textContent = opt.label; + if (opt.stack) o.style.fontFamily = opt.stack; + if ((cfg.appFont || cfg.topbarFont || "") === opt.val) o.selected = true; + fontSelect.appendChild(o); + } + // Aperçu live à droite du select : affiche un petit texte avec la police. + const fontPreview = document.createElement("span"); + fontPreview.style.cssText = "font-size:14px; color:var(--text-muted); padding:6px 10px; border:1px solid var(--border); border-radius:6px; background:var(--bg-muted);"; + const _refreshFontPreview = (val) => { + const stack = _appFontStack(val); + fontPreview.style.fontFamily = stack || "inherit"; + fontPreview.textContent = "Aperçu — Vendredi 24.04.2026"; + }; + _refreshFontPreview(cfg.appFont || cfg.topbarFont || ""); + fontSelect.addEventListener("change", async () => { + const v = fontSelect.value; + _applyAppFont(v); + cfg.appFont = v; + delete cfg.topbarFont; // nettoyage de l'ancienne clé. + _refreshFontPreview(v); + await saveAdminConfig(cfg); + }); + fontWrap.appendChild(fontSelect); + fontWrap.appendChild(fontPreview); + fontRow.querySelector(".admin-row-control").appendChild(fontWrap); + container.appendChild(fontRow); } // Helper pour créer une ligne label/desc + zone contrôle (utilisée par @@ -2863,6 +3241,169 @@ async function _initTextZoomFromConfig() { } } +// grande palette de polices proposées (incluant Arial, Helvetica, +// Tahoma, Verdana, Georgia, Times, Courier, etc. + variantes système). +// "" = défaut (police héritée de l'app, currently system-ui). +// La police choisie s'applique à TOUTE la page (body) et donc aux cards, +// popups, tooltips, panel admin — pas seulement à la topbar. +const APP_FONT_OPTIONS = [ + { val: "", label: "Défaut (système)" }, + { val: "system", label: "Système moderne (recommandé)", + stack: 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' }, + + // ── Sans-serif classiques ────────────────────────────────────────────── + { val: "arial", label: "Arial", + stack: 'Arial, Helvetica, sans-serif' }, + { val: "helvetica", label: "Helvetica", + stack: '"Helvetica Neue", Helvetica, Arial, sans-serif' }, + { val: "verdana", label: "Verdana", + stack: 'Verdana, Geneva, sans-serif' }, + { val: "tahoma", label: "Tahoma", + stack: 'Tahoma, Geneva, sans-serif' }, + { val: "trebuchet", label: "Trebuchet MS", + stack: '"Trebuchet MS", Tahoma, sans-serif' }, + { val: "calibri", label: "Calibri", + stack: 'Calibri, Candara, "Segoe UI", sans-serif' }, + { val: "segoe", label: "Segoe UI", + stack: '"Segoe UI", Tahoma, sans-serif' }, + { val: "gillsans", label: "Gill Sans", + stack: '"Gill Sans", "Gill Sans MT", Calibri, sans-serif' }, + { val: "futura", label: "Futura", + stack: 'Futura, "Trebuchet MS", Arial, sans-serif' }, + { val: "optima", label: "Optima", + stack: 'Optima, "Segoe UI", Candara, sans-serif' }, + + // ── Serif ────────────────────────────────────────────────────────────── + { val: "times", label: "Times New Roman", + stack: '"Times New Roman", Times, serif' }, + { val: "georgia", label: "Georgia", + stack: 'Georgia, "Times New Roman", serif' }, + { val: "cambria", label: "Cambria", + stack: 'Cambria, Georgia, serif' }, + { val: "garamond", label: "Garamond", + stack: 'Garamond, "EB Garamond", Georgia, serif' }, + { val: "palatino", label: "Palatino", + stack: '"Palatino Linotype", "Book Antiqua", Palatino, serif' }, + { val: "bookman", label: "Bookman", + stack: '"Bookman Old Style", Bookman, Georgia, serif' }, + + // ── Monospace ────────────────────────────────────────────────────────── + { val: "courier", label: "Courier New", + stack: '"Courier New", Courier, monospace' }, + { val: "consolas", label: "Consolas", + stack: 'Consolas, "Lucida Console", monospace' }, + { val: "lucida_mono", label: "Lucida Console", + stack: '"Lucida Console", Consolas, monospace' }, + { val: "jetbrains", label: "Mono moderne (JetBrains/Fira)", + stack: '"JetBrains Mono", "Fira Code", Consolas, monospace' }, + + // ── Display / fun ────────────────────────────────────────────────────── + { val: "comic", label: "Comic Sans MS", + stack: '"Comic Sans MS", "Comic Neue", cursive' }, + { val: "impact", label: "Impact", + stack: 'Impact, "Arial Narrow Bold", sans-serif' }, + { val: "brush", label: "Brush Script", + stack: '"Brush Script MT", "Lucida Handwriting", cursive' }, + { val: "copperplate", label: "Copperplate", + stack: 'Copperplate, "Copperplate Gothic Light", serif' }, + + // ── Condensée ────────────────────────────────────────────────────────── + { val: "condensed", label: "Condensée (Arial Narrow)", + stack: '"Arial Narrow", "Helvetica Narrow", "Roboto Condensed", sans-serif' } +]; + +function _appFontStack(val) { + const opt = APP_FONT_OPTIONS.find(o => o.val === val); + return (opt && opt.stack) || ""; +} + +// luminance d'une couleur hex (#rgb / #rrggbb) → 0..1. Sert à choisir +// automatiquement un texte LISIBLE (blanc ou foncé) sur le fond de topbar. +function _hexToLuminance(hex) { + if (!hex || typeof hex !== "string") return 1; + let m = hex.replace(/^#/, "").trim(); + if (m.length === 3) m = m.split("").map(c => c + c).join(""); // #abc → #aabbcc + if (m.length !== 6) return 1; + const r = parseInt(m.slice(0, 2), 16); + const g = parseInt(m.slice(2, 4), 16); + const b = parseInt(m.slice(4, 6), 16); + if ([r, g, b].some(isNaN)) return 1; + // Formule pondérée perception humaine. + return (0.299 * r + 0.587 * g + 0.114 * b) / 255; +} +function _readableTextOn(hex) { + return _hexToLuminance(hex) > 0.62 ? "#1a1f2b" : "#ffffff"; +} + +// applique la couleur de fond de la topbar (variable CSS --topbar-bg) +// + une couleur de texte lisible automatique (--topbar-text). Vide → on +// retire les deux variables, fallback aux defaults thème. +// on pose AUSSI la classe html.has-topbar-color pour que les boutons +// de la topbar adoptent un look translucide cohérent avec le fond choisi +// (sinon ils restent gris/blancs et deviennent illisibles sur fond foncé). +function _applyTopbarColor(color) { + const html = document.documentElement; + const v = (color || "").trim(); + if (v) { + html.style.setProperty("--topbar-bg", v); + html.style.setProperty("--topbar-text", _readableTextOn(v)); + html.classList.add("has-topbar-color"); + } else { + html.style.removeProperty("--topbar-bg"); + html.style.removeProperty("--topbar-text"); + html.classList.remove("has-topbar-color"); + } +} + +// applique la police à TOUTE la page via --app-font sur :root, héritée +// par body (cf. CSS body { font-family: var(--app-font, inherit); }). +function _applyAppFont(val) { + const html = document.documentElement; + const stack = _appFontStack(val); + if (stack) { + html.style.setProperty("--app-font", stack); + } else { + html.style.removeProperty("--app-font"); + } +} + +async function _initTopbarStyleFromConfig() { + try { + const cfg = await loadAdminConfig(); + _applyTopbarColor(cfg.topbarColor || ""); + // ancien `topbarFont` migré vers `appFont` (couvre toute la page). + _applyAppFont(cfg.appFont || cfg.topbarFont || ""); + } catch (e) { + LOG.warn("topbarStyle", "init failed", { err: e && e.message }); + } +} + +// la topbar étant sticky, les éléments stickys sous elle (bannière +// session, barre de progression, alerte session) doivent connaître sa +// hauteur RÉELLE pour ne pas se faire couper. Sa hauteur varie selon la +// police, le zoom, la taille de la date, donc on la mesure dynamiquement +// via ResizeObserver et on expose --topbar-height sur :root. +let _topbarRO = null; +function _initTopbarHeightVar() { + const topbar = document.querySelector("header.topbar"); + if (!topbar) return; + const updateVar = () => { + const h = Math.ceil(topbar.getBoundingClientRect().height); + if (h > 0) { + document.documentElement.style.setProperty("--topbar-height", h + "px"); + } + }; + updateVar(); + try { + if (_topbarRO) _topbarRO.disconnect(); + _topbarRO = new ResizeObserver(updateVar); + _topbarRO.observe(topbar); + } catch (e) { + // Fallback : pas de ResizeObserver → re-mesure au resize fenêtre. + window.addEventListener("resize", updateVar); + } +} + // v2026.5.39 : génère un texte décrivant l'emplacement physique du cache // chrome.storage.local selon le navigateur + l'OS détecté. Sert de tooltip // (title=) sur le champ "Durée du cache" dans le panel admin. @@ -2990,6 +3531,227 @@ function _watchOsThemeChanges() { else if (mq.addListener) mq.addListener(handler); // fallback Safari ancien } +// R12p : export du cache planning au format JSON (toutes les clés +// planning_cache_*). Logge chaque étape (succès / échec) pour diagnostic. +async function exportPlanningCache() { + LOG.info("cache-export", "début export du cache planning"); + try { + const all = await chrome.storage.local.get(null); + const cacheData = {}; + let count = 0; + let totalBytes = 0; + for (const [k, v] of Object.entries(all)) { + if (k.startsWith(CACHE_PREFIX)) { + cacheData[k] = v; + count++; + } + } + if (count === 0) { + LOG.warn("cache-export", "aucune clé planning_cache_* trouvée → rien à exporter"); + showToast("Aucun cache", "Rien à exporter (aucune date en cache)"); + return; + } + const json = JSON.stringify(cacheData, null, 2); + totalBytes = json.length; + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `planning-cache-${todayISO()}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + LOG.warn("cache-export", `✅ export OK → ${a.download}`, + { fichier: a.download, jours: count, taille_kb: Math.round(totalBytes / 1024) }); + showToast("Cache exporté", `${count} jour(s) — ${Math.round(totalBytes / 1024)} Ko`); + } catch (err) { + LOG.error("cache-export", "❌ export échoué", { err: err && err.message, stack: err && err.stack }); + showToast("Erreur export", (err && err.message) || String(err)); + } +} + +// R12p : import du cache planning. Si pas de cache local → import direct, +// sinon modal "Tout écraser" / "Fusionner" / "Annuler". Logs verbeux. +async function importPlanningCache() { + LOG.info("cache-import", "ouverture du sélecteur de fichier"); + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json,application/json"; + input.onchange = async () => { + const file = input.files && input.files[0]; + if (!file) { + LOG.info("cache-import", "aucun fichier sélectionné"); + return; + } + LOG.info("cache-import", "fichier sélectionné", { nom: file.name, taille_kb: Math.round(file.size / 1024) }); + let data; + try { + const text = await file.text(); + data = JSON.parse(text); + } catch (e) { + LOG.error("cache-import", "❌ JSON invalide", { err: e && e.message }); + showToast("JSON invalide", e.message || String(e)); + return; + } + const importedKeys = Object.keys(data).filter(k => k.startsWith(CACHE_PREFIX)); + if (importedKeys.length === 0) { + LOG.warn("cache-import", "❌ aucune clé planning_cache_* dans le fichier"); + showToast("Aucun cache trouvé", "Le fichier ne contient aucune clé planning_cache_*"); + return; + } + LOG.info("cache-import", `${importedKeys.length} clé(s) trouvée(s) dans le fichier`); + + const allLocal = await chrome.storage.local.get(null); + const localKeys = Object.keys(allLocal).filter(k => k.startsWith(CACHE_PREFIX)); + + if (localKeys.length === 0) { + // Pas de cache local → import direct + try { + const toSet = {}; + for (const k of importedKeys) toSet[k] = data[k]; + await chrome.storage.local.set(toSet); + LOG.warn("cache-import", `✅ import direct OK (cache local vide) — ${importedKeys.length} jour(s)`); + showToast("Cache importé", `${importedKeys.length} jour(s) ajouté(s)`); + } catch (err) { + LOG.error("cache-import", "❌ écriture chrome.storage échouée", { err: err && err.message }); + showToast("Erreur import", (err && err.message) || String(err)); + } + return; + } + + LOG.info("cache-import", `cache local existant (${localKeys.length} jour(s)) → modal de choix`); + showAlertModal({ + title: "Cache déjà présent", + message: `Tu as déjà ${localKeys.length} jour(s) en cache local. Le fichier en contient ${importedKeys.length}. Que faire ?`, + buttons: [ + { label: "Annuler", variant: "secondary", action: () => { + LOG.info("cache-import", "annulé par l'user"); + } }, + { + label: "Tout écraser", + variant: "danger", + action: async () => { + try { + await chrome.storage.local.remove(localKeys); + const toSet = {}; + for (const k of importedKeys) toSet[k] = data[k]; + await chrome.storage.local.set(toSet); + LOG.warn("cache-import", + `✅ écrasement OK — ${localKeys.length} ancien(s) supprimé(s), ${importedKeys.length} importé(s)`); + showToast("Cache remplacé", `${importedKeys.length} jour(s) — anciennes données supprimées`); + } catch (err) { + LOG.error("cache-import", "❌ écrasement échoué", { err: err && err.message }); + showToast("Erreur import", (err && err.message) || String(err)); + } + } + }, + { + label: "Fusionner", + variant: "primary", + action: async () => { + try { + const toSet = {}; + let added = 0, replaced = 0, kept = 0; + for (const k of importedKeys) { + const local = allLocal[k]; + const imported = data[k]; + if (!local) { toSet[k] = imported; added++; } + else { + const localAt = (local && local.savedAt) || 0; + const importedAt = (imported && imported.savedAt) || 0; + if (importedAt > localAt) { toSet[k] = imported; replaced++; } + else { kept++; } + } + } + if (Object.keys(toSet).length > 0) await chrome.storage.local.set(toSet); + LOG.warn("cache-import", + `✅ fusion OK — ${added} ajouté(s), ${replaced} remplacé(s), ${kept} local(s) gardé(s)`); + showToast("Fusion OK", `${added} ajouté(s), ${replaced} remplacé(s), ${kept} gardé(s)`); + } catch (err) { + LOG.error("cache-import", "❌ fusion échouée", { err: err && err.message }); + showToast("Erreur fusion", (err && err.message) || String(err)); + } + } + } + ] + }); + }; + input.click(); +} + +// R12p : export / import de admin_config (paramètres). Pas de modal — +// import = écrasement direct. Logs verbeux. +async function exportAdminConfig() { + LOG.info("config-export", "début export des paramètres"); + try { + const stored = await chrome.storage.local.get(ADMIN_CONFIG_KEY); + const cfg = (stored && stored[ADMIN_CONFIG_KEY]) || {}; + if (!cfg || Object.keys(cfg).length === 0) { + LOG.warn("config-export", "aucun admin_config en local — export d'un objet vide"); + } + const json = JSON.stringify({ [ADMIN_CONFIG_KEY]: cfg }, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `planning-config-${todayISO()}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + LOG.warn("config-export", `✅ export OK → ${a.download}`, + { fichier: a.download, taille_octets: json.length }); + showToast("Paramètres exportés", a.download); + } catch (err) { + LOG.error("config-export", "❌ export échoué", { err: err && err.message, stack: err && err.stack }); + showToast("Erreur export", (err && err.message) || String(err)); + } +} + +async function importAdminConfig() { + LOG.info("config-import", "ouverture du sélecteur de fichier"); + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json,application/json"; + input.onchange = async () => { + const file = input.files && input.files[0]; + if (!file) { + LOG.info("config-import", "aucun fichier sélectionné"); + return; + } + LOG.info("config-import", "fichier sélectionné", { nom: file.name, taille_octets: file.size }); + let data; + try { + const text = await file.text(); + data = JSON.parse(text); + } catch (e) { + LOG.error("config-import", "❌ JSON invalide", { err: e && e.message }); + showToast("JSON invalide", e.message || String(e)); + return; + } + // On accepte deux formats : { admin_config: {...} } OU directement {...} + const cfg = (data && typeof data === "object" && data[ADMIN_CONFIG_KEY]) + ? data[ADMIN_CONFIG_KEY] + : data; + if (!cfg || typeof cfg !== "object") { + LOG.error("config-import", "❌ format invalide — pas d'objet admin_config"); + showToast("Fichier invalide", "Aucune config admin_config trouvée"); + return; + } + try { + await chrome.storage.local.set({ [ADMIN_CONFIG_KEY]: cfg }); + LOG.warn("config-import", `✅ import OK — recharge la page pour appliquer`, + { keys: Object.keys(cfg) }); + showToast("Paramètres importés", "Recharge la page pour appliquer"); + } catch (err) { + LOG.error("config-import", "❌ écriture chrome.storage échouée", { err: err && err.message }); + showToast("Erreur import", (err && err.message) || String(err)); + } + }; + input.click(); +} + function renderAdminSectionDiagnostics(container, cfg, saveFn) { const h = document.createElement("h3"); h.textContent = "Diagnostics"; @@ -3010,23 +3772,113 @@ function renderAdminSectionDiagnostics(container, cfg, saveFn) { `; container.appendChild(info); - // v2026.5.38 : toggle "Logs verbeux (debug)" — par défaut OFF. - // Quand activé : LOG.info() devient visible dans la console (étapes - // détaillées loadForDate, fetch, render, etc.). LOG.warn et LOG.error - // restent toujours visibles peu importe ce flag. + // R12p : on regroupe tout ce qui touche au cache dans une sous-section + // dédiée (durée + export + import) pour que ce soit logique visuellement. + const SUB_TITLE_STYLE = "margin:24px 0 8px 0; font-size:14px; font-weight:600; color:var(--text); border-bottom:1px solid var(--border); padding-bottom:4px;"; + const SUB_HINT_STYLE = "font-size:12px; color:var(--text-faint); margin:0 0 10px 0;"; + + // ─── Sous-section : Cache ───────────────────────────────────────────── + const cacheTitle = document.createElement("div"); + cacheTitle.textContent = "📦 Cache du planning"; + cacheTitle.style.cssText = SUB_TITLE_STYLE; + container.appendChild(cacheTitle); + + // Durée du cache + const cacheDurRow = _makeAdminRow( + "Durée du cache (jours)", + "Au-delà, les anciens caches sont purgés automatiquement. Défaut : 7 jours." + ); + cacheDurRow.title = _getCacheLocationHint(); + const cacheInput = document.createElement("input"); + cacheInput.type = "number"; + cacheInput.min = "1"; + cacheInput.max = "365"; + cacheInput.step = "1"; + cacheInput.className = "admin-input admin-input-num"; + cacheInput.value = String(cfg.cacheDays || 7); + cacheInput.addEventListener("change", async () => { + let v = parseInt(cacheInput.value, 10); + if (isNaN(v) || v < 1) v = 1; + if (v > 365) v = 365; + cacheInput.value = String(v); + cfg.cacheDays = v; + try { + await saveAdminConfig(cfg); + LOG.info("admin", "cacheDays modifié", { cacheDays: v }); + showToast("Durée du cache", `${v} jour(s)`); + } catch (err) { + LOG.error("admin", "saveAdminConfig (cacheDays) a échoué", { err: err && err.message }); + showToast("Erreur", "Impossible d'enregistrer (voir console F12)"); + } + }); + cacheDurRow.querySelector(".admin-row-control").appendChild(cacheInput); + container.appendChild(cacheDurRow); + + // Export + Import du cache + const cacheBtnRow = document.createElement("div"); + cacheBtnRow.style.cssText = "display:flex; gap:8px; flex-wrap:wrap; margin-top:8px;"; + const exportCacheBtn = document.createElement("button"); + exportCacheBtn.type = "button"; + exportCacheBtn.className = "btn"; + exportCacheBtn.textContent = "💾 Exporter le cache (.json)"; + exportCacheBtn.addEventListener("click", () => exportPlanningCache()); + cacheBtnRow.appendChild(exportCacheBtn); + const importCacheBtn = document.createElement("button"); + importCacheBtn.type = "button"; + importCacheBtn.className = "btn"; + importCacheBtn.textContent = "📂 Importer un cache (.json)"; + importCacheBtn.addEventListener("click", () => importPlanningCache()); + cacheBtnRow.appendChild(importCacheBtn); + container.appendChild(cacheBtnRow); + + // ─── Sous-section : Paramètres ──────────────────────────────────────── + const cfgTitle = document.createElement("div"); + cfgTitle.textContent = "⚙ Paramètres (équipe, thème, EV…)"; + cfgTitle.style.cssText = SUB_TITLE_STYLE; + container.appendChild(cfgTitle); + + const cfgHint = document.createElement("p"); + cfgHint.textContent = "L'import remplace directement les paramètres courants — pas de fusion."; + cfgHint.style.cssText = SUB_HINT_STYLE; + container.appendChild(cfgHint); + + const cfgBtnRow = document.createElement("div"); + cfgBtnRow.style.cssText = "display:flex; gap:8px; flex-wrap:wrap;"; + const exportCfgBtn = document.createElement("button"); + exportCfgBtn.type = "button"; + exportCfgBtn.className = "btn"; + exportCfgBtn.textContent = "💾 Exporter les paramètres (.json)"; + exportCfgBtn.addEventListener("click", () => exportAdminConfig()); + cfgBtnRow.appendChild(exportCfgBtn); + const importCfgBtn = document.createElement("button"); + importCfgBtn.type = "button"; + importCfgBtn.className = "btn"; + importCfgBtn.textContent = "📂 Importer les paramètres (.json)"; + importCfgBtn.addEventListener("click", () => importAdminConfig()); + cfgBtnRow.appendChild(importCfgBtn); + container.appendChild(cfgBtnRow); + + // ─── Sous-section : Système ────────────────────────────────────────── + const sysTitle = document.createElement("div"); + sysTitle.textContent = "🛠 Système"; + sysTitle.style.cssText = SUB_TITLE_STYLE; + container.appendChild(sysTitle); + + // Toggle "Logs verbeux (debug)" const debugRow = document.createElement("label"); debugRow.className = "admin-debug-row"; - debugRow.style.cssText = "display:flex; align-items:center; gap:10px; margin-top:16px; padding:10px; background:var(--bg-muted); border-radius:6px; cursor:pointer;"; + debugRow.style.cssText = "display:flex; align-items:center; gap:10px; padding:10px; background:var(--bg-muted); border-radius:6px; cursor:pointer;"; const debugCheckbox = document.createElement("input"); debugCheckbox.type = "checkbox"; debugCheckbox.checked = LOG.isDebug(); const debugText = document.createElement("div"); debugText.style.cssText = "flex:1;"; - debugText.innerHTML = `Logs verbeux (debug)
Affiche les étapes détaillées dans la console (F12). À activer si tu veux signaler un bug avec un max de contexte.
`; + debugText.innerHTML = `Logs verbeux (debug)
Affiche les étapes détaillées dans la console (F12). À activer pour signaler un bug avec un max de contexte.
`; debugRow.appendChild(debugCheckbox); debugRow.appendChild(debugText); debugCheckbox.addEventListener("change", () => { LOG.setDebug(debugCheckbox.checked); + LOG.warn("admin", `debug logs ${debugCheckbox.checked ? "activés" : "désactivés"}`); showToast("Debug logs", debugCheckbox.checked ? "ACTIVÉ — voir console F12" : "désactivé"); }); container.appendChild(debugRow); @@ -3802,7 +4654,17 @@ async function loadForDate(isoDate, opts = {}) { for (const tech of merged.techs) { for (const iv of tech.interventions) { if (iv.ghost) { - iv._disappearChecking = true; // marquer "en cours d'analyse" + // verdicts FINAUX = uniquement les "officiellement clos" + // (terminated-clos / closed / terminated). Les "Fait" (terminated- + // pending) sont ré-analysés sur Actualiser pour détecter une + // éventuelle clôture officielle ultérieure. + const finalVerdicts = ["terminated-clos", "closed", "terminated"]; + if (!opts.doStatusRefresh && finalVerdicts.includes(iv._disappearStatus)) { + iv._disappearChecking = false; + iv.ghost = false; + continue; + } + iv._disappearChecking = true; ghostsToAnalyze.push({ tech, iv }); } } @@ -3822,27 +4684,9 @@ async function loadForDate(isoDate, opts = {}) { }); console.log(`[load] 1er rendu complet à ${Math.round(performance.now() - t0)} ms`); - // v4.2.5 : analyser les ghosts (tickets disparus du planning) pour décider - // s'il faut les garder en vert (terminés par tech / clôturés) ou les - // retirer définitivement (déplacés / annulés). Asynchrone en arrière-plan. - if (ghostsToAnalyze.length > 0 && !isRefreshAborted(myToken)) { - console.log(`[load] analyse de ${ghostsToAnalyze.length} ticket(s) disparu(s)…`); - analyzeDisappearedInterventions(merged.techs, ghostsToAnalyze, myToken) - .then(() => { - if (!isRefreshAborted(myToken)) { - renderFromData({ - techs: merged.techs, - targetDate: isoDate, - captureTime: Date.now(), - source: "fresh", - lastRefreshKind: activeRefreshButton - }); - writeCache(isoDate, { techs: merged.techs }) - .catch(e => LOG.warn("cache", "writeCache fail (disappear-analysis)", { err: e && e.message })); - } - }) - .catch(err => console.error("[disappear-analysis]", err)); - } + // on n'analyse plus les ghosts en parallèle. On le fait APRÈS + // refreshStatuses (les nouvelles iv en premier, les ghosts en second) + // avec une barre de progression unifiée. // 5. Fetch des fiches en arrière-plan UNIQUEMENT pour obtenir : // - le statut Clôturé/Résolu (pour le ✓ vert et le fond vert) @@ -3873,21 +4717,28 @@ async function loadForDate(isoDate, opts = {}) { // Évite d'attendre le retry de 60s quand on vient juste de se reconnecter. _maybeRetryFetchUser("after_load_success"); - // v4.3.2 : avant de lancer le fetch des fiches (lourd, ~460 KB chacune), - // on fait d'abord un batch xhr2 (léger, ~2-5 KB chacun) pour récupérer - // les vraies infos contact/lieu de toutes les interventions en parallèle. + // R12b : retour à la logique v42 en 2 phases distinctes — + // Phase A : prefetchAllXhr2 EN PARALLÈLE (concurrency=6, rapide) + // → toutes les cartes ont leurs infos courtes contact/lieu + // en bloc dès le 1er chargement. + // Phase B : processInterventionsSequentially → fiches + ghosts en + // séquentiel strict (1 à la fois, ordre d'affichage). if (!isRefreshAborted(myToken)) { await prefetchAllXhr2(merged.techs, myToken, opts.doStatusRefresh); } - if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted(myToken)) { - const tFiches = performance.now(); - const nFiches = merged.techs.flatMap(t=>t.interventions).filter(i=>i.type==="AL-Intervention").length; - console.log(`[load] début fetch des ${nFiches} fiches (statuts)…`); - await refreshStatuses(merged.techs, isoDate, { forceAll: !!opts.doStatusRefresh, myToken }); - console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`); - } else { - console.log(`[load] PAS DE FETCH : needFetch=${needFetch}, doStatusRefresh=${!!opts.doStatusRefresh}, aborted=${isRefreshAborted(myToken)}`); + if (!isRefreshAborted(myToken)) { + const cacheAgeMs = (cached && cached.savedAt) ? (Date.now() - cached.savedAt) : Infinity; + const cacheTooOld = cacheAgeMs > 24 * 3600 * 1000; + const useFreshChecksum = !!(opts.forceRefetch || opts.doStatusRefresh || opts._fromPartialBtn || cacheTooOld); + const tSeq = performance.now(); + console.log(`[load] phase B séquentielle (forceAll=${!!opts.doStatusRefresh}, freshChecksum=${useFreshChecksum})`); + await processInterventionsSequentially(merged.techs, isoDate, { + forceAll: !!opts.doStatusRefresh, + myToken, + useFreshChecksum + }); + console.log(`[load] phase B finie en ${Math.round(performance.now() - tSeq)} ms`); } // 6. Sauvegarder dans le cache (une seule fois, après que tout soit enrichi) @@ -3964,10 +4815,20 @@ async function fetchPlanningForDate(isoDate) { showEvUnreachable(); } } else if (resp.error === "no_team_configured") { - // v2026.5.41 : aucun technicien sélectionné dans Paramètres → Équipe + // libellé bouton simplifié à "Ouvrir paramètres". showError( - "Aucun technicien sélectionné. Ouvrez ⚙ Paramètres → Équipe pour " + - "choisir le groupe EasyVista et cocher les techniciens à afficher." + "Choisissez votre groupe EasyVista et cochez les techniciens que " + + "vous voulez voir apparaître dans le planning. La configuration est " + + "enregistrée localement et restera persistante entre les sessions.", + { + centered: true, + icon: "👥", + title: "Aucune équipe configurée", + actionLabel: "Ouvrir paramètres", + actionHandler: () => { + if (typeof showAdminPanel === "function") showAdminPanel("team"); + } + } ); } else { showError("Erreur de fetch : " + (resp.error || "inconnue")); @@ -4240,18 +5101,41 @@ function mergeCacheAndFresh(cached, fresh) { // - Règle générale : fresh wins sur les champs live, cache wins sur les // champs enrichis qui ne sont pas dans le fresh. // - Une interventoin en cache mais plus en fresh → marquée "ghost" + // + // v2026.5.44 (mode diagnostic) : logs ULTRA-détaillés sur les disparitions. + // Aucune intervention n'est jamais retirée silencieusement — on log tout + // ce qu'on voit pour pouvoir comprendre cas par cas. if (!cached || !cached.techs) { + LOG.info("disparition", "merge: pas de cache → aucune disparition possible", + { freshTechs: fresh.techs.length }); return { techs: fresh.techs }; } - // Indexer le cache par actionId + // indexer le cache par actionId ET par ref. Bug avant : on stockait + // un wrapper { iv, techId, techName } et on consultait `cachedIv.startTime` + // (qui n'existait pas → tout passait en _timeChanged=true). En plus, si EV + // recycle l'actionId d'une iv, le matching par actionId échouait → l'iv + // était poussée comme nouvelle et toute son enrichissement (ficheFetched, + // bulleDescription, …) était perdu. Fallback par ref → robuste. const cachedByAction = new Map(); + const cachedByRef = new Map(); + let cacheCount = 0; for (const tech of cached.techs) { for (const iv of tech.interventions || []) { - cachedByAction.set(iv.actionId, iv); + if (iv.actionId !== undefined && iv.actionId !== null) { + cachedByAction.set(iv.actionId, iv); + } + if (iv.ref) { + cachedByRef.set(iv.ref, iv); + } + cacheCount++; } } + let freshCount = 0; + for (const tech of fresh.techs) freshCount += (tech.interventions || []).length; + LOG.info("disparition", `merge: comparaison cache(${cacheCount}) vs fresh(${freshCount})`, + { freshTechs: fresh.techs.length, cachedTechs: cached.techs.length }); const resultTechs = fresh.techs.map(t => ({ ...t, interventions: [] })); const freshActionIds = new Set(); @@ -4260,13 +5144,29 @@ function mergeCacheAndFresh(cached, fresh) { const outTech = resultTechs.find(t => t.id === tech.id); for (const iv of tech.interventions) { freshActionIds.add(iv.actionId); - const cachedIv = cachedByAction.get(iv.actionId); + // fallback ref si l'actionId a changé entre cache et fresh. + let cachedIv = (iv.actionId !== undefined && iv.actionId !== null) + ? cachedByAction.get(iv.actionId) + : null; + if (!cachedIv && iv.ref) { + cachedIv = cachedByRef.get(iv.ref); + } if (cachedIv) { + // détecter si les horaires OU le tech ont changé entre cache et + // fresh. Si oui → forcer le re-fetch (l'iv a été déplacée/réassignée). + const timeChanged = (cachedIv.startTime !== iv.startTime) || + (cachedIv.endTime !== iv.endTime) || + (cachedIv.startDate !== iv.startDate) || + (String(cachedIv.techId) !== String(iv.techId)); // On part du cache (qui a les champs enrichis), puis on remplace // les champs "live" depuis le fresh (horaires, type, formLink). const merged = { + _timeChanged: timeChanged, ...cachedIv, // Champs live venant du fresh (le planning peut avoir bougé) + // actionId du fresh prioritaire (les vieux caches avaient + // parfois actionId=undefined → on récupère le bon depuis le fresh). + actionId: iv.actionId || cachedIv.actionId, techId: iv.techId || cachedIv.techId, type: iv.type || cachedIv.type, label: iv.label || cachedIv.label, @@ -4298,26 +5198,115 @@ function mergeCacheAndFresh(cached, fresh) { }; outTech.interventions.push(merged); } else { - outTech.interventions.push(iv); + // iv totalement nouvelle (pas dans le cache) → marquée pour + // que le prefetch xhr2 + fiche se déclenche. + outTech.interventions.push({ ...iv, _isNewIv: true }); + } + } + } + + // sets de ref/requestId fresh — un ghost dont la ref ou le requestId + // matche une iv fresh est en réalité la MÊME intervention (EV a juste + // recyclé l'actionId quand le statut a changé). On le drop pour ne pas + // afficher de doublon visuel. + const freshRefs = new Set(); + const freshRequestIds = new Set(); + // signature composite pour AL-Absence et AL-Reservation — l'EV + // recycle parfois leur actionId entre 2 requêtes, ce qui faisait que le + // merge les "perdait" au 2e refresh (visuel d'absent qui disparaissait). + // Clé = techId|type|startDate|endDate|isPompier|label + const freshAbsenceSigs = new Set(); + const _absenceSig = (iv, techId) => + [techId, iv.type, iv.startDate || "", iv.endDate || "", iv.isPompier ? "P" : "", (iv.label || "").trim()].join("|"); + for (const tech of fresh.techs) { + for (const iv of tech.interventions) { + if (iv.ref) freshRefs.add(iv.ref); + if (iv.requestId) freshRequestIds.add(String(iv.requestId)); + if (iv.type === "AL-Absence" || iv.type === "AL-Reservation") { + freshAbsenceSigs.add(_absenceSig(iv, tech.id)); } } } // Ajouter les interventions qui sont en cache mais plus en fresh + // v2026.5.44 : log détaillé pour chaque disparition + on ne retire plus + // SILENCIEUSEMENT les absences/réservations — on les marque ghost aussi + // pour qu'on les voie et qu'on log leur disparition explicitement. + let ghostsTotal = 0; for (const tech of cached.techs) { const outTech = resultTechs.find(t => t.id === tech.id); - if (!outTech) continue; + if (!outTech) { + // Le tech n'existe plus dans le fresh : on log et on saute (le tech + // a été retiré de l'équipe ou de l'XML EV). + const orphans = (tech.interventions || []).filter(iv => !freshActionIds.has(iv.actionId)); + if (orphans.length > 0) { + LOG.info("disparition", + `merge: tech '${tech.name}' (id=${tech.id}) absent du fresh — ${orphans.length} iv perdues`, + { orphanRefs: orphans.map(o => o.ref || o.actionId) }); + } + continue; + } for (const iv of tech.interventions || []) { if (!freshActionIds.has(iv.actionId)) { - // v5.0.1 : les absences et réservations supprimées côté EasyVista - // sont définitivement retirées (pas ghost). La logique ghost est - // conçue pour les interventions dont on veut garder trace en attendant - // la vérification du statut (clos/annulé). Absences/réservations n'ont - // pas de notion de statut, une disparition = suppression pure. - if (iv.type === "AL-Absence" || iv.type === "AL-Reservation") { - continue; // ne pas rajouter + // si la ref ou le requestId existe dans le fresh, c'est la même + // intervention sous un nouvel actionId. On drop pour ne pas dupliquer. + if ((iv.ref && freshRefs.has(iv.ref)) || + (iv.requestId && freshRequestIds.has(String(iv.requestId)))) { + LOG.info("disparition", + `merge: ghost actionId=${iv.actionId} déjà présent dans le fresh sous ref=${iv.ref || "?"} (actionId recyclé) → drop`); + continue; } - const ghost = { ...iv, ghost: true }; + // pour AL-Absence et AL-Reservation, EV recycle parfois leur + // actionId entre requêtes. On vérifie via signature composite si une + // iv fresh "équivalente" existe (même tech, dates, type, label) → + // dans ce cas, c'est la MÊME absence/réservation, pas une disparition, + // on ignore (le fresh a déjà la version à jour). + if (iv.type === "AL-Absence" || iv.type === "AL-Reservation") { + const sig = _absenceSig(iv, tech.id); + if (freshAbsenceSigs.has(sig)) { + LOG.info("disparition", + `merge: ${iv.type} actionId=${iv.actionId} matché par signature dans le fresh — actionId recyclé, pas de drop`); + continue; + } + } + ghostsTotal++; + // Log ULTRA détaillé de la disparition détectée + LOG.info("disparition", + `GHOST détecté → ref=${iv.ref || "?"} actionId=${iv.actionId} requestId=${iv.requestId || "?"} tech='${tech.name}' (id=${tech.id})`, + { + ref: iv.ref, + actionId: iv.actionId, + requestId: iv.requestId, + techId: tech.id, + techName: tech.name, + type: iv.type, + label: iv.label, + startTime: iv.startTime, + endTime: iv.endTime, + startDate: iv.startDate, + endDate: iv.endDate, + statusEnCache: iv.status, + ficheFetched: !!iv.ficheFetched, + xhr2Fetched: !!iv.xhr2Fetched, + categoryLine: iv.categoryLine, + bulleContact: iv.bulleContact, + bulleLieu: iv.bulleLieu, + formLink: iv.formLink ? iv.formLink.substring(0, 120) + "…" : null + }); + // les réservations/absences disparues du planning EV sont + // retirées immédiatement (pas de "ghost" pour elles) — comportement + // v5.0.1 d'origine, demandé par Quentin. + if (iv.type === "AL-Reservation" || iv.type === "AL-Absence") { + continue; // on ne rajoute pas → disparait du planning + } + const ghost = { + ...iv, + ghost: true, + _diagnosticDetectedAt: Date.now(), + _diagnosticVerdict: null, + _diagnosticDecisionNormal: null, + _diagnosticReasoning: [] + }; outTech.interventions.push(ghost); } } @@ -4327,6 +5316,14 @@ function mergeCacheAndFresh(cached, fresh) { ); } + if (ghostsTotal === 0) { + LOG.info("disparition", "merge: ✅ aucune disparition (toutes les iv du cache sont dans le fresh)"); + } else { + LOG.info("disparition", + `merge: 📋 RÉCAP — ${ghostsTotal} GHOST(s) détecté(s), aucun retiré (mode diagnostic v2026.5.44)`, + { totalCache: cacheCount, totalFresh: freshCount, totalGhosts: ghostsTotal }); + } + return { techs: resultTechs }; } @@ -4384,11 +5381,15 @@ function parseAllActionsFromFicheHtml(html) { if (!html) return []; // Décoder : dans le HTML, les JSON imbriqués ont \u0022 pour " et \/ pour / const decoded = html - .replace(/\\u0022/g, '"') .replace(/\\\//g, '/'); + // R12u : on NE pré-décode PAS " ici — les " littéraux à l'intérieur + // d'une valeur sont encodés " (Unicode escape) dans le JSON et le + // parser doit les voir comme du texte (6 chars), pas comme des " réels. + // Les délimiteurs JSON (autour de "value", "rows", etc.) sont déjà des " + // bare et restent intacts. Le décodage \uXXXX se fait par + // decodeUnicodeEscapes() en aval, sur chaque valeur déjà extraite. const actions = []; - // Chercher chaque bloc "rows":[...] const rowsRegex = /"rows":\[/g; let m; while ((m = rowsRegex.exec(decoded)) !== null) { @@ -4407,7 +5408,28 @@ function parseAllActionsFromFicheHtml(html) { if (values.length < 12) continue; // Une "vraie" action a 14 valeurs. On se contente de 12 minimum // pour avoir au moins la description. + // + // v2026.5.44 on extrait aussi actionId / amDoneById depuis values[13] + // qui est un JSON stringifié contenant {NAME, ACTION_ID, AM_DONE_BY_ID, …}. + // Permet de filtrer la "bonne action" par ACTION_ID au lieu de matcher + // par nom de tech (le matching par nom échouait par exemple sur les + // actions où l'intervenant est noté "EZV_WS_REST_USER" ou un format + // qui ne correspond pas exactement au tech.name de la config). + let actionId = null; + let amDoneById = null; + if (values.length >= 14 && values[13]) { + const raw13 = values[13]; + // Parsing tolérant via regex (on accepte avec ou sans guillemets autour + // des valeurs numériques) — robuste aux formats JSON avec ou sans + // échappements résiduels. + const mAid = raw13.match(/"ACTION_ID"\s*:\s*"?(\d+)"?/); + const mDid = raw13.match(/"AM_DONE_BY_ID"\s*:\s*"?(\d*)"?/); + if (mAid) actionId = mAid[1]; + if (mDid && mDid[1]) amDoneById = mDid[1]; + } actions.push({ + actionId: actionId, // ID unique pour matching exact + amDoneById: amDoneById, // ID du tech ayant fait l'action intervenant: decodeUnicodeEscapes(values[2] || ""), type: decodeUnicodeEscapes(values[4] || ""), dateCreation: values[8] || "", @@ -4429,6 +5451,10 @@ function extractValuesFromRowBlock(block) { const mIdx = block.indexOf('"value":"', i); if (mIdx < 0) break; const start = mIdx + '"value":"'.length; + // R12u : on lit jusqu'au prochain " non échappé. + // Les escapes \X (\\, \") avancent de 2. + // Les " littéraux internes sont encodés " dans le block (parce + // qu'on n'a PAS pré-décodé) → 6 chars qui ne déclenchent pas le break. let j = start; while (j < block.length) { if (block[j] === '\\') { j += 2; continue; } @@ -4480,21 +5506,86 @@ function hasClosedAlInterventionInHtml(html) { * `LOGIN: commentaire`. Nettoie d'abord le HTML de la description. */ function hasTechCommentInDescription(description) { - if (!description) return false; - // Décoder unicode puis remplacer les
par \n pour faciliter le regex + return !!extractTechCommentFromDescription(description); +} + +/** + * v2026.5.44 retourne le commentaire tech complet ou null. + * + * Le commentaire tech apparaît APRÈS une signature du coordinateur du type + * "ECM 24.04" / "AWR 23/04/26" / "KAA 28.04.2026" — soit 2-5 lettres + * majuscules suivies d'une date courte, puis un retour à la ligne, puis + * le commentaire libre saisi par le tech. + */ +function extractTechCommentFromDescription(description) { + if (!description) return null; const txt = decodeUnicodeEscapes(description) .replace(//gi, '\n') .replace(/<\/?p[^>]*>/gi, '\n') .replace(/<[^>]+>/g, '') .replace(/ /g, ' ') .replace(/&/g, '&'); - return RX_LOGIN_COMMENTAIRE.test(txt); + + // R12q : on essaie 2 patterns successifs. + // 1. SIGNATURE puis commentaire : "XXX JJ.MM\nTEXTE…" + // (le tech a tapé sa signature ECM/AWR/etc puis ligne nouvelle + texte). + // 2. LOGIN: TEXTE — fallback : "vyjuva: ok", "awr: a faire", etc. + // (parfois le tech écrit juste son login sans signature au-dessus). + const rxSig = /\b([A-Z]{2,5})\s+(\d{1,2}[.\/]\d{1,2}(?:[.\/]\d{2,4})?)\s*\n+([\s\S]+)/; + const m = txt.match(rxSig); + if (m) { + const commentaire = m[3].trim(); + if (commentaire && commentaire.length >= 3) { + return { signature: `${m[1]} ${m[2]}`, commentaire, full: commentaire }; + } + } + + // Fallback : pattern login: texte + const m2 = txt.match(RX_LOGIN_COMMENTAIRE); + if (m2) { + const commentaire = (m2[2] || "").trim(); + if (commentaire && commentaire.length >= 3) { + return { signature: m2[1], commentaire, full: commentaire }; + } + } + + return null; } /** * Normalise un nom "Nom, Prénom" (insensible à la casse, accents ignorés) - * pour comparaison. + * v2026.5.44 récupère un formLink FRAIS pour une référence (S260…/I260…) + * via les APIs EV basicAutoComplete + redirectHeader. Garantit un checksum + * valide pour la session en cours. Retourne null si erreur/iv inconnue. + * + * Le formLink retourné est dans le format attendu par fetchFiche : + * "internalurltime=…&eventName=formEvent&target=…&checksum=…&sender=…" + * (sans PHPSESSID — il sera ajouté par background.js). */ +async function getFreshFicheFormLinkForRef(ref) { + if (!ref) return null; + const origin = (state.session && state.session.origin) || state.lastKnownOrigin; + const sid = state.session && state.session.phpsessid; + if (!origin || !sid) return null; + try { + const r1 = await fetch(`${origin}/api/v1/internal/search/basicAutoComplete?search=${encodeURIComponent(ref)}&PHPSESSID=${encodeURIComponent(sid)}`, { credentials: "include" }); + const j1 = await r1.json(); + const meta = j1 && j1.data && j1.data.METAOBJECTS && j1.data.METAOBJECTS[0]; + const line = meta && meta.LINES && meta.LINES[0]; + if (!meta || !line || !line.PK || !meta.TYPE_GUID || !line.CHECKSUM) return null; + const r2 = await fetch(`${origin}/api/v1/internal/search/redirectHeader?pk=${encodeURIComponent(line.PK)}&guid=${encodeURIComponent(meta.TYPE_GUID)}&checksum=${encodeURIComponent(line.CHECKSUM)}&PHPSESSID=${encodeURIComponent(sid)}`, { credentials: "include" }); + const j2 = await r2.json(); + const urlStr = (typeof j2 === "string") ? j2 : (j2 && j2.url) || ""; + if (!urlStr || !urlStr.includes("checksum=")) return null; + const qIdx = urlStr.indexOf("?"); + const queryStr = qIdx >= 0 ? urlStr.substring(qIdx + 1) : urlStr; + return queryStr.split("&").filter(p => !p.startsWith("PHPSESSID=")).join("&"); + } catch (e) { + LOG.info("freshLink", `getFreshFicheFormLinkForRef erreur pour ${ref}`, { err: e && e.message }); + return null; + } +} + function normalizeName(s) { if (!s) return ""; return s @@ -4509,17 +5600,20 @@ function normalizeName(s) { * Compare l'intervenant de l'action avec le nom du tech (insensible casse/accents). * Ignore les actions système (EZV_WS_REST_USER, vide). */ -function actionBelongsToTech(action, techName) { +function actionBelongsToTech(action, techName, techId) { + // R12s : on accepte 2 voies d'attribution : + // 1. action.amDoneById === techId (le tech a réalisé/modifié l'action, + // même si elle a été créée par RPH ou un autre intervenant) + // 2. intervenant matche le nom du tech (ancien comportement) + if (techId && action.amDoneById && String(action.amDoneById) === String(techId)) { + return true; + } const interv = normalizeName(action.intervenant); if (!interv || interv === "ezv_ws_rest_user") return false; const tech = normalizeName(techName); if (!tech) return false; - // Le nom du tech dans notre config est souvent "Prénom Nom" alors que - // l'EasyVista affiche "Nom, Prénom". On accepte les deux ordres. - // Simple test : au moins un mot du nom tech (longueur > 2) est dans l'intervenant. const techParts = tech.split(/[\s,]+/).filter(p => p.length >= 3); if (techParts.length === 0) return false; - // Exiger que TOUS les mots significatifs du nom tech soient dans l'intervenant return techParts.every(p => interv.includes(p)); } @@ -4530,7 +5624,15 @@ function actionBelongsToTech(action, techName) { * Modifie directement les tech.interventions en place (retire les ghosts à * retirer, met à jour les propriétés des ghosts à garder). */ -async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken) { +async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken, opts = {}) { + // v2026.5.44 (mode diagnostic) : on lance l'analyse de chaque ghost, + // mais on FORCE _disappearRemove = false à la fin de chaque analyse + // (dans analyzeOneDisappearedIv). Aucun retrait effectif. On log juste + // le verdict et la décision QU'AURAIT pris le code normal. + + LOG.info("disparition", `🔬 analyse de ${ghostsToAnalyze.length} GHOST(s)`, + { ghosts: ghostsToAnalyze.map(g => ({ ref: g.iv.ref, tech: g.tech.name, type: g.iv.type })) }); + // Traiter en parallèle pour rester rapide (max 3 fiches en parallèle) const concurrency = 3; const queue = [...ghostsToAnalyze]; @@ -4543,20 +5645,89 @@ async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken) try { await analyzeOneDisappearedIv(tech, iv); } catch (err) { - console.warn("[disappear] analyse échouée pour", iv.actionId, err); - // En cas d'erreur, on garde l'iv visible mais sans marquage spécial + LOG.error("disparition", + `❌ analyse a JETÉ une exception pour ref=${iv.ref || "?"} actionId=${iv.actionId}`, + { err: err && (err.message || String(err)), stack: err && err.stack }); + // En cas d'erreur, on garde l'iv visible (mode diagnostic) iv._disappearChecking = false; - iv.ghost = false; // on la laisse visible plutôt que perdre de l'info + iv.ghost = false; iv._disappearStatus = "error"; + iv._diagnosticVerdict = "error"; + iv._diagnosticDecisionNormal = "KEEP (erreur fetch)"; + iv._diagnosticReasoning = iv._diagnosticReasoning || []; + iv._diagnosticReasoning.push("Exception pendant l'analyse: " + (err && err.message)); + iv._disappearRemove = false; } + // signaler la progression au callback unifié + if (typeof opts.onProgress === "function") { + try { opts.onProgress(); } catch (e) { /* ignore */ } + } + // rendu en direct après chaque ghost analysé pour que la card + // se mette à jour (verdict ✓ / ⏳ / disparait) en temps réel. + try { updateInterventionRow(iv); } catch (e) { /* ignore */ } } })()); } await Promise.all(workers); - // Filtrer les iv qui doivent être retirées définitivement + // v2026.5.44 : SYNTHÈSE — on log le verdict de CHAQUE ghost analysé, + // côte à côte (table mentale facile à lire dans la console). + const summary = []; for (const tech of techs) { - tech.interventions = tech.interventions.filter(iv => !iv._disappearRemove); + for (const iv of tech.interventions) { + if (iv._diagnosticVerdict !== undefined && iv._diagnosticVerdict !== null) { + summary.push({ + ref: iv.ref, + actionId: iv.actionId, + requestId: iv.requestId, + tech: tech.name, + type: iv.type, + verdict: iv._diagnosticVerdict, + decisionNormale: iv._diagnosticDecisionNormal, + decisionAppliquee: "KEEP (forcé par mode diagnostic v2026.5.44)", + raisons: iv._diagnosticReasoning + }); + } + } + } + LOG.warn("disparition", `📊 SYNTHÈSE FINALE — ${summary.length} ghost(s) analysé(s)`, summary); + + // filtre selon le mode. + // - Mode DIAGNOSTIC (debug ON) : on garde tout, on log juste ce qu'on + // aurait retiré pour permettre au coordinateur de comprendre les + // verdicts cas par cas avant de basculer en prod. + // - Mode PROD (debug OFF) : on retire vraiment les iv marquées + // _disappearRemove=true depuis tech.interventions. + const isDiag = (typeof LOG !== "undefined") && LOG.isDebug && LOG.isDebug(); + if (isDiag) { + for (const tech of techs) { + for (const iv of tech.interventions) { + if (iv._disappearRemove === true) { + LOG.info("disparition", + `🛡️ KEEP forcé (debug) — ref=${iv.ref || "?"} actionId=${iv.actionId} aurait été retirée (verdict=${iv._diagnosticVerdict})`, + { ref: iv.ref, actionId: iv.actionId, requestId: iv.requestId }); + iv._disappearRemove = false; + } + } + } + } else { + let removedTotal = 0; + for (const tech of techs) { + const before = tech.interventions.length; + tech.interventions = tech.interventions.filter(iv => { + if (iv._disappearRemove === true) { + LOG.warn("disparition", + `🗑 RETIRÉ — ref=${iv.ref || "?"} actionId=${iv.actionId} (verdict=${iv._diagnosticVerdict})`, + { ref: iv.ref, actionId: iv.actionId, requestId: iv.requestId }); + return false; + } + return true; + }); + removedTotal += before - tech.interventions.length; + } + if (removedTotal > 0) { + LOG.warn("disparition", `🗑 ${removedTotal} iv retirée(s) du planning (mode prod)`); + } } } @@ -4566,115 +5737,339 @@ async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken) * et iv._disappearRemove (true si à retirer). */ async function analyzeOneDisappearedIv(tech, iv) { - // v4.3.0 : court-circuit pour les réservations (AL-Reservation). Elles n'ont - // pas de notion de "terminé par tech" ni de statut clos/résolu à afficher - // (pas de fiche à ouvrir). Quand une réservation disparaît du planning, - // elle est juste retirée — inutile de re-fetcher sa fiche. - if (iv.type === "AL-Reservation") { - iv._disappearChecking = false; - iv._disappearStatus = "cancelled"; - iv._disappearRemove = true; - return; - } - - // Étape 1 : re-fetch la fiche - const resp = await sendMessage({ - type: "fetchFiche", - formLink: iv.formLink - }); - if (!resp || !resp.ok) { - // En cas d'erreur fetch : on garde visible (pas de décision) - iv._disappearChecking = false; - iv._disappearStatus = "error"; - iv.ghost = false; - return; - } - const html = resp.html; - - // Étape 2 : statut global de la fiche - const ficheData = parseFicheHtml(html); - const status = ficheData.status || iv.status || null; - iv.status = status; // garder à jour - - if (isClosedStatus(status) || isResolvedStatus(status)) { - // CAS 1 : clôturé / résolu → garder, vert ✓✓ (double check) - iv._disappearChecking = false; - iv._disappearStatus = "closed"; - iv._disappearRemove = false; - iv.ghost = false; - return; - } - - // Étape 3 : parser toutes les actions de la fiche - const actions = parseAllActionsFromFicheHtml(html); - - // Identifier les actions AL-Intervention au nom du tech. + // v2026.5.44 (mode diagnostic) : on trace TOUT et on ne supprime jamais. // - // Pour savoir si une AL-Intervention spécifique est fermée ou ouverte, - // on utilise l'indicateur global `hasClosedAlInterventionInHtml` : - // - SI la fiche contient "AL-Intervention" dans le JSON timeline - // → l'action AL-Intervention est fermée (terminée par le tech) - // - SINON → elle est encore ouverte - const alActionsForTech = actions.filter(a => - a.type === "AL-Intervention" && actionBelongsToTech(a, tech.name || tech.label || "") - ); - const hasClosedAl = hasClosedAlInterventionInHtml(html); - - // CAS 2 : action AL-Intervention encore ouverte au nom du tech - if (alActionsForTech.length > 0 && !hasClosedAl) { - // Vérifier sur quel jour elle est planifiée maintenant. Si on ne peut - // pas déterminer, on retire par prudence (elle a été bougée, sinon - // elle serait encore dans le fresh). - // On regarde si une action ouverte référence explicitement notre jour. - // Simple heuristique : on regarde les dates dans les descriptions. + // même logique que le rafraîchissement : + // B1. fetchFiche(iv.formLink) (cible action_id) + // B2. fallback fetchFiche(requestId) si tronquée + // C. si toutes tronquées → FICHE_TRONQUEE → KEEP + // 1. statut global de la fiche + // 2. parser les actions, trouver celle dont ACTION_ID == iv.actionId + // 3. lire la description COMPLÈTE (pas tronquée) de cette action + // 4. chercher ": ..." dans cette description complète + iv._diagnosticReasoning = iv._diagnosticReasoning || []; + const tag = `ref=${iv.ref || "?"} actionId=${iv.actionId} requestId=${iv.requestId || "?"} tech='${tech.name}'`; + const reason = (msg) => { iv._diagnosticReasoning.push(msg); LOG.info("disparition", ` ↳ ${tag} | ${msg}`); }; + const verdict = (status, decisionNormal, ...rs) => { iv._disappearChecking = false; - iv._disappearStatus = "moved"; - iv._disappearRemove = true; // retirer (déplacée) - return; - } - - // CAS 3 : action AL-Intervention FERMÉE au nom du tech → chercher un - // commentaire tech dans les descriptions des actions du tech. - if (alActionsForTech.length > 0 && hasClosedAl) { - const anyHasComment = alActionsForTech.some(a => - hasTechCommentInDescription(a.description) - ); - if (anyHasComment) { - // Terminée par le tech → garder, vert ✓ simple - iv._disappearChecking = false; - iv._disappearStatus = "terminated"; + iv._disappearStatus = status; + iv._diagnosticVerdict = status; + iv._diagnosticDecisionNormal = decisionNormal; + for (const r of rs) reason(r); + // mode diagnostic = actif UNIQUEMENT quand le flag debug log + // est activé (Paramètres → Diagnostics → Logs verbeux). Sinon mode + // prod : on applique la décision normale (REMOVE → l'iv sera retirée). + const isDiag = (typeof LOG !== "undefined") && LOG.isDebug && LOG.isDebug(); + const shouldRemove = !!decisionNormal && /\bREMOVE\b/i.test(decisionNormal); + LOG.warn("disparition", + `🔎 VERDICT ${tag} → ${status.toUpperCase()} | code normal : ${decisionNormal} | mode actuel : ${isDiag ? "DIAGNOSTIC (KEEP forcé)" : (shouldRemove ? "PROD → REMOVE" : "PROD → KEEP")}`, + { ref: iv.ref, actionId: iv.actionId, requestId: iv.requestId, type: iv.type, raisons: iv._diagnosticReasoning }); + if (isDiag) { iv._disappearRemove = false; iv.ghost = false; - return; + } else { + iv._disappearRemove = shouldRemove; + // Si on garde, on retire le flag ghost pour qu'elle s'affiche normalement. + // Si on supprime, on laisse ghost=true (elle sera filtrée plus bas). + if (!shouldRemove) iv.ghost = false; } - // Pas de commentaire détecté → retirer (annulée) - iv._disappearChecking = false; - iv._disappearStatus = "cancelled"; - iv._disappearRemove = true; - return; + }; + + LOG.info("disparition", `▶️ DÉBUT analyse ${tag} type=${iv.type} formLink=${iv.formLink ? "(présent)" : "ABSENT"}`, + { ref: iv.ref, actionId: iv.actionId, requestId: iv.requestId, type: iv.type, status: iv.status }); + + // Court-circuit pour les réservations (AL-Reservation) : pas de fiche à + // re-fetcher. En mode normal elles seraient retirées. En diagnostic on + // garde et on log. + if (iv.type === "AL-Reservation") { + return verdict("cancelled-reservation", "REMOVE", + "AL-Reservation disparue du planning : code normal la retire (pas de fiche à consulter)"); + } + if (iv.type === "AL-Absence") { + return verdict("cancelled-absence", "REMOVE", + "AL-Absence disparue du planning : code normal la retire (pas de fiche à consulter)"); } - // CAS 4 : aucune action AL-Intervention au nom du tech dans la fiche → - // vérifier si une action quelconque au nom du tech existe avec commentaire. - // Si oui, on considère que le tech a travaillé dessus. - const anyActionForTech = actions.filter(a => - actionBelongsToTech(a, tech.name || tech.label || "") - ); - const anyHasComment = anyActionForTech.some(a => - hasTechCommentInDescription(a.description) - ); - if (anyHasComment) { - iv._disappearChecking = false; - iv._disappearStatus = "terminated"; - iv._disappearRemove = false; - iv.ghost = false; - return; + // ─── Étape A : récupérer URL fresh via basicAutoComplete + redirectHeader + if (!iv.ref) { + return verdict("error", "KEEP (pas de ref)", "Étape A — iv.ref absente, impossible de chercher via basicAutoComplete"); + } + const origin = (state.session && state.session.origin) || state.lastKnownOrigin; + const sid = (state.session && state.session.phpsessid); + if (!origin || !sid) { + return verdict("error", "KEEP (pas de session)", `Étape A — session incomplète (origin=${origin}, sid=${sid ? "(présent)" : "ABSENT"})`); } - // CAS 5 (défaut) : aucune trace claire du tech → retirer - iv._disappearChecking = false; - iv._disappearStatus = "cancelled"; - iv._disappearRemove = true; + reason(`Étape A1 — basicAutoComplete?search=${iv.ref}`); + let pk, guid, freshChecksumInternal; + try { + const r1 = await fetch(`${origin}/api/v1/internal/search/basicAutoComplete?search=${encodeURIComponent(iv.ref)}&PHPSESSID=${encodeURIComponent(sid)}`, { credentials: "include" }); + const j1 = await r1.json(); + const meta = j1 && j1.data && j1.data.METAOBJECTS && j1.data.METAOBJECTS[0]; + const line = meta && meta.LINES && meta.LINES[0]; + if (!meta || !line || !line.PK || !meta.TYPE_GUID || !line.CHECKSUM) { + LOG.warn("disparition", ` ↳ ${tag} | ✗ Étape A1 — JSON inattendu`, { json: j1 }); + return verdict("error", "KEEP (basicAutoComplete vide)", + `Étape A1 — pas de pk/guid/checksum dans la réponse basicAutoComplete pour ref=${iv.ref}`); + } + pk = line.PK; + guid = meta.TYPE_GUID; + freshChecksumInternal = line.CHECKSUM; + reason(`✓ Étape A1 — pk=${pk} guid=${guid} checksum_interne=${freshChecksumInternal.substring(0, 12)}…`); + } catch (err) { + return verdict("error", "KEEP (erreur basicAutoComplete)", + `Étape A1 — exception : ${err && err.message}`); + } + + reason(`Étape A2 — redirectHeader?pk=${pk}&guid=${guid}&checksum=${freshChecksumInternal.substring(0, 12)}…`); + let freshFormLink, freshFullUrl; + try { + const r2 = await fetch(`${origin}/api/v1/internal/search/redirectHeader?pk=${encodeURIComponent(pk)}&guid=${encodeURIComponent(guid)}&checksum=${encodeURIComponent(freshChecksumInternal)}&PHPSESSID=${encodeURIComponent(sid)}`, { credentials: "include" }); + const j2 = await r2.json(); + // j2 est une string : "index.php?PHPSESSID=...&internalurltime=...&eventName=formEvent&target=...&checksum=...&sender=..." + const urlStr = (typeof j2 === "string") ? j2 : (j2 && j2.url) || ""; + if (!urlStr || !urlStr.includes("checksum=")) { + LOG.warn("disparition", ` ↳ ${tag} | ✗ Étape A2 — URL redirectHeader invalide`, { json: j2 }); + return verdict("error", "KEEP (redirectHeader vide)", + `Étape A2 — URL invalide retournée par redirectHeader`); + } + freshFullUrl = `${origin}/${urlStr}`; + // Extraire le formLink (sans le PHPSESSID qui sera ajouté par background) + const qIdx = urlStr.indexOf("?"); + const queryStr = qIdx >= 0 ? urlStr.substring(qIdx + 1) : urlStr; + // background.js construit `${origin}/index.php?${formLink}&PHPSESSID=...` donc on + // retire le PHPSESSID du formLink pour éviter le doublon. + freshFormLink = queryStr.split("&").filter(p => !p.startsWith("PHPSESSID=")).join("&"); + reason(`✓ Étape A2 — URL fresh obtenue, formLink="${freshFormLink.substring(0, 100)}…"`); + LOG.info("disparition", ` ↳ ${tag} | 🔗 URL fresh :`, { url: freshFullUrl }); + // Sauvegarder pour réutilisation (ouverture au clic, etc.) + iv.ficheFreshFormLink = freshFormLink; + iv.ficheFreshFullUrl = freshFullUrl; + } catch (err) { + return verdict("error", "KEEP (erreur redirectHeader)", + `Étape A2 — exception : ${err && err.message}`); + } + + // ─── Étape B : fetchFiche avec le formLink fresh ─────────────────── + const MIN_FICHE_BYTES = 20000; + reason(`Étape B — fetchFiche avec formLink fresh`); + const respB = await sendMessage({ type: "fetchFiche", formLink: freshFormLink }); + if (!respB || !respB.ok) { + return verdict("error", "KEEP (erreur réseau)", + `Étape B — fetchFiche a échoué (resp=${respB ? JSON.stringify({ ok: respB.ok, error: respB.error }) : "null"})`); + } + const html = respB.html || ""; + if (html.length < MIN_FICHE_BYTES) { + LOG.warn("disparition", ` ↳ ${tag} | 📋 Contenu de la fiche tronquée (500 chars)`, + { snippet: html.substring(0, 500).replace(/\s+/g, " ") }); + return verdict("fiche_tronquee", "KEEP (fiche illisible)", + `Étape B — fiche reçue ${html.length} octets (< ${MIN_FICHE_BYTES}) malgré checksum frais — anomalie EV`); + } + reason(`✓ Étape B — fiche complète reçue (${html.length} octets)`); + + // ─── Étape 2 : statut global de la fiche (Clôturé/Résolu) ──────── + const ficheData = parseFicheHtml(html); + const ficheStatus = ficheData.status || iv.status || null; + iv.status = ficheStatus; + const isOfficiallyClosed = isClosedStatus(ficheStatus) || isResolvedStatus(ficheStatus); + reason(`Étape 2 — statut global = ${JSON.stringify(ficheStatus)}, officiellementClos=${isOfficiallyClosed}`); + + // si le statut global correspond aux libellés "Clôturé/Résolu" de + // la config admin → on AFFIRME terminated-clos d'office. On extrait quand + // même le commentaire tech (pour le tooltip) si on en trouve un, mais le + // verdict est figé : ✓✓ vert. Plus de risque de tomber en cancelled/moved. + if (isOfficiallyClosed) { + const allActions = parseAllActionsFromFicheHtml(html); + const techName = tech.name || tech.label || ""; + const techActions = allActions.filter(a => actionBelongsToTech(a, techName, tech.id)); + const techActionWithComment = techActions + .map(a => ({ a, c: extractTechCommentFromDescription(a.description) })) + .filter(x => x.c); + if (techActionWithComment.length > 0) { + const best = techActionWithComment[0]; + iv._diagnosticTechComment = best.c.full; + iv._diagnosticActionInfo = { + intervenant: best.a.intervenant, + dateCreation: best.a.dateCreation, + dateFin: best.a.dateFin, + type: best.a.type + }; + // /o : décoder + convertir
/

→ \n et stripper HTML. + // retirer aussi la ligne du commentaire tech ("vyjuva: ok", + // etc.) de la description — elle est déjà affichée dans le bloc + // dédié "Commentaire" plus bas. Évite la duplication dans le popup. + if (best.a.description) { + const decoded = decodeUnicodeEscapes(best.a.description) + .replace(//gi, "\n") + .replace(/<\/?p[^>]*>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&"); + // strip strictement la ligne "login: …" (lowercase + chiffres + // uniquement, sans flag /i). Avant : le /i matchait aussi "Lieu : " + // ou "Service : " et tronquait toute la description après. + const cleaned = decoded + .replace(/\n+\s*[a-z0-9_]{3,12}\s*:\s+[\s\S]*$/, "") + .replace(/\s+$/, ""); + iv.bulleDescription = cleaned; + iv.ficheActionText = cleaned; + } + } + iv._diagnosticOfficiallyClosed = true; + return verdict("terminated-clos", "KEEP (vert ✓✓)", + `Statut "${ficheStatus}" matche la config Clôturé/Résolu → terminée officielle (${techActionWithComment.length > 0 ? "commentaire tech récupéré" : "pas de commentaire tech"})`); + } + + // ─── Étape 3 : parser TOUTES les actions de la fiche ──────────────────── + const actions = parseAllActionsFromFicheHtml(html); + reason(`Étape 3 — parseAllActionsFromFicheHtml a trouvé ${actions.length} action(s) dans la fiche`); + const actionsBrief = actions.map((a, idx) => ({ + idx, + actionId: a.actionId, + amDoneById: a.amDoneById, + type: a.type, + intervenant: a.intervenant, + dateCreation: a.dateCreation, + dateFin: a.dateFin, + descSnippet: (a.description || "").substring(0, 100).replace(/\s+/g, " ") + })); + LOG.info("disparition", ` ↳ ${tag} | DÉTAIL des ${actions.length} actions`, actionsBrief); + + // ─── Étape 4 : filtrer par tech + date + commentaire ────────────── + const techName = tech.name || tech.label || ""; + const ghostDate = iv.startDate || iv.endDate || iv.currentDate || null; // JJ/MM/AAAA + const ghostDateShort = ghostDate ? ghostDate.substring(0, 10) : null; + reason(`Étape 4 — recherche actions du tech '${techName}' avec date ≈ ${ghostDateShort || "?"}`); + + // R12s : on passe aussi tech.id pour matcher via amDoneById quand + // l'action a été créée par RPH/un autre intervenant mais réalisée par le tech. + const techActions = actions.filter(a => actionBelongsToTech(a, techName, tech.id)); + reason(`Étape 4 — ${techActions.length} action(s) au tech (sur ${actions.length} totales)`); + + const techActionsBrief = techActions.map(a => { + const c = extractTechCommentFromDescription(a.description); + return { + type: a.type, + intervenant: a.intervenant, + dateCreation: a.dateCreation, + dateFin: a.dateFin, + hasComment: !!c, + commentaire: c ? c.full : null, + descSnippet: (a.description || "").substring(0, 200).replace(/\s+/g, " ") + }; + }); + LOG.info("disparition", ` ↳ ${tag} | ACTIONS DU TECH (avec dates + commentaires)`, techActionsBrief); + + // Filtrer celles dont la date matche le jour du ghost + const matchDate = (dStr) => { + if (!dStr || !ghostDateShort) return false; + return dStr.startsWith(ghostDateShort); + }; + const sameDay = techActions.filter(a => matchDate(a.dateCreation) || matchDate(a.dateFin)); + reason(`Étape 4 — actions du tech sur le jour ${ghostDateShort} : ${sameDay.length}`); + + const sameDayWithComment = sameDay.map(a => ({ a, c: extractTechCommentFromDescription(a.description) })).filter(x => x.c); + if (sameDayWithComment.length > 0) { + const best = sameDayWithComment[0]; + iv._diagnosticTechComment = best.c.full; + iv._diagnosticActionInfo = { intervenant: best.a.intervenant, dateCreation: best.a.dateCreation, dateFin: best.a.dateFin, type: best.a.type }; + iv._diagnosticOfficiallyClosed = isOfficiallyClosed; + // /m : alimenter bulleDescription pour que l'extraction de signature + // (pattern "XXX JJ.MM") sur la carte fonctionne aussi pour les ghosts. + // Décoder les escapes pour que le texte s'affiche correctement. + if (best.a.description) { + // /o : decode + retire la ligne "login: …" (commentaire tech) + // de la description — déjà affichée dans le bloc Commentaire. + const decoded = decodeUnicodeEscapes(best.a.description) + .replace(//gi, "\n") + .replace(/<\/?p[^>]*>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&"); + // strict lowercase (sans /i) pour ne pas matcher Lieu/Service/etc. + const cleaned = decoded + .replace(/\n+\s*[a-z0-9_]{3,12}\s*:\s+[\s\S]*$/, "") + .replace(/\s+$/, ""); + iv.bulleDescription = cleaned; + iv.ficheActionText = cleaned; + } + if (isOfficiallyClosed) { + return verdict("terminated-clos", "KEEP (vert ✓✓)", + `Action du tech le ${ghostDateShort} + commentaire + statut "${ficheStatus}" → terminée et validée`); + } + // statut "Suspendu" → ✓ jaune avec label "Suspendu" (au lieu de "Fait") + if (isSuspendedStatus(ficheStatus)) { + return verdict("terminated-suspended", "KEEP (jaune ✓ Suspendu)", + `Action du tech le ${ghostDateShort} + commentaire mais statut "${ficheStatus}" → suspendue (commentaire affiché)`); + } + return verdict("terminated-pending", "KEEP (gris « fait »)", + `Action du tech le ${ghostDateShort} + commentaire mais statut "${ficheStatus}" pas encore clos → terminée par tech, en attente de clôture officielle`); + } + + if (sameDay.length > 0) { + // R12r : on log les descriptions COMPLÈTES des actions sameDay qui n'ont + // pas matché pour qu'on puisse adapter le pattern d'extraction. + const descDump = sameDay.map((a, i) => ({ + idx: i, + intervenant: a.intervenant, + dateCreation: a.dateCreation, + dateFin: a.dateFin, + type: a.type, + descLen: (a.description || "").length, + descRaw: (a.description || "").substring(0, 1500) + })); + LOG.warn("disparition", + ` ↳ ${tag} | ⚠ aucune action sameDay n'a matché extractTechCommentFromDescription — voir descRaw ci-dessous`, + { sameDayCount: sameDay.length, actions: descDump }); + return verdict("cancelled", "REMOVE", + `Action du tech le ${ghostDateShort} mais SANS commentaire "login: …" → présumée annulée`); + } + + // action tech avec commentaire mais sur un autre jour → on traite + // quand même comme "Fait" (le tech a fait le travail, juste avant) avec + // commentaire affiché dans le tooltip. Si statut Suspendu → ✓ jaune. + const techActionWithComment = techActions.map(a => ({ a, c: extractTechCommentFromDescription(a.description) })).filter(x => x.c); + if (techActionWithComment.length > 0) { + const best = techActionWithComment[0]; + iv._diagnosticTechComment = best.c.full; + iv._diagnosticActionInfo = { + intervenant: best.a.intervenant, + dateCreation: best.a.dateCreation, + dateFin: best.a.dateFin, + type: best.a.type + }; + iv._diagnosticOfficiallyClosed = isOfficiallyClosed; + // /m : alimenter bulleDescription décodée + if (best.a.description) { + // /o : decode + retire la ligne "login: …" (commentaire tech) + // de la description — déjà affichée dans le bloc Commentaire. + const decoded = decodeUnicodeEscapes(best.a.description) + .replace(//gi, "\n") + .replace(/<\/?p[^>]*>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&"); + // strict lowercase (sans /i) pour ne pas matcher Lieu/Service/etc. + const cleaned = decoded + .replace(/\n+\s*[a-z0-9_]{3,12}\s*:\s+[\s\S]*$/, "") + .replace(/\s+$/, ""); + iv.bulleDescription = cleaned; + iv.ficheActionText = cleaned; + } + if (isOfficiallyClosed) { + return verdict("terminated-clos", "KEEP (vert ✓✓)", + `Action AVEC commentaire (autre jour ${best.a.dateCreation || best.a.dateFin}) + statut "${ficheStatus}" clos → ✓✓`); + } + if (isSuspendedStatus(ficheStatus)) { + return verdict("terminated-suspended", "KEEP (jaune ✓ Suspendu)", + `Action AVEC commentaire (autre jour ${best.a.dateCreation || best.a.dateFin}) mais statut "${ficheStatus}" → suspendue`); + } + return verdict("terminated-pending", "KEEP (gris « fait »)", + `Action AVEC commentaire (autre jour ${best.a.dateCreation || best.a.dateFin}) → terminée par tech, statut "${ficheStatus}" pas encore clos`); + } + + return verdict("cancelled", "REMOVE", + `Aucune action du tech '${techName}' avec commentaire trouvée → présumée annulée/orpheline`); } // ============================================================================ @@ -4689,11 +6084,18 @@ async function refreshStatuses(techs, isoDate, opts = {}) { // 1. Interventions du (des) pompier(s) en premier // 2. Puis les autres techs par ordre alphabétique du nom de famille // 3. (Les absents n'ont pas d'interventions à fetcher) + // ordre = pompier d'abord, puis alpha, et au sein d'un tech, par + // startTime (matin → après-midi). compareTechs implémente exactement ça. const sortedTechs = [...techs].sort((a, b) => compareTechs(a, b, isoDate)); const toFetch = []; for (const tech of sortedTechs) { - for (const iv of tech.interventions) { + // on garantit l'ordre par startTime (matin → après-midi) au sein + // de chaque tech même si tech.interventions n'a pas été retrié. + const sortedIvs = [...tech.interventions].sort((a, b) => + (a.startTime || "").localeCompare(b.startTime || "") + ); + for (const iv of sortedIvs) { if (iv.type !== "AL-Intervention") continue; if (!iv.formLink) continue; // v4 : on skip les interventions déjà closes/résolues dont la fiche a @@ -4701,13 +6103,12 @@ async function refreshStatuses(techs, isoDate, opts = {}) { // Le statut "Clôturé" ne change plus une fois atteint, pas la peine de // refetcher à chaque refresh. const statusClosed = isClosedStatus(iv.status) || isResolvedStatus(iv.status); + // iv clos officiel (✓✓) → on ne re-fetch JAMAIS, même en forceAll. if (statusClosed && iv.ficheFetched) continue; - // v4.1.7 : pause/reprise par date. Sans forceAll (= chargement normal - // au retour sur une date), on skip les iv déjà enrichies (ficheFetched) - // pour ne pas refetcher inutilement. Un clic sur "rafraichir" active - // forceAll, ce qui refetche les non-closes même si déjà enrichies (pour - // voir passer les statuts "En cours" → "Exécution" → "Clôturé"). - if (!forceAll && iv.ficheFetched) continue; + // "Actualiser" (pas forceAll) → on re-fetch SEULEMENT les iv qui + // ont changé d'heure depuis le cache OU qui ne sont pas encore fetchées. + // Pour "Tout recharger" (forceAll=true), on re-fetch tout sauf les clos. + if (!forceAll && iv.ficheFetched && !iv._timeChanged) continue; toFetch.push(iv); } } @@ -4716,10 +6117,10 @@ async function refreshStatuses(techs, isoDate, opts = {}) { setRefreshing(true); - // v4.1.7 : barre de progression visible uniquement si on est en train de - // rafraichir la date actuellement affichée. Si l'user change de date - // pdt le refresh, isRefreshAborted() deviendra true et on sortira. - const showBar = (state.currentDate === isoDate); + // si l'appelant gère la barre lui-même (skipBuiltinProgressBar + + // onProgress), on ne touche pas à la barre interne — c'est la barre + // unifiée nouvelles+ghosts gérée par loadForDate. + const showBar = (state.currentDate === isoDate) && !opts.skipBuiltinProgressBar; if (showBar) { updateProgressBar(0, toFetch.length); showProgressBar(); @@ -4742,13 +6143,16 @@ async function refreshStatuses(techs, isoDate, opts = {}) { for (let i = 0; i < toFetch.length; i++) { if (isRefreshAborted(myToken)) break; - await fetchAndUpdateIntervention(toFetch[i], myToken); + await fetchAndUpdateIntervention(toFetch[i], myToken, { useFreshChecksum: !!opts.useFreshChecksum }); sinceLastCacheWrite++; - // Progression — uniquement si la barre concerne la date visible + // Progression — barre interne OU callback unifié if (showBar && state.currentDate === isoDate) { updateProgressBar(i + 1, toFetch.length); } + if (typeof opts.onProgress === "function") { + try { opts.onProgress(); } catch (e) { /* ignore */ } + } // Sauvegarde périodique du cache pdt le fetch if (sinceLastCacheWrite >= CACHE_WRITE_EVERY) { @@ -4770,12 +6174,19 @@ async function refreshStatuses(techs, isoDate, opts = {}) { } // Résoudre le sort des ghosts + // v2026.5.44 (mode diagnostic) : on NE retire plus rien automatiquement. + // Avant : on retirait silencieusement les ghosts dont le statut était dans + // CANCELLED_STATUS (Annulé/Supprimé). Maintenant on log ce qu'on aurait + // retiré pour pouvoir comprendre. for (const tech of techs) { - tech.interventions = tech.interventions.filter(iv => { - if (!iv.ghost) return true; - if (CANCELLED_STATUS.includes(iv.status)) return false; - return true; - }); + for (const iv of tech.interventions) { + if (!iv.ghost) continue; + if (CANCELLED_STATUS.includes(iv.status)) { + LOG.info("disparition", + `🛡️ KEEP forcé (refreshStatuses) — ref=${iv.ref || "?"} statusEV='${iv.status}'`, + { ref: iv.ref, actionId: iv.actionId, requestId: iv.requestId, status: iv.status }); + } + } } // Sauvegarde finale du cache @@ -4797,7 +6208,139 @@ async function refreshStatuses(techs, isoDate, opts = {}) { } } -async function fetchAndUpdateIntervention(iv, myToken) { +// ============================================================================ +// orchestrateur séquentiel UNIQUE +// ============================================================================ +// Remplace prefetchAllXhr2 + refreshStatuses + analyzeDisappearedInterventions. +// Une seule passe, dans l'ordre d'affichage des cartes : +// 1. pompier(s) en premier +// 2. autres techs par ordre alphabétique (nom de famille) +// 3. au sein de chaque tech : matin → après-midi (par startTime) +// +// Pour chaque iv : +// - ghost → analyzeOneDisappearedIv (verdict ✓ / ✓✓ / disparition) +// - réelle → fetchAndUpdateIntervention (xhr2 + fiche + timeline en 1 appel) +// - réelle déjà fetchée mais sans xhr2 → ensureBulleDescription +// +// Une iv à la fois. await la réponse avant la suivante. Stop = break immédiat. +async function processInterventionsSequentially(techs, isoDate, opts = {}) { + const myToken = opts.myToken; + const forceAll = !!opts.forceAll; + const useFreshChecksum = !!opts.useFreshChecksum; + + const sortedTechs = [...techs].sort((a, b) => compareTechs(a, b, isoDate)); + + // R12b : on construit UNIQUEMENT la file des fiches + ghosts (séquentiel, + // lent). Le xhr2 (rapide, parallèle) est lancé séparément AVANT, par + // prefetchAllXhr2 — comme en v42. Voir loadForDate. + const ficheQueue = []; + + for (const tech of sortedTechs) { + const sortedIvs = [...(tech.interventions || [])].sort((a, b) => + (a.startTime || "").localeCompare(b.startTime || "") + ); + for (const iv of sortedIvs) { + if (iv.ghost) { + // verdicts FINAUX = uniquement clos/résolu. Les "Fait" + // (terminated-pending) sont re-analysés à chaque Actualiser pour + // détecter une éventuelle clôture officielle qui aurait suivi. + const finalVerdicts = ["terminated-clos", "closed", "terminated"]; + if (!forceAll && finalVerdicts.includes(iv._disappearStatus)) { + iv._disappearChecking = false; + iv.ghost = false; + continue; + } + iv._disappearChecking = true; + ficheQueue.push({ tech, iv, kind: "ghost" }); + continue; + } + if (iv.type !== "AL-Intervention") continue; + if (!iv.actionId) continue; + const statClos = isClosedStatus(iv.status) || isResolvedStatus(iv.status); + // "Tout recharger" (forceAll=true) doit ré-analyser MÊME les iv + // déjà closes — pour rafraîchir un éventuel changement (réouverture, + // nouveau commentaire, etc.). On ne skip que sur "Actualiser" simple. + if (statClos && iv.ficheFetched && !forceAll) continue; + let needFiche = false; + if (iv.formLink) { + if (forceAll) needFiche = true; + else if (!iv.ficheFetched) needFiche = true; + else if (iv._timeChanged) needFiche = true; + } + if (needFiche) ficheQueue.push({ tech, iv, kind: "fiche" }); + } + } + + const total = ficheQueue.length; + if (total === 0) { + console.log(`[seq] rien à traiter (fiches+ghosts)`); + return; + } + + console.log(`[seq] ${total} iv à traiter (fiches+ghosts, séquentiel)`); + setRefreshing(true); + + let done = 0; + const showBar = (state.currentDate === isoDate) && !opts.skipBuiltinProgressBar; + if (showBar) { + showProgressBar(); + updateProgressBar(0, total); + } + + const CACHE_WRITE_EVERY = 5; + let sinceLastWrite = 0; + + try { + for (const { tech, iv, kind } of ficheQueue) { + if (isRefreshAborted(myToken)) break; + try { + if (kind === "ghost") { + await analyzeOneDisappearedIv(tech, iv); + } else if (kind === "fiche") { + await fetchAndUpdateIntervention(iv, myToken, { useFreshChecksum }); + } + } catch (err) { + console.warn(`[seq] iv ${iv.actionId} (${kind}) erreur:`, err); + if (kind === "ghost") { + iv._disappearChecking = false; + iv.ghost = false; + } + } + try { updateInterventionRow(iv); } catch (e) { /* ignore */ } + done++; + if (showBar && state.currentDate === isoDate) { + updateProgressBar(done, total); + } + if (typeof opts.onProgress === "function") { + try { opts.onProgress(); } catch (e) { /* ignore */ } + } + sinceLastWrite++; + if (sinceLastWrite >= CACHE_WRITE_EVERY) { + try { await writeCache(isoDate, { techs }); sinceLastWrite = 0; } + catch (err) { console.warn("[seq cache] écriture intermédiaire échouée:", err); } + } + } + + // Sauvegarde finale (même si abort → on persiste ce qui a été fait) + try { await writeCache(isoDate, { techs }); } + catch (e) { LOG.warn("cache", "writeCache fail (final seq)", { err: e && e.message }); } + + if (!isRefreshAborted(myToken)) { + renderFromData({ + techs, + targetDate: isoDate, + captureTime: Date.now(), + source: "fresh+sequential", + lastRefreshKind: activeRefreshButton + }); + } + } finally { + setRefreshing(false); + if (showBar) hideProgressBar(); + } +} + +async function fetchAndUpdateIntervention(iv, myToken, opts = {}) { try { // Bail-out coopératif : si l'utilisateur a cliqué sur "Arrêter" ou a // changé de date, on ne fetch pas cette intervention. @@ -4807,6 +6350,19 @@ async function fetchAndUpdateIntervention(iv, myToken) { return; } + // v2026.5.44 si demandé (cache > 24h ou refresh manuel), on + // récupère un formLink avec checksum FRAIS via basicAutoComplete + + // redirectHeader avant de fetch la fiche. Garantit qu'EV nous sert + // bien la fiche complète et pas une page tronquée. + let formLinkToUse = iv.formLink; + if (opts.useFreshChecksum && iv.ref) { + const fresh = await getFreshFicheFormLinkForRef(iv.ref); + if (fresh) { + formLinkToUse = fresh; + iv.formLink = fresh; // on persiste pour les prochains usages (ouverture, etc.) + } + } + // v4.1.2 : pour chaque interventoin on fait xhr2 PUIS fiche. // - xhr2 : récupère les VRAIES infos contact/lieu (attr1/attr2 du XML // sont parfois erronées si le tech a corrigé après planif). @@ -4847,12 +6403,12 @@ async function fetchAndUpdateIntervention(iv, myToken) { // Retry léger : une erreur réseau ponctuelle (timeout, 5xx) ne doit pas // perdre la ligne. 1 seul retry après 400ms. Session expirée n'est PAS // retryée (ça ne passera pas mieux la 2e fois). - let ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink }); + let ficheResp = await sendMessage({ type: "fetchFiche", formLink: formLinkToUse }); if (isRefreshAborted(myToken)) return; if (!ficheResp.ok && ficheResp.error !== "session_expired" && !isRefreshAborted(myToken)) { await new Promise(r => setTimeout(r, 400)); if (!isRefreshAborted(myToken)) { - ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink }); + ficheResp = await sendMessage({ type: "fetchFiche", formLink: formLinkToUse }); } } if (isRefreshAborted(myToken)) return; @@ -4862,13 +6418,38 @@ async function fetchAndUpdateIntervention(iv, myToken) { iv.ficheFetchError = ficheResp.error || "fetch_failed"; if (ficheResp.error === "session_expired") { state.session = null; - // v4.1.12 : afficher immédiatement la bannière de session expirée - // pour que l'utilisateur voie pourquoi le fetch s'arrête. + // session expirée → ⚠ + bannière + abort de tout le refresh. + iv._fetchWarning = true; showSessionExpiredBanner(); + abortCurrentRefresh(); } return; } + // réponse OK mais TRONQUÉE (< 20 Ko après 3 retries) → ⚠ sur la + // carte + probe de session. Si session morte → bannière + abort tout. + if (ficheResp.truncated) { + iv._fetchWarning = true; + iv.ficheFetchError = "truncated"; + console.warn(`[seq] iv ${iv.actionId} (ref=${iv.ref}) fiche tronquée (${ficheResp.size} octets) → probe session…`); + try { + const probe = await sendMessage({ type: "checkSession" }); + if (!probe || !probe.ok) { + console.warn(`[seq] session morte (probe=${probe && probe.error}) → abort + bannière`); + state.session = null; + showSessionExpiredBanner(); + abortCurrentRefresh(); + return; + } + } catch (e) { + console.warn("[seq] probe session échouée :", e); + } + // Session OK mais fiche tronquée → on garde le ⚠ visible, on continue + // (l'iv suivante peut très bien marcher). + iv.ficheFetched = true; + return; + } + const fiche = parseFicheHtml(ficheResp.html); iv.status = fiche.status; // v4.2.5 : on retire définitivement le champ commentaireTech (obsolète @@ -4950,6 +6531,8 @@ async function fetchAndUpdateIntervention(iv, myToken) { } } iv.ficheFetched = true; + // succès complet → on retire le ⚠ s'il y en avait un. + if (iv._fetchWarning) { delete iv._fetchWarning; delete iv.ficheFetchError; } // Rendu incrémental : mettre à jour la ligne dans le DOM immédiatement // (statut clos → fond vert + ✓, commentaire tech dans le tooltip). @@ -4993,7 +6576,10 @@ async function fetchAndUpdateIntervention(iv, myToken) { // (utilisé par "Tout recharger") async function prefetchAllXhr2(techs, myToken, forceAll) { if (!techs) return; - // Lister les iv qui ont besoin d'un xhr2 + // R12b : retour au comportement v42 — PARALLÈLE (concurrency=6) pour avoir + // les infos courtes (contact/lieu) sur TOUTES les cartes en bloc dès le + // 1er chargement. Chaque xhr2 fait ~400 octets, donc même 30 iv en + // parallèle reste léger pour EV. const needed = []; for (const tech of techs) { for (const iv of tech.interventions || []) { @@ -5006,16 +6592,13 @@ async function prefetchAllXhr2(techs, myToken, forceAll) { } if (needed.length === 0) return; - console.log(`[load] pré-fetch xhr2 batch : ${needed.length} interventoin(s)…`); + console.log(`[load] pré-fetch xhr2 batch : ${needed.length} iv (parallèle, concurrency=6)…`); const t0 = performance.now(); - // Si forceAll, reset le flag pour que ensureBulleDescription re-fetch if (forceAll) { for (const iv of needed) iv.xhr2Fetched = false; } - // Batch en parallèle avec concurrency limitée (6) — assez rapide, pas trop - // aggressif sur EasyVista. const concurrency = 6; const queue = [...needed]; const workers = []; @@ -5074,15 +6657,44 @@ async function ensureBulleDescription(iv) { } } -function isClosedStatus(s) { - return !!s && CLOSED_STATUS.some(x => s.includes(x)); +// R12x : normalise + stem un libellé statut pour comparaison TRÈS souple. +// - lowercase +// - suppression des accents +// - suppression des terminaisons verbales courantes (er, ée, ées, és, +// ent, e, s) pour rapprocher "Terminé/Terminer/Termine/Terminée". +// Du coup l'user saisit "Terminé" une fois et toutes les conjugaisons / +// variantes orthographiques matchent automatiquement. +function _normalizeStatus(s) { + if (!s) return ""; + return String(s).toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, ""); } -function isResolvedStatus(s) { - return !!s && RESOLVED_STATUS.some(x => s.includes(x)); +function _stemStatus(s) { + let n = _normalizeStatus(s); + // Boucle pour gérer les terminaisons multiples (ex: "terminees" → "terminee" → "terminee") + for (let i = 0; i < 3; i++) { + const nn = n.replace(/(ees|ent|ees|ee|er|es|s|e)$/, ""); + if (nn === n || nn.length < 4) break; + n = nn; + } + return n; } -function isCancelledStatus(s) { - return !!s && CANCELLED_STATUS.some(x => s.includes(x)); +function _statusMatchesAny(s, list) { + if (!s || !list || !list.length) return false; + const stemS = _stemStatus(s); + const normS = _normalizeStatus(s); + for (const x of list) { + if (!x) continue; + const stemX = _stemStatus(x); + if (stemX && (stemS.includes(stemX) || normS.includes(stemX))) return true; + } + return false; } +function isClosedStatus(s) { return _statusMatchesAny(s, CLOSED_STATUS); } +function isResolvedStatus(s) { return _statusMatchesAny(s, RESOLVED_STATUS); } +function isCancelledStatus(s) { return _statusMatchesAny(s, CANCELLED_STATUS); } +// statut "Suspendu" (en attente d'info bénéficiaire/demandeur etc.) +// → ✓ jaune avec label "Suspendu" au lieu de "Fait". +function isSuspendedStatus(s) { return _statusMatchesAny(s, ["Suspendu"]); } // ============================================================================ // Parsing d'une fiche individuelle (HTML) @@ -5121,7 +6733,11 @@ function parseFicheHtml(html) { }; // STATUS_FR (valeur parfois encodée en \u00XX) - let m = html.match(/"dbFieldName"\s*:\s*"STATUS_FR"[^}]*?"value"\s*:\s*"([^"]{2,30})"/); + // limite portée à 200 chars — certains statuts EV sont longs + // (ex: "Suspendu : Attente info bénéficiaire/demandeur" = 47 chars). + // L'ancienne limite 30 chars les ratait → status=null → pas de + // détection terminated-suspended. + let m = html.match(/"dbFieldName"\s*:\s*"STATUS_FR"[^}]*?"value"\s*:\s*"([^"]{2,200})"/); if (m) out.status = decodeJsonString(m[1]); // RFC_NUMBER (fallback au cas où le XML n'aurait pas la ref) @@ -5636,14 +7252,13 @@ function renderFromData(data) { function detectOverlaps(techs) { if (!techs) return; for (const tech of techs) { + // on inclut maintenant les AL-Reservation pour détecter les + // conflits Réservation × Intervention (les 2 marqués rouge). const ivs = (tech.interventions || []).filter(iv => iv && iv.startTime && iv.endTime && !iv._disappearRemove && - iv.type !== "AL-Reservation" && // v4.3.2 : le pompier est une absence "tolérée" qui chevauche par - // nature les heures de travail (garde volontaire) — on l'exclut des - // conflits. En revanche les congés/maladies/formations restent - // détectés car une inter planifiée pdt une absence, c'est un vrai pb. + // nature les heures de travail — on l'exclut des conflits. !iv.isPompier ); // Reset flag sur toutes les inters du tech (y compris celles ignorées) @@ -5736,7 +7351,7 @@ function renderCaptureInfo(data, stats) { function computeStats(techs, targetDate) { let pompiers = 0, absents = 0, available = 0; let totalInterventions = 0, morning = 0, afternoon = 0; - let closed = 0, resolved = 0; + let closed = 0, resolved = 0, done = 0; for (const tech of techs) { const isPompier = tech.interventions.some(iv => iv.isPompier); const isAbsent = isTechAbsent(tech, targetDate); @@ -5765,11 +7380,21 @@ function computeStats(techs, targetDate) { const s = timeToMinutes(iv.startTime); if (s !== null && s < 12 * 60) morning++; else if (s !== null) afternoon++; - if (isClosedStatus(iv.status)) closed++; - else if (isResolvedStatus(iv.status)) resolved++; + const isStClosed = isClosedStatus(iv.status); + const isStResolved = isResolvedStatus(iv.status); + if (isStClosed) closed++; + else if (isStResolved) resolved++; + // compter les "faits" — englobe les ✓✓ (clos/résolu) ET les ✓ + // (Fait pending, Suspendu, terminated-clos via diagnostic ghost). + const ds = iv._disappearStatus; + const isDone = isStClosed || isStResolved || + ds === "closed" || ds === "terminated" || + ds === "terminated-clos" || ds === "terminated-pending" || + ds === "terminated-suspended"; + if (isDone) done++; } } - return { totalTechs: techs.length, available, pompiers, absents, totalInterventions, morning, afternoon, closed, resolved }; + return { totalTechs: techs.length, available, pompiers, absents, totalInterventions, morning, afternoon, closed, resolved, done }; } function renderStats(s) { @@ -5777,8 +7402,9 @@ function renderStats(s) { el.innerHTML = ` ${s.totalInterventions} intervention${s.totalInterventions > 1 ? "s" : ""} (${s.morning} matin · ${s.afternoon} après-midi) - ${(s.closed + s.resolved > 0) ? `·${s.closed + s.resolved} clos` : ""} - · + ${(s.done > 0) ? `·${s.done} fait${s.done > 1 ? "s" : ""}` : ""} + ${(s.closed + s.resolved > 0) ? `/${s.closed + s.resolved} clos` : ""} + // ${s.available} tech. dispo + ${s.pompiers} pompier${s.pompiers > 1 ? "s" : ""} @@ -5825,7 +7451,7 @@ function compareTechs(a, b, targetDate) { // la plage 08:00-18:00. function isTechAbsent(tech, isoDate) { const recurring = RECURRING_ABSENCES[tech.id]; - if (recurring) { + if (recurring && isoDate) { const day = isoToDate(isoDate).getDay(); if (recurring.includes(day)) return true; } @@ -5909,6 +7535,23 @@ function buildCard(tech, isoDate) { card.classList.add("absence-cat-" + absenceCategory); } + // (issue #1) : un tech peut être Pompier ET avoir une autre absence + // (maladie, congé, etc.). Avant, le statut Pompier masquait l'absence. + // On capte ici la catégorie de l'absence NON-pompier pour pouvoir afficher + // les deux côte à côte. + let extraAbsenceCategory = null; + let extraAbsenceBlock = null; + if (isPompier && isAbsent) { + const real = tech.interventions.find(iv => iv.type === "AL-Absence" && !iv.isPompier); + if (real) { + extraAbsenceBlock = real; + extraAbsenceCategory = real.absenceCategory || "absent"; + } + } + if (extraAbsenceCategory) { + card.classList.add("absence-cat-" + extraAbsenceCategory); + } + const realInterventions = tech.interventions.filter(iv => iv.type !== "AL-Absence" && !iv.isPompier ); @@ -5971,10 +7614,15 @@ function buildCard(tech, isoDate) { header.appendChild(nameEl); if (isPompier || isAbsent || isRecurring) { + // on regroupe le(s) badge(s) dans un container collé à droite + // (flex parent .card-header est space-between, le wrapper est aligné + // automatiquement à l'opposé du nom du tech). + const badgeWrap = document.createElement("div"); + badgeWrap.className = "card-tech-badge-wrap"; + const badge = document.createElement("div"); badge.className = "card-tech-badge"; if (isRecurring) { - // v2026.5.41 : absence récurrente (configurée par tech) → badge "Absent" cyan badge.classList.add("badge-recurring"); badge.textContent = "Absent"; } else if (isPompier) { @@ -5982,12 +7630,8 @@ function buildCard(tech, isoDate) { badge.textContent = "Pompier"; } else if (absenceCategory === "maladie") { badge.classList.add("badge-maladie"); - // v2026.5.40 r15 : libellé complet "Maladie/Accident" partout. - // En horizontal, la sidebar est élargie (180px) + font 8.5px pour - // que ça tienne sans tronquer. 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"); @@ -5996,7 +7640,34 @@ function buildCard(tech, isoDate) { badge.classList.add("badge-absent"); badge.textContent = "Absent"; } - header.appendChild(badge); + badgeWrap.appendChild(badge); + + // /f (issue #1) : badge SUPPLÉMENTAIRE Pompier + autre absence, + // séparé par "/" et collé à droite avec le badge principal. + if (isPompier && extraAbsenceCategory) { + const sep = document.createElement("span"); + sep.className = "card-tech-badge-sep"; + sep.textContent = "/"; + badgeWrap.appendChild(sep); + + const badge2 = document.createElement("div"); + badge2.className = "card-tech-badge"; + if (extraAbsenceCategory === "maladie") { + badge2.classList.add("badge-maladie"); + badge2.textContent = "Maladie/Accident"; + } else if (extraAbsenceCategory === "conge") { + const multiDay = extraAbsenceBlock + && extraAbsenceBlock.startDate && extraAbsenceBlock.endDate + && extraAbsenceBlock.startDate !== extraAbsenceBlock.endDate; + badge2.classList.add("badge-conge"); + badge2.textContent = multiDay ? "Congés" : "Congé"; + } else { + badge2.classList.add("badge-absent"); + badge2.textContent = "Absent"; + } + badgeWrap.appendChild(badge2); + } + header.appendChild(badgeWrap); } // v2026.5.32 : stats rapides pour la vue horizontale (cachées en classique) @@ -6050,6 +7721,25 @@ function buildCard(tech, isoDate) { note.textContent = "En pompier aujourd'hui"; } body.appendChild(note); + + // (issue #1) : si tech aussi absent (autre que pompier), afficher + // une note absence supplémentaire EN DESSOUS de la note pompier. + if (extraAbsenceBlock) { + const note2 = document.createElement("div"); + note2.className = "card-status-note absent"; + if (extraAbsenceCategory) note2.classList.add("absent-" + extraAbsenceCategory); + const ab = extraAbsenceBlock; + const multiDay = ab.startDate && ab.endDate && ab.startDate !== ab.endDate; + const catLabel = extraAbsenceCategory === "maladie" ? "Maladie/Accident" + : extraAbsenceCategory === "conge" ? (multiDay ? "Congés" : "Congé") + : null; + let txt = multiDay + ? `Absent du ${ab.startDate.substring(0, 5)} au ${ab.endDate.substring(0, 5)}` + : "Absent toute la journée"; + if (catLabel) txt += ` — ${catLabel}`; + note2.textContent = txt; + body.appendChild(note2); + } } else if (isAbsent && absenceBlocks.length) { const note = document.createElement("div"); note.className = "card-status-note absent"; @@ -6243,6 +7933,11 @@ function buildCard(tech, isoDate) { } } + // le séparateur per-card "X faites" introduit en a été retiré + // — l'information est désormais dans la barre stats globale au format + // "… · X faits / Y clos · …", visible aussi bien en classique qu'en + // sidebar horizontale (où l'élément #stats est relocalisé). + card.appendChild(body); return card; } @@ -6326,6 +8021,8 @@ function _buildMiniCardsRow(realInterventions, cardEl) { card.className = "iv-mini-card color-" + colorKey; // v2026.5.41 : conflit avec absence/réservation → mini-card rouge plein if (iv._absenceConflict) card.classList.add("intervention-conflict-absence"); + // conflit horaire avec une autre iv → mini-card rouge + if (iv._hasOverlap) card.classList.add("intervention-conflict-overlap"); card.dataset.ivIdx = String(ivIdx); if (iv.ref) card.dataset.ref = iv.ref; @@ -6385,6 +8082,30 @@ function _buildMiniCardsRow(realInterventions, cardEl) { card.appendChild(txt); + // ✓✓ pour clos/résolu, ✓ pour terminated-pending (gris), + // ✓ jaune pour terminated-suspended. + // rideau horaire — masquer le ✓/✓✓ tant que le seuil n'est pas + // atteint (12h pour le matin, 15h pour l'aprèm). + const miniRevealed = _isVerdictRevealed(iv); + const miniSc = getStatusClass(iv); + const miniPending = miniRevealed && iv._disappearStatus === "terminated-pending"; + const miniSuspended = miniRevealed && iv._disappearStatus === "terminated-suspended"; + if (miniSc || miniPending || miniSuspended) { + const checkEl = document.createElement("div"); + checkEl.className = "iv-mini-status-check"; + if (miniSuspended) checkEl.classList.add("suspended"); + if (miniSc === "status-closed" || miniSc === "status-resolved") { + checkEl.textContent = "✓✓"; + checkEl.classList.add("double"); + } else { + checkEl.textContent = "✓"; + } + card.appendChild(checkEl); + if (miniSc) card.classList.add(miniSc); + if (miniPending || miniSuspended) card.classList.add("has-pending-check"); + if (miniSuspended) card.classList.add("status-suspended"); + } + // v2026.5.40 r8 : hover sur la mini-card = comportement comme une row // .intervention-v2 en vue classique → ouvre la GRANDE popup ancrée. // r9 : pas d'épinglage si réservation (clic sans effet). @@ -6559,10 +8280,60 @@ function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, return wrap; } +// RIDEAU HORAIRE — on continue de fetcher les statuts mais on RETARDE +// leur affichage. Une intervention du matin (start < 12h) ne montre son +// verdict (✓ / ✓✓ / Fait / Suspendu / commentaire) qu'à partir de 12:00. +// Une intervention de l'après-midi (start ≥ 12h) attend 15:00. +// - Avant le seuil : aucun marqueur, aucune couleur de clôture, pas de +// commentaire dans le tooltip → comme si la fiche n'avait pas encore +// été analysée. +// - Après le seuil : on affiche tel quel le statut courant, qu'il soit +// "Fait", "✓✓", "Suspendu" ou même encore "À faire". +// Le rideau ne s'applique QUE quand on regarde le planning d'aujourd'hui. +// En mode debug, tout est révélé pour pouvoir tester les verdicts. +// +// PAS de tick automatique : la transition se fait UNIQUEMENT quand le +// coordinateur recharge la page (F5) ou clique "Actualiser". À ce moment-là +// updateInterventionRow / buildInterventionRow / buildTooltipHTML rejouent +// leur logique avec l'heure courante et le rideau s'ouvre. +function _isVerdictRevealed(iv) { + try { + if (LOG && LOG.isDebug && LOG.isDebug()) return true; + } catch (_) {} + // Pas d'iv → on révèle (pas de raison de cacher). + if (!iv) return true; + // Date affichée différente d'aujourd'hui → pas de rideau (passé/futur). + if (state && state.currentDate && state.currentDate !== todayISO()) return true; + // Réservation / absence : pas concerné par le rideau. + if (iv.type === "AL-Reservation" || iv.type === "AL-Absence") return true; + // Pas d'heure de début exploitable → on révèle (on ne peut pas décider). + const start = iv.startTime; + if (!start) return true; + const m = String(start).match(/^(\d{1,2}):(\d{2})/); + if (!m) return true; + const startMin = parseInt(m[1], 10) * 60 + parseInt(m[2], 10); + const now = new Date(); + const nowMin = now.getHours() * 60 + now.getMinutes(); + // < 12h = matin → rideau jusqu'à 12:00 ; ≥ 12h = aprèm → rideau jusqu'à 15:00. + const threshold = (startMin < 12 * 60) ? (12 * 60) : (15 * 60); + return nowMin >= threshold; +} + function getStatusClass(iv) { + // tant que le rideau n'est pas levé, on retourne null → la card ne + // prend pas la classe verte/violette. Le statut reste stocké dans iv.status, + // simplement non visible. + if (!_isVerdictRevealed(iv)) return null; // v4.2.5 : priorité aux statuts de disparition analysés if (iv._disappearStatus === "closed") return "status-closed"; if (iv._disappearStatus === "terminated") return "status-terminated"; + // "terminated-clos" → carte verte (clôturé officiel + commentaire) + // "terminated-pending" → PAS de carte verte, juste un check ✓ + if (iv._disappearStatus === "terminated-clos") return "status-closed"; + if (iv._disappearStatus === "terminated-pending") return null; + // "terminated-suspended" → ✓ jaune (statut Suspendu côté EV). + // Pas de fond vert, juste un check coloré jaune. + if (iv._disappearStatus === "terminated-suspended") return null; if (iv._disappearStatus === "error") return null; if (isClosedStatus(iv.status)) return "status-closed"; if (isResolvedStatus(iv.status)) return "status-resolved"; @@ -6867,12 +8638,23 @@ function buildInterventionRow(iv, cardEl) { if (iv.isPompier) row.classList.add("is-pompier-line"); // v2026.5.41 : intervention en conflit avec une absence/réservation → rouge plein if (iv._absenceConflict) row.classList.add("intervention-conflict-absence"); - // v4.3.3 : on ne marque plus les ghosts visuellement (classe is-ghost - // retirée). Les tickets disparus sont soit retirés (_disappearRemove), - // soit affichés en vert (_disappearStatus). Plus de barrage. - // v4.2.5 : indicateur "en cours d'analyse" (ticket disparu, on re-fetch - // la fiche pour décider de le garder en vert ou le retirer). + // conflit horaire entre 2 interventions → carte rouge + if (iv._hasOverlap) row.classList.add("intervention-conflict-overlap"); + // indicateur visuel "en cours d'analyse" — visible tant que pas de + // verdict, retiré dès que verdict tombe (le verdict() pose + // _disappearChecking=false et un updateInterventionRow() rebuild la card). if (iv._disappearChecking) row.classList.add("_checking"); + // marqueur pour décaler la signature à gauche quand un ✓ est ajouté + // (sinon le check se superpose à la signature). + // terminated-suspended a aussi un ✓ (jaune), même décalage. + // rideau horaire — on n'ajoute le marqueur que si le verdict est + // révélé (sinon on n'affiche rien du tout pour cette iv). + const _rowRevealed = _isVerdictRevealed(iv); + if (_rowRevealed && (iv._disappearStatus === "terminated-pending" || + iv._disappearStatus === "terminated-suspended")) { + row.classList.add("has-pending-check"); + } + if (_rowRevealed && iv._disappearStatus === "terminated-suspended") row.classList.add("status-suspended"); const colorKey = deriveColorKey(iv); row.classList.add("color-" + colorKey); @@ -6886,6 +8668,7 @@ function buildInterventionRow(iv, cardEl) { if (iv.formLink && !iv.ghost && iv.type !== "AL-Absence") { row.classList.add("clickable"); + row.dataset.clickBound = "1"; // v4.1.8 : plus de title au survol (info déjà dans le tooltip en bas) // Clic normal : ouvre l'onglet et change de page @@ -6935,11 +8718,16 @@ function buildInterventionRow(iv, cardEl) { row.appendChild(refHeader); // Check ✓ + bouton copier à droite de la ref (pas pour réservation / absence) - if (statusClass && iv.type !== "AL-Reservation" && iv.type !== "AL-Absence") { + // on ajoute aussi un ✓ pour "terminated-pending" (gris) et un + // ✓ pour "terminated-suspended" (jaune) — sans rendre la carte verte. + // rideau horaire — pas de ✓ tant que pas révélé. + const showPendingCheck = _rowRevealed && iv._disappearStatus === "terminated-pending"; + const showSuspendedCheck = _rowRevealed && iv._disappearStatus === "terminated-suspended"; + if ((statusClass || showPendingCheck || showSuspendedCheck) && + iv.type !== "AL-Reservation" && iv.type !== "AL-Absence") { const statusEl = document.createElement("div"); statusEl.className = "iv-status-check"; - // v4.2.5 : ✓✓ double pour clôturé/résolu (statut officiel EasyVista) - // ✓ simple pour "terminé par tech" (commentaire LOGIN: détecté) + if (showSuspendedCheck) statusEl.classList.add("suspended"); if (statusClass === "status-closed" || statusClass === "status-resolved") { statusEl.textContent = "✓✓"; statusEl.classList.add("double"); @@ -6961,6 +8749,15 @@ function buildInterventionRow(iv, cardEl) { row.appendChild(copyBtn); } + // ⚠ si la fiche n'a pas pu être récupérée correctement. + if (iv._fetchWarning) { + const warnEl = document.createElement("div"); + warnEl.className = "iv-fetch-warning"; + warnEl.textContent = "⚠"; + warnEl.title = "Fiche partiellement récupérée — vérifie ta session EasyVista"; + row.appendChild(warnEl); + } + // ─── Ligne 2 gauche : heures VERTICALES (début / ↓ / fin) ───────────────── const timeEl = document.createElement("div"); timeEl.className = "iv-time-vertical"; @@ -7150,12 +8947,45 @@ async function openInterventionInNewTab(iv, opts = {}) { let target = null; let checksum = null; - // v4.1.4 : on fetch TOUJOURS la fiche à la volée au clic pour extraire un - // checksum FRAIS. Ne pas utiliser iv.ficheChecksum du cache : les checksums - // EasyVista peuvent expirer entre le fetch arrière-plan et le clic utilisateur. - // + // v2026.5.44 Tentative préférée : basicAutoComplete + redirectHeader + // (méthode officielle EV pour transformer une référence en URL fiche). + // Si iv.ref est dispo, on demande à EV un checksum FRAIS sans fetcher la + // fiche entière. C'est plus rapide et plus fiable. + if (iv.ref) { + try { + console.log("[click] basicAutoComplete pour ref=", iv.ref); + const r1 = await fetch(`${session.origin}/api/v1/internal/search/basicAutoComplete?search=${encodeURIComponent(iv.ref)}&PHPSESSID=${encodeURIComponent(session.phpsessid)}`, { credentials: "include" }); + const j1 = await r1.json(); + const meta = j1 && j1.data && j1.data.METAOBJECTS && j1.data.METAOBJECTS[0]; + const line = meta && meta.LINES && meta.LINES[0]; + if (meta && line && line.PK && meta.TYPE_GUID && line.CHECKSUM) { + const r2 = await fetch(`${session.origin}/api/v1/internal/search/redirectHeader?pk=${encodeURIComponent(line.PK)}&guid=${encodeURIComponent(meta.TYPE_GUID)}&checksum=${encodeURIComponent(line.CHECKSUM)}&PHPSESSID=${encodeURIComponent(session.phpsessid)}`, { credentials: "include" }); + const j2 = await r2.json(); + const urlStr = (typeof j2 === "string") ? j2 : (j2 && j2.url) || ""; + // Extraire target & checksum frais de l'URL retournée + const mTarget = urlStr.match(/[?&]target=([^&]+)/); + const mChecksum = urlStr.match(/[?&]checksum=([a-f0-9]+)/); + if (mTarget && mChecksum) { + target = decodeURIComponent(mTarget[1]); + checksum = mChecksum[1]; + console.log(`[click] ✓ checksum frais via redirectHeader : target=${target} checksum=${checksum}`); + iv.ficheTarget = target; + iv.ficheChecksum = checksum; + } else { + console.warn("[click] redirectHeader URL sans target/checksum :", urlStr); + } + } else { + console.warn("[click] basicAutoComplete sans pk/guid/checksum, fallback sur fetch fiche"); + } + } catch (err) { + console.warn("[click] erreur basicAutoComplete/redirectHeader, fallback :", err && err.message); + } + } + + // v4.1.4 (fallback navigation) : on fetch la fiche pour extraire le + // checksum dans le HTML. Comportement legacy. // Retry automatique en cas d'échec du pattern checksum. - { + if (!target || !checksum) { console.log("[click] fetch fiche fraîche pour iv", iv.actionId, "requestId=", iv.requestId); let attempts = 0; const maxAttempts = 2; @@ -7851,6 +9681,53 @@ function updateInterventionRow(iv) { row.classList.remove("status-closed", "status-resolved", "status-terminated"); if (sc) row.classList.add(sc); + // retirer/ajouter l'animation "en cours d'analyse" et le marqueur + // décalage signature pour le ✓ pending. + row.classList.toggle("_checking", !!iv._disappearChecking); + // rideau horaire pour les marqueurs pending/suspended. + const _updRevealed = _isVerdictRevealed(iv); + row.classList.toggle("has-pending-check", + _updRevealed && ( + iv._disappearStatus === "terminated-pending" || + iv._disappearStatus === "terminated-suspended")); + + // dès qu'un ghost a son verdict (iv.ghost=false posé par verdict()), + // on attache le click handler s'il n'existait pas — l'user peut cliquer + // sur la carte pour ouvrir la fiche, sans attendre la fin de l'actualisation. + if (iv.formLink && !iv.ghost && iv.type !== "AL-Absence" && !row.dataset.clickBound) { + row.classList.add("clickable"); + row.dataset.clickBound = "1"; + row.addEventListener("click", (e) => { + if (e.target.closest(".intervention-copy")) return; + const background = !!(e.ctrlKey || e.metaKey); + openInterventionInNewTab(iv, { background }); + }); + row.addEventListener("auxclick", (e) => { + if (e.button !== 1) return; + if (e.target.closest(".intervention-copy")) return; + e.preventDefault(); + openInterventionInNewTab(iv, { background: true }); + }); + row.addEventListener("mousedown", (e) => { + if (e.button === 1) e.preventDefault(); + }); + } + + // ⚠ si la fiche n'a pas pu être récupérée correctement (tronquée / + // session expirée). On l'insère/met à jour dans le coin haut-droit de la row. + let warnEl = row.querySelector(".iv-fetch-warning"); + if (iv._fetchWarning) { + if (!warnEl) { + warnEl = document.createElement("div"); + warnEl.className = "iv-fetch-warning"; + warnEl.textContent = "⚠"; + warnEl.title = "Fiche partiellement récupérée — vérifie ta session EasyVista"; + row.appendChild(warnEl); + } + } else if (warnEl) { + warnEl.remove(); + } + // Classe de couleur sur la ligne (la pastille hérite via CSS) const colorKey = deriveColorKey(iv); row.classList.remove(...ALL_COLOR_CLASSES); @@ -7869,15 +9746,20 @@ function updateInterventionRow(iv) { } // Check ✓ : ajouter/retirer/mettre à jour selon statut + // R11b : on gère AUSSI le ✓ pour terminated-pending en direct (avant on + // ne créait le ✓ pending qu'au renderFromData final → l'user voyait les + // ✓✓ apparaître au fur et à mesure mais les ✓ seulement à la fin). + // rideau horaire — pas de ✓ tant que le seuil n'est pas atteint. let checkEl = row.querySelector(".iv-status-check"); - if (sc) { - // v4.2.5 : ✓✓ pour clos/résolu, ✓ pour terminé tech + const showPendingCheck = _updRevealed && iv._disappearStatus === "terminated-pending"; + const showSuspendedCheck = _updRevealed && iv._disappearStatus === "terminated-suspended"; + // ajout du ✓ jaune pour terminated-suspended. + if (sc || showPendingCheck || showSuspendedCheck) { const isDouble = (sc === "status-closed" || sc === "status-resolved"); const desiredText = isDouble ? "✓✓" : "✓"; if (!checkEl) { checkEl = document.createElement("div"); checkEl.className = "iv-status-check"; - // Insérer après la ref (avant le bouton copier s'il existe) const copy = row.querySelector(".intervention-copy"); if (copy) row.insertBefore(checkEl, copy); else if (refEl && refEl.nextSibling) row.insertBefore(checkEl, refEl.nextSibling); @@ -7885,8 +9767,11 @@ function updateInterventionRow(iv) { } checkEl.textContent = desiredText; checkEl.classList.toggle("double", isDouble); + checkEl.classList.toggle("suspended", showSuspendedCheck); + row.classList.toggle("status-suspended", showSuspendedCheck); } else if (checkEl) { checkEl.remove(); + row.classList.remove("status-suspended"); } // Bouton 📋 copier : ajouter si on a maintenant une ref et qu'il n'existe pas @@ -7951,7 +9836,75 @@ function updateInterventionRow(iv) { slot.dataset.title = deriveShortTitle(iv); if (iv.ref) slot.dataset.ref = iv.ref; } + + // R11b : mini-card timeline (vue horizontale) — ✓/✓✓ live aussi. + const miniCard = card.querySelector( + `.iv-mini-card[data-iv-idx="${row.dataset.ivIdx}"]` + ); + if (miniCard) { + miniCard.classList.remove("status-closed", "status-resolved", "status-terminated", "has-pending-check"); + if (sc) miniCard.classList.add(sc); + if (showPendingCheck || showSuspendedCheck) miniCard.classList.add("has-pending-check"); + miniCard.classList.toggle("status-suspended", showSuspendedCheck); + let miniCheck = miniCard.querySelector(".iv-mini-status-check"); + if (sc || showPendingCheck || showSuspendedCheck) { + const isDouble = (sc === "status-closed" || sc === "status-resolved"); + if (!miniCheck) { + miniCheck = document.createElement("div"); + miniCheck.className = "iv-mini-status-check"; + miniCard.appendChild(miniCheck); + } + miniCheck.textContent = isDouble ? "✓✓" : "✓"; + miniCheck.classList.toggle("double", isDouble); + miniCheck.classList.toggle("suspended", showSuspendedCheck); + } else if (miniCheck) { + miniCheck.remove(); + miniCard.classList.remove("status-suspended"); + } + } } + + // régénérer le HTML des popups épinglés ouverts pour CETTE iv + // — sans ça, un popup ouvert AVANT le verdict ne montre jamais le + // commentaire/badge même si l'analyse a réussi en arrière-plan. + try { + if (typeof pinnedPopups !== "undefined" && Array.isArray(pinnedPopups)) { + for (const p of pinnedPopups) { + if (!p || !p.iv || !p.el) continue; + const sameIv = (p.iv === iv) || + (iv.actionId && String(p.iv.actionId) === String(iv.actionId)) || + (iv.ref && p.iv.ref === iv.ref); + if (!sameIv) continue; + // Mettre à jour la référence iv (au cas où l'objet a changé) + p.iv = iv; + const topbar = p.el.querySelector(".pinned-popup-topbar"); + const dragbar = p.el.querySelector(".pinned-popup-dragbar"); + try { + p.el.innerHTML = buildTooltipHTML(iv); + } catch (e) { /* ignore */ } + // Virer les boutons pin résiduels du tooltip de base, restaurer + // topbar/dragbar propres au popup. + const oldPin = p.el.querySelector('.tooltip-pinbtn[data-action="pin"]'); + if (oldPin) oldPin.remove(); + if (topbar) p.el.appendChild(topbar); + if (dragbar) p.el.appendChild(dragbar); + } + } + } catch (e) { /* ignore */ } + + // si l'iv survolée actuellement (tooltip "live") = celle qu'on + // vient d'updater, on régénère son contenu aussi. + try { + if (state && state.currentTooltipIv && ( + state.currentTooltipIv === iv || + (iv.actionId && String(state.currentTooltipIv.actionId) === String(iv.actionId)) + )) { + const tip = tooltipEl(); + if (tip && tip.classList.contains("visible")) { + tip.innerHTML = buildTooltipHTML(iv); + } + } + } catch (e) { /* ignore */ } } // ============================================================================ @@ -8888,7 +10841,30 @@ async function _refreshPinnedPopupIv(popup, iv) { // pas abortée par les checks isRefreshAborted) const token = (typeof currentRefreshToken !== "undefined") ? currentRefreshToken : 0; - await fetchAndUpdateIntervention(iv, token); + // refresh manuel d'une seule iv depuis le popup. + // - iv ghost (ou avec un verdict de disparition) → analyse complète + // (fiche + statut + commentaire tech) via analyzeOneDisappearedIv + // pour rafraîchir le verdict ✓✓ / ✓ / et le commentaire dans le popup. + // - sinon → fetchAndUpdateIntervention classique (xhr2 + fiche). + if (iv.ghost || iv._disappearStatus) { + // On reset les marqueurs pour forcer une nouvelle analyse propre + iv._disappearChecking = true; + iv._diagnosticTechComment = null; + iv._diagnosticActionInfo = null; + iv._diagnosticOfficiallyClosed = false; + // Trouver le tech (l'iv connaît son techId) + const tech = (state.currentData && state.currentData.techs) + ? state.currentData.techs.find(t => String(t.id) === String(iv.techId)) + : null; + if (tech) { + await analyzeOneDisappearedIv(tech, iv); + } else { + // Fallback si tech introuvable (rare) + await fetchAndUpdateIntervention(iv, token, { useFreshChecksum: true }); + } + } else { + await fetchAndUpdateIntervention(iv, token, { useFreshChecksum: true }); + } // Régénérer le HTML du tooltip avec les nouvelles infos. // On doit réinjecter juste le contenu, en gardant la topbar et la dragbar @@ -10182,15 +12158,14 @@ function bindTooltipInteractions() { const kind = btn.dataset.kind || "absence"; _triggerDeleteItem(actionId, kind, btn); } else if (action === "open-fiche") { - // v2026.5.40 r18 : clic sur la référence → ouvre la fiche EV + // clic sur la référence → utilise openInterventionInNewTab qui + // génère un checksum FRAIS via basicAutoComplete + redirectHeader. e.preventDefault(); e.stopPropagation(); const iv = state.currentTooltipIv; - if (iv && iv.formLink && state.session) { - const url = state.session.origin + "/index.php?" + iv.formLink - + "&PHPSESSID=" + encodeURIComponent(state.session.phpsessid); + if (iv) { const background = !!(e.ctrlKey || e.metaKey || e.button === 1); - window.open(url, "_blank", background ? "noopener" : "noopener"); + openInterventionInNewTab(iv, { background }); } } }); @@ -10310,6 +12285,8 @@ function buildTooltipHTML(iv) { } else { rows.push(`

Référence
${refSafe}
`); } + // R12m : séparateur après la Référence pour aérer le tooltip. + rows.push(`
`); } if (iv.ghost) { @@ -10338,6 +12315,54 @@ function buildTooltipHTML(iv) {
${_pinIcon}
Info
Aucun détail disponible
`; } + // v2026.5.44 commentaire tech extrait par le diagnostic ghost. + // - Statut officiellement clos/résolu → ✅ vert (terminé + validé) + // - Statut pas clos → ⏳ "fait" en attente + // rideau horaire — on n'affiche le bloc commentaire qu'une fois le + // seuil franchi (12h matin / 15h aprèm). Avant ça, le commentaire + // existe en mémoire mais reste invisible. + if (iv._diagnosticTechComment && _isVerdictRevealed(iv)) { + // R12e : retire le préfixe "login: " du commentaire. + const stripped = iv._diagnosticTechComment.replace(/^[a-z0-9_]{3,12}\s*:\s*/, ""); + const cmtSafe = escapeHtml(stripped); + const info = iv._diagnosticActionInfo || {}; + // on retire l'heure du méta (dateCreation/dateFin) — l'utilisateur + // veut juste la date, pas le HH:MM:SS qu'EasyVista accole. + const _stripTime = (s) => String(s || "").replace(/[\sT]+\d{1,2}:\d{2}(:\d{2})?\s*$/, "").trim(); + const metaDate = _stripTime(info.dateFin || info.dateCreation || ""); + // badge selon le verdict : + // - terminated-clos → "Terminé (clos)" + ✅ + // - terminated-suspended → "Suspendu" en JAUNE + // - terminated-pending → juste "Fait" + const isSuspended = iv._disappearStatus === "terminated-suspended"; + const labelText = iv._diagnosticOfficiallyClosed + ? "Terminé (clos)" + : (isSuspended ? "Suspendu" : "Fait"); + const badgeContent = iv._diagnosticOfficiallyClosed + ? `${escapeHtml(labelText)}
✅` + : (isSuspended + ? `${escapeHtml(labelText)}` + : escapeHtml(labelText)); + // R12j : layout 2 colonnes — + // gauche : badge "✅ Terminé (clos)" centré vertical + // droite : meta (auteur+date) + commentaire empilés + const metaHtml = (info.intervenant || metaDate) + ? `
${escapeHtml(info.intervenant || "")}${(info.intervenant && metaDate) ? " — " : ""}${escapeHtml(metaDate)}
` + : ""; + const dd = ` +
+
+
+ ${badgeContent} +
+
+ ${metaHtml} +
${cmtSafe}
+
+
+
`; + rows.push(`
Commentaire
${dd}`); + } // v4.1.13/14 : boutons d'action en haut à droite (recharger + épingler) return `
@@ -10470,7 +12495,12 @@ function showLoading() { document.getElementById("cards").innerHTML = ""; } -function showError(msg) { +// showError accepte un 2e argument optionnel. +// options.centered : true → carte centrée avec icône, titre, description. +// options.icon, options.title : pour le mode centré. +// options.actionLabel + options.actionHandler : bouton d'action. +// Mode par défaut (sans options.centered) = bandeau rouge classique. +function showError(msg, options) { document.getElementById("loading").classList.add("hidden"); document.getElementById("stats").classList.add("hidden"); document.getElementById("session-needed").classList.add("hidden"); @@ -10478,7 +12508,45 @@ function showError(msg) { if (evUnr) evUnr.classList.add("hidden"); document.getElementById("cards").innerHTML = ""; const box = document.getElementById("error-box"); - box.textContent = msg; + box.innerHTML = ""; + const isCentered = !!(options && options.centered); + box.classList.toggle("error-box-centered", isCentered); + + if (isCentered) { + if (options.icon) { + const iconEl = document.createElement("div"); + iconEl.className = "error-icon"; + iconEl.textContent = options.icon; + box.appendChild(iconEl); + } + if (options.title) { + const titleEl = document.createElement("div"); + titleEl.className = "error-title"; + titleEl.textContent = options.title; + box.appendChild(titleEl); + } + const descEl = document.createElement("div"); + descEl.className = "error-description"; + descEl.textContent = msg; + box.appendChild(descEl); + } else { + const span = document.createElement("span"); + span.textContent = msg; + span.style.marginRight = "10px"; + box.appendChild(span); + } + + if (options && options.actionLabel && typeof options.actionHandler === "function") { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn btn-primary"; + btn.textContent = options.actionLabel; + btn.addEventListener("click", (e) => { + e.preventDefault(); + try { options.actionHandler(); } catch (err) { console.warn("[showError action]", err); } + }); + box.appendChild(btn); + } box.classList.remove("hidden"); }