From 519fca42ce10a0b0edb3d8cd1f8191114c052ac0 Mon Sep 17 00:00:00 2001 From: FroSteel Date: Tue, 1 Sep 2026 15:24:30 +0200 Subject: [PATCH] =?UTF-8?q?v2026.5.49=20=E2=80=94=20S=C3=A9curit=C3=A9=20h?= =?UTF-8?q?ors=20session,=20onglet=20Email=20clarifi=C3=A9,=20lecture=20au?= =?UTF-8?q?=20d=C3=A9filement,=20affichage=20et=20synchronisation=20fiabil?= =?UTF-8?q?is=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 35 + README.md | 12 +- firefox-updates.json | 5 + src/manifest.json | 2 +- src/viewer.css | 244 ++++++- src/viewer.html | 27 +- src/viewer.js | 1513 +++++++++++++++++++++++++++--------------- 7 files changed, 1271 insertions(+), 567 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a061afc..3d3eced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,41 @@ --- +## v2026.5.49 — Sécurité hors session, onglet Email clarifié, lecture au défilement, affichage et synchronisation fiabilisés + +> Cette version renforce la **sécurité** quand aucune session EasyVista n'est active, clarifie l'**onglet Email** (déplacement et annulation bien séparés, aperçu de la copie), améliore la **lecture du planning au défilement** (le nom du technicien, son compteur et la frise horaire restent visibles), et corrige en profondeur l'**affichage de la reconnexion** ainsi que plusieurs comportements de la synchronisation et des réservations. + +### Sécurité et confidentialité (sans session EasyVista) + +- **Aucune écriture dans le dossier partagé sans session active** : tant que vous n'êtes pas identifié dans EasyVista, l'extension travaille sur le **cache local seul** et ne touche plus au dossier partagé. Un ancien cache reste consultable, mais il n'est plus alimenté ni relu depuis le partage. +- **Aucune modification sans session active** : déplacement d'intervention, suppression, création ou édition d'absence, réservation — tout est bloqué proprement tant que la session n'est pas rétablie, avec un message clair. Cela évite d'écrire des données incohérentes qui seraient ensuite propagées aux autres postes. + +### Onglet Email clarifié + +- **Déplacement et annulation séparés** : l'onglet Email présente désormais deux sections distinctes (déplacement d'une intervention / annulation-suppression), chacune avec sa case d'activation et son propre modèle de message. +- **Copie (CC) commune avec aperçu en direct** : un bloc « Copie (CC) » en tête montre, sous forme de puces, les adresses effectivement retenues (les adresses invalides sont barrées) — vous voyez immédiatement à qui la copie sera envoyée. +- **Variables cliquables en colonnes** pour les deux modèles (déplacement et annulation), rangées par thème pour une insertion rapide. + +### Lecture du planning au défilement (vue classique) + +- **En-tête de carte collant** : quand vous faites défiler une carte, le **nom du technicien**, son **nombre d'interventions** et la **frise horaire** restent visibles en haut — plus besoin de remonter pour savoir de qui il s'agit. +- **Survol d'une pause** : passer la souris sur une pause met la **frise horaire en évidence** (sans faner les interventions), pour bien distinguer le créneau libre. + +### Affichage et reconnexion fiabilisés + +- La bannière **« Session EasyVista expirée »** s'affiche au bon endroit (dans le corps, sous la barre de recherche) et **uniquement quand de vraies cartes sont affichées** ; sinon un message centré prend la place du planning. Plus de fausse bannière au-dessus d'un écran vide. +- **Plus de bulles de techniciens vides** : lors d'un rafraîchissement automatique alors que la session est morte, l'extension détecte la page de connexion EasyVista et évite d'afficher une équipe vide. +- La **barre de progression** ne recouvre plus le nom du technicien pendant un rafraîchissement lorsqu'on a fait défiler. + +### Corrections + +- **Noms de contact nettoyés** : les caractères parasites (« ? » et caractères de remplacement) parfois collés aux noms venant d'EasyVista sont retirés. +- **Deux interventions du même technicien le même jour** (deux actions distinctes) s'affichent désormais chacune sur sa propre carte, sans en perdre. +- **Caractères parasités depuis EasyVista** (dans les libellés d'intervention, contacts, lieux) corrigés à la source. +- **Indicateur d'écart d'adresse** remis à jour après correction : actualiser une intervention dont l'adresse a été corrigée retire bien l'alerte. + +--- + ## v2026.5.48 — Notification par email des déplacements, onglet Email, fiches réservation fiables entre postes, recherche et reconnexion durcies > Cette version apporte une vraie nouveauté : après le déplacement d'une intervention (autre jour ou autre technicien), l'extension peut ouvrir un **brouillon d'email pré-rempli** dans votre application mail pour prévenir les techniciens concernés — l'envoi reste un geste manuel. Elle corrige aussi en profondeur la circulation des informations de réservation entre postes, le bouton de reconnexion quand il n'y a aucun cache, et plusieurs comportements de la recherche. diff --git a/README.md b/README.md index 6f5ff74..a0a40dc 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Extension Chrome / Firefox pour visualiser de manière claire et rapide le plann - **Auteur** : FroSteel (FRS), Technicien DGNSI — Canton de Vaud - **Public cible** : coordinateurs DGNSI qui pilotent dans EasyVista (`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) le planning de l'équipe technicienne - **Démarrage projet** : jeudi 16 avril 2026 -- **Version actuelle** : [`v2026.5.48`](https://gitea.netaplaid.ch/FroSteel/Planification/releases/tag/v2026.5.48) (latest) +- **Version actuelle** : [`v2026.5.49`](https://gitea.netaplaid.ch/FroSteel/Planification/releases/tag/v2026.5.49) (latest) - **Contact** : voir [page wiki Contact](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Contact) ou [ouvrir une issue](https://gitea.netaplaid.ch/FroSteel/Planification/issues/new) - **Manifest** : V3 (Chrome/Edge/Firefox 140+) - **Format** : `.zip` (Chromium) + `.xpi` signé Mozilla (Firefox) @@ -121,7 +121,15 @@ Le numéro de **majeure** n'est **pas** un mois et **pas** un chiffre lié au ca ## Versions notables -### `v2026.5.48` (latest, 24 juillet 2026) — Notification par email des déplacements, onglet Email, fiches réservation fiables entre postes +### `v2026.5.49` (latest, 1 septembre 2026) — Sécurité hors session, onglet Email clarifié, lecture au défilement, affichage fiabilisé + +- Sans session EasyVista active : plus d'écriture dans le dossier partagé (cache local seul) ni de modification (déplacement, suppression, absence, réservation bloqués proprement) +- Onglet Email séparé en deux sections (déplacement / annulation), copie (CC) commune avec aperçu en direct des adresses retenues, variables en colonnes +- Vue classique : nom du technicien, compteur et frise horaire restent visibles au défilement ; survol d'une pause qui met la frise en évidence +- Bannière « Session expirée » bien positionnée et affichée uniquement sur du vrai contenu ; plus de bulles de techniciens vides quand la session est morte +- Corrections : noms de contact nettoyés, deux interventions d'un même technicien le même jour affichées séparément, caractères parasités depuis EasyVista, indicateur d'écart d'adresse remis à jour après correction + +### `v2026.5.48` (24 juillet 2026) — Notification par email des déplacements, onglet Email, fiches réservation fiables entre postes - Brouillon d'email pré-rempli après un déplacement (autre jour ou autre technicien), modèle personnalisable avec variables et aperçu, case par déplacement - Adresses des techniciens depuis l'annuaire EasyVista, noms tronqués complétés, domaine configurable diff --git a/firefox-updates.json b/firefox-updates.json index ff33cd5..da147df 100644 --- a/firefox-updates.json +++ b/firefox-updates.json @@ -2,6 +2,11 @@ "addons": { "planification-dgnsi@netaplaid.ch": { "updates": [ + { + "version": "2026.5.49", + "update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.49/planification-v2026.5.49-firefox.xpi", + "update_hash": "sha256:73ac4e44e9fdfb1c3c179c7cc5d2e497b5cf27d207947cc559ab620bddd09fa1" + }, { "version": "2026.5.48", "update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.48/planification-v2026.5.48-firefox.xpi", diff --git a/src/manifest.json b/src/manifest.json index 474c277..afe642e 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planification", - "version": "2026.5.48", + "version": "2026.5.49", "description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par FroSteel — DGNSI, Canton de Vaud.", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl/El4I9gxcZkp5bnheg4ZBCKxckRLt9xQ5x+i6phEPYlydl6NapWlkbOyycUaeTZcpoEh/tKQnpMpoEc5jY8zCoRTwh/vfejVBZE6ANDG/dc3iix/1Z8OhRI4zMaRYQ+DQTjS5Q6l6wkcwzbQB85HZNhke/k1qIW6RCuBjeJYmES5WVDwzfCNVvaF6uQvdOCpLjrA3+LI6vUBEwaMg1hM42mC3dU0z9p7qzAby/dH3XpZD9HTFXLJL9/1zryN9rXNDpvZwYWnJ0hLTu5tfmURkvgKOwquX1KJy9vupmhUALRtS08Fplx/e3kUH3LD+coS1ExLwoOZ+1ORBUE9JCB8wIDAQAB", "permissions": [ diff --git a/src/viewer.css b/src/viewer.css index 094e58d..ff7d41b 100644 --- a/src/viewer.css +++ b/src/viewer.css @@ -64,6 +64,22 @@ --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); + /* v2026.5.49 — relief des interventions : elles se posent en surface + claire (--bg-elevated) sur le corps de carte, qui recule (--iv-canvas). + Ombre un cran au-dessus de --shadow (qui porte les cartes elles-mêmes) + pour que la hiérarchie carte < intervention se voie sans devenir + tape-à-l'oeil. --iv-canvas est un gris un peu plus soutenu que + --bg-muted : sur du blanc, l'écart de --bg-muted se voyait à peine. */ + --iv-canvas: #eaecf1; + --iv-relief: 0 1px 2px rgba(20, 30, 50, 0.10), 0 2px 5px rgba(20, 30, 50, 0.07); + /* v2026.5.49 — fond des plages LIBRES (pauses affichées, créneaux du mode + heure exacte). HACHURES claires plutôt qu'un voile uni : un aplat + légèrement plus clair se voyait à peine, et monter son contraste le + rapprochait des tuiles d'intervention. Le rayé reprend le langage des + trous de la timeline (45°) — impossible à confondre avec une surface + pleine, et lisible de loin. */ + --iv-libre: repeating-linear-gradient(45deg, + var(--bg-elevated) 0 7px, var(--iv-canvas) 7px 14px); --radius: 8px; --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; --mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace; @@ -112,6 +128,16 @@ --shadow: 0 1px 3px rgba(0, 0, 0, 0.3); --shadow-hover: 0 2px 10px rgba(0, 0, 0, 0.4); + /* v2026.5.49 — sur fond sombre l'ombre porte moins : elle est plus dense + et c'est surtout l'écart de clarté (corps --iv-canvas / intervention + --bg-elevated #21242b) qui fait le relief. */ + --iv-canvas: #191c21; + --iv-relief: 0 1px 2px rgba(0, 0, 0, 0.45), 0 2px 6px rgba(0, 0, 0, 0.30); + /* v2026.5.49 — en sombre, éclaircir veut dire monter vers la lumière : + les hachures partent de --bg-hover, sinon elles se confondaient avec le + fond. */ + --iv-libre: repeating-linear-gradient(45deg, + var(--bg-hover) 0 7px, var(--iv-canvas) 7px 14px); } /* ========================================================================== @@ -1204,7 +1230,11 @@ html.view-horizontal .session-needed { border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); - overflow: hidden; + /* v2026.5.49 — `clip` au lieu de `hidden` : coupe le débordement (coins + arrondis) SANS créer de conteneur de défilement, sinon l'en-tête sticky + (ci-dessous) se collerait à la carte au lieu de la fenêtre (même piège que + body { overflow-x: clip }). */ + overflow: clip; display: flex; flex-direction: column; } @@ -1219,6 +1249,40 @@ html.view-horizontal .session-needed { gap: 10px; } +/* v2026.5.49 — VUE CLASSIQUE : l'en-tête de carte (nom du tech + badges) + suit le scroll et reste collé sous l'empilement du haut (topbar + bandeaux + session + ligne stats/recherche), tant que sa carte est à l'écran. Sinon le + nom disparaissait dès qu'on scrollait une carte haute. Même offset que la + barre de progression (--stack-top + --topline-h, mesurés en JS). En vue + horizontale, l'en-tête fait partie de la ligne : pas de sticky. */ +html:not(.view-horizontal) .card-header { + position: sticky; + /* v2026.5.49 — pendant un rafraîchissement, la barre de progression est + sticky au MÊME offset (--stack-top + --topline-h) avec un z-index supérieur : + elle recouvrait le nom du tech collé quand on avait scrollé. On ajoute donc + --progress-h (hauteur réelle de la barre, mesurée en JS ; 0 hors refresh) + pour caler l'en-tête JUSTE SOUS la barre. */ + top: calc(var(--stack-top, 56px) + var(--topline-h, 0px) + var(--progress-h, 0px)); + /* au-dessus du contenu de la frise qui défile dessous (slot hover z2, midi + z3, trou survolé z6) ; reste sous la banderole session (z8) et la barre de + progression (z9), qui sont dans un autre contexte d'empilement. */ + z-index: 7; +} + +/* v2026.5.49 — VUE CLASSIQUE : la frise horaire (.timeline) reste collée JUSTE + SOUS l'en-tête du tech quand on scrolle, tant que la carte est à l'écran — + pour garder le repère horaire ET le total d'interventions visibles (le total + vit dans l'en-tête .tech-row-stats, déjà collant). Elle se cale sous l'en-tête + via --card-header-h (hauteur RÉELLE de l'en-tête de CETTE carte, mesurée en JS + car un nom long fait 2-3 lignes). Fond opaque (--bg-muted) : le contenu défile + dessous sans transparence. z-index 6 : sous l'en-tête (7), au-dessus du corps + qui défile. */ +html:not(.view-horizontal) .card-body > .timeline { + position: sticky; + top: calc(var(--stack-top, 56px) + var(--topline-h, 0px) + var(--progress-h, 0px) + var(--card-header-h, 0px)); + z-index: 6; +} + .card-tech-name { font-weight: 600; font-size: 12px; @@ -1276,6 +1340,41 @@ html.view-horizontal .session-needed { .card-body { padding: 0; flex: 1; + /* v2026.5.49 — le corps de la carte RECULE (fond en retrait) pour que + les interventions posées dessus ressortent. Avant, corps et interventions + partageaient le même blanc : tout le contenu se lisait comme un seul + aplat, séparé par des filets de 1 px. */ + background: var(--iv-canvas); +} + +/* v2026.5.49 — vue classique, option « Afficher les pauses » ACTIVE : les + plages libres REMPLISSENT le vide de la carte au lieu de rester des bandes + de 44 px suivies d'un grand aplat gris. Les cartes d'une même ligne ont la + hauteur de la plus chargée : tout ce qui dépasse le contenu est du temps + disponible, il se lit donc comme tel. Le corps passe en colonne flex et + chaque pause reçoit le MÊME flex-grow (posé par _buildGapPlaceholder ; + v2026.5.49) : toutes les plages ont la même taille. Les autres + éléments gardent leur taille (flex-shrink: 0) — sans cela, un contenu plus + haut que la carte comprimerait les interventions. Option inactive ou vue + horizontale : rien ne change (les pauses y sont masquées ou gérées par les + règles .gap-placeholder-mini). */ +html.show-iv-gaps:not(.view-horizontal) .card-body { + display: flex; + flex-direction: column; +} +html.show-iv-gaps:not(.view-horizontal) .card-body > *:not(.gap-placeholder) { + flex-shrink: 0; +} + +/* v2026.5.49 — cartes d'absence : leur couleur de catégorie est peinte sur + la CARTE (dégradé vers --iv-canvas) ; un fond opaque sur le corps la + masquerait. Le corps se contente donc de laisser passer, et les + interventions éventuelles gardent leur relief (tuile claire sur le + dégradé, qui finit sur la même teinte de retrait). */ +.card.absence-cat-maladie .card-body, +.card.absence-cat-conge .card-body, +.card.absence-cat-recurring .card-body { + background: transparent; } .card-empty { @@ -1606,12 +1705,18 @@ html.view-horizontal .iv-exact-card.iv-zero-duration { gap: 2px 10px; align-items: start; padding: 10px 12px 12px 8px; - border-top: 1px solid var(--border); cursor: default; transition: background 0.08s; position: relative; + /* v2026.5.49 — tuile posée sur le corps de carte en retrait : surface + claire, contour et ombre. Remplace le simple filet border-top, qui ne + distinguait rien (même fond de part et d'autre du trait). */ + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: var(--iv-relief); + margin: 6px 8px; } -.intervention-v2:first-child { border-top: none; } .intervention-v2:hover { background: var(--bg-hover); } /* Pastille colorée (barre verticale) */ @@ -1637,23 +1742,25 @@ html.view-horizontal .iv-exact-card.iv-zero-duration { /* : 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. */ + le ✓✓ ou ✓ existant. + v2026.5.49 — ce bandeau se COMPOSE avec le relief de la tuile : une + box-shadow unique aurait effacé l'ombre. */ .intervention-v2.status-closed { - box-shadow: inset -4px 0 0 var(--c-closed); + box-shadow: inset -4px 0 0 var(--c-closed), var(--iv-relief); } .intervention-v2.status-closed:hover { filter: brightness(0.96); } .intervention-v2.status-resolved { - box-shadow: inset -4px 0 0 var(--c-resolved); + box-shadow: inset -4px 0 0 var(--c-resolved), var(--iv-relief); } .intervention-v2.status-resolved:hover { filter: brightness(0.96); } .intervention-v2.status-terminated { - box-shadow: inset -4px 0 0 var(--c-terminated, #16a34a); + box-shadow: inset -4px 0 0 var(--c-terminated, #16a34a), var(--iv-relief); } .intervention-v2.status-terminated:hover { filter: brightness(0.96); @@ -3239,6 +3346,48 @@ header.topbar::before { color: var(--text-muted); font-size: 13px; } +/* v2026.5.49 — chaque type d'email (déplacement / annulation) est une + SECTION visuellement délimitée : encadré léger + liseré d'accent à gauche, + pour qu'on distingue d'un coup d'oeil les deux zones. */ +.admin-email-section { + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: 8px; + padding: 12px 14px 14px; + margin: 18px 0 0; +} +.admin-email-section .admin-email-section-titre { + margin-top: 0; + font-size: 17px; +} +/* v2026.5.49 — aperçu en direct des adresses en COPIE (CC) sous le champ : + une puce par adresse retenue, pour VOIR ce qui sera ajouté ; les saisies + invalides sont barrées en rouge. */ +.cc-apercu { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 8px; + font-size: 12px; +} +.cc-apercu-intro { color: var(--text-muted); } +.cc-apercu-vide { color: var(--text-faint); font-style: italic; } +.cc-apercu-chip { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + background: var(--accent-soft); + color: var(--accent); + border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); + font-weight: 600; +} +.cc-apercu-chip-invalide { + background: var(--danger-soft); + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 35%, transparent); + text-decoration: line-through; +} .admin-team-table { width: 100%; border-collapse: collapse; @@ -4294,18 +4443,18 @@ html.theme-dark { /* Carte entière : couleur de fond + barre gauche épaisse pour absence */ .card.absence-cat-maladie { border-left: 4px solid var(--c-maladie); - background: linear-gradient(to bottom, var(--c-maladie-soft) 0%, var(--bg-elevated) 40%); + background: linear-gradient(to bottom, var(--c-maladie-soft) 0%, var(--iv-canvas) 40%); } html.theme-dark .card.absence-cat-maladie { - background: linear-gradient(to bottom, rgba(67, 56, 202, 0.12) 0%, var(--bg-elevated) 40%); + background: linear-gradient(to bottom, rgba(67, 56, 202, 0.12) 0%, var(--iv-canvas) 40%); } .card.absence-cat-conge { border-left: 4px solid var(--c-conge); - background: linear-gradient(to bottom, var(--c-conge-soft) 0%, var(--bg-elevated) 40%); + background: linear-gradient(to bottom, var(--c-conge-soft) 0%, var(--iv-canvas) 40%); } html.theme-dark .card.absence-cat-conge { - background: linear-gradient(to bottom, rgba(6, 182, 212, 0.12) 0%, var(--bg-elevated) 40%); + background: linear-gradient(to bottom, rgba(6, 182, 212, 0.12) 0%, var(--iv-canvas) 40%); } /* Pompier — on reprend le style existant mais on accentue le border-left */ @@ -4387,10 +4536,10 @@ html.theme-dark .card.absence-cat-conge { /* Carte entière : bordure gauche cyan + fond dégradé */ .card.absence-cat-recurring { border-left: 4px solid var(--c-conge); - background: linear-gradient(to bottom, var(--c-conge-soft) 0%, var(--bg-elevated) 40%); + background: linear-gradient(to bottom, var(--c-conge-soft) 0%, var(--iv-canvas) 40%); } html.theme-dark .card.absence-cat-recurring { - background: linear-gradient(to bottom, rgba(6, 182, 212, 0.12) 0%, var(--bg-elevated) 40%); + background: linear-gradient(to bottom, rgba(6, 182, 212, 0.12) 0%, var(--iv-canvas) 40%); } /* v2026.5.46 — vue horizontale : en échelle exacte, la carte d'un tech @@ -5259,7 +5408,7 @@ html.view-horizontal .horizontal-sidebar .date-custom { } /* 13. Barre de rafraîchissement en vue horizontale. - v2026.5.47 — FroSteel : la barre doit RESTER dans la barre de gauche, + v2026.5.47 — la barre doit RESTER dans la barre de gauche, AU-DESSUS des initiales — pas dans le contenu où elle couvrait les heures du mode « échelle exacte ». Elle repasse donc en overlay sur la SIDEBAR (left:0, width = --sidebar-w) ; top suit --stack-top (sous la @@ -5388,10 +5537,14 @@ html:not(.show-iv-gaps) .gap-placeholder:not(.gap-placeholder-full) { margin: 6px 0; font-size: 16px; font-weight: 700; - color: var(--text-faint); - border: 1px dashed var(--border); + color: var(--text-muted); + border: 1px dashed var(--border-strong); border-radius: 6px; - background: transparent; + /* v2026.5.49 — plage libre VISIBLE : sur le fond de retrait des cartes + (--iv-canvas), un rectangle transparent au contour pâle se voyait à + peine. Voile clair + contour tireté = « emplacement disponible », sans + atteindre la surface pleine d'une intervention. */ + background: var(--iv-libre); cursor: help; user-select: none; transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; @@ -5460,6 +5613,21 @@ html.view-horizontal .gap-placeholder:not(.gap-placeholder-mini) { z-index: 6; } +/* v2026.5.49 — survol d'une PAUSE : on fane (désature + assombrit) les + interventions de la FRISE (.timeline-slot) uniquement, pour que la plage + libre survolée — qui garde sa couleur vive et son contour accent — ressorte, + même collée à une installation bleue (les deux se confondaient). La frise + existe dans les deux vues (classique et horizontale), donc l'effet vaut pour + les deux. On NE fane PAS les cartes d'intervention en dessous (.iv-mini-card, + .intervention-v2) : seule la timeline s'assombrit. La pause + elle-même (.timeline-hole / .gap-placeholder) n'est jamais ciblée. .gap-dimming + est posée par highlightGap sur la carte (classique) ou la ligne (horizontal). */ +.gap-dimming .timeline-slot { + filter: saturate(0.3) brightness(0.82); + opacity: 0.45; + transition: filter 0.12s ease, opacity 0.12s ease; +} + /* : placeholder "—" sous le séparateur d'une période vide, dans la vue classique. Reprend le même design que .iv-mini-block-empty en vue horizontale : encadré dashed, "—" centré, hauteur fixe. */ @@ -5471,10 +5639,11 @@ html.view-horizontal .gap-placeholder:not(.gap-placeholder-mini) { margin: 6px 0; font-size: 16px; font-weight: 700; - color: var(--text-faint); - border: 1px dashed var(--border); + color: var(--text-muted); + border: 1px dashed var(--border-strong); border-radius: 6px; - background: transparent; + /* v2026.5.49 — même éclaircissement que .gap-placeholder. */ + background: var(--iv-libre); user-select: none; pointer-events: none; } @@ -5760,6 +5929,9 @@ html.view-horizontal .iv-mini-cards { overflow: hidden; cursor: pointer; transition: background 0.1s, border-color 0.1s, box-shadow 0.1s; + /* v2026.5.49 — même relief qu'en vue classique : la mini-carte est + posée sur le corps de carte, qui est en retrait. */ + box-shadow: var(--iv-relief); } .iv-mini-card:hover { background: var(--bg-hover); @@ -5977,10 +6149,11 @@ html.view-horizontal .pinned-popups-dock { min-height: 36px; font-size: calc(14px * var(--text-scale)); font-weight: 700; - color: var(--text-faint); - border: 1px dashed var(--border); + color: var(--text-muted); + border: 1px dashed var(--border-strong); border-radius: 6px; - background: transparent; + /* v2026.5.49 — même éclaircissement que .gap-placeholder. */ + background: var(--iv-libre); user-select: none; pointer-events: none; } @@ -7071,13 +7244,20 @@ html.view-horizontal .iv-exact-lane + .iv-exact-lane { position: absolute; top: 0; bottom: 0; - background: transparent; + /* v2026.5.49 — le créneau libre se VOIT sans qu'il faille le survoler : + voile clair sur la bande en retrait, contour tireté discret. Même langage + que les pauses de la vue classique (clair + tireté = disponible). */ + background: var(--iv-libre); + border: 1px dashed var(--border-strong); + border-radius: 4px; cursor: pointer; z-index: 0; } .iv-exact-hole:hover, .iv-exact-hole.highlight { - background: rgba(127, 127, 127, 0.12); + /* v2026.5.49 — le survol reste plus marqué que l'état de repos, qui + n'est plus transparent : teinte d'accent au lieu d'un gris. */ + background: var(--accent-soft); outline: 1px dashed var(--accent, #3b82f6); outline-offset: -2px; } @@ -7135,8 +7315,17 @@ html.view-horizontal .iv-exact-lane + .iv-exact-lane { font-size: calc(10.5px * var(--text-scale, 1)); font-weight: 700; color: #ffffff; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.75); pointer-events: none; + /* v2026.5.49 — la zone est désormais bornée au prochain rendez-vous : sur + un créneau de 30 min l'heure déborde du rectangle. Pastille opaque (au lieu + d'un simple texte ombré) pour qu'elle reste lisible hors de la zone, sur + les hachures d'un créneau libre comme sur le fond de la bande. */ + padding: 1px 5px; + border-radius: 5px; + background: var(--accent, #3b82f6); +} +.iv-exact-selpreview.danger .iv-exact-selpreview-hour { + background: #b03030; } /* v2026.5.47 — un rendez-vous en ERREUR (début == fin) tombe dans la zone qu'on créerait : surbrillance ET bulle passent en rouge (le clic droit est @@ -7348,8 +7537,7 @@ html.view-horizontal.horizontal-exact .cards { } /* ── Puces de variables du modèle d'email (v2026.5.48) ─────────────── */ -/* Interactives : grossissent au survol, s'enfoncent au clic (demande - FroSteel). Le style de base vit ici (plus d'inline) pour que :hover et +/* Interactives : grossissent au survol, s'enfoncent au clic. Le style de base vit ici (plus d'inline) pour que :hover et :active puissent surcharger la bordure. */ .admin-var-chip { font-family: monospace; diff --git a/src/viewer.html b/src/viewer.html index 25b3c17..5f01a71 100644 --- a/src/viewer.html +++ b/src/viewer.html @@ -118,17 +118,6 @@
- -
Chargement…
+ + +
diff --git a/src/viewer.js b/src/viewer.js index d1a8fa4..47a3910 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -17,300 +17,11 @@ // ============================================================================ // viewer.js — vue claire du planning techniciens // ============================================================================ -// v2026.5.48 — stats : le pompier du jour ne compte plus dans « tech. dispo » -// (il est compté à part, il apparaissait deux fois). -// v2026.5.48 — méta réservation (n°/ville/rue/catégorie) : elle vit dans la -// réservation du STORE (partagée via le dossier, horodatée -// resMetaAt — l'édition la plus récente gagne la fusion). Le -// carnet local devient un tampon de pose consommé à l'ingestion : -// fin de la ré-injection à chaque parse qui faisait hériter les -// infos d'origine à toute réservation du même créneau. -// v2026.5.48 — réservation re-planifiée DANS EasyVista : méta transférée sur -// la nouvelle occurrence quand le rapprochement est sans ambiguïté. -// v2026.5.48 — deux réservations au même créneau : chacune garde sa méta -// (édition ciblée par actionId, pose jamais par-dessus l'existant). -// v2026.5.48 — emails des techniciens : colonne dans Paramètres → Équipe -// (saisie, récupération annuaire EV, ou déduction prenom.nom@vd.ch). -// v2026.5.48 — option « Email après un déplacement » : brouillon Outlook -// (mailto) pré-adressé aux 2 techs concernés, envoi manuel. -// v2026.5.48 — audit réservations (9 correctifs) : tech dans l'identité -// (ivKey — actionId recyclé inter-techs) ; champs vidés jamais -// ressuscités par la fusion ; carnet : clé avec heure de fin, -// TTL sur la date cible, vidage disque après persistance du -// jour ; isPompier jamais sur une réservation (et sur le sujet -// seul) ; « réservé toute la journée » sur les heures affichées -// (multi-jours) ; purge du store épargne aujourd'hui/futur ; -// champs pré-remplis protégés du lookup n° ; libellé du bouton -// supprimer conservé après un échec. -// v2026.5.48 — email de déplacement : case « prévenir par email » VISIBLE -// dans la fenêtre de déplacement ET la confirmation drag & drop -// (bascule unique _setMoveEmailEnabled, synchronisée avec -// Paramètres → Équipe, persistante) ; modèle d'email éditable -// (sujet + message, {variables} documentées, modèle de base en -// config — rien en dur) rempli par _moveEmailRempli. -// v2026.5.48 — légende des variables CLIQUABLE : un clic insère la variable -// à la position du curseur, dans le Sujet ou le Message. -// v2026.5.48 — check complet email : garde « aucun changement » dans -// _applyReschedule (plus de brouillon Avant = Après) ; UN seul -// comportement d'enregistrement dans l'onglet Équipe (équipe + -// adresses + domaine + modèle ensemble) ; domaine des adresses -// déduites configurable (cfg.emailDomain) ; lieu/contact du -// brouillon avec les mêmes replis que la carte ; toast si un -// destinataire n'a pas d'adresse ; garde de session sur le -// bouton annuaire ; échec d'écriture de l'option signalé et -// case remise dans l'état réel ; libellés harmonisés. -// v2026.5.48 — l'email ne se propose que pour un VRAI déplacement (autre -// jour ou autre technicien — consigne FroSteel, pas d'email -// pour un simple ajustement d'heures ni pour les réservations) ; -// zone du modèle refaite au propre : bloc encadré, grille de -// variables cliquables, APERÇU en direct sur un exemple fictif. -// v2026.5.48 — adresse(s) de tech introuvable(s) : le brouillon s'ouvre -// QUAND MÊME (destinataires vides/partiels, le coordinateur -// complète dans l'email) ; notification en bas à droite qui -// dit qui manque. -// v2026.5.48 — recherche : la recherche « qui ne part pas quand on -// arrête de taper » est corrigée. Cause : pendant la lecture -// LENTE de l'index (dossier partagé, réseau interne), tout -// survol/focus de la barre relançait _srDoSearch qui TUAIT la -// recherche en vol (jeton _searchSeq) et repartait de zéro — -// les recherches s'entretuaient en boucle, et deux -// constructions d'index concurrentes se réinitialisaient -// mutuellement. Correctifs : index à VOL UNIQUE -// (_srCacheBuildInflight), relance au survol bloquée pendant -// une recherche en vol ou un debounce (_srSearchEnCours), -// roue visible dès la phase d'index, timer de debounce remis -// à null au déclenchement, Entrée = recherche immédiate -// (câblage existant, désormais fiable). -// v2026.5.48 — retouches FroSteel : (a) recherche — cliquer la bascule -// « Planifiée (passé) / AFFICHER » ne ferme plus la zone de -// résultats (l'élément cliqué était réécrit par innerHTML et -// remontait DÉTACHÉ au gestionnaire clic-extérieur ; garde -// isConnected — couvre aussi le pied « N de plus ») ; (b) la -// pastille de statut des résultats ne s'affiche que pour un -// état FINAL (Clôturé / Fait / Terminée), plus de « En -// cours » (le liseré de couleur reste) ; (c) fenêtre de -// modification : plus d'air entre la case email et les -// boutons ; (d) aperçu du modèle : n° d'exemple au vrai -// format EV (S2602407_00233). -// v2026.5.48 — case email des fenêtres de déplacement (fenêtre de -// modification ET confirmation drag & drop) : DÉCOCHER ne -// vaut plus que pour CE déplacement (change.sansEmail — -// l'option globale reste active, la prochaine fenêtre -// revient cochée) ; COCHER quand l'option est inactive -// l'active pour de bon (inchangé). L'option ne se désactive -// durablement que dans Paramètres → Email. -// v2026.5.48 — retours FroSteel : (a) migration du modèle de base par -// EMPREINTE tolérante (_empreinteModeleEmail — fins de ligne, -// espaces, tirets typographiques ignorés) : l'égalité au -// caractère près pouvait rater la migration et laisser -// l'ancien format dans l'onglet Email ; (b) puces de -// variables INTERACTIVES (survol = grossit, clic = s'enfonce, -// styles dans viewer.css) ; (c) « Domaine des adresses » -// déménagé dans l'onglet Équipe (barre d'outils, à côté des -// emails qu'il sert) — réglage GLOBAL : toutes les adresses -// déduites le suivent, re-render immédiat après Enregistrer. -// v2026.5.48 — modèle de base : le CONTACT sort, et une vraie séparation -// visuelle AVANT / APRÈS (deux blocs titrés, lisibles d'un -// coup d'œil) ; la migration au boot couvre désormais la -// LISTE des anciens modèles de base (_ANCIENS_MODELES_BASE : -// les deux générations précédentes) — un modèle personnalisé n'est jamais touché. -// v2026.5.48 — modèle de base : les HEURES sortent (dates seules), mise -// en forme puces « • » + lignes d'air ; migration au boot pour -// les postes restés sur l'ancien modèle de base (un modèle -// personnalisé n'est jamais touché) ; _moveEmailRempli -// supprime aussi les lignes à puce vides et préserve -// l'indentation du modèle. La zone Message reste du TEXTE -// (contrainte mailto:) : un collage depuis Outlook/Word garde -// caractères, puces et sauts de ligne — jamais gras/couleurs. -// v2026.5.48 — retours FroSteel : séparateur « ; » entre les -// destinataires du mailto (Outlook FR/CH ne reconnaît pas la -// virgule) ; la colonne Email et le bouton « Récupérer les -// emails » REVIENNENT dans l'onglet Équipe (boutons au-dessus -// de la table, complétion des noms comprise) ; l'onglet Email -// ne garde qu'Options (email après déplacement + domaine) et -// Modèle ; textes et libellés raccourcis partout (cases des -// fenêtres de déplacement : « Prévenir les techniciens par -// email ») ; légende des variables AU-DESSUS des champs -// Sujet/Message, en colonnes logiques Intervention / Avant / -// Après (MOVE_EMAIL_VARIABLE_GROUPES), descriptions en -// infobulles. -// v2026.5.48 — onglet « Email » dédié dans les Paramètres (demande -// FroSteel) : adresses des techniciens (boutons Récupérer / -// Enregistrer AU-DESSUS de la liste), option « email après un -// déplacement », domaine et modèle avec aperçu — tout au même -// endroit, l'onglet Équipe n'a plus que le groupe, la liste et -// les absences (son Enregistrer passe au-dessus de la table). -// La récupération annuaire complète AUSSI un nom tronqué -// (« Martins, Solange » → « De Almeida Martins, Solange », -// garde anti-homonyme _nomCompletCompatible) : le nom config -// est celui affiché sur les cartes. -// v2026.5.48 — aucun cache + session expirée : le bouton « Ouvrir -// EasyVista » de l'écran plein pouvait devenir définitivement -// inopérant. Deux verrous dans _checkForRestoredSession : -// (a) si state.session est déjà posé mais qu'un écran plein -// est encore affiché (chargement jamais abouti), un clic de -// reconnexion RETENTE le chargement au lieu d'être ignoré -// (même PHPSESSID = le polling ne faisait plus jamais rien) ; -// (b) le toast « Reconnecté » et le masquage des écrans -// pleins (« Session expirée » / « EV inaccessible » — ce -// dernier n'étant jamais masqué par renderFromData) sont -// conditionnés à un RENDU réel du planning, plus de faux -// « Reconnecté » sur une page vide quand le fetch a échoué. -// v2026.5.46 — bug « navigation rapide entre dates » : rendus périmés -// refusés (verrous storeSyncFolder + pipeline 3bis + filet renderFromData). -// v2026.5.46 — bug « tech retiré encore affiché » : la projection filtre -// l'annuaire du jour par l'équipe configurée actuelle (TEAM). -// v2026.5.46 — vue horizontale « échelle exacte » : lignes d'heures et -// ligne « maintenant » bornées avant le bloc des absents journée entière ; -// isTechAbsent évalue la plage configurée (DAY_START/DAY_END). -// v2026.5.46 — bug « navigation rapide » (suite) : jeton d'annulation pris -// dès l'entrée de loadForDate + gardes après les await pré-pipeline. -// v2026.5.46 — même famille : garde du chemin de boot sans session -// (refreshSessionAndLoad) contre une navigation pendant la lecture du store. -// v2026.5.46 — retours de revue : propriété du pipeline (_loadPipelineToken, -// « Arrêter » ciblé, 3bis avorté sans effet), re-rendu des réglages permis, -// double plage pour « absent toute la journée », _dayRange() unique, -// segments calculés une fois, filtre TEAM en hasOwnProperty. -// v2026.5.46 — retours de revue (2e passe) : cycle de vie du bouton -// « Arrêter » lié à la propriété du pipeline (plus de bouton fantôme, clic -// sans pipeline sans effet), zones absentes adjacentes fusionnées (plus de -// tronçons dans les interstices), dayLen dans _dayRange(). -// v2026.5.46 — bug « réservations multiples le même jour » : le créneau -// (heures) entre dans ivKey/_ivAbsenceSig des réservations/absences — deux -// créneaux d'un même tech/jour ne fusionnent plus (un seul s'affichait quand -// EV partageait le request_id). + modal Modifier réservation : le N° -// d'intervention auto-remplit ville/rue/catégorie (helper _wireResNumberLookup -// partagé avec la création). -// v2026.5.46 — passe /simplify : _fusionnerPlages() remplace 4 copies de -// la fusion d'intervalles, _jourAffiche() nomme le prédicat « jour affiché » -// (3 sites), tolérance des interstices dérivée du gap CSS réel, dayE -// destructuré partout où dayStart+dayLen le re-dérivait. -// v2026.5.46 — bug « boucle de conflit du cache dossier » : le CAS de -// storeSyncFolder utilisait le savedAt renvoyé par « stat » (que l'hôte sert -// depuis _index.json, parfois périmé) comme référence d'écriture, alors que -// l'hôte compare le CAS au savedAt RELU dans le fichier. Index en retard = -// conflit permanent, écriture jamais aboutie, index jamais rafraîchi (deadlock). -// On prend désormais la référence CAS dans le fichier réellement lu et fusionné -// (folder.savedAt), pas dans stat : l'écriture aboutit et guérit l'index. -// v2026.5.46 — deux correctifs d'affichage (vue horizontale) : -// 1. fond des cartes d'absence : en échelle exacte la carte est haute et le -// dégradé « couleur → neutre à 40 % » laissait le bas vide → en vue -// horizontale la couleur douce remplit toute la carte (CSS). -// 2. plus de DOUBLON absence récurrente + vraie absence : si le tech a une -// vraie absence EV ce jour (congé/maladie), l'absence récurrente configurée -// ne s'affiche plus (fond/badge/note suivent la vraie absence). -// v2026.5.47 — passage au numéro de version 2026.5.47 (la 2026.5.46 est publiée ; -// le travail non poussé se fait désormais sous 2026.5.47). Pas de pull. -// v2026.5.47 — vue horizontale : la barre de progression « Actualisation -// X/Y » ne recouvre plus le haut de la sidebar (badge/titre/zone de reconnexion) ; -// elle se cale au-dessus de la zone de CONTENU, à droite de la sidebar -// (--sidebar-w mesuré en JS, robuste aux breakpoints/zoom). -// v2026.5.47b — la barre passe SOUS la banderole de session/reconnexion -// quand il y en a une (top = --stack-top, qui intègre la hauteur des banderoles), -// au lieu de la recouvrir ; top:0 quand il n'y a pas de banderole. -// v2026.5.47 — onglet Synchro : bouton « Tester la communication » qui fait -// l'aller-retour extension → hôte (version) → dossier (stat réel) et affiche un -// diagnostic lisible. Va de pair avec l'hôte natif v2026.1.8 (verrou dédié sur -// _index.json contre les « lost update » multi-postes ; icône tray ré-affichée -// au redémarrage d'explorer via TaskbarCreated). -// v2026.5.46 — refonte complète de la couche données : store unifié par -// jour (planv2_*), fusion unique vie/mort, verdict unique par intervention, -// projection rendu centralisée, sync dossier v2. Cf. section « COUCHE -// DONNÉES v2 » plus bas pour le modèle complet. +// Rendu des cartes et timelines, synchronisation via le dossier partagé, +// détection de session EasyVista, panneau de configuration, et emails de +// déplacement / annulation. // -// v2026.5.46 — vue horizontale « échelle de temps exacte » (option -// Apparence, exclusive avec « Afficher les pauses ») : cartes posées en -// position réelle sur une bande continue, cf. _buildExactRow. -// -// v2026.5.46 — fiabilisation de la détection de session EasyVista -// (faux « déconnecté ») : cookie overwrite ignoré + vérification serveur -// avant bannière, expiration sur preuve uniquement, cookie sans onglet EV, -// contre-vérification des statuts HTTP côté background. -// -// v2026.5.46 — mode exact : poignée de déplacement à gauche des cartes -// (remplace le bloc d'heures), règle des heures alignée par MESURE runtime -// (_alignExactRuler), vraie césure midi (style timeline-noon). -// -// v2026.5.46 — mode exact : cartes étroites jamais vides (ville + fin -// d'adresse compactes), tolérance d'empilement 10 min (les enchaînements -// pile ne s'empilent plus), ligne « maintenant » UNIQUE et continue. -// -// v2026.5.46 — mode exact : sous-lignes en FLUX réel (.iv-exact-lane), -// bande à hauteur auto — un empilement n'est plus jamais rogné. -// -// v2026.5.46 — mode exact : position/ancres/hauteur des cartes -// verrouillées (!important) — la règle « position: relative » des cartes -// closed/resolved (mode flux, plus spécifique) cassait posé + taille. -// -// v2026.5.46 — la méta réservation (n° DS/ville/rue/catégorie, locale au -// poste, EV ne la stocke pas) remonte enfin au dossier partagé et aux autres -// postes : ivMerge la complète depuis l'autre copie (n'était jamais recopiée -// → écrasée par l'iv EV re-fetchée) + l'édition « infos seules » déclenche -// bien storeSave/push (avant : mutation mémoire sans persistance). -// -// v2026.5.46 — (1) ouverture de fiche des interventions faites/clôturées -// réparée (formLink retiré au dossier → régénération via la réf) ; (2) mode -// exact : cartes à fond neutre, seul le bord gauche est coloré et sert de -// poignée (clic = modifier, glisser = déplacer). -// -// v2026.5.46 — mode exact : bord gauche à points colorés sur TOUTES les -// cartes (drag bloqué pour faites/clôturées/réservations/absences), bulles -// moins hautes (sous-ligne 60→48 px) ; croix native du champ recherche masquée. -// -// v2026.5.46 — mode exact : lignes d'heures verticales CONTINUES qui -// traversent tout le tableau (calque global, _layoutExactGrid) ; règle des -// heures réellement fixe en haut au scroll (top:0, la topbar est masquée). -// -// v2026.5.46 — mode exact : fines lignes aux demi-heures (h30) ; les -// traits passent DERRIÈRE les bulles d'intervention (z-index sous les cartes). -// -// v2026.5.46 — mode exact : vide sous les bulles supprimé (bande centrée, -// hauteur ↑), heures pleines vs demi-heures (pointillé) différenciées, aucune -// ligne sur les techs absents, adresse abrégée tenue plus longtemps. -// -// v2026.5.46 — mode exact : bulles un chouïa plus hautes (texte qui -// respire) et espace haut/bas resserré ; léger contour conservé. -// -// v2026.5.46 — mode exact : bulles encore agrandies (remplissent leur -// zone) et ensemble resserré (marges de bande et entre sous-lignes réduites). -// -// v2026.5.46 — mode exact : bulles encore un peu plus grandes + n° -// d'intervention détaché du bloc ville+adresse (qui restent groupés). -// -// v2026.5.46 — mode exact : bulles encore agrandies + plus d'espace sous -// le n° ; bandelette pompier refixée en haut (le centrage la décentrait). -// -// v2026.5.46 — mode exact : bulles encore agrandies ; pompier — bandelette -// figée en haut (taille inchangée) + bulles centrées dans l'espace restant. -// -// v2026.5.46 — contact : deux numéros de téléphone séparés par un espace -// n'étaient plus collés à l'affichage (re-découpe du bloc capturé). -// -// v2026.5.46 — alerte doublons de réservation RETIRÉE à la -// demande : le marquage ROUGE des conflits (detectOverlaps, pré-existant) -// reste seul en place, comme avant. -// -// v2026.5.46 — vue par heure : les réservations affichent le lieu (meta) -// sinon le créateur ; catégorie « Autre » en fuchsia sur toutes les vues. -// -// v2026.5.46 — « Réservation » en tête de bulle comme le n° d'intervention -// (gabarit + espace dessous) ; « Autre » passe du fuchsia au vert lime. -// -// v2026.5.46 — « Autre » : lime pas retenu → rose framboise. -// -// v2026.5.46 — vue par heure : les journées totalement VIDES ont enfin -// leurs trous (survol « Libre », clic droit, drop précis) — la bande est -// construite même sans aucune intervention. -// -// v2026.5.46 — « Autre » en violet foncé ; la ligne du temps saute les -// techs absents (helper _exactAbsentSegments partagé avec les lignes d'heures). -// -// v2026.5.46 — la ligne du temps n'a plus ni point ni trait sur la règle -// des heures : elle démarre au haut des cartes. -// -// v2026.5.46 — la ligne du temps est REPOSÉE immédiatement après chaque -// rendu/resize/bascule (avant : détruite par le rendu, elle ne revenait qu'au -// tick d'horloge de 30 s → souvent absente). +// Historique détaillé des versions : voir CHANGELOG.md. // // Idée de base : on récupère tout depuis le XML EasyVista (calendar_block) en // 1 seule requête. attr1/attr2/attr3 + textContent contiennent déjà ref, @@ -470,13 +181,25 @@ let TEAM = {}; // v2026.5.48 — emails des techniciens ({ id: email }, Paramètres → // Équipe) et option « brouillon email après un déplacement ». let TEAM_EMAILS = {}; +// v2026.5.49 — #14 : login EasyVista par technicien (id → login), pour +// lever l'ambiguïté des homonymes / doubles comptes à la récupération d'email. +let TEAM_LOGINS = {}; let MOVE_EMAIL_ENABLED = false; // v2026.5.48 — domaine des adresses déduites (prenom.nom@), -// configurable dans Paramètres → Email (R14) ; valeur de 1er lancement ci-dessous. +// configurable dans Paramètres → Email ; valeur de 1er lancement ci-dessous. const DEFAULT_EMAIL_DOMAIN = "vd.ch"; let EMAIL_DOMAIN = ""; +// v2026.5.49 — adresses en COPIE (CC) ajoutées à TOUS les brouillons d'email +// (déplacement, annulation…), configurables dans Paramètres → Email. Saisie +// libre séparée par « ; ». +// v2026.5.49 — défaut VIDE : aucune adresse nominative n'est écrite en dur +// (dépôt Gitea PUBLIC + données personnelles/nLPD). Le coordinateur saisit ses +// CC une seule fois dans Paramètres → Email ; la valeur est alors persistée en +// admin_config (chrome.storage.local, hors dépôt). +const DEFAULT_EMAIL_CC = ""; +let EMAIL_CC = ""; // v2026.5.48 — modèle de l'email de déplacement (sujet + message), éditable -// dans Paramètres → Email (R14) ; rechargé au boot depuis admin_config. +// dans Paramètres → Email ; rechargé au boot depuis admin_config. let MOVE_EMAIL_SUBJECT = ""; let MOVE_EMAIL_BODY = ""; // Modèle de PREMIER LANCEMENT (filet, comme DEFAULT_GROUP_ID) : sert à remplir @@ -485,7 +208,7 @@ let MOVE_EMAIL_BODY = ""; const DEFAULT_MOVE_EMAIL_SUBJECT = "Planification — intervention {numero} déplacée"; // v2026.5.48 — modèle de base SANS les heures (dates seules), sans // contact, mis en forme : blocs AVANT / APRÈS bien séparés, puces « • » et -// lignes d'air (demandes FroSteel). Les variables retirées (heures, contact) +// lignes d'air. Les variables retirées (heures, contact) // restent disponibles dans la légende pour qui veut les rajouter. const DEFAULT_MOVE_EMAIL_BODY = [ "Bonjour,", @@ -564,13 +287,55 @@ const MOVE_EMAIL_VARIABLES = [ ["lieu", "lieu de l'intervention"] ]; // v2026.5.48 — regroupement d'AFFICHAGE de la légende (colonnes logiques -// au lieu d'une longue liste — demande FroSteel). Les descriptions restent +// au lieu d'une longue liste). Les descriptions restent // dans MOVE_EMAIL_VARIABLES (source unique) et servent d'infobulles. const MOVE_EMAIL_VARIABLE_GROUPES = [ ["Intervention", ["numero", "contact", "lieu"]], ["Avant", ["ancien_tech", "ancienne_date", "ancienne_heure_debut", "ancienne_heure_fin"]], ["Après", ["nouveau_tech", "nouvelle_date", "nouvelle_heure_debut", "nouvelle_heure_fin"]] ]; + +// v2026.5.49 — email d'ANNULATION (ticket #12) : envoyé au technicien quand +// le coordinateur SUPPRIME une réservation (bouton Supprimer). Symétrique de +// l'email de déplacement, mais SANS avant/après (une annulation n'a qu'un état). +// Flag et modèle DÉDIÉS : couper les emails d'annulation ne coupe pas ceux de +// déplacement, ce sont deux décisions produit indépendantes. +let DELETE_EMAIL_ENABLED = false; +let DELETE_EMAIL_SUBJECT = ""; +let DELETE_EMAIL_BODY = ""; +const DEFAULT_DELETE_EMAIL_SUBJECT = "Planification — intervention {numero} annulée"; +const DEFAULT_DELETE_EMAIL_BODY = [ + "Bonjour,", + "", + "L'intervention {numero} prévue le {date} est annulée.", + "", + " • Technicien : {technicien}", + " • Date : {date}", + " • Lieu : {lieu}", + "", + "Merci d'en prendre note." +].join("\n"); +// Anciens modèles de base de l'annulation (migration au boot, comme le +// déplacement) : un poste resté EXACTEMENT sur l'un d'eux reçoit l'actuel ; un +// modèle personnalisé n'est jamais touché. Vide au 1er jour (aucun ancien). +const _ANCIENS_MODELES_BASE_DELETE = []; +const DELETE_EMAIL_VARIABLES = [ + ["numero", "n° de l'intervention (ex. I250123)"], + ["technicien", "technicien concerné"], + ["date", "date de l'intervention annulée (JJ/MM/AAAA)"], + ["heure_debut", "heure de début"], + ["heure_fin", "heure de fin"], + ["contact", "contact de l'intervention"], + ["lieu", "lieu de l'intervention"] +]; +// Une annulation n'a qu'un seul état : un seul groupe d'affichage. +// v2026.5.49 — variables en COLONNES logiques, comme le modèle de +// déplacement (avant : un seul groupe = une seule colonne). Une annulation n'a +// pas d'« avant/après », on répartit donc en Intervention / Technicien & lieu. +const DELETE_EMAIL_VARIABLE_GROUPES = [ + ["Intervention", ["numero", "date", "heure_debut", "heure_fin"]], + ["Technicien & lieu", ["technicien", "contact", "lieu"]] +]; // v2026.5.46 — vue horizontale « échelle de temps exacte » (option // Apparence). Miroir de cfg.horizontalExactMode, lu au boot et au toggle. let HORIZONTAL_EXACT_MODE = false; @@ -587,6 +352,42 @@ function highlightGap(cardEl, gapIdx, on) { cardEl.querySelectorAll(`[data-gap-idx="${gapIdx}"]`).forEach(el => { el.classList.toggle("gap-hover-active", on); }); + // v2026.5.49 — au survol d'une pause, on assombrit la FRISE de la carte + // via .gap-dimming (le CSS ne cible que .timeline-slot, pas les cartes + // d'intervention) : la plage libre, qui garde sa couleur vive, ressort alors + // même collée à une installation bleue (le survol bleu se confondait avec + // elle). Point unique : highlightGap est appelé par les deux sources de + // survol (la frise .timeline-hole et le rectangle .gap-placeholder), en vue + // classique ET horizontale. + cardEl.classList.toggle("gap-dimming", on); +} + +/** + * v2026.5.49 — fabrique unique du rectangle « plage libre » de la vue + * classique : pause entre deux interventions, demi-journée vide (`full`, qui + * reste visible même quand l'option « Afficher les pauses » est inactive) et + * journée entière d'un tech sans rendez-vous. Les plages se partagent le vide + * de la carte à parts ÉGALES (flex-grow 1 pour toutes) : elles ont la même + * taille quelle que soit leur durée (cf. la règle .card-body en colonne flex). + */ +function _buildGapPlaceholder(sMin, eMin, cardEl, options) { + const ph = document.createElement("div"); + ph.className = "gap-placeholder" + (options && options.full ? " gap-placeholder-full" : ""); + ph.dataset.startMin = String(sMin); + ph.dataset.endMin = String(eMin); + ph.dataset.kind = "hole"; + // v2026.5.49 — plages de la vue classique : hauteur FIXE et identique + // pour toutes (le min-height:44px du CSS), sans s'étirer. Le flex-grow:1 du + // l'ancien comportement les rendait égales entre elles mais « immenses » quand la carte était + // haute (peu de pauses, ou carte alignée sur une carte chargée) : elles + // gonflaient pour remplir tout le vide. flex-grow:0 = « assez grand pour + // voir le vide, pas plus ». La vue horizontale n'est pas concernée (ses + // plages sont les .gap-placeholder-mini, réglées ailleurs). + ph.style.flexGrow = "0"; + ph.textContent = "—"; + _bindGapPlaceholder(ph, cardEl); + if (typeof bindTimelinePopover === "function") bindTimelinePopover(ph); + return ph; } function _bindGapPlaceholder(phEl, cardEl) { @@ -650,28 +451,44 @@ function _attachMiniGapsResizeWatcher() { _trimRO.observe(document.body); } +// v2026.5.49 — migration d'un modèle d'email : si le modèle enregistré est +// resté EXACTEMENT sur un ANCIEN modèle de base (empreinte tolérante), le +// remplace par l'actuel ; un modèle personnalisé n'est jamais touché. Factorise +// le même mécanisme pour le déplacement et l'annulation (liste vide = no-op). +async function _migrerModeleEmailSiAncien(cfg, bodyKey, anciensModeles, modeleActuel) { + if (!anciensModeles.length) return; + const empreinte = _empreinteModeleEmail(cfg[bodyKey]); + if (empreinte && anciensModeles.some((m) => _empreinteModeleEmail(m) === empreinte)) { + cfg[bodyKey] = modeleActuel; + await saveAdminConfig(cfg); + } +} + async function _initTeamFromConfig() { try { const cfg = await loadAdminConfig(); TEAM = cfg.team || {}; RECURRING_ABSENCES = cfg.recurringAbsences || {}; TEAM_EMAILS = cfg.teamEmails || {}; // v2026.5.48 + TEAM_LOGINS = cfg.teamLogins || {}; // v2026.5.49 (#14) MOVE_EMAIL_ENABLED = !!cfg.moveEmailEnabled; // v2026.5.48 - // v2026.5.48 — le modèle de base a changé (dates seules, - // puces, blocs AVANT/APRÈS) : un poste resté sur un ANCIEN modèle de base - // reçoit l'actuel. Comparaison par EMPREINTE tolérante (R18) : l'égalité - // au caractère près ratait la migration au moindre détail de stockage. - // Un modèle réellement personnalisé n'est jamais touché. - const _empreinteStockee = _empreinteModeleEmail(cfg.moveEmailBody); - if (_empreinteStockee && - _ANCIENS_MODELES_BASE.some((m) => _empreinteModeleEmail(m) === _empreinteStockee)) { - cfg.moveEmailBody = DEFAULT_MOVE_EMAIL_BODY; - await saveAdminConfig(cfg); - } + // v2026.5.48 — le modèle de base a changé : un poste resté EXACTEMENT sur un + // ANCIEN modèle de base reçoit l'actuel (empreinte tolérante) ; un + // modèle personnalisé n'est jamais touché. + await _migrerModeleEmailSiAncien(cfg, "moveEmailBody", _ANCIENS_MODELES_BASE, DEFAULT_MOVE_EMAIL_BODY); // v2026.5.48 — un modèle vidé retombe sur celui de base. MOVE_EMAIL_SUBJECT = String(cfg.moveEmailSubject || "").trim() ? cfg.moveEmailSubject : DEFAULT_MOVE_EMAIL_SUBJECT; MOVE_EMAIL_BODY = String(cfg.moveEmailBody || "").trim() ? cfg.moveEmailBody : DEFAULT_MOVE_EMAIL_BODY; EMAIL_DOMAIN = String(cfg.emailDomain || "").trim() || DEFAULT_EMAIL_DOMAIN; // v2026.5.48 + // v2026.5.49 — CC global : au 1er lancement (clé absente) → valeur de + // départ ; une chaîne vidée volontairement reste vide (aucun CC). + EMAIL_CC = (typeof cfg.emailCc === "string") ? cfg.emailCc : DEFAULT_EMAIL_CC; + // v2026.5.49 — email d'annulation : même migration (liste vide au 1er + // jour → no-op tant qu'aucun ancien modèle d'annulation n'existe). + DELETE_EMAIL_ENABLED = !!cfg.deleteEmailEnabled; + await _migrerModeleEmailSiAncien(cfg, "deleteEmailBody", _ANCIENS_MODELES_BASE_DELETE, DEFAULT_DELETE_EMAIL_BODY); + DELETE_EMAIL_SUBJECT = String(cfg.deleteEmailSubject || "").trim() ? cfg.deleteEmailSubject : DEFAULT_DELETE_EMAIL_SUBJECT; + DELETE_EMAIL_BODY = String(cfg.deleteEmailBody || "").trim() ? cfg.deleteEmailBody : DEFAULT_DELETE_EMAIL_BODY; // v2026.5.46 — mode « échelle de temps exacte » (vue horizontale). HORIZONTAL_EXACT_MODE = !!cfg.horizontalExactMode; document.documentElement.classList.toggle("horizontal-exact", HORIZONTAL_EXACT_MODE); @@ -852,7 +669,7 @@ function _ivResMetaOf(rec) { } // v2026.5.48 — consommation du carnet : applique l'entrée correspondante à // une réservation du STORE qui n'a pas encore de méta, puis l'EFFACE (une pose -// ne sert qu'une fois). Ne touche jamais une réservation déjà renseignée (R4 : +// ne sert qu'une fois). Ne touche jamais une réservation déjà renseignée ( // deux réservations peuvent occuper le même créneau, chacune garde la sienne). // La suppression n'est écrite sur disque qu'au prochain storeSave réussi // (_resMetaDirty) : un crash avant la persistance du jour re-consommera @@ -1247,12 +1064,27 @@ async function init() { await refreshSessionAndLoad(); } +// v2026.5.49 — la détection lancée au démarrage n'est pas attendue ; on +// garde sa promesse pour que le chargement sans session puisse s'y raccrocher +// (il lui faut le contexte réseau avant de solliciter le dossier partagé) sans +// déclencher une seconde sonde ni une seconde réconciliation du même jour. +let _detectionReseauEnCours = null; + /** * v5.0.11 : détecte si on est en interne (bureau VPN) ou externe (télétravail), * de manière asynchrone au démarrage. Résultat utilisé pour choisir le bon * domaine lors de la reconnexion. */ -async function detectNetworkContextAsync(force = false) { +function detectNetworkContextAsync(force = false) { + if (!force && _detectionReseauEnCours) return _detectionReseauEnCours; + const detection = _detectNetworkContext(force).finally(() => { + if (_detectionReseauEnCours === detection) _detectionReseauEnCours = null; + }); + _detectionReseauEnCours = detection; + return detection; +} + +async function _detectNetworkContext(force) { try { const resp = await sendMessage({ type: "detectNetwork", force }); if (resp && resp.ok) { @@ -1266,7 +1098,11 @@ async function detectNetworkContextAsync(force = false) { // v2026.5.46 — passage (ou confirmation) en INTERNE → on (re)lance la // synchro dossier pour la date affichée, ce qui relance le host natif s'il // était éteint. En externe, storeSyncFolder ne fait rien. - if (resp.context === "internal" && !wasInternal && state.currentDate) { + // v2026.5.49 — seulement s'il y a un jour À L'ÉCRAN à faire converger : + // au démarrage il n'y en a pas encore et c'est le chargement qui réconcilie, + // les deux partaient sinon en même temps sur le dossier (lecture réseau + // faite deux fois, conflit d'écriture à la clé). + if (resp.context === "internal" && !wasInternal && state.currentDay) { reconcileCacheForDate(state.currentDate).catch(() => {}); } } @@ -1278,27 +1114,19 @@ async function detectNetworkContextAsync(force = false) { async function refreshSessionAndLoad() { const resp = await sendMessage({ type: "getSession" }); if (!resp.ok || !resp.session) { - // si un store existe pour le jour demandé, on l'affiche avec une - // bannière "session expirée" sticky au-dessus. Sinon écran plein. - const dateAuLancement = state.currentDate; - const day = await storeLoad(dateAuLancement); - // v2026.5.46 — l'utilisateur a pu naviguer pendant la lecture du store - // (boot sans session) : si la date affichée a changé entre-temps, on ne - // touche ni l'état ni l'écran (sinon les données du jour lu seraient - // affichées sous l'étiquette du nouveau jour). - if (state.currentDate !== dateAuLancement) return; - if (day) { - state.currentDay = day; - renderFromData({ - techs: storeProject(day, state.currentDate), - targetDate: state.currentDate, - captureTime: day.savedAt || null, - source: "store" - }); - showSessionExpiredBanner(); - } else { - showSessionNeeded(); - } + state.session = null; + // v2026.5.49 — sans session on charge quand même par loadForDate : lui + // lit le dossier partagé avant de choisir l'écran (cartes + bannière, ou + // écran plein si rien nulle part). Ce chemin ne regardait que le store + // local, donc un jour présent seulement dans le dossier restait invisible + // jusqu'à un rafraîchissement manuel — la convergence de fond, elle, ne + // re-rend pas (sa garde _jourAffiche exige un jour déjà chargé). + // La détection réseau s'attend : tant que le contexte est inconnu, la + // synchro dossier ne sollicite pas l'hôte (la sonde du démarrage n'a pas + // encore répondu à cet instant). La garde de navigation, elle, est celle + // de loadForDate (jeton de rafraîchissement). + await detectNetworkContextAsync(); + await loadForDate(state.currentDate); return; } state.session = resp.session; @@ -3042,12 +2870,47 @@ function updateSessionIndicator() { * session expirée. Affiche la bannière "Session expirée" avec bouton "Me * reconnecter". */ +// v2026.5.49 — du VRAI contenu est-il RENDU dans #cards (au moins une +// intervention ou absence), ou seulement des bulles de tech vides / rien ? +// La banderole (#session-expired-banner) est un bandeau NON bloquant posé +// au-dessus des cartes : on ne l'affiche donc QUE si de vraies cartes sont +// visibles ; sinon on met le message CENTRÉ (#session-needed) À LA PLACE des +// cartes (pas de banderole au-dessus de bulles vides, pas de +// scintillement). On teste le rendu réel (post-projection), pas le store brut : +// un store dont toutes les ivs sont filtrées (équipe, tombstones) rend vide. +// Contenu réel = un slot/mini-carte/row d'intervention OU une carte de tech +// absent (.is-absent : absence pleine journée ou récurrente) OU pompier +// (.is-pompier) — ces deux classes ne sont posées QUE sur un vrai contenu, +// jamais sur une bulle de tech vide. (Une absence partielle rend un +// .iv-mini-card-absence, déjà couvert.) Un jour entièrement absent a de vraies +// cartes mais aucun slot d'intervention : sans .is-absent il serait pris à tort +// pour un jour vide et effacé par showSessionNeeded(). +function _contenuReelAffiche() { + return document.querySelectorAll( + "#cards .timeline-slot, #cards .iv-mini-card, #cards .intervention-v2, " + + "#cards .iv-exact-card, #cards .card.is-absent, #cards .card.is-pompier" + ).length > 0; +} + +// v2026.5.49 — une réponse EasyVista est-elle en fait la PAGE DE LOGIN SSO ? +// Quand la session est morte mais que le cookie PHPSESSID traîne encore, EV +// répond 200 avec le HTML de login (champ mot de passe / IAM) au lieu du XML. +// Même heuristique que la recherche (cf. runSearchEV). Sert à ne PAS parser un +// login comme un planning vide (ce qui reconstruit tout le roster TEAM en +// bulles de tech vides). +function _estPageLogin(txt) { + return typeof txt === "string" && /type="password"|iamlogin|\/iam\/accueil/i.test(txt); +} + function handleSessionExpired() { if (state.sessionExpired) return; state.sessionExpired = true; state.sessionExpireAt = null; LOG.warn("session", "session EV expirée"); - showSessionExpiredBanner(); + // banderole SEULEMENT si de vraies cartes sont affichées ; sinon message + // centré à la place des cartes (v2026.5.49). + if (_contenuReelAffiche()) showSessionExpiredBanner(); + else showSessionNeeded(); const el = document.getElementById("app-session"); if (el) el.classList.add("hidden"); } @@ -3144,15 +3007,22 @@ function getDefaultAdminConfig() { // v2026.5.48 — emails des techniciens (saisis ou récupérés depuis // l'annuaire EV ; à défaut, déduits du nom « Nom, Prénom » → prenom.nom@vd.ch). teamEmails: {}, + teamLogins: {}, // v2026.5.49 (#14) : login EV par tech (homonymes) // v2026.5.48 — proposer un brouillon d'email aux 2 techs après un déplacement. moveEmailEnabled: false, // v2026.5.48 — modèle de l'email (sujet + message), éditable dans - // Paramètres → Email (R14). Valeurs de PREMIER LANCEMENT : le runtime lit + // Paramètres → Email. Valeurs de PREMIER LANCEMENT : le runtime lit // toujours la config, jamais ces constantes directement. moveEmailSubject: DEFAULT_MOVE_EMAIL_SUBJECT, moveEmailBody: DEFAULT_MOVE_EMAIL_BODY, // v2026.5.48 — domaine des adresses déduites depuis le nom. emailDomain: DEFAULT_EMAIL_DOMAIN, + // v2026.5.49 — adresses en copie (CC) de TOUS les emails générés. + emailCc: DEFAULT_EMAIL_CC, + // v2026.5.49 — email d'annulation (ticket #12) : flag + modèle dédiés. + deleteEmailEnabled: false, + deleteEmailSubject: DEFAULT_DELETE_EMAIL_SUBJECT, + deleteEmailBody: DEFAULT_DELETE_EMAIL_BODY, // v2026.5.45 — diagnostic disparitions : si true, on NE retire JAMAIS // les ghosts (verdict CANCELLED/REMOVE → on log seulement). Sert à valider // les verdicts cas par cas avant de basculer en prod. Décorrélé de @@ -3451,6 +3321,7 @@ function renderAdminSectionTeam(container, cfg, saveFn) { included: true, days: (cfg.recurringAbsences[id] || []).slice(), email: cfg.teamEmails[id] || "", // v2026.5.48 — colonne Email de retour ici + login: (cfg.teamLogins && cfg.teamLogins[id]) || "", // v2026.5.49 (#14) nomComplete: false // v2026.5.48 — nom complété par l'annuaire }); } @@ -3458,11 +3329,12 @@ function renderAdminSectionTeam(container, cfg, saveFn) { // v2026.5.48 — recopie l'état de la table (rows) dans cfg. MERGE avec // l'existant (v2026.5.45 issue #3 : on ne touche QUE les techs présents // dans la vue, les autres gardent leurs entrées). Le nom suit la ligne : un - // nom complété par l'annuaire (R14) part dans cfg.team au même Enregistrer. + // nom complété par l'annuaire part dans cfg.team au même Enregistrer. function recolteEquipe() { const newTeam = { ...(cfg.team || {}) }; const newRecAbs = { ...(cfg.recurringAbsences || {}) }; const newEmails = { ...(cfg.teamEmails || {}) }; + const newLogins = { ...(cfg.teamLogins || {}) }; // v2026.5.49 (#14) for (const r of rows) { if (!r.id) continue; if (r.included) { @@ -3471,16 +3343,20 @@ function renderAdminSectionTeam(container, cfg, saveFn) { else delete newRecAbs[r.id]; if (r.email) newEmails[r.id] = r.email; else delete newEmails[r.id]; + if (r.login) newLogins[r.id] = r.login; + else delete newLogins[r.id]; } else { - // Tech décoché → on retire son entrée de team, recurringAbsences et emails + // Tech décoché → on retire son entrée de team, recurringAbsences, emails, login delete newTeam[r.id]; delete newRecAbs[r.id]; delete newEmails[r.id]; + delete newLogins[r.id]; } } cfg.team = newTeam; cfg.recurringAbsences = newRecAbs; cfg.teamEmails = newEmails; + cfg.teamLogins = newLogins; // v2026.5.49 (#14) // v2026.5.48 — le domaine des adresses part avec le même Enregistrer. cfg.emailDomain = (domaineSaisi || "").trim() || DEFAULT_EMAIL_DOMAIN; domaineSaisi = cfg.emailDomain; @@ -3516,7 +3392,7 @@ function renderAdminSectionTeam(container, cfg, saveFn) { stats.textContent = `${included} personne(s) incluse(s) sur ${rows.length} connue(s).`; tableWrap.appendChild(stats); - // v2026.5.48 — boutons AU-DESSUS de la table (demande FroSteel), + // v2026.5.48 — boutons AU-DESSUS de la table, // avec la récupération annuaire (emails manquants + noms complets) ici. const toolbar = document.createElement("div"); toolbar.className = "admin-team-toolbar"; @@ -3563,7 +3439,7 @@ function renderAdminSectionTeam(container, cfg, saveFn) { // Annuaire EV, séquentiel (EasyVista est lent, le statut reste lisible) : // remplit les adresses VIDES des personnes incluses et complète un nom - // tronqué (R14, garde anti-homonyme). Une adresse saisie n'est jamais + // tronqué (garde anti-homonyme). Une adresse saisie n'est jamais // écrasée. mailBtn.addEventListener("click", async () => { if (!state.session || !state.session.phpsessid) { @@ -3581,9 +3457,10 @@ function renderAdminSectionTeam(container, cfg, saveFn) { for (let i = 0; i < cibles.length; i++) { const r = cibles[i]; mailStatus.textContent = `Recherche ${i + 1}/${cibles.length} — ${r.name}…`; - const res = await _fetchTechEmailFromEV(r.name); + const res = await _fetchTechEmailFromEV(r.name, r.login); if (!res) continue; if (!r.email && res.email) { r.email = res.email; adresses++; } + if (!r.login && res.login) r.login = res.login; // v2026.5.49 (#14) : capture le login trouvé if (res.fullName && res.fullName !== r.name && _nomCompletCompatible(r.name, res.fullName)) { r.name = res.fullName; r.nomComplete = true; @@ -3600,7 +3477,7 @@ function renderAdminSectionTeam(container, cfg, saveFn) { const table = document.createElement("table"); table.className = "admin-team-table"; const thead = document.createElement("thead"); - thead.innerHTML = "InclureIDNom affichéEmailAbsences récurrentes"; + thead.innerHTML = "InclureIDNom affichéEmailLoginAbsences récurrentes"; table.appendChild(thead); const tbody = document.createElement("tbody"); table.appendChild(tbody); @@ -3648,7 +3525,7 @@ function renderAdminSectionTeam(container, cfg, saveFn) { } tr.appendChild(tdName); - // Email — v2026.5.48 : colonne de retour ici (demande FroSteel). + // Email — v2026.5.48 : colonne de retour ici. // Vide = l'adresse proposée (grisée) sera utilisée. const tdMail = document.createElement("td"); const mailInput = document.createElement("input"); @@ -3661,6 +3538,19 @@ function renderAdminSectionTeam(container, cfg, saveFn) { tdMail.appendChild(mailInput); tr.appendChild(tdMail); + // Login EasyVista — v2026.5.49 (#14) : renseigné à la main ou capturé + // à la récupération d'email ; sert de discriminant EXACT entre homonymes. + const tdLogin = document.createElement("td"); + const loginInput = document.createElement("input"); + loginInput.type = "text"; + loginInput.className = "modal-form-input"; + loginInput.style.width = "120px"; + loginInput.value = r.login || ""; + loginInput.placeholder = "login EV"; + loginInput.addEventListener("input", () => { r.login = loginInput.value.trim(); }); + tdLogin.appendChild(loginInput); + tr.appendChild(tdLogin); + // Jours d'absence récurrente — v2026.5.40 : du lundi (1) au // vendredi (5), pas de samedi/dimanche (jamais utilisés au DGNSI). const tdAbs = document.createElement("td"); @@ -3817,64 +3707,32 @@ function renderAdminSectionTeam(container, cfg, saveFn) { // v2026.5.48 — onglet « Email » : l'email proposé après un // déplacement (option + domaine) et son modèle. Les ADRESSES des techniciens -// sont dans l'onglet Équipe (colonne Email + bouton Récupérer — retour -// demande FroSteel). Textes courts, légende des variables AU-DESSUS des champs +// sont dans l'onglet Équipe (colonne Email + bouton Récupérer). Textes courts, légende des variables AU-DESSUS des champs // Sujet/Message, en colonnes logiques (MOVE_EMAIL_VARIABLE_GROUPES). Chaque // bouton « Enregistrer » de l'onglet sauve tout l'onglet (jamais deux // boutons à périmètres disjoints dans une même vue). -function renderAdminSectionEmail(container, cfg, saveFn) { - const h = document.createElement("h3"); - h.textContent = "Email"; - h.className = "admin-section-title"; - container.appendChild(h); +// v2026.5.49 — rend un bloc « modèle d'email » complet : légende de variables +// cliquables (insertion au curseur), Sujet, Message, aperçu en direct sur un +// exemple fictif, boutons Enregistrer / Rétablir. Factorisé pour servir le +// modèle de DÉPLACEMENT et celui d'ANNULATION — ils ne diffèrent que par leurs +// variables, leurs valeurs par défaut et leurs clés de config. +// spec : { titre, subjectKey, bodyKey, defaultSubject, defaultBody, variables, +// groupes, exemple }. Enregistrer recolte dans cfg puis appelle saveFn. +// v2026.5.49 — style partagé des boîtes de la section Email (Options, modèle +// déplacement, modèle annulation) : une seule source (avant : dupliqué par le +// split du rendu). +const _ADMIN_BOX_STYLE = "border:1px solid var(--border, #8884); border-radius:10px; " + + "padding:14px 16px 16px; margin:8px 0 18px; max-width:680px;"; - const desc = document.createElement("p"); - desc.className = "admin-section-desc"; - desc.textContent = "Email proposé après un déplacement. Rien ne part automatiquement : " + - "l'extension ouvre un brouillon, vous l'envoyez."; - container.appendChild(desc); +function _renderBlocModeleEmail(container, cfg, saveFn, spec) { + const titre = document.createElement("h3"); + titre.className = "admin-section-title"; + titre.style.marginTop = "14px"; + titre.textContent = spec.titre; + container.appendChild(titre); - const BOX_STYLE = "border:1px solid var(--border, #8884); border-radius:10px; " + - "padding:14px 16px 16px; margin:8px 0 18px; max-width:680px;"; - const mkTitre = (txt) => { - const t = document.createElement("h3"); - t.className = "admin-section-title"; - t.style.marginTop = "14px"; - t.textContent = txt; - container.appendChild(t); - return t; - }; - - // ── Bloc 1 : option + domaine ──────────────────────────────────────────── - mkTitre("1. Options"); - const optBox = document.createElement("div"); - optBox.style.cssText = BOX_STYLE; - container.appendChild(optBox); - - const mailMoveRow = _makeAdminRow( - "Email après un déplacement", - "Ouvre un brouillon pré-rempli après un déplacement vers un autre jour ou un autre technicien." - ); - const mailMoveCb = document.createElement("input"); - mailMoveCb.type = "checkbox"; - mailMoveCb.checked = !!cfg.moveEmailEnabled; - mailMoveCb.addEventListener("change", async () => { - // v2026.5.48 — DANS le panneau : un seul écrivain, la copie cfg - // partagée. Les modals de déplacement passent par _setMoveEmailEnabled. - cfg.moveEmailEnabled = mailMoveCb.checked; - MOVE_EMAIL_ENABLED = mailMoveCb.checked; - await saveAdminConfig(cfg); - showToast("Option enregistrée", mailMoveCb.checked ? "Email activé" : "Email désactivé"); - }); - mailMoveRow.querySelector(".admin-row-control").appendChild(mailMoveCb); - optBox.appendChild(mailMoveRow); - // v2026.5.48 — le « Domaine des adresses » vit dans l'onglet Équipe, à - // côté des emails qu'il sert (demande FroSteel). - - // ── Bloc 2 : modèle de l'email ─────────────────────────────────────────── - mkTitre("2. Modèle de l'email"); const modelBox = document.createElement("div"); - modelBox.style.cssText = BOX_STYLE; + modelBox.style.cssText = _ADMIN_BOX_STYLE; container.appendChild(modelBox); const modelDesc = document.createElement("p"); @@ -3884,8 +3742,7 @@ function renderAdminSectionEmail(container, cfg, saveFn) { "variable est vide est retirée du message."; modelBox.appendChild(modelDesc); - // v2026.5.48 — la légende AVANT les champs (demande FroSteel), en - // colonnes logiques ; la description de chaque variable est en infobulle. + // légende AVANT les champs, en colonnes logiques ; description en infobulle. let insererVariable = null; // branchée plus bas (les champs n'existent pas encore) const varsBox = document.createElement("div"); varsBox.className = "admin-email-variables"; @@ -3897,21 +3754,20 @@ function renderAdminSectionEmail(container, cfg, saveFn) { varsBox.appendChild(varsTitre); const varsCols = document.createElement("div"); varsCols.style.cssText = "display:flex; gap:18px; flex-wrap:wrap;"; - const DESC_VAR = new Map(MOVE_EMAIL_VARIABLES); - for (const [titreGroupe, nomsVars] of MOVE_EMAIL_VARIABLE_GROUPES) { + const DESC_VAR = new Map(spec.variables); + for (const [titreGroupe, nomsVars] of spec.groupes) { const col = document.createElement("div"); col.className = "admin-var-groupe"; col.style.cssText = "display:flex; flex-direction:column; gap:4px; min-width:150px;"; - const titre = document.createElement("div"); - titre.textContent = titreGroupe; - titre.style.cssText = "font-size:11px; font-weight:700; text-transform:uppercase; " + + const t = document.createElement("div"); + t.textContent = titreGroupe; + t.style.cssText = "font-size:11px; font-weight:700; text-transform:uppercase; " + "letter-spacing:.04em; color:var(--text-faint, inherit); margin-bottom:2px;"; - col.appendChild(titre); + col.appendChild(t); for (const nomVar of nomsVars) { const chip = document.createElement("button"); chip.type = "button"; - // v2026.5.48 — style et interactivité (survol/appui) dans - // viewer.css : l'inline empêchait :hover de changer la bordure. + // style et interactivité (survol/appui) dans viewer.css. chip.className = "admin-var-chip"; chip.textContent = "{" + nomVar + "}"; chip.title = DESC_VAR.get(nomVar) || ""; @@ -3937,7 +3793,7 @@ function renderAdminSectionEmail(container, cfg, saveFn) { subjInput.type = "text"; subjInput.className = "modal-form-input admin-email-subject"; subjInput.style.cssText = "width:100%; display:block;"; - subjInput.value = String(cfg.moveEmailSubject || "").trim() ? cfg.moveEmailSubject : DEFAULT_MOVE_EMAIL_SUBJECT; + subjInput.value = String(cfg[spec.subjectKey] || "").trim() ? cfg[spec.subjectKey] : spec.defaultSubject; modelBox.appendChild(subjInput); _mkModelLbl("Message"); @@ -3945,22 +3801,20 @@ function renderAdminSectionEmail(container, cfg, saveFn) { bodyArea.className = "modal-form-input"; bodyArea.rows = 10; bodyArea.style.cssText = "width:100%; display:block; resize:vertical; font-family:inherit;"; - bodyArea.value = String(cfg.moveEmailBody || "").trim() ? cfg.moveEmailBody : DEFAULT_MOVE_EMAIL_BODY; + bodyArea.value = String(cfg[spec.bodyKey] || "").trim() ? cfg[spec.bodyKey] : spec.defaultBody; modelBox.appendChild(bodyArea); - // v2026.5.48 — recopie sujet/message dans cfg (repli sur les modèles de - // base si un champ est vidé). Le domaine se règle dans l'onglet Équipe (R18). - function recolteOptionsEmail() { - cfg.moveEmailSubject = subjInput.value.trim() ? subjInput.value : DEFAULT_MOVE_EMAIL_SUBJECT; - cfg.moveEmailBody = bodyArea.value.trim() ? bodyArea.value.replace(/\r\n/g, "\n") : DEFAULT_MOVE_EMAIL_BODY; - subjInput.value = cfg.moveEmailSubject; - bodyArea.value = cfg.moveEmailBody; + // recopie sujet/message dans cfg (repli sur le modèle de base si un champ vidé). + function recolte() { + cfg[spec.subjectKey] = subjInput.value.trim() ? subjInput.value : spec.defaultSubject; + cfg[spec.bodyKey] = bodyArea.value.trim() ? bodyArea.value.replace(/\r\n/g, "\n") : spec.defaultBody; + subjInput.value = cfg[spec.subjectKey]; + bodyArea.value = cfg[spec.bodyKey]; majApercu(); } - // v2026.5.48 — insertion à l'endroit du curseur, dans le dernier champ - // utilisé (Sujet ou Message). mousedown neutralisé sur les puces pour que le - // champ garde focus et curseur. Champ jamais focalisé → insertion en fin. + // insertion à l'endroit du curseur, dans le dernier champ utilisé (Sujet ou + // Message). mousedown neutralisé sur les puces pour garder focus et curseur. let dernierChamp = bodyArea; subjInput.addEventListener("focus", () => { dernierChamp = subjInput; }); bodyArea.addEventListener("focus", () => { dernierChamp = bodyArea; }); @@ -3977,18 +3831,6 @@ function renderAdminSectionEmail(container, cfg, saveFn) { majApercu(); }; - // v2026.5.48 — APERÇU en direct sur un exemple FICTIF. - const EXEMPLE_APERCU = { - numero: "S2602407_00233", // v2026.5.48 — vrai format EV (demande FroSteel) - ancien_tech: "Alpha, Anna", - nouveau_tech: "Bravo, Boris", - ancienne_date: "21/07/2026", - nouvelle_date: "22/07/2026", - ancienne_heure_debut: "09:00", ancienne_heure_fin: "11:00", - nouvelle_heure_debut: "14:00", nouvelle_heure_fin: "16:00", - contact: "Contact, Test — 021 000 00 75", - lieu: "Lausanne / Pl. de l'Exemple 10" - }; const apercuTitre = document.createElement("div"); apercuTitre.className = "admin-section-desc"; apercuTitre.style.cssText = "margin:14px 0 4px;"; @@ -4009,9 +3851,9 @@ function renderAdminSectionEmail(container, cfg, saveFn) { modelBox.appendChild(apercuBox); function majApercu() { apercuSujet.textContent = "Sujet : " + _moveEmailRempli( - subjInput.value.trim() ? subjInput.value : DEFAULT_MOVE_EMAIL_SUBJECT, EXEMPLE_APERCU); + subjInput.value.trim() ? subjInput.value : spec.defaultSubject, spec.exemple); apercuCorps.textContent = _moveEmailRempli( - bodyArea.value.trim() ? bodyArea.value : DEFAULT_MOVE_EMAIL_BODY, EXEMPLE_APERCU); + bodyArea.value.trim() ? bodyArea.value : spec.defaultBody, spec.exemple); } subjInput.addEventListener("input", majApercu); bodyArea.addEventListener("input", majApercu); @@ -4024,7 +3866,7 @@ function renderAdminSectionEmail(container, cfg, saveFn) { modelSaveBtn.className = "btn btn-primary"; modelSaveBtn.textContent = "💾 Enregistrer"; modelSaveBtn.addEventListener("click", () => { - recolteOptionsEmail(); + recolte(); saveFn(); }); const modelResetBtn = document.createElement("button"); @@ -4032,10 +3874,10 @@ function renderAdminSectionEmail(container, cfg, saveFn) { modelResetBtn.className = "btn btn-secondary"; modelResetBtn.style.marginLeft = "10px"; modelResetBtn.textContent = "Rétablir le modèle de base"; - // v2026.5.48 — remplit les champs SANS sauver. + // remplit les champs SANS sauver. modelResetBtn.addEventListener("click", () => { - subjInput.value = DEFAULT_MOVE_EMAIL_SUBJECT; - bodyArea.value = DEFAULT_MOVE_EMAIL_BODY; + subjInput.value = spec.defaultSubject; + bodyArea.value = spec.defaultBody; majApercu(); showToast("Modèle de base rempli", "Clique Enregistrer pour le garder"); }); @@ -4044,6 +3886,211 @@ function renderAdminSectionEmail(container, cfg, saveFn) { modelBox.appendChild(modelBtns); } +// v2026.5.49 — aperçu en direct des adresses en COPIE (CC) sous le champ : +// on parse la saisie EXACTEMENT comme _ccList (mêmes séparateurs ; , espaces, +// validation _emailValide, dédoublonnage), et on affiche une puce par adresse +// retenue — pour VOIR ce qui sera réellement ajouté en copie. Les fragments +// invalides sont montrés à part (puce barrée) pour ne pas laisser croire qu'ils +// comptent. Purement visuel (ne touche ni cfg ni EMAIL_CC). +function _majApercuCc(apercuEl, value) { + apercuEl.textContent = ""; + const vus = new Set(); + const valides = []; + const invalides = []; + for (const brut of String(value || "").split(/[;,\s]+/)) { + const e = brut.trim(); + if (!e) continue; + if (!_emailValide(e)) { invalides.push(e); continue; } + const k = e.toLowerCase(); + if (vus.has(k)) continue; + vus.add(k); + valides.push(e); + } + if (!valides.length && !invalides.length) { + const vide = document.createElement("span"); + vide.className = "cc-apercu-vide"; + vide.textContent = "Personne en copie pour l'instant."; + apercuEl.appendChild(vide); + return; + } + const intro = document.createElement("span"); + intro.className = "cc-apercu-intro"; + intro.textContent = valides.length === 0 ? "Aucune adresse valide : " + : valides.length === 1 ? "1 adresse en copie : " + : valides.length + " adresses en copie : "; + apercuEl.appendChild(intro); + for (const e of valides) { + const chip = document.createElement("span"); + chip.className = "cc-apercu-chip"; + chip.textContent = e; + apercuEl.appendChild(chip); + } + for (const e of invalides) { + const chip = document.createElement("span"); + chip.className = "cc-apercu-chip cc-apercu-chip-invalide"; + chip.textContent = e; + chip.title = "Adresse invalide : elle ne sera PAS ajoutée en copie."; + apercuEl.appendChild(chip); + } +} + +// v2026.5.49 — une SECTION Email complète pour UN type (déplacement OU +// annulation/suppression) : un titre de type, la case « activer », puis le +// modèle. Regroupe tout ce qui concerne un type au MÊME endroit — avant, les +// deux cases vivaient dans un bloc « Options » commun, séparé des deux modèles, +// ce qui mélangeait déplacement et annulation. opts.onToggle applique la valeur +// au cfg ET à la variable globale du type (MOVE_/DELETE_EMAIL_ENABLED). +function _renderEmailTypeSection(container, cfg, saveFn, opts) { + const section = document.createElement("div"); + section.className = "admin-email-section"; + container.appendChild(section); + + const titre = document.createElement("h3"); + titre.className = "admin-section-title admin-email-section-titre"; + titre.textContent = opts.titre; + section.appendChild(titre); + + const optBox = document.createElement("div"); + optBox.style.cssText = _ADMIN_BOX_STYLE; + section.appendChild(optBox); + + const row = _makeAdminRow(opts.enableLabel, opts.enableDesc); + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = !!opts.enabled; + cb.addEventListener("change", async () => { + opts.onToggle(cb.checked); + await saveAdminConfig(cfg); + showToast("Option enregistrée", cb.checked ? opts.onMsg : opts.offMsg); + }); + row.querySelector(".admin-row-control").appendChild(cb); + optBox.appendChild(row); + + _renderBlocModeleEmail(section, cfg, saveFn, opts.modele); +} + +function renderAdminSectionEmail(container, cfg, saveFn) { + const h = document.createElement("h3"); + h.textContent = "Email"; + h.className = "admin-section-title"; + container.appendChild(h); + + const desc = document.createElement("p"); + desc.className = "admin-section-desc"; + desc.textContent = "Emails proposés après un déplacement ou une annulation, et " + + "destinataires en copie. Rien ne part automatiquement : l'extension ouvre un " + + "brouillon, vous l'envoyez."; + container.appendChild(desc); + + // ── Bloc commun : adresses en COPIE (CC) ───────────────────────────────── + // v2026.5.49 — le CC est commun aux DEUX types (déplacement + + // annulation), il vit dans son propre bloc au-dessus des deux sections. Sous + // le champ, un APERÇU en direct montre les adresses réellement retenues + // (mêmes règles que _ccList) : on VOIT ce qui sera ajouté en copie, invalides + // signalées. Pas de valeur par défaut (DEFAULT_EMAIL_CC reste vide). + const ccTitre = document.createElement("h3"); + ccTitre.className = "admin-section-title"; + ccTitre.style.marginTop = "14px"; + ccTitre.textContent = "Copie (CC)"; + container.appendChild(ccTitre); + const ccBox = document.createElement("div"); + ccBox.style.cssText = _ADMIN_BOX_STYLE; + container.appendChild(ccBox); + + const ccDesc = document.createElement("p"); + ccDesc.className = "admin-section-desc"; + ccDesc.style.cssText = "margin:0 0 6px;"; + ccDesc.textContent = "Ces adresses reçoivent une copie de CHAQUE brouillon " + + "(déplacement et annulation). Séparez-les par « ; ». Videz le champ pour n'ajouter personne."; + ccBox.appendChild(ccDesc); + const ccInput = document.createElement("input"); + ccInput.type = "text"; + ccInput.className = "modal-form-input"; + ccInput.style.cssText = "width:100%; display:block;"; + ccInput.value = typeof cfg.emailCc === "string" ? cfg.emailCc : DEFAULT_EMAIL_CC; + ccBox.appendChild(ccInput); + + // aperçu en direct des adresses retenues : mis à jour à chaque frappe. + const ccApercu = document.createElement("div"); + ccApercu.className = "cc-apercu"; + ccBox.appendChild(ccApercu); + _majApercuCc(ccApercu, ccInput.value); + ccInput.addEventListener("input", () => _majApercuCc(ccApercu, ccInput.value)); + + ccInput.addEventListener("change", async () => { + cfg.emailCc = ccInput.value; + EMAIL_CC = ccInput.value; + _majApercuCc(ccApercu, ccInput.value); + const ok = await saveAdminConfig(cfg); + const cc = _ccList([]); + showToast(ok ? "Copie enregistrée" : "Copie non enregistrée", + ok ? (cc.length ? "En copie : " + cc.join(", ") : "Personne en copie") + : "L'écriture des paramètres a échoué"); + }); + + // ── Section 1 : email de DÉPLACEMENT (case activer + modèle) ───────────── + // exemple FICTIF pour l'aperçu (n° au vrai format EV). + const EXEMPLE_APERCU_MOVE = { + numero: "S2602407_00233", + ancien_tech: "Alpha, Anna", + nouveau_tech: "Bravo, Boris", + ancienne_date: "21/07/2026", + nouvelle_date: "22/07/2026", + ancienne_heure_debut: "09:00", ancienne_heure_fin: "11:00", + nouvelle_heure_debut: "14:00", nouvelle_heure_fin: "16:00", + contact: "Contact, Test — 021 000 00 75", + lieu: "Lausanne / Pl. de l'Exemple 10" + }; + _renderEmailTypeSection(container, cfg, saveFn, { + titre: "Email de déplacement", + enableLabel: "Activer l'email de déplacement", + enableDesc: "Ouvre un brouillon pré-rempli après un déplacement vers un autre jour ou un autre technicien.", + enabled: !!cfg.moveEmailEnabled, + onToggle: (v) => { cfg.moveEmailEnabled = v; MOVE_EMAIL_ENABLED = v; }, + onMsg: "Email de déplacement activé", + offMsg: "Email de déplacement désactivé", + modele: { + titre: "Modèle", + subjectKey: "moveEmailSubject", + bodyKey: "moveEmailBody", + defaultSubject: DEFAULT_MOVE_EMAIL_SUBJECT, + defaultBody: DEFAULT_MOVE_EMAIL_BODY, + variables: MOVE_EMAIL_VARIABLES, + groupes: MOVE_EMAIL_VARIABLE_GROUPES, + exemple: EXEMPLE_APERCU_MOVE + } + }); + + // ── Section 2 : email d'ANNULATION / SUPPRESSION (case activer + modèle) ─ + const EXEMPLE_APERCU_DELETE = { + numero: "S2602407_00233", + technicien: "Alpha, Anna", + date: "21/07/2026", + heure_debut: "09:00", heure_fin: "11:00", + contact: "Contact, Test — 021 000 00 75", + lieu: "Lausanne / Pl. de l'Exemple 10" + }; + _renderEmailTypeSection(container, cfg, saveFn, { + titre: "Email d'annulation / suppression", + enableLabel: "Activer l'email d'annulation", + enableDesc: "Ouvre un brouillon prévenant le technicien quand vous supprimez une de ses réservations.", + enabled: !!cfg.deleteEmailEnabled, + onToggle: (v) => { cfg.deleteEmailEnabled = v; DELETE_EMAIL_ENABLED = v; }, + onMsg: "Email d'annulation activé", + offMsg: "Email d'annulation désactivé", + modele: { + titre: "Modèle", + subjectKey: "deleteEmailSubject", + bodyKey: "deleteEmailBody", + defaultSubject: DEFAULT_DELETE_EMAIL_SUBJECT, + defaultBody: DEFAULT_DELETE_EMAIL_BODY, + variables: DELETE_EMAIL_VARIABLES, + groupes: DELETE_EMAIL_VARIABLE_GROUPES, + exemple: EXEMPLE_APERCU_DELETE + } + }); +} + // v2026.5.41 : Section EasyVista — édition manuelle des domaines (interne / // externe). Les valeurs par défaut restent hardcodées (DEFAULT_EV_ORIGINS), // mais l'utilisateur peut les remplacer ou en ajouter d'autres si EasyVista @@ -4922,6 +4969,9 @@ function renderAdminSectionImpersonate(container, cfg, saveFn) { label: "Convertir", variant: "primary", action: async () => { + // v2026.5.49 — cet import LIT le dossier partagé : exige une session + // (blocage « cache partagé seulement si connecté »). + if (!_exigeSession("importer l'ancien cache")) return; convBtn.disabled = true; convBtn.textContent = "Conversion…"; convReport.textContent = "Lecture de la liste des jours…"; @@ -5739,8 +5789,7 @@ function renderAdminSectionSync(container, cfg, saveFn) { intro.style.cssText = SUB_HINT_STYLE; container.appendChild(intro); - // v2026.5.47 — panneau réorganisé en SECTIONS logiques (retour FroSteel : - // « c'était pas clair, Re-vérifier n'était pas au bon endroit »). Chaque action + // v2026.5.47 — panneau réorganisé en SECTIONS logiques. Chaque action // vit dans la section qui la concerne : // 1. État → grille + « Re-vérifier l'état » (c'est lui qu'il rafraîchit) // 2. Dossier → le chemin du partage @@ -6536,6 +6585,13 @@ function showAbsenceModal(preselectTechId, opts = {}) { * Appelle le background script qui fait le POST avec la bonne session. */ async function submitAbsence(opts) { + // v2026.5.49 — aucune écriture EV (création réservation/absence/pompier, ou + // édition qui recrée) sans session : cohérent avec le blocage déplacement/ + // suppression. Les appelants attrapent déjà l'exception. + if (!state.session) { + showToast("Connexion requise", "Connectez-vous à EasyVista pour créer ou modifier."); + throw new Error("no_session"); + } const resp = await sendMessage({ type: "submitAbsence", techIds: opts.techIds, @@ -6647,6 +6703,39 @@ async function _attachReservationMeta(techIds, isoDate, startHHMM, meta, opts = } } +// v2026.5.49 — #10(a) : propagation de la méta d'une réservation créée pour +// un AUTRE jour que celui affiché. Le carnet (_resMetaStore) est LOCAL et n'est +// jamais partagé ; il n'est consommé (→ gravé dans l'iv → poussé au dossier) que +// par storeApplyFresh DU JOUR concerné. Si la résa vise un autre jour, ce jour +// n'est ingéré nulle part → la méta reste piégée chez le créateur et n'atteint +// jamais les autres postes (la purge TTL 7 j peut même la détruire). On CONVERGE +// donc ce jour EN SILENCE (fetch EV → ingestion, qui consomme le carnet → push +// dossier), sans toucher l'écran : jour non affiché, storeApplyFresh est pur et +// storeSyncFolder ne réécrit state / ne re-rend que pour le jour affiché. +// Best-effort : un échec ne compromet pas la réservation déjà créée. +async function _convergeReservationDay(iso) { + try { + // le jour affiché est déjà pris en charge par loadForDate + _attachReservationMeta. + if (!state.session || _jourAffiche(iso)) return; + // v2026.5.49 (revue) — fetch PUR du jour de fond : surtout PAS + // fetchPlanningForDate, qui est COUPLÉE À L'UI (setRefreshing, bannières, + // state.session=null sur corps vide/anti-scraping) et repeindrait l'écran du + // jour AFFICHÉ (fausse « session expirée », session perdue). Ici on ne veut + // qu'ingérer un autre jour EN SILENCE : sur le moindre échec (corps vide, + // session, EV injoignable) on repart sans rien toucher — la résa est déjà + // créée, la méta sera consommée à la prochaine ouverture de ce jour. + const resp = await sendMessage({ type: "fetchPlanning", session: state.session, unixDate: isoToUnixDate(iso) }); + if (!resp || !resp.ok || !resp.xml || resp.xml.length < 20) return; + const day0 = await storeLoad(iso); + const applied = storeApplyFresh(day0, parsePlanningXml(resp.xml, iso), iso); // consomme le carnet → grave la méta + await storeSave(iso, applied.day); // persiste + programme le push dossier + await storeSyncFolder(iso).catch(() => {}); // fusion/écriture immédiate au partage + LOG.info("reservation", "jour cible convergé — méta propagée au dossier", { iso }); + } catch (e) { + LOG.warn("reservation", "convergence du jour cible échouée (ignoré)", { iso, err: e && e.message }); + } +} + // v2026.1.6 — FRS : VRAIE récupération EV par n° de DS (même logique qu'une // intervention planifiée : basicAutoComplete + redirectHeader → formLink → // fetch fiche). En arrière-plan + logs détaillés. @@ -6881,6 +6970,12 @@ function showReservationModal(preselectTechId, opts = {}) { // jour et écrase l'écran courant. if (sd === dateAtOpen) { await _attachReservationMeta(techIds, sd, st, _meta); + } else { + // v2026.5.49 — #10(a) : la résa vise un AUTRE jour → converger ce + // jour en arrière-plan pour que sa méta atteigne le dossier partagé, + // sinon elle reste piégée dans le carnet local du créateur (les autres + // postes ne la voient jamais). Fire-and-forget : n'allonge pas la création. + _convergeReservationDay(sd); } } } catch (err) { @@ -7404,6 +7499,12 @@ function showDouchetteModal(preselectTechId) { * Retourne { okCount, errors: [{techId, error}], confirmed }. */ async function submitDouchette(techIds, dateIso) { + // v2026.5.49 — aucune action EV sans session (cohérent avec le blocage + // déplacement/suppression/création). L'appelant attrape déjà l'exception. + if (!state.session) { + showToast("Connexion requise", "Connectez-vous à EasyVista pour envoyer sur la douchette."); + throw new Error("no_session"); + } const resp = await sendMessage({ type: "submitDouchette", techIds: techIds, @@ -7592,10 +7693,28 @@ function ivKey(iv) { // sans lui, une réservation recyclée d'un AUTRE tech au même créneau // matchait byKey et héritait de l'enregistrement (méta comprise), et deux // réservations de techs différents liées à la même intervention fusionnaient. - const slot = (iv.type === "AL-Reservation" || iv.type === "AL-Absence") + // v2026.5.49 — interventions bi-technicien (#9) : une même intervention + // (même request_id) affectée à 2 techniciens produit 2 actions EV (done_by_id + // distinct). Avec un slot VIDE pour les interventions, les 2 collisionnaient + // sur "q:requestId" → dayDedupeIvs en fusionnait une et l'intervention + // n'apparaissait que sur UN tech. On ajoute le tech à l'identité — mais PAS + // l'heure/la date (contrairement aux résa/absences dont le créneau EST + // l'identité) : l'heure d'une intervention change au drag et doit garder la + // même identité. Une intervention sans tech garde un slot vide (inchangé). + const isReserv = (iv.type === "AL-Reservation" || iv.type === "AL-Absence"); + const slot = isReserv ? "@" + (iv.techId || "") + "|" + (iv.startDate || "") + "T" + (iv.startTime || "") - : ""; - if (iv.requestId) return "q:" + iv.requestId + slot; + : (iv.techId ? "@" + iv.techId : ""); + // v2026.5.49 — un même tech peut avoir DEUX actions dans la MÊME + // intervention le même jour (même request_id + même tech, actionId distincts) : + // sans discriminant, les deux collisionnaient sur "q:requestId@tech" et une + // seule s'affichait. On ajoute l'actionId — SEULEMENT au cas request_id (au + // cas "a:" l'actionId EST déjà la clé). Une action garde son actionId au drag + // (seule l'heure change) → identité stable ; un éventuel recyclage d'actionId + // inter-fetch est rattrapé par byRef dans storeApplyFresh (ref stable), sans + // re-fusionner les 2 actions distinctes (garde seenIdx sur byRef). + const actDisc = (!isReserv && iv.actionId != null) ? "#" + iv.actionId : ""; + if (iv.requestId) return "q:" + iv.requestId + slot + actDisc; if (iv.actionId !== undefined && iv.actionId !== null) return "a:" + iv.actionId + slot; if (iv.ref) return "r:" + iv.ref + slot; return null; @@ -7668,6 +7787,19 @@ function _ivStampDisplayTimes(iv, isoDate) { // (infobulle.lieu, xhr2). En cas de désaccord → lieu en ROUGE + explication // au survol. Comparaison tolérante : casse, accents, tirets/ponctuation et // espaces ignorés. +// v2026.5.49 — #2 : EasyVista renvoie parfois des octets non-UTF-8 isolés +// (NBSP latin-1, accent mal encodé) qui, décodés en UTF-8 par background.js, +// deviennent U+FFFD (le « ? » collé à « rue… ») ou des caractères invisibles. +// On les retire à l'ENTRÉE (adresses, emails, libellés) pour des valeurs justes, +// SANS toucher aux lettres accentuées (é à ô ç…), aux tabulations ni aux sauts de +// ligne. NBSP / narrow-NBSP → espace normal (fréquents dans les adresses FR). +function scrubEvText(s) { + if (typeof s !== "string") return s; + return s + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u200B-\u200F\uFEFF\uFFFD]/g, "") + .replace(/[\u00A0\u202F]/g, " "); +} + function _extractFicheLocation(html) { if (!html) return null; let m = /"label":"Localisation"[^}]{0,600}?"value":"((?:[^"\\]|\\.)*)"/.exec(html); @@ -7675,7 +7807,7 @@ function _extractFicheLocation(html) { if (!m || !m[1]) return null; let v = m[1]; try { v = JSON.parse('"' + v + '"'); } catch (e) { v = v.replace(/\\\//g, "/"); } - v = String(v).trim(); + v = scrubEvText(String(v)).trim(); // v2026.5.49 — #2 : sans caractères fantômes EV return v || null; } @@ -7844,12 +7976,32 @@ function ivMerge(a, b) { return w; } +// v2026.5.49 — #13 (minimisation nLPD) : pour une ABSENCE (donnée de santé), +// le NOM de qui l'a saisie est une PII superflue — jamais affichée (une absence +// se montre par sa CATÉGORIE/couleur via absenceCategory, pas par ce libellé +// brut). On la retire de ce qui est PERSISTÉ et PARTAGÉ : la partie +// « / Créé par : Nom » du libellé EasyVista et le champ reservationCreator. Le +// motif et la catégorie (nécessaires à la couleur) restent. N'agit que sur la +// COPIE sérialisée (ivStrip) : l'objet en mémoire garde son libellé frais et la +// session le repeuple au fetch EV suivant (storeApplyFresh). reservationCreator +// ne sert qu'aux RÉSERVATIONS (rapprochement re-planif _replanCompatibles, +// affichage « qui a réservé »), jamais aux absences → sûr à retirer ici. +function _ivMinimiseSante(c) { + if (!c || c.type !== "AL-Absence") return c; + if (typeof c.label === "string") { + const i = c.label.search(/\s*\/\s*Créé par\s*:/i); + if (i >= 0) c.label = c.label.slice(0, i).trim(); + } + if (c.reservationCreator) delete c.reservationCreator; + return c; +} + /** Copie persistable (sans transitoires) ; forFolder retire aussi la plomberie session. */ function ivStrip(iv, forFolder) { const c = { ...iv }; for (const f of IV_TRANSIENT_FIELDS) delete c[f]; if (forFolder) for (const f of IV_FOLDER_DROP_FIELDS) delete c[f]; - return c; + return _ivMinimiseSante(c); } /** Dédoublonne une liste d'ivs par identité stable (fusion ivMerge) + purge les tombstones expirés. */ @@ -8066,7 +8218,15 @@ function storeApplyFresh(day, freshTechs, isoDate) { const k = ivKey(r); if (k && !byKey.has(k)) byKey.set(k, i); if (r.ref && !byRef.has(r.ref)) byRef.set(r.ref, i); - if (r.type === "AL-Absence" || r.type === "AL-Reservation") { + // v2026.5.49 — #10(b) : ne PAS indexer les RÉSERVATIONS mortes + // (tombstone) dans bySig. Sinon une NOUVELLE réservation au même créneau + // matchait par signature une réservation supprimée, la faisait « revivre » + // avec son ancienne méta, et la méta fraîche du carnet était ignorée + // (_resMetaConsumeInto voit le record déjà pourvu). Une résa disparue qui + // revient vraiment reste reconnue par ivKey/byRef ; à défaut elle est + // (correctement) traitée comme une nouvelle occurrence. Les absences (sans + // méta) gardent leur comportement. + if (r.type === "AL-Absence" || (r.type === "AL-Reservation" && !ivIsDead(r))) { const s = _ivAbsenceSig(r, r.techId); if (!bySig.has(s)) bySig.set(s, i); } @@ -8088,7 +8248,22 @@ function storeApplyFresh(day, freshTechs, isoDate) { } const k = ivKey(iv); let idx = (k && byKey.has(k)) ? byKey.get(k) : undefined; - if (idx === undefined && iv.ref && byRef.has(iv.ref)) idx = byRef.get(iv.ref); + // v2026.5.49 — un enregistrement DÉJÀ apparié CE fetch ne se re-fusionne + // pas : _ivApplyLiveFields mute l'enregistrement en place (donc périme la map + // byKey construite avant la boucle). Sans cette garde, si un byRef s'est + // emparé de l'index avant, la clé byKey d'une AUTRE action retombe dessus et + // écrase → une des deux actions disparaît (selon l'ordre des heures). Deux + // ivs fraîches distinctes n'ont jamais la même clé byKey → sûr. + if (idx !== undefined && seenIdx.has(idx)) idx = undefined; + // v2026.5.49 — byRef ne re-rapproche PAS un enregistrement store DÉJÀ + // apparié ce fetch : deux actions du même tech partagent le ref (même + // intervention) mais sont deux interventions DISTINCTES (chacune son + // actionId dans ivKey) → la 2e doit être AJOUTÉE, pas fusionnée dans la 1re. + // Le rapprochement d'une seule action re-planifiée (ref stable) reste couvert. + if (idx === undefined && iv.ref && byRef.has(iv.ref)) { + const cand = byRef.get(iv.ref); + if (!seenIdx.has(cand)) idx = cand; + } if (idx === undefined && (iv.type === "AL-Absence" || iv.type === "AL-Reservation")) { const s = _ivAbsenceSig(iv, tech.id); if (bySig.has(s)) idx = bySig.get(s); @@ -8249,6 +8424,13 @@ async function storeMarkDeleted(isoDate, actionId) { if (String(rec.actionId) === String(actionId)) { rec.deletedAt = Date.now(); ivSetVerdict(rec, "gone", "user", "retirée manuellement du planning"); + // v2026.5.49 — #10(b) : purger l'éventuelle pose carnet NON + // consommée de cette réservation (indexée tech+date+créneau), pour + // qu'une nouvelle réservation au même créneau n'en hérite pas. + if (rec.type === "AL-Reservation") { + const ck = _resMetaKey(String(rec.techId), rec.startDate, rec.startTime, rec.endTime); + if (_resMetaStore[ck]) { delete _resMetaStore[ck]; _resMetaDirty = true; _resMetaPersist(); } + } found = true; } } @@ -8286,6 +8468,22 @@ function storeMarkMoved(day, rec, change) { } } +// v2026.5.49 — #13 : verrou de session à l'affichage. Le ticket exige un +// cache « accessible qu'aux personnes connectées ». Sans session EasyVista +// valide, la donnée de SANTÉ (absence « maladie ») du cache n'est PAS révélée : +// on projette une COPIE neutralisée (« Absent » générique, couleur neutre) — et +// JAMAIS l'iv du store, partagée par référence (cf. storeProject). La détection +// d'absence (isTechAbsent/isPompier) ne dépend pas d'absenceCategory : le tech +// reste « absent », seul le MOTIF santé est masqué. Le détail revient à la +// reconnexion. Seule « maladie » est une donnée de santé ; Congé/Pompier restent +// (le conseil : Congé = admin, Pompier = opérationnel). Complément de l'ACL +// groupe AD sur le partage (infra), qui protège l'accès DIRECT au fichier. +function _ivMasquerSanteSansSession(iv) { + if (state.session) return iv; + if (!iv || iv.type !== "AL-Absence" || iv.absenceCategory !== "maladie") return iv; + return { ...iv, absenceCategory: null, label: "Absent", reservationCreator: undefined }; +} + // ── Projection rendu (LE seul filtre d'affichage) ──────────────────────────── /** @@ -8336,7 +8534,7 @@ function storeProject(day, isoDate) { const t = byTech.get(String(iv.techId)); if (!t) continue; // tech plus dans l'équipe du jour → non rendu (données conservées) _ivStampDisplayTimes(iv, isoDate || day.date); // heures d'affichage du jour (multi-jours) - t.interventions.push(iv); + t.interventions.push(_ivMasquerSanteSansSession(iv)); } for (const t of techs) { t.interventions.sort((a, b) => (_ivShownStart(a)).localeCompare(_ivShownStart(b))); @@ -8762,8 +8960,22 @@ function scheduleFolderPush(isoDate) { * le flash d'une iv parquée/déplacée qui réapparaissait via la sync. * @author FroSteel */ +// v2026.5.49 — blocage ferme lié à la connexion : les opérations sensibles +// (lecture/écriture du dossier PARTAGÉ, déplacement, suppression) exigent une +// session EasyVista active. Sans session : on ne touche pas au dossier partagé +// (cache LOCAL seulement) et aucune mutation ne part. EasyVista reste +// l'autorité d'authentification (règle 10) ; ceci en est le relais côté UI. +function _exigeSession(quoi) { + if (state.session) return true; + showToast("Connexion requise", "Connectez-vous à EasyVista pour " + quoi + "."); + return false; +} + async function storeSyncFolder(isoDate, opts = {}) { try { + // v2026.5.49 — pas de session EV → on NE touche PAS au dossier partagé + // (ni lecture ni écriture) : cache local seulement. + if (!state.session) return; if (state.networkContext !== "internal") { LOG.info("sync", `synchro dossier ignorée — réseau ${state.networkContext || "inconnu"} (host non sollicité)`); return; @@ -8915,7 +9127,10 @@ async function loadForDate(isoDate, opts = {}) { // le dossier d'abord et on affiche directement les cartes complètes. // (Le commentaire d'ordre de chargement plus haut prévoyait déjà ce cas // « jour sans store local → besoin du dossier », jamais implémenté.) - const _reconcileUpFront = !state.session || !day; + // v2026.5.49 — le dossier partagé n'est réconcilié D'ABORD que si on a une + // session ET pas de jour local (jour présent seulement au partage). SANS + // session, on ne lit PLUS le dossier : cache local seul. + const _reconcileUpFront = !!state.session && !day; if (_reconcileUpFront) { await reconcileCacheForDate(isoDate); if (isRefreshAborted(myToken)) return; // v2026.5.46 — navigation entre-temps @@ -8929,7 +9144,15 @@ async function loadForDate(isoDate, opts = {}) { // bouton « Arrêter » fantôme d'un pipeline mort (session tombée en vol, // pipeline supplanté → son finally ne masque plus le bouton lui-même). showAbortButton(false); - // afficher le store + bannière, plutôt que l'écran "session" + // v2026.5.49 — cache LOCAL seul (pas de dossier hors session). On rend la + // projection du store, PUIS on décide sur le rendu RÉEL : de vraies cartes + // affichées → banderole non bloquante ; rien de réel (aucun cache, ou store + // dont toutes les ivs sont filtrées à la projection : équipe, tombstones) + // → message centré À LA PLACE des cartes. On teste _contenuReelAffiche() + // (post-projection) plutôt que le store brut : un store non vide peut se + // projeter en zéro carte. showSessionNeeded() vide #cards, donc la + // projection vide éventuellement rendue ne subsiste pas (pas de flash : le + // navigateur peint après le tick JS synchrone). if (day) { renderFromData({ techs: storeProject(day, isoDate), @@ -8937,7 +9160,8 @@ async function loadForDate(isoDate, opts = {}) { captureTime: day.savedAt || null, source: "store" }); - showSessionExpiredBanner(); + if (_contenuReelAffiche()) showSessionExpiredBanner(); + else showSessionNeeded(); } else { showSessionNeeded(); } @@ -9133,9 +9357,11 @@ async function fetchPlanningForDate(isoDate) { // une bannière non bloquante en haut, le cache reste visible. // Si rien n'est rendu (1er chargement, pas de cache), on affiche // l'écran plein comme avant. - const hasCacheRendered = - document.getElementById("cards") && - document.getElementById("cards").children.length > 0; + // v2026.5.49 — cache « utile » = de vraies cartes RENDUES à l'écran, pas + // des bulles de tech vides (sinon banderole au-dessus de rien → message + // centré). On teste le rendu réel : couvre le forceRefetch (showLoading a + // vidé #cards → rien de réel → centré) et la projection filtrée. + const hasCacheRendered = _contenuReelAffiche(); if (resp.error === "no_session" || resp.error === "session_expired") { state.session = null; if (hasCacheRendered) { @@ -9201,9 +9427,10 @@ async function fetchPlanningForDate(isoDate) { if (!resp.xml || resp.xml.length < 20) { LOG.warn("viewer", "XML planning vide — session probablement invalide"); state.session = null; - const hasCacheRendered = - document.getElementById("cards") && - document.getElementById("cards").children.length > 0; + // v2026.5.49 — cache « utile » = de vraies cartes RENDUES à l'écran, pas + // des bulles de tech vides (sinon banderole au-dessus de rien → message + // centré). On teste le rendu réel (post-projection, couvre forceRefetch). + const hasCacheRendered = _contenuReelAffiche(); if (hasCacheRendered) { showSessionExpiredBanner(); } else { @@ -9212,6 +9439,21 @@ async function fetchPlanningForDate(isoDate) { return null; } + // v2026.5.49 — la réponse peut être une PAGE DE LOGIN SSO (session morte, + // cookie PHPSESSID encore présent) : 200 + HTML > 20 caractères qui passe le + // garde ci-dessus. parsePlanningXml en tirerait TOUT le roster TEAM en + // bulles de tech vides (byTechId est construit sur TEAM) → au rafraîchissement + // automatique (poll de reconnexion), l'écran affichait des techniciens vides + // + banderole au lieu du message centré « session expirée ». On détecte le + // login (même heuristique que la recherche) et on traite comme session morte. + if (_estPageLogin(resp.xml)) { + LOG.warn("viewer", "réponse planning = page login SSO — session expirée"); + state.session = null; + if (_contenuReelAffiche()) showSessionExpiredBanner(); + else showSessionNeeded(); + return null; + } + // Parser le HTML complet du planning (contient TOUT : ref, catégorie, // contact, lieu, description, formLinks, request_id + checksum) const techs = parsePlanningXml(resp.xml, isoDate); @@ -9296,7 +9538,7 @@ function parsePlanningXml(xml, isoDate) { } function actionNodeToIntervention(node) { - const get = name => node.getAttribute(name) || ""; + const get = name => scrubEvText(node.getAttribute(name) || ""); // v2026.5.49 — #2 : adresses/libellés sans caractères fantômes EV const actionId = get("action_id"); if (!actionId) return null; @@ -9409,7 +9651,7 @@ function actionNodeToIntervention(node) { originalType: actionType, // type brut (pour debug) reservationLabel: reservationLabel, // "Ecrans", "Rollout", etc. reservationCreator: reservationCreator, // "Nom, Prénom" du coordinateur - // méta réservation : jamais dans le XML EV — posée à l'ingestion (R2) + // méta réservation : jamais dans le XML EV — posée à l'ingestion resNumero: "", resVille: "", resRue: "", resCategorie: "", cssClass: cssClass, // v2026.5.48 — le test pompier se fait sur le SUJET (pas le label @@ -11002,7 +11244,9 @@ function cleanHtmlBlock(html) { } function decodeJsonString(s) { - return s + // v2026.5.49 — #2 : scrubEvText nettoie les caractères parasites EV (U+FFFD, + // contrôles, zero-width) sur les valeurs décodées (email, resVille/resRue…). + return scrubEvText(s .replace(/\\r/g, "") .replace(/\\n/g, "\n") .replace(/\\t/g, "\t") @@ -11012,7 +11256,7 @@ function decodeJsonString(s) { .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => { try { return String.fromCharCode(parseInt(hex, 16)); } catch { return _; } - }); + })); } /** @@ -11381,6 +11625,11 @@ function renderFromData(data) { if (typeof _trimMiniGapsForFit === "function") { requestAnimationFrame(() => _trimMiniGapsForFit()); } + // v2026.5.49 — mesurer la hauteur d'en-tête de chaque carte APRÈS layout, + // pour coller la frise (.timeline) juste dessous au scroll (vue classique). + if (typeof _syncCardHeaderHeights === "function") { + requestAnimationFrame(() => _syncCardHeaderHeights()); + } // v2026.5.46 — mise en avant recherche : après chaque rendu, on applique // une éventuelle demande de surbrillance en attente (accrochée au rendu réel // plutôt qu'à un timing deviné). @@ -12110,21 +12359,32 @@ function buildCard(tech, isoDate) { const { dayS, dayE } = _dayRange(); if (startMin == null) startMin = dayS; startMin = Math.max(dayS, Math.min(startMin, dayE - 30)); - const endMin = Math.min(startMin + 60, dayE); + // v2026.5.46 — seules les INTERVENTIONS et RÉSERVATIONS occupent la + // journée : une absence (AL-Absence, récurrente…) ne bloque PAS la pose + // d'une réservation/congé (le menu restait grisé « Journée complète » + // sur un tech absent dont la carte était pourtant vide). + const spans = (tech.interventions || []) + .filter(iv2 => iv2.type === "AL-Intervention" || iv2.type === "AL-Reservation") + .map(iv2 => [timeToMinutes(_ivShownStart(iv2)), timeToMinutes(_ivShownEnd(iv2))]) + .filter(p => p[0] !== null && p[1] !== null && p[1] > p[0]) + .sort((x, y) => x[0] - y[0]); + // v2026.5.49 — la durée proposée s'arrête au prochain rendez-vous, elle + // ne le chevauche plus. En mode exact, le trou sous le curseur porte déjà + // les bornes du créneau libre (mêmes valeurs que la surbrillance de survol) ; + // ailleurs on prend le prochain créneau occupé. + const trou = ev.target.closest(".iv-exact-hole"); + const prochainOccupe = spans.find(p => p[0] >= startMin); + const libreDebut = trou ? parseInt(trou.dataset.startMin, 10) : null; + const libreFin = trou ? parseInt(trou.dataset.endMin, 10) + : (prochainOccupe ? prochainOccupe[0] : null); + const creneau = _creneauPropose(startMin, libreDebut, libreFin); + startMin = creneau.startMin; + const endMin = creneau.endMin; // v2026.5.46 — journée déjà COMPLÈTE (l'union des créneaux affichés // couvre toute la plage) → le menu s'affiche quand même, mais boutons // grisés avec la raison. let disabledReason = null; { - // v2026.5.46 — seules les INTERVENTIONS et RÉSERVATIONS occupent la - // journée : une absence (AL-Absence, récurrente…) ne bloque PAS la pose - // d'une réservation/congé (le menu restait grisé « Journée complète » - // sur un tech absent dont la carte était pourtant vide). - const spans = (tech.interventions || []) - .filter(iv2 => iv2.type === "AL-Intervention" || iv2.type === "AL-Reservation") - .map(iv2 => [timeToMinutes(_ivShownStart(iv2)), timeToMinutes(_ivShownEnd(iv2))]) - .filter(p => p[0] !== null && p[1] !== null && p[1] > p[0]) - .sort((x, y) => x[0] - y[0]); let cov = dayS; for (const p of spans) { if (p[0] > cov) break; @@ -12505,6 +12765,17 @@ function buildCard(tech, isoDate) { if (HORIZONTAL_EXACT_MODE && !isRecurringDay) { body.appendChild(_buildExactRow([], card, isAbsent)); } + // v2026.5.49 — vue classique : la journée d'un tech SANS rendez-vous est + // libre de bout en bout ; on la remplit des mêmes plages hachurées que les + // pauses (découpe à midi comme partout) au lieu de laisser un aplat gris + // sous le message. Masquées quand l'option « Afficher les pauses » est + // inactive, et jamais posées pour un absent — il n'y a rien à y planifier. + // (Cachées en vue horizontale par la règle .gap-placeholder:not(-mini).) + if (!isAbsent && !isRecurringDay) { + const MIDI = 12 * 60; + body.appendChild(_buildGapPlaceholder(DAY_START, MIDI, card)); + body.appendChild(_buildGapPlaceholder(MIDI, DAY_END, card)); + } card.appendChild(body); return card; } @@ -12579,15 +12850,7 @@ function buildCard(tech, isoDate) { const MID = 12 * 60; const _addGap = (sMin, eMin) => { if (sMin == null || eMin == null || eMin <= sMin) return; - const ph = document.createElement("div"); - ph.className = "gap-placeholder"; - ph.dataset.startMin = String(sMin); - ph.dataset.endMin = String(eMin); - ph.dataset.kind = "hole"; - ph.textContent = "—"; - body.appendChild(ph); - _bindGapPlaceholder(ph, card); - if (typeof bindTimelinePopover === "function") bindTimelinePopover(ph); + body.appendChild(_buildGapPlaceholder(sMin, eMin, card)); }; // v2026.5.46 — FLUX CHRONOLOGIQUE UNIQUE (plus de découpage en deux // listes Matin/Après-midi) : les éléments sont rendus dans l'ordre des @@ -12604,15 +12867,7 @@ function buildCard(tech, isoDate) { const _addFullGap = (sMin, eMin) => { // demi-journée entièrement vide → gap visible même quand l'option // « Afficher les pauses » est désactivée (gap-placeholder-full). - const ph = document.createElement("div"); - ph.className = "gap-placeholder gap-placeholder-full"; - ph.dataset.startMin = String(sMin); - ph.dataset.endMin = String(eMin); - ph.dataset.kind = "hole"; - ph.textContent = "—"; - body.appendChild(ph); - _bindGapPlaceholder(ph, card); - if (typeof bindTimelinePopover === "function") bindTimelinePopover(ph); + body.appendChild(_buildGapPlaceholder(sMin, eMin, card, { full: true })); }; const _renderDayFlow = (items) => { @@ -13377,13 +13632,14 @@ function _buildExactRow(realInterventions, cardEl, isAbsent) { if (typeof _cursorToTimeOnTimeline !== "function") return; const t = _cursorToTimeOnTimeline(row, ev.clientX); if (!t) { selPreview.style.display = "none"; selInfo.style.display = "none"; return; } - const sm = Math.max(DAY_START, Math.min(t.totalMin, DAY_END - 30)); - const em = Math.min(sm + 60, DAY_END); + const hs = parseInt(ev.currentTarget.dataset.startMin, 10); + const he = parseInt(ev.currentTarget.dataset.endMin, 10); + // v2026.5.49 — la zone s'arrête à la fin du créneau libre survolé + // (même calcul que le clic droit, cf. _creneauPropose). + const { startMin: sm, endMin: em } = _creneauPropose(t.totalMin, hs, he); selPreview.style.left = ((sm - DAY_START) / DAY_LEN) * 100 + "%"; selPreview.style.width = ((em - sm) / DAY_LEN) * 100 + "%"; selHour.textContent = minutesToTime(sm) + "–" + minutesToTime(em); - const hs = parseInt(ev.currentTarget.dataset.startMin, 10); - const he = parseInt(ev.currentTarget.dataset.endMin, 10); // v2026.5.47 — la bulle info fixe se cale au CENTRE du créneau libre // survolé (zone SANS rendez-vous) → jamais par-dessus une intervention. // (Le popover mobile « Libre / durée » a été retiré des trous, ci-dessous.) @@ -13437,7 +13693,7 @@ function _buildExactRow(realInterventions, cardEl, isAbsent) { row.appendChild(h); } // Posée APRÈS les trous → visible au-dessus d'eux ; sous les bulles - // (z-index 1) qui la recouvrent si la zone d'1 h déborde sur une occupation. + // (z-index 1), qui restent au-dessus en cas de recouvrement. row.appendChild(selPreview); // Bulle info FIXE (temps disponible / alerte), au-dessus de tout (z-index 3). row.appendChild(selInfo); @@ -14231,6 +14487,28 @@ function _cursorToTimeOnTimeline(timelineBarEl, clientX) { return { hour: Math.floor(totalMin / 60), minute: totalMin % 60, totalMin }; } +/** + * v2026.5.49 — créneau PROPOSÉ (surbrillance de survol et clic droit) à + * partir de l'heure sous le curseur : 1 h par défaut, mais JAMAIS au-delà de + * la fin du créneau libre. À 30 min d'un rendez-vous on propose 30 min, au + * lieu d'une heure dont la seconde moitié passait derrière l'occupation + * suivante (l'heure de fin s'y cachait) et qui posait une réservation + * chevauchante. Le début est reculé d'un cran quand le snap 30 min le pose + * SUR la fin du créneau (curseur dans la moitié droite d'un trou court), + * sinon la zone serait vide. + * @param {number} curseurMin heure sous le curseur, déjà snappée + * @param {number|null} libreDebut début du créneau libre (null = début de journée) + * @param {number|null} libreFin fin du créneau libre (null = fin de journée) + */ +function _creneauPropose(curseurMin, libreDebut, libreFin) { + const { dayS, dayE } = _dayRange(); + const debut = libreDebut == null ? dayS : Math.max(dayS, libreDebut); + const fin = libreFin == null ? dayE : Math.min(dayE, libreFin); + let startMin = Math.max(debut, Math.min(curseurMin, dayE - 30)); + if (startMin >= fin) startMin = Math.max(debut, fin - 30); + return { startMin, endMin: Math.min(startMin + 60, fin) }; +} + // calcule l'heure projetée quand le curseur est dans la card-body // (pas sur la timeline). Logique simple : "au-dessus / en-dessous" des iv // existantes — l'heure se cale pour s'enchaîner avec la voisine sans @@ -14558,13 +14836,13 @@ function _showRescheduleEditModal(iv) { } }); // v2026.5.48 — la case ne désactive plus JAMAIS l'option globale : - // - option inactive + cocher → active l'option pour de bon (R8) ; + // - option inactive + cocher → active l'option pour de bon ; // - option active + décocher → pas d'email pour CE déplacement seulement, // la prochaine fenêtre revient cochée. L'option ne se désactive que // dans Paramètres → Email. let emailCeDeplacement = MOVE_EMAIL_ENABLED; const mailMoveLbl = document.createElement("label"); - // v2026.5.48 — plus d'air entre la case et les boutons (demande FroSteel). + // v2026.5.48 — plus d'air entre la case et les boutons. mailMoveLbl.style.cssText = "display:flex; align-items:center; gap:8px; margin:18px 0 14px; cursor:pointer; font-weight:600;"; mailMoveLbl.title = "Décocher : pas d'email pour ce déplacement uniquement. " + "L'option se désactive dans Paramètres → Email."; @@ -14581,7 +14859,7 @@ function _showRescheduleEditModal(iv) { } }); mailMoveLbl.appendChild(mailMoveCbEdit); - // v2026.5.48 — libellé court (demande FroSteel) ; la règle « seulement si + // v2026.5.48 — libellé court ; la règle « seulement si // autre jour ou autre technicien » reste documentée dans l'onglet Email. mailMoveLbl.appendChild(document.createTextNode("✉ Prévenir les techniciens par email")); card.appendChild(mailMoveLbl); @@ -14666,6 +14944,9 @@ function _attachRescheduleHandlers(timeEl, iv) { } function _startReschedulePotentialDrag(iv, ev) { + // v2026.5.49 — pas de glisser-déplacer sans session : le déplacement serait + // bloqué à l'application, on n'amorce même pas le drag. + if (!state.session) return; const sMin = _hhmmToMin(iv.startTime); const eMin = _hhmmToMin(iv.endTime); if (sMin == null || eMin == null) return; @@ -15496,6 +15777,8 @@ function _showRescheduleConfirmModal(iv, change) { // ─── Application : appel API + refresh ────────────────────────────────────── async function _applyReschedule(iv, change) { + // v2026.5.49 — pas de déplacement sans session EV. + if (!_exigeSession("déplacer une intervention")) return; const oldStart = iv.startTime; const oldEnd = iv.endTime; // v2026.5.46 — capture AVANT mutation du store (les projections @@ -15667,7 +15950,7 @@ function _emailForTech(techId) { // cartes. Le nom complet vient de LAST_NAME/FIRST_NAME de la MÊME fiche que // l'email (même personne, pas d'homonyme croisé), sinon du champ « Nom » du // résultat de recherche. fullName vide si introuvable. -async function _fetchTechEmailFromEV(techName) { +async function _fetchTechEmailFromEV(techName, loginConnu) { const origin = (state.session && state.session.origin) || state.lastKnownOrigin; const sid = state.session && state.session.phpsessid; if (!origin || !sid || !techName) return null; @@ -15683,13 +15966,30 @@ async function _fetchTechEmailFromEV(techName) { if (line && line.PK && line.CHECKSUM && mo.TYPE_GUID) { employes.push({ pk: line.PK, guid: mo.TYPE_GUID, checksum: line.CHECKSUM, - nomAnnuaire: String((typeof _srGetField === "function" && _srGetField(line, "Nom")) || "").trim() + nomAnnuaire: String((typeof _srGetField === "function" && _srGetField(line, "Nom")) || "").trim(), + login: String((typeof _srGetField === "function" && _srGetField(line, "Login")) || "").trim() // v2026.5.49 (#14) }); } } } const dec = (s) => ((typeof decodeJsonString === "function") ? decodeJsonString(s) : s).trim(); - for (const hit of employes.slice(0, 2)) { // homonymes : 2 fiches au plus + // v2026.5.49 — #14 : si le login du tech est connu, il DISCRIMINE de + // façon déterministe parmi les homonymes / doubles comptes → on ne garde + // que la fiche au login EXACT ; sinon on retombe sur les 2 premières fiches + // par nom (comportement d'avant, faute de mieux). Le champ « Login » est + // celui qu'affiche déjà la recherche annuaire (_SR_TYPE_CONFIG.AM_EMPLOYEE), + // donc _srGetField le trouve (tolérant casse + préfixes @@hidden_/@@$$). + let candidats = employes; + const loginNorm = _srNorm(loginConnu); + if (loginNorm) { + const exact = employes.filter(e => _srNorm(e.login) === loginNorm); + if (exact.length) candidats = exact; + // v2026.5.49 — #14 durci : un login configuré qui ne correspond à + // AUCUNE fiche (annuaire sans champ Login exploitable, ou login erroné) se + // signale pour diagnostic du 1er usage réel ; on retombe sur le tri par nom. + else if (employes.length) LOG.warn("email", "login fourni sans correspondance annuaire (repli sur le nom)", { login: loginConnu, fiches: employes.length }); + } + for (const hit of candidats.slice(0, 2)) { // homonymes : 2 fiches au plus const formLink = await _ficheFormLinkFromHit(origin, sid, hit); if (!formLink) continue; const resp = await sendMessage({ type: "fetchFiche", formLink }); @@ -15702,7 +16002,7 @@ async function _fetchTechEmailFromEV(techName) { const mPrenom = /"dbFieldName"\s*:\s*"FIRST_NAME"[^}]*?"value"\s*:\s*"([^"]{2,80})"/i.exec(html); let fullName = (mNom && mPrenom) ? (dec(mNom[1]) + ", " + dec(mPrenom[1])) : ""; if (!fullName && hit.nomAnnuaire.includes(",")) fullName = hit.nomAnnuaire; - return { email, fullName }; + return { email, fullName, login: hit.login || loginConnu || "" }; } } } catch (e) { @@ -15768,6 +16068,53 @@ async function _setMoveEmailEnabled(actif) { // du coordinateur : mailto ne sait qu'ouvrir un brouillon, jamais envoyer. // v2026.5.48 — le sujet et le message viennent du MODÈLE éditable de Paramètres → // Équipe ({variables} remplacées ici, cf. MOVE_EMAIL_VARIABLES). +// v2026.5.49 — une adresse est utilisable dans un mailto si elle ne contient +// aucun séparateur/caractère qui casserait l'URL (espace ? & # , ;). Le « % » +// est exclu aussi (passe sécurité) : les adresses to/cc sont écrites NON +// encodées dans le mailto, un « %0D%0A »/« %40 » pourrait sinon franchir la +// validation et injecter un paramètre. Une vraie adresse n'a jamais de %. +function _emailValide(e) { + return /^[^\s@?&#,;%]+@[^\s@?&#,;%]+$/.test(e || ""); +} + +// v2026.5.49 — adresses en copie (CC) valides tirées de la saisie libre +// EMAIL_CC (Paramètres → Email) : séparateurs « ; » « , » espaces/retours, +// validées, dédupliquées, et privées des adresses déjà destinataires (pas de +// doublon to+cc). Vaut pour TOUS les brouillons (déplacement, annulation…). +function _ccList(destinataires) { + const dejaTo = new Set((destinataires || []).map((e) => String(e).toLowerCase())); + const vus = new Set(); + const out = []; + for (const brut of String(EMAIL_CC || "").split(/[;,\s]+/)) { + const e = brut.trim(); + if (!_emailValide(e)) continue; + const k = e.toLowerCase(); + if (vus.has(k) || dejaTo.has(k)) continue; + vus.add(k); + out.push(e); + } + return out; +} + +// v2026.5.49 — construit et ouvre le brouillon mailto (destinataires + CC +// global + sujet + corps). Séparateur « ; » (Outlook FR/CH ne lit pas la +// virgule). L'ancre est insérée AU document avant le click (sinon Firefox +// n'ouvre rien). Renvoie le nombre d'adresses mises en copie. +function _ouvrirBrouillonMail(dests, sujet, corps) { + const cc = _ccList(dests); + let url = "mailto:" + dests.join(";") + "?"; + if (cc.length) url += "cc=" + cc.join(";") + "&"; + url += "subject=" + encodeURIComponent(sujet) + + "&body=" + encodeURIComponent(String(corps).replace(/\n/g, "\r\n")); + const a = document.createElement("a"); + a.href = url; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + return cc.length; +} + function _emailAfterMove(opts) { if (!MOVE_EMAIL_ENABLED) return; try { @@ -15779,23 +16126,22 @@ function _emailAfterMove(opts) { const newTechId = String(change.newTechId || ""); const newTechName = change.newTechName || (TEAM && TEAM[newTechId]) || ("tech " + newTechId); const targetIso = change.newDateIso || sourceIso; - // v2026.5.48 — consigne FroSteel : le brouillon ne concerne que les + // v2026.5.48 — consigne produit : le brouillon ne concerne que les // VRAIS déplacements — autre JOUR ou autre TECHNICIEN. Un simple // ajustement d'heures (même tech, même jour) n'ouvre rien. if (String(oldTechId) === newTechId && targetIso === sourceIso) return; // adresses validées (pas de ?/&/,/espace : une adresse malformée saisie // dans les Paramètres ne doit pas corrompre l'URL mailto). - const valide = (e) => /^[^\s@?&#,;]+@[^\s@?&#,;]+$/.test(e || ""); const adrAncien = _emailForTech(oldTechId); const adrNouveau = _emailForTech(newTechId); - const dests = [...new Set([adrAncien, adrNouveau].filter(valide))]; + const dests = [...new Set([adrAncien, adrNouveau].filter(_emailValide))]; // v2026.5.48 — adresse(s) introuvable(s) : le brouillon s'ouvre QUAND // MÊME, destinataires laissés vides ou partiels — le coordinateur complète // directement dans l'email, sans passer par les Paramètres. La notification // en bas à droite dit qui manque. const manquants = []; - if (!valide(adrAncien)) manquants.push(oldTechName || ("tech " + oldTechId)); - if (newTechId !== String(oldTechId) && !valide(adrNouveau)) manquants.push(newTechName); + if (!_emailValide(adrAncien)) manquants.push(oldTechName || ("tech " + oldTechId)); + if (newTechId !== String(oldTechId) && !_emailValide(adrNouveau)) manquants.push(newTechName); if (manquants.length) { showToast(manquants.length > 1 ? "Adresses manquantes" : "Adresse manquante", manquants.join(" et ") + (manquants.length > 1 ? " n'ont" : " n'a") + @@ -15816,26 +16162,50 @@ function _emailAfterMove(opts) { }; const sujet = _moveEmailRempli(MOVE_EMAIL_SUBJECT || DEFAULT_MOVE_EMAIL_SUBJECT, valeurs); const corps = _moveEmailRempli(MOVE_EMAIL_BODY || DEFAULT_MOVE_EMAIL_BODY, valeurs); - // v2026.5.48 — séparateur « ; » entre les destinataires : Outlook - // (réglages FR/CH) ne reconnaît pas la virgule — vérifié par FroSteel, - // « a,b » finit en une seule adresse invalide. - const url = "mailto:" + dests.join(";") + - "?subject=" + encodeURIComponent(sujet) + - "&body=" + encodeURIComponent(corps.replace(/\n/g, "\r\n")); - // même motif que les exports (cache/config) : l'ancre doit être DANS le - // document au moment du click, sinon Firefox n'ouvre rien. - const a = document.createElement("a"); - a.href = url; - a.rel = "noopener"; - document.body.appendChild(a); - a.click(); - a.remove(); - LOG.info("email", "brouillon de déplacement ouvert", { destinataires: dests.length, ref: ref || null }); + // v2026.5.49 — construction/ouverture factorisée (déplacement + + // annulation) : ajoute le CC global et gère l'ancre DOM (Firefox). + const ccN = _ouvrirBrouillonMail(dests, sujet, corps); + LOG.info("email", "brouillon de déplacement ouvert", { destinataires: dests.length, cc: ccN, ref: ref || null }); } catch (e) { LOG.warn("email", "ouverture du brouillon échouée (ignoré)", { err: e && e.message }); } } +// v2026.5.49 — après une SUPPRESSION de réservation appliquée (bouton +// Supprimer confirmé), si l'option est active, ouvre un brouillon d'email +// (mailto) prévenant le technicien concerné que l'intervention est annulée. +// Les champs sont capturés AVANT la suppression par l'appelant (l'iv n'existe +// plus après le refetch). L'envoi reste un geste manuel du coordinateur. +function _emailAfterDelete(opts) { + if (!DELETE_EMAIL_ENABLED) return; + try { + const { ref, techId, techName, lieu, contact, dateIso, start, end } = opts || {}; + const adr = _emailForTech(techId); + const dests = [...new Set([adr].filter(_emailValide))]; + // adresse introuvable : le brouillon s'ouvre quand même, destinataire à + // compléter dans l'email (parité avec le déplacement). + if (!_emailValide(adr)) { + showToast("Adresse manquante", + (techName || ("tech " + techId)) + " n'a pas d'adresse — complète le destinataire dans l'email"); + } + const valeurs = { + numero: ref || "", + technicien: techName || ("tech " + techId), + date: _isoToDDMMYYYY(dateIso), + heure_debut: start || "", + heure_fin: end || "", + contact: contact || "", + lieu: lieu || "" + }; + const sujet = _moveEmailRempli(DELETE_EMAIL_SUBJECT || DEFAULT_DELETE_EMAIL_SUBJECT, valeurs); + const corps = _moveEmailRempli(DELETE_EMAIL_BODY || DEFAULT_DELETE_EMAIL_BODY, valeurs); + const ccN = _ouvrirBrouillonMail(dests, sujet, corps); + LOG.info("email", "brouillon d'annulation ouvert", { destinataires: dests.length, cc: ccN, ref: ref || null }); + } catch (e) { + LOG.warn("email", "ouverture du brouillon d'annulation échouée (ignoré)", { err: e && e.message }); + } +} + // v2026.5.46 — apres un deplacement reussi, la douchette des techniciens // impactes est resynchronisee automatiquement : jour SOURCE pour l'ancien // tech, jour CIBLE pour le nouveau — un seul envoi s'ils partagent le meme @@ -17174,6 +17544,14 @@ function cleanContactName(raw) { s = s.replace(rx, " "); } + // v2026.5.49 — retirer les « ? » (et le caractère de remplacement Unicode + // U+FFFD, souvent rendu « ? ») collés au nom : c'est un encodage cassé côté + // EasyVista, jamais un vrai caractère d'un nom de personne. Ex : "?Exemple" + // → "Exemple", "Exemple?, FroSteel" → "FroSteel". + s = s.replace(/[?\uFFFD]/g, " "); + // ... et recoller l'espace laisse devant une virgule/point-virgule + // ("Exemple?, FroSteel" -> "Exemple , FroSteel" -> "FroSteel"). + s = s.replace(/\s+([,;:])/g, "$1"); // Espaces multiples → un seul s = s.replace(/\s{2,}/g, " ").trim(); // Ponctuation en bord @@ -19819,6 +20197,8 @@ function _restorePinnedPopupFromDock(popup) { */ function _triggerDeleteItem(actionId, kind, triggerBtn) { if (!actionId) return; + // v2026.5.49 — pas de suppression sans session EV. + if (!_exigeSession("supprimer")) return; const label = kind === "reservation" ? "cette réservation" : "cette absence"; showAlertModal({ title: "Confirmer la suppression", @@ -19829,6 +20209,32 @@ function _triggerDeleteItem(actionId, kind, triggerBtn) { label: "Supprimer", variant: "danger", action: async () => { + // v2026.5.49 — capturer les champs AVANT toute mutation (l'iv + // disparaît de l'affichage après le refetch). Seules les RÉSERVATIONS + // déclenchent l'email d'annulation (pas les absences : annoncer à un + // tech l'annulation de sa propre absence n'aurait pas de sens). + let ctxAnnulation = null; + if (kind === "reservation") { + const ivSuppr = findIvByActionId(actionId); + if (ivSuppr) { + // pour une réservation, le n° et le lieu (ville/rue) vivent dans + // la méta saisie par le coordinateur (resNumero/resVille/resRue), + // pas dans iv.ref/ficheLocation — c'est « où l'intervention se + // trouvait » (ticket #12). Replis sur les champs de la carte. + const meta = _ivResMetaOf(ivSuppr); + const lieuMeta = [meta.ville, meta.rue].filter(Boolean).join(" / "); + ctxAnnulation = { + ref: meta.numero || ivSuppr.ref || "", + techId: ivSuppr.techId, + techName: ivSuppr.techName || "", + lieu: lieuMeta || ivSuppr.ficheLocation || (ivSuppr.infobulle && ivSuppr.infobulle.lieu) || ivSuppr.bulleLieu || "", + contact: (ivSuppr.infobulle && ivSuppr.infobulle.contact) || ivSuppr.bulleContact || "", + dateIso: ivSuppr.startDate || state.currentDate, + start: ivSuppr.startTime || "", + end: ivSuppr.endTime || "" + }; + } + } const labelBtnAvant = triggerBtn ? triggerBtn.textContent : ""; if (triggerBtn) { triggerBtn.disabled = true; @@ -19844,6 +20250,10 @@ function _triggerDeleteItem(actionId, kind, triggerBtn) { throw new Error(resp && resp.error ? resp.error : "erreur inconnue"); } showToast("Supprimé", "L'élément a été retiré du planning."); + // v2026.5.49 — prévenir le technicien concerné par email + // (brouillon ; option globale Paramètres → Email). Une seule fois : + // ce handler ne s'exécute qu'au clic confirmé, bouton désactivé. + if (ctxAnnulation) _emailAfterDelete(ctxAnnulation); unpinTooltip(); closeAllPinnedPopups(); // v2026.5.46 — tombstone AVANT le refresh : empêche la sync @@ -20045,6 +20455,8 @@ async function reloadSingleIntervention(iv, btnEl) { iv.bulleDescription = parsed.description; const infob = parseActionText(parsed.description); if (infob) iv.infobulle = infob; + // v2026.5.49 — #3 : l'adresse de l'ACTION a pu changer → recalcul du flag. + _updateLocMismatch(iv); } if (parsed.label) iv.label = parsed.label; iv.xhr2Fetched = true; @@ -20061,6 +20473,14 @@ async function reloadSingleIntervention(iv, btnEl) { iv.status = fiche.status; if (fiche.rfc && !iv.ref) iv.ref = fiche.rfc; if (fiche.formSenderGuid) iv.formSenderGuid = fiche.formSenderGuid; + // v2026.5.49 — #3 : re-extraire l'adresse du TICKET (Localisation) et + // recalculer le flag d'écart d'adresse. reloadSingleIntervention (bouton ↻ + // « Actualiser cette intervention ») re-fetchait la fiche SANS le faire → + // le rouge « ⚠ Adresse » restait après une correction. (fetchAndUpdate- + // Intervention et le popup épinglé le faisaient déjà.) + const _ficheLoc = _extractFicheLocation(ficheResp.html); + if (_ficheLoc) iv.ficheLocation = _ficheLoc; + _updateLocMismatch(iv); // ─── timeline API : texte complet ────────────────────────────── if (fiche.formId && fiche.formChecksum && fiche.formSenderGuid && iv.actionId) { @@ -21175,7 +21595,7 @@ async function _srBuildCacheIndexInterne(force) { let dossierOk = false; // 1) DOSSIER d'abord (hôte natif présent + réseau interne) try { - if (state.networkContext === "internal" && typeof nmProbe === "function" && await nmProbe()) { + if (state.session && state.networkContext === "internal" && typeof nmProbe === "function" && await nmProbe()) { const path = await nmSharePath(); if (path) { const lst = await nmSend({ action: "list", path }); @@ -21221,7 +21641,7 @@ async function _srSearchEV(query, signal) { try { res = await fetch(url, { method: "GET", credentials: "include", headers: { "Accept": "application/json, text/plain, */*", "X-Requested-With": "XMLHttpRequest" }, signal }); } catch (e) { if (e && e.name === "AbortError") return null; LOG.warn("search", "réseau basicAutoComplete", { err: e && e.message }); return []; } const text = await res.text(); - if (/type="password"|iamlogin|\/iam\/accueil/i.test(text)) { LOG.warn("search", "réponse = page login (session expirée)"); if (typeof showSessionExpiredBanner === "function") showSessionExpiredBanner(); return []; } + if (_estPageLogin(text)) { LOG.warn("search", "réponse = page login (session expirée)"); if (typeof showSessionExpiredBanner === "function") showSessionExpiredBanner(); return []; } let json = null; try { json = JSON.parse(text); } catch (e) {} if (!json) { LOG.warn("search", "réponse basicAutoComplete non-JSON"); return []; } const metas = (json.data && json.data.METAOBJECTS) || []; @@ -21377,7 +21797,12 @@ function _srCacheMatches(query) { const terms = _srParseQuery(query); if (!terms.length) return []; const needle = _srFirstNeedle(terms); - const hits = _searchCacheList.filter(iv => _srQueryMatches(iv, terms)); + // v2026.5.49 — #13 : le verrou de session couvre AUSSI la recherche. + // L'index _searchCacheList est bâti sur les fichiers-jour BRUTS (hors + // storeProject, donc sans le masque). Sans session, on neutralise chaque iv + // (absence « maladie ») AVANT le match ET le rendu : « maladie » ne matche plus + // le haystack, et Tech/Jour/tooltip ne révèlent plus le motif santé d'un tech. + const hits = _searchCacheList.map(_ivMasquerSanteSansSession).filter(iv => _srQueryMatches(iv, terms)); return hits.map(iv => { const fields = [["Réf", iv.ref], ["Catégorie", deriveShortTitle(iv)], ["Contact", iv.bulleContact], ["Adresse", iv.bulleLieu], ["Tech", iv.techName], @@ -21474,7 +21899,7 @@ function _srBuildCard(it) { const _onShownDay = !!(_hereIso && state.currentDate && _hereIso === state.currentDate); // v2026.5.48 — la pastille de statut ne s'affiche que pour un état // FINAL (Clôturé / Fait / Terminée) ; « En cours » et autres n'apportent - // rien (demande FroSteel). Le liseré de couleur (acc-*) reste. + // rien. Le liseré de couleur (acc-*) reste. const pillHtml = (sk === "clos" || sk === "fait") ? `${_srStatusIcon(sk)} ${escapeHtml(it.statusLabel)}` : ""; if (pillHtml || w || _onShownDay) { @@ -21484,7 +21909,21 @@ function _srBuildCard(it) { } const actions = document.createElement("div"); actions.className = "sr-actions"; const evBtn = document.createElement("button"); evBtn.className = "sr-btn"; evBtn.textContent = "EasyVista ↗"; - evBtn.addEventListener("click", () => { _srOpenRecord(it); _srClear(); }); actions.appendChild(evBtn); + // v2026.5.49 — #15 : Ctrl/Cmd+clic ou clic molette → ouvre EasyVista en + // ARRIÈRE-PLAN et GARDE la recherche affichée (patron des lignes du planning). + // Un clic simple conserve le comportement d'avant (ouvre au 1er plan + ferme). + evBtn.addEventListener("click", (e) => { + const background = !!(e.ctrlKey || e.metaKey); + _srOpenRecord(it, { background }); + if (!background) _srClear(); + }); + evBtn.addEventListener("auxclick", (e) => { + if (e.button !== 1) return; // molette uniquement + e.preventDefault(); + _srOpenRecord(it, { background: true }); + }); + evBtn.addEventListener("mousedown", (e) => { if (e.button === 1) e.preventDefault(); }); // pas d'auto-scroll + actions.appendChild(evBtn); if (it.day) { const iso = _srFrToIso(it.day); if (iso) { @@ -21702,8 +22141,10 @@ function _srPagerLoadMore() { } let _srOpening = false; -async function _srOpenRecord(it) { - if (_srOpening) return; _srOpening = true; +async function _srOpenRecord(it, opts = {}) { + // v2026.5.49 — #15 : la garde de ré-entrance ne vaut que pour l'ouverture + // en AVANT-PLAN ; en arrière-plan on autorise plusieurs ouvertures d'affilée. + if (!opts.background) { if (_srOpening) return; _srOpening = true; } try { const sess = state.session; if (!sess || !sess.origin || !sess.phpsessid) { LOG.warn("search", "ouverture impossible : pas de session"); return; } @@ -21721,9 +22162,11 @@ async function _srOpenRecord(it) { target = String(target || "").replace(/^"+|"+$/g, "").trim(); if (target && !/^https?:\/\//i.test(target)) target = sess.origin.replace(/\/+$/, "") + "/" + target.replace(/^\/+/, ""); if (!target || !/^https?:\/\//i.test(target)) { LOG.warn("search", "redirectHeader sans URL exploitable"); return; } - window.open(target, "_blank", "noopener"); + // v2026.5.49 — #15 : chrome.tabs.create honore l'arrière-plan + // (active:false), contrairement à window.open. Permission "tabs" déjà là. + await chrome.tabs.create({ url: target, active: !opts.background }); } catch (e) { LOG.warn("search", "openRecord", { err: e && e.message }); } - finally { _srOpening = false; } + finally { if (!opts.background) _srOpening = false; } } function _srSpin(on) { const s = document.getElementById("ev-search-spin"); if (s) s.classList.toggle("hidden", !on); } @@ -21801,6 +22244,14 @@ function _srSyncStickyOffsets() { const tl = document.getElementById("topline"); const tlH = (tl && tl.offsetParent !== null) ? tl.offsetHeight : 0; root.setProperty("--topline-h", tlH + "px"); + // v2026.5.49 — hauteur de la barre de progression quand elle est visible. + // En vue classique l'en-tête de carte (sticky, MÊME offset que la barre mais + // z-index inférieur) doit se caler EN DESSOUS d'elle pendant un + // rafraîchissement, sinon la barre recouvrait le nom du tech dès qu'on avait + // scrollé. 0 hors rafraîchissement → l'en-tête reprend sa place normale. + const pb = document.getElementById("progress-bar"); + const pbH = (pb && !pb.classList.contains("hidden") && pb.offsetParent !== null) ? pb.offsetHeight : 0; + root.setProperty("--progress-h", pbH + "px"); // v2026.5.47 — largeur RÉELLE de la sidebar horizontale → --sidebar-w : // la barre de progression se cale à sa DROITE (au-dessus du contenu, plus // par-dessus la sidebar/zone de reconnexion). Mesure robuste aux breakpoints @@ -21809,6 +22260,18 @@ function _srSyncStickyOffsets() { const sb = document.getElementById("horizontal-sidebar"); const sbW = (sb && sb.offsetParent !== null) ? sb.offsetWidth : 0; if (sbW > 0) root.setProperty("--sidebar-w", sbW + "px"); + _syncCardHeaderHeights(); +} +// v2026.5.49 — VUE CLASSIQUE : hauteur RÉELLE de l'en-tête de CHAQUE carte, +// exposée en --card-header-h SUR la carte, pour que la frise (.timeline) se colle +// JUSTE sous l'en-tête au scroll (un nom long fait 2-3 lignes → hauteur variable +// d'une carte à l'autre, d'où une mesure par carte). No-op en vue horizontale. +function _syncCardHeaderHeights() { + if (document.documentElement.classList.contains("view-horizontal")) return; + document.querySelectorAll("#cards .card").forEach(card => { + const h = card.querySelector(".card-header"); + if (h) card.style.setProperty("--card-header-h", h.offsetHeight + "px"); + }); } // alias rétro-compat (anciens appels) function _srSyncTopbarHeight() { _srSyncStickyOffsets(); }