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 @@
-
-
Session EasyVista expirée
-
Reconnectez-vous à EasyVista pour continuer.
-
-
-
-
EasyVista est inaccessible pour le moment.
-
Réessayez dans quelques instants, ou ouvrez EasyVista directement.
-
-
-
Chargement…
+
+
+
Session EasyVista expirée
+
Reconnectez-vous à EasyVista pour continuer.
+
+
+
+
EasyVista est inaccessible pour le moment.
+
Réessayez dans quelques instants, ou ouvrez EasyVista directement.
+
+
+
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 = "
Inclure
ID
Nom affiché
Email
Absences récurrentes
";
+ thead.innerHTML = "
Inclure
ID
Nom affiché
Email
Login
Absences 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(); }