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">?
→ \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(`