From d6b94e7cf384de8c85a639111df0866fbb486fff Mon Sep 17 00:00:00 2001 From: Quentin Rouiller Date: Fri, 24 Jul 2026 02:34:23 +0200 Subject: [PATCH] =?UTF-8?q?v2026.5.48=20=E2=80=94=20Notification=20par=20e?= =?UTF-8?q?mail=20des=20d=C3=A9placements,=20onglet=20Email,=20fiches=20r?= =?UTF-8?q?=C3=A9servation=20fiables=20entre=20postes,=20recherche=20et=20?= =?UTF-8?q?reconnexion=20durcies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Brouillon d'email pré-adressé aux deux techniciens après un déplacement vers un autre jour ou un autre technicien (fenêtre de modification ou glisser-déposer) ; envoi manuel, destinataires séparés par « ; » (Outlook), brouillon ouvert même sans adresse (notification indique qui manque). - Case « Prévenir les techniciens par email » dans les fenêtres de déplacement : décochée, ne vaut que pour ce déplacement-là ; l'option ne se désactive durablement que dans Paramètres → Email. - Nouvel onglet Paramètres → Email : option et modèle du message personnalisable (variables cliquables par thème Intervention / Avant / Après, aperçu en direct, modèle de base en blocs AVANT / APRÈS) ; migration automatique des anciens modèles de base. - Adresses des techniciens dans l'onglet Équipe (colonne Email, boutons au-dessus de la liste) : récupération depuis l'annuaire EasyVista, noms affichés tronqués complétés (garde anti-homonyme), adresse saisie jamais écrasée, domaine des adresses déduites configurable. - Informations de réservation fiables entre postes : elles voyagent avec la réservation, horodatées (la modification la plus récente gagne) ; plus de fuites entre créneaux, techniciens ou postes ; transfert conservé sur re-planification EasyVista non ambiguë ; pompier du jour plus compté en double ; « réservé toute la journée » sur les heures affichées. - Bouton « Ouvrir EasyVista » fiable sans cache (l'écran se ferme après reconnexion, re-cliquer relance le chargement). - Recherche : départ fiable en fin de frappe, Entrée immédiate, roue visible dès le début, « Planifiée (passé) » ne ferme plus la zone, statuts affichés seulement pour Clôturé / Fait. --- CHANGELOG.md | 26 + README.md | 23 +- firefox-updates.json | 7 +- src/manifest.json | 2 +- src/viewer.css | 26 + src/viewer.js | 1462 +++++++++++++++++++++++++++++++++++++----- 6 files changed, 1381 insertions(+), 165 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce43f35..2088a89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,32 @@ --- +## 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. + +### Email après un déplacement (nouveau) + +- **Brouillon automatique** : après un déplacement vers un **autre jour** ou un **autre technicien** (fenêtre de modification ou glisser-déposer), un brouillon pré-adressé aux deux techniciens s'ouvre dans l'application mail (ex. Outlook). Un simple ajustement d'heures n'ouvre rien. **Rien n'est envoyé automatiquement.** +- **Case « Prévenir les techniciens par email »** dans les fenêtres de déplacement : quand l'option est active, la décocher ne vaut que pour **ce déplacement-là** — le prochain déplacement la retrouve cochée. L'option ne se désactive durablement que dans les Paramètres. +- **Destinataires** séparés par « ; » (compatibilité Outlook) ; si une adresse manque, le brouillon s'ouvre quand même, à compléter directement dans l'email (une notification indique qui manque). +- **Nouvel onglet « Email »** dans les Paramètres : option, et **modèle du message** entièrement personnalisable (sujet + texte), avec **variables cliquables** rangées par thème (Intervention / Avant / Après) et **aperçu en direct** sur un exemple. Le modèle de base présente le déplacement en deux blocs AVANT / APRÈS bien lisibles. +- **Adresses des techniciens** dans l'onglet Équipe (colonne Email, boutons au-dessus de la liste) : bouton « **Récupérer les emails** » qui remplit les adresses manquantes depuis l'annuaire EasyVista — et **complète au passage un nom affiché tronqué** (ex. « Martins » → « De Almeida Martins »), avec un garde-fou contre les homonymes. Une adresse saisie à la main n'est jamais écrasée. Champ vide = adresse proposée `prenom.nom@domaine` (domaine réglable au même endroit, appliqué à tous). + +### Informations de réservation fiables entre postes + +- Les informations saisies sur une réservation (n° d'intervention, ville, rue, catégorie) **voyagent désormais avec la réservation** dans le dossier partagé, horodatées : la modification la plus récente gagne, sur tous les postes. +- Fin des « fuites » d'informations : une réservation posée par un autre coordinateur ne récupère plus les infos d'une réservation précédente au même créneau, un identifiant recyclé par EasyVista ne fait plus hériter les infos d'un autre technicien, et un champ volontairement vidé ne « ressuscite » plus. +- Une réservation **re-planifiée directement dans EasyVista** conserve ses informations quand le rapprochement est sans ambiguïté. +- Divers : le pompier du jour n'est plus compté en double dans les statistiques ; « réservé toute la journée » se calcule sur les heures affichées ; les champs pré-remplis du formulaire ne sont plus écrasés par le remplissage automatique. + +### Reconnexion et recherche + +- **Bouton « Ouvrir EasyVista »** (écran « Session expirée » sans aucun cache) : après la reconnexion, l'écran se ferme réellement et le planning s'affiche ; re-cliquer relance le chargement au lieu d'être ignoré. +- **Recherche** : la recherche part de façon fiable quand on arrête de taper (les relances au survol ne l'interrompent plus), **Entrée** lance la recherche immédiatement, la roue de chargement est visible dès le début, déplier « Planifiée (passé) » ne ferme plus la zone de résultats, et seuls les statuts **Clôturé / Fait** sont affichés sur les résultats. + +--- + ## v2026.5.47 — Vue « échelle de temps exacte » : aide à la saisie, erreurs mises en évidence, corrections de fiabilité > Cette version peaufine la vue horizontale « échelle de temps exacte » : un vrai retour visuel accompagne la pose d'un rendez-vous ou d'une absence, et les erreurs de saisie (rendez-vous dont le début est égal à la fin) sont désormais bien visibles et signalées. Elle corrige aussi plusieurs problèmes de navigation entre dates et de synchronisation du dossier partagé, réorganise le panneau de synchronisation, et ajoute un bouton de test de la communication avec le script du poste. diff --git a/README.md b/README.md index e61ccd4..abeadf3 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** : Quentin Rouiller (QRO), 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.47`](https://gitea.netaplaid.ch/FroSteel/Planification/releases/tag/v2026.5.47) (latest) +- **Version actuelle** : [`v2026.5.48`](https://gitea.netaplaid.ch/FroSteel/Planification/releases/tag/v2026.5.48) (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) @@ -66,6 +66,12 @@ Les mises à jour sont **manuelles** : à chaque nouvelle release, retélécharg - Badge + barre gauche colorée + dégradé fond - Absences récurrentes (configurées par tech) : cyan (depuis v2026.5.30) +### Email après un déplacement (depuis v2026.5.48) +- Brouillon d'email pré-adressé aux deux techniciens après un déplacement vers un autre jour ou un autre technicien (fenêtre de modification ou glisser-déposer) — l'envoi reste manuel, rien ne part automatiquement +- Case « Prévenir les techniciens par email » dans les fenêtres de déplacement : la décocher ne vaut que pour ce déplacement-là +- Onglet Paramètres → Email : option et modèle du message personnalisable (variables cliquables par thème, aperçu en direct) +- Adresses des techniciens dans l'onglet Équipe : récupération depuis l'annuaire EasyVista (complète aussi un nom affiché tronqué), déduction `prenom.nom@domaine` sinon + ### User et session - Badge user avec photo/initiales en topbar - Badge cliquable (depuis v2026.5.26) : popup avec ⚙ Paramètres + ⊞ Vue + compteur session MM:SS @@ -115,7 +121,20 @@ Le numéro de **majeure** n'est **pas** un mois et **pas** un chiffre lié au ca ## Versions notables -### `v2026.5.46` (latest, 9 juillet 2026) — Couche données v2, vue « échelle de temps exacte », session fiabilisée, réservations partagées +### `v2026.5.48` (latest, 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 +- Les informations de réservation voyagent avec la réservation entre les postes (horodatées, plus de fuites entre créneaux ou techniciens) +- Bouton « Ouvrir EasyVista » fiable sans cache ; recherche durcie (Entrée immédiate, plus d'interruptions au survol, statuts finals seulement) + +### `v2026.5.47` (18 juillet 2026) — Vue « échelle de temps exacte » : aide à la saisie, erreurs mises en évidence + +- Retour visuel de pose (zone + heure choisie + bulle « temps disponible »), rendez-vous à durée nulle visibles en rouge et bloquants, interventions courtes visibles +- Corrections : navigation rapide entre dates, synchro du dossier partagé (conflit d'index), doublons d'absences, réservations multiples le même jour +- Panneau Synchro réorganisé + bouton « Tester la communication » ; script du poste 2026.1.8 + +### `v2026.5.46` (9 juillet 2026) — Couche données v2, vue « échelle de temps exacte », session fiabilisée, réservations partagées - Refonte complète de la couche données : store unifié par jour, un seul verdict par intervention, synchronisation du dossier partagé en sous-dossier v2 - Nouvelle disposition « échelle de temps exacte » pour la vue horizontale (option Apparence) diff --git a/firefox-updates.json b/firefox-updates.json index 56c3ac3..ff33cd5 100644 --- a/firefox-updates.json +++ b/firefox-updates.json @@ -2,10 +2,15 @@ "addons": { "planification-dgnsi@netaplaid.ch": { "updates": [ + { + "version": "2026.5.48", + "update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.48/planification-v2026.5.48-firefox.xpi", + "update_hash": "sha256:be687aa2795649d7f1f9883f3384365bd224e0ab94987ff852b0901f5554a51a" + }, { "version": "2026.5.47", "update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.47/planification-v2026.5.47-firefox.xpi", - "update_hash": "sha256:e97883102af2f00fd468c9be62deac6c1d2546ef3e25afe90d79a390bbd1f0bb" + "update_hash": "sha256:0418062357b7c29bdd7a01b6d6e06eeb20e2ee419c67684a84855862af113cc2" }, { "version": "2026.5.46", diff --git a/src/manifest.json b/src/manifest.json index 048f5e4..89acdaf 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planification", - "version": "2026.5.47", + "version": "2026.5.48", "description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin Rouiller — 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 b1f6b4f..a7486e3 100644 --- a/src/viewer.css +++ b/src/viewer.css @@ -7346,3 +7346,29 @@ html.view-horizontal.horizontal-exact .cards { .reschedule-timeline-mark.reschedule-exact-mark { border-radius: 4px; } + +/* ── Puces de variables du modèle d'email (v2026.5.48) ─────────────── */ +/* Interactives : grossissent au survol, s'enfoncent au clic (demande + Quentin). Le style de base vit ici (plus d'inline) pour que :hover et + :active puissent surcharger la bordure. */ +.admin-var-chip { + font-family: monospace; + font-size: 12px; + padding: 2px 8px; + align-self: flex-start; + border: 1px solid var(--border, #888); + border-radius: 6px; + background: transparent; + color: inherit; + cursor: pointer; + transition: transform 0.09s ease, border-color 0.09s ease, + background-color 0.09s ease; +} +.admin-var-chip:hover { + transform: scale(1.07); + border-color: var(--accent, #4a90d9); + background: rgba(127, 127, 127, 0.12); +} +.admin-var-chip:active { + transform: scale(0.92); +} diff --git a/src/viewer.js b/src/viewer.js index 65d0f65..850426a 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -17,6 +17,145 @@ // ============================================================================ // 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 Quentin, 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 Quentin : (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 Quentin : (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 Quentin : 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 +// Quentin) : 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 @@ -328,6 +467,110 @@ LOG.warn("planification", // _initTeamFromConfig() (appelée tôt dans init()). Tant qu'elles ne sont pas // chargées, elles restent vides → aucun fetch tenté. 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 = {}; +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. +const DEFAULT_EMAIL_DOMAIN = "vd.ch"; +let EMAIL_DOMAIN = ""; +// 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. +let MOVE_EMAIL_SUBJECT = ""; +let MOVE_EMAIL_BODY = ""; +// Modèle de PREMIER LANCEMENT (filet, comme DEFAULT_GROUP_ID) : sert à remplir +// admin_config la première fois et de repli si le modèle est vidé — le +// brouillon lui-même est toujours construit depuis la config. +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 Quentin). Les variables retirées (heures, contact) +// restent disponibles dans la légende pour qui veut les rajouter. +const DEFAULT_MOVE_EMAIL_BODY = [ + "Bonjour,", + "", + "L'intervention {numero} a été déplacée.", + "", + "---------- AVANT ----------", + " • Technicien : {ancien_tech}", + " • Date : {ancienne_date}", + "", + "---------- APRÈS ----------", + " • Technicien : {nouveau_tech}", + " • Date : {nouvelle_date}", + "", + " • Lieu : {lieu}", + "", + "Merci de prendre note du changement." +].join("\n"); +// Anciens modèles de base (les deux générations précédentes), gardés pour la migration : un +// poste dont le modèle enregistré est resté EXACTEMENT l'un d'eux reçoit le +// modèle de base actuel au boot (un modèle personnalisé n'est jamais touché). +const _ANCIENS_MODELES_BASE = [ + [ + "Bonjour,", + "", + "L'intervention {numero} a été déplacée :", + "- Avant : {ancien_tech}, le {ancienne_date} de {ancienne_heure_debut} à {ancienne_heure_fin}", + "- Après : {nouveau_tech}, le {nouvelle_date} de {nouvelle_heure_debut} à {nouvelle_heure_fin}", + "- Contact : {contact}", + "- Lieu : {lieu}", + "", + "Merci de prendre note du changement." + ].join("\n"), + [ + "Bonjour,", + "", + "L'intervention {numero} a été déplacée.", + "", + " • Avant : {ancien_tech} — {ancienne_date}", + " • Après : {nouveau_tech} — {nouvelle_date}", + "", + " • Contact : {contact}", + " • Lieu : {lieu}", + "", + "Merci de prendre note du changement." + ].join("\n") +]; +// v2026.5.48 — empreinte TOLÉRANTE d'un modèle : fins de ligne, espaces +// multiples, tirets typographiques et lignes vides ne comptent pas. Sert à +// reconnaître un ancien modèle de BASE même si le stockage a normalisé des +// détails (la comparaison au caractère près ratait la migration → l'onglet +// Email continuait d'afficher l'ancien format). +function _empreinteModeleEmail(s) { + return String(s || "") + .replace(/\r\n?/g, "\n") + .replace(/\u00A0/g, " ") + .replace(/[—–]/g, "-") + .split("\n") + .map((l) => l.replace(/[ \t]+/g, " ").trim()) + .filter(Boolean) + .join("\n"); +} +// Les variables du modèle et leur signification — la légende affichée dans +// Paramètres → Email est générée depuis CETTE liste (une seule source). +const MOVE_EMAIL_VARIABLES = [ + ["numero", "n° de l'intervention (ex. I250123)"], + ["ancien_tech", "technicien AVANT le déplacement"], + ["nouveau_tech", "technicien APRÈS le déplacement"], + ["ancienne_date", "date avant (JJ/MM/AAAA)"], + ["nouvelle_date", "date après (JJ/MM/AAAA)"], + ["ancienne_heure_debut", "heure de début avant"], + ["ancienne_heure_fin", "heure de fin avant"], + ["nouvelle_heure_debut", "heure de début après"], + ["nouvelle_heure_fin", "heure de fin après"], + ["contact", "contact de l'intervention"], + ["lieu", "lieu de l'intervention"] +]; +// v2026.5.48 — regroupement d'AFFICHAGE de la légende (colonnes logiques +// au lieu d'une longue liste — demande Quentin). 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.46 — vue horizontale « échelle de temps exacte » (option // Apparence). Miroir de cfg.horizontalExactMode, lu au boot et au toggle. let HORIZONTAL_EXACT_MODE = false; @@ -412,6 +655,23 @@ async function _initTeamFromConfig() { const cfg = await loadAdminConfig(); TEAM = cfg.team || {}; RECURRING_ABSENCES = cfg.recurringAbsences || {}; + TEAM_EMAILS = cfg.teamEmails || {}; // v2026.5.48 + 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 — 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.46 — mode « échelle de temps exacte » (vue horizontale). HORIZONTAL_EXACT_MODE = !!cfg.horizontalExactMode; document.documentElement.classList.toggle("horizontal-exact", HORIZONTAL_EXACT_MODE); @@ -506,46 +766,107 @@ const STORE_PREFIX = "planv2_"; // + YYYY-MM-DD → entrée jour schém const LEGACY_CACHE_PREFIX = "planning_cache_"; // ancien format (purgé au boot) // v2026.5.46 — meta des réservations créées depuis l'extension (n° DS, -// ville, rue, catégorie). EasyVista ne stocke PAS ces infos : on les garde -// localement et on les réapplique à CHAQUE parse, pour qu'elles survivent aux -// refresh EV et au recyclage d'actionId des réservations. -// Structure : { "||": { numero, ville, rue, categorie } } +// ville, rue, catégorie). EasyVista ne stocke PAS ces infos. +// v2026.5.48 — le carnet est un TAMPON DE POSE, plus une source permanente : +// une entrée n'attend que l'apparition de la réservation saisie dans le store +// (le parse qui suit la création), puis elle est CONSOMMÉE. La méta vit ensuite +// DANS la réservation du store et voyage par le dossier partagé (ivMerge et +// _ivApplyLiveFields la préservent). Avant, le carnet était réappliqué à CHAQUE +// parse : toute réservation occupant plus tard le même créneau (tech|date|heure) +// — y compris celle d'un autre coordinateur — héritait des infos d'origine. +// Structure : { "||-": { numero, ville, rue, categorie, at } } +// v2026.5.48 — l'heure de FIN fait partie de la clé : deux réservations du +// même tech au même début (fins différentes) posées coup sur coup gardaient +// une seule entrée (la 2e écrasait la 1re, attribuable à la mauvaise résa). const RESERVATION_META_KEY = "reservation_meta"; +// TTL compté depuis la DATE CIBLE de l'entrée (pas la pose) : une réservation +// créée 3 semaines à l'avance garde sa méta jusqu'au jour J (v2026.5.48). +const RESERVATION_META_TTL_MS = 7 * 24 * 60 * 60 * 1000; let _resMetaStore = {}; -function _resMetaKey(techId, isoDate, hhmm) { - return `${isoDate}|${techId}|${(hhmm || "").slice(0, 5)}`; +// v2026.5.48 — consommations pas encore écrites sur disque : le carnet +// n'est vidé qu'après qu'un jour a été RÉELLEMENT persisté (storeSave), sinon +// un crash entre les deux perdait la méta des deux côtés. +let _resMetaDirty = false; +function _resMetaKey(techId, ddmmyyyyDate, hhmmDebut, hhmmFin) { + return `${ddmmyyyyDate}|${techId}|${(hhmmDebut || "").slice(0, 5)}-${(hhmmFin || "").slice(0, 5)}`; +} +function _resMetaPersist() { + chrome.storage.local.set({ [RESERVATION_META_KEY]: _resMetaStore }) + .catch(e => LOG.warn("reservation", "écriture carnet méta échouée", { err: e && e.message })); } async function loadResMetaStore() { try { const r = await chrome.storage.local.get(RESERVATION_META_KEY); _resMetaStore = r[RESERVATION_META_KEY] || {}; } catch (e) { _resMetaStore = {}; } + // v2026.5.48 — purge au boot : + // - entrées d'ANCIEN format (clé sans heure de fin — 5.46/5.47 publiées et + // la refonte du carnet) : inertes (le lookup ne les matchera jamais) et potentiellement + // mal attribuées (le bug d'origine) → supprimées d'office ; + // - entrées dont la DATE CIBLE est passée depuis plus du TTL : posées mais + // jamais consommées (réservation supprimée avant le parse du jour). + const now = Date.now(); + let dirty = false; + for (const [k, m] of Object.entries(_resMetaStore)) { + const [dateCible, , creneau] = k.split("|"); + const dm = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(dateCible || ""); + const cibleMs = dm ? new Date(`${dm[3]}-${dm[2]}-${dm[1]}T23:59:59`).getTime() : 0; + const ancienFormat = !creneau || creneau.indexOf("-") < 0; + if (!m || typeof m !== "object" || ancienFormat || !cibleMs || + now - cibleMs > RESERVATION_META_TTL_MS) { + delete _resMetaStore[k]; + dirty = true; + } + } + if (dirty) _resMetaPersist(); } -function _resMetaGet(techId, isoDate, hhmm) { - return _resMetaStore[_resMetaKey(techId, isoDate, hhmm)] || null; -} -async function _resMetaSet(techIds, isoDate, hhmm, meta) { +function _resMetaSet(techIds, isoDate, hhmmDebut, hhmmFin, meta) { // La clé date doit être au format du XML calendar_block (JJ/MM/AAAA = iv.startDate), - // pas en ISO, sinon le lookup au parse ne matche jamais. + // pas en ISO, sinon le lookup à l'ingestion ne matche jamais. const dkey = (typeof isoToDDMMYYYY === "function") ? isoToDDMMYYYY(isoDate) : isoDate; for (const techId of (techIds || [])) { - _resMetaStore[_resMetaKey(String(techId), dkey, hhmm)] = { + _resMetaStore[_resMetaKey(String(techId), dkey, hhmmDebut, hhmmFin)] = { numero: meta.numero || "", ville: meta.ville || "", - rue: meta.rue || "", categorie: meta.categorie || "" + rue: meta.rue || "", categorie: meta.categorie || "", at: Date.now() }; } - try { await chrome.storage.local.set({ [RESERVATION_META_KEY]: _resMetaStore }); } - catch (e) { LOG.warn("reservation", "écriture meta persistante échouée", { err: e && e.message }); } + _resMetaPersist(); } -// v2026.5.46 — set par clé "brute" : la date est déjà au format XML -// (JJ/MM/AAAA = iv.startDate). Utilisé pour éditer une réservation déjà affichée. -async function _resMetaSetRaw(techId, ddmmyyyyDate, hhmm, meta) { - _resMetaStore[_resMetaKey(String(techId), ddmmyyyyDate, hhmm)] = { - numero: meta.numero || "", ville: meta.ville || "", - rue: meta.rue || "", categorie: meta.categorie || "" - }; - try { await chrome.storage.local.set({ [RESERVATION_META_KEY]: _resMetaStore }); } - catch (e) { LOG.warn("reservation", "écriture meta (édition) échouée", { err: e && e.message }); } +// v2026.5.48 — les 4 champs de méta réservation se manipulent par CES +// helpers (consommation, édition, fusion, transfert) : une seule liste de +// champs à maintenir, et l'horodatage de saisie (resMetaAt, arbitre des +// fusions dans ivMerge) ne peut pas être oublié en route. +function _ivHasResMeta(iv) { + return !!(iv.resNumero || iv.resVille || iv.resRue || iv.resCategorie); +} +function _ivSetResMeta(rec, meta, at) { + rec.resNumero = meta.numero || ""; + rec.resVille = meta.ville || ""; + rec.resRue = meta.rue || ""; + rec.resCategorie = meta.categorie || ""; + rec.resMetaAt = at || Date.now(); +} +function _ivResMetaOf(rec) { + return { numero: rec.resNumero || "", ville: rec.resVille || "", + rue: rec.resRue || "", categorie: rec.resCategorie || "" }; +} +// 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 : +// 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 +// l'entrée au prochain boot au lieu de perdre la méta (v2026.5.48). +function _resMetaConsumeInto(rec) { + if (rec.type !== "AL-Reservation") return false; + if (_ivHasResMeta(rec)) return false; + const key = _resMetaKey(String(rec.techId), rec.startDate, rec.startTime, rec.endTime); + const meta = _resMetaStore[key]; + if (!meta) return false; + _ivSetResMeta(rec, meta, meta.at); + delete _resMetaStore[key]; + _resMetaDirty = true; + return true; } // v2026.5.46 — assignations pompier : liste de périodes @@ -2509,7 +2830,15 @@ async function _checkForRestoredSession() { if (!resp || !resp.ok || !resp.session || !resp.session.phpsessid) return; const newId = resp.session.phpsessid; const oldPhpsessid = state.session ? state.session.phpsessid : null; - if (newId === oldPhpsessid) return; + // v2026.5.48 — même PHPSESSID : normalement rien à faire… SAUF si + // l'utilisateur vient de cliquer « Ouvrir EasyVista / Me reconnecter » + // (state.reconnecting) alors qu'un écran plein est encore affiché : la + // session technique est déjà dans state mais le chargement du planning + // n'a jamais abouti (échec de fetch resté à l'écran). Ignorer le tick + // rendait le bouton définitivement inopérant (l'id ne changera plus) ; + // on retente donc le chargement complet. + const relanceDemandee = state.reconnecting && _sessionFullScreenVisible(); + if (newId === oldPhpsessid && !relanceDemandee) return; // v2026.1.6 — QRO : anti-boucle « session SSO intermédiaire ». Un PHPSESSID // existe dans le cookie mais l'auth SSO n'a pas abouti → page de login, @@ -2520,7 +2849,11 @@ async function _checkForRestoredSession() { // est tenté immédiatement → vraie reconnexion détectée tout de suite). if (newId === state._failedRestoreId && (Date.now() - (state._failedRestoreAt || 0)) < 30000) return; - LOG.info("session", "nouvelle session détectée :", newId); + if (newId !== oldPhpsessid) { + LOG.info("session", "nouvelle session détectée :", newId); + } else { + LOG.info("session", "reconnexion demandée, écran plein encore affiché → rechargement retenté"); + } if (state.reconnectTimeoutId) { clearTimeout(state.reconnectTimeoutId); state.reconnectTimeoutId = null; @@ -2545,13 +2878,36 @@ async function _checkForRestoredSession() { hideReconnectingBanner(); hideSessionExpiredBanner(); hideReconnectFailedBanner(); - showToast("Reconnecté", "Session EasyVista renouvelée"); + // v2026.5.48 — ne déclarer « Reconnecté » (et ne masquer les écrans + // pleins) que si le planning a RÉELLEMENT rendu. Certains échecs de + // fetch gardent state.session (EV inaccessible, SSO sans user) et + // réaffichent leur propre écran plein : les masquer ici avec un toast + // « Reconnecté » mentirait sur une page vide. Quand le rendu a eu lieu, + // renderFromData a déjà masqué « session nécessaire » mais jamais + // « EV inaccessible » — d'où le masquage explicite des deux. + const cartes = document.getElementById("cards"); + const aRendu = !!(cartes && cartes.children.length > 0); + if (aRendu) { + hideSessionNeeded(); + hideEvUnreachable(); + showToast("Reconnecté", "Session EasyVista renouvelée"); + } } } catch (e) { // Silencieux : on réessayera au prochain tick / au prochain focus. } } +// v2026.5.48 — un des deux écrans pleins (« Session expirée » / +// « EasyVista inaccessible ») est-il affiché ? Sert au polling de reconnexion +// pour distinguer « rien à faire » de « chargement jamais abouti ». +function _sessionFullScreenVisible() { + return ["session-needed", "ev-unreachable"].some((id) => { + const el = document.getElementById(id); + return el && !el.classList.contains("hidden"); + }); +} + /** * Initialise le tick du compteur de session (toutes les secondes). * Pas de requête réseau : décompte purement local depuis state.sessionExpireAt. @@ -2785,6 +3141,18 @@ function getDefaultAdminConfig() { dayStart: 8, dayEnd: 18, cacheDays: 7, + // 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: {}, + // 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 + // 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.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 @@ -2865,6 +3233,7 @@ async function showAdminPanel(initialSection) { const sections = [ { id: "appearance", label: "Apparence", render: renderAdminSectionAppearance }, { id: "team", label: "Équipe", render: renderAdminSectionTeam }, + { id: "email", label: "Email", render: renderAdminSectionEmail }, // v2026.5.48 { id: "easyvista", label: "EasyVista", render: renderAdminSectionEV }, { id: "sync", label: "Synchronisation",render: renderAdminSectionSync }, { id: "data", label: "Données", render: renderAdminSectionData }, @@ -2956,9 +3325,16 @@ function renderAdminSectionTeam(container, cfg, saveFn) { const desc = document.createElement("p"); desc.className = "admin-section-desc"; - desc.textContent = "Sélectionnez les personnes qui doivent apparaître dans la planification. Les IDs viennent d'EasyVista (bouton Détecter) ou peuvent être saisis manuellement."; + desc.textContent = "Cochez les personnes à afficher dans le planning. La liste vient du groupe EasyVista ci-dessous."; container.appendChild(desc); + // v2026.5.48 — les adresses sont ICI (colonne Email) ; l'onglet Email ne + // garde que l'option « après un déplacement » et le modèle du message. + const emailHint = document.createElement("p"); + emailHint.className = "admin-section-desc"; + emailHint.textContent = "L'email envoyé après un déplacement (option et modèle) se règle dans l'onglet « Email »."; + container.appendChild(emailHint); + // ---- v2026.5.41 : Sélecteur de groupe EasyVista (SI-CSS / SI-EXT / …) ---- // Placé en tête de la section Équipe : c'est le groupe qui détermine quels // techniciens sont listés. Détection live depuis le ` + + `✉ Prévenir les techniciens par email` + ``, html: true, buttons: (() => { @@ -14604,7 +15449,8 @@ function _showRescheduleConfirmModal(iv, change) { }; const doApply = async (chg) => { try { - await _applyReschedule(iv, chg); + // v2026.5.48 — la décision email du moment part avec le change. + await _applyReschedule(iv, Object.assign({}, chg, { sansEmail: !emailCeDeplacement })); } catch (err) { LOG.warn("reschedule", "confirm modal apply err", { err: err && err.message }); showToast("Échec", err.message || "Impossible d'appliquer le déplacement"); @@ -14630,6 +15476,20 @@ function _showRescheduleConfirmModal(iv, change) { return btns; })() }); + // v2026.5.48 — câblage de la case email de la modal de confirmation + // (showAlertModal construit le DOM en synchrone, la case est déjà là). + // v2026.5.48 : décocher = CE déplacement seulement ; cocher quand l'option est + // inactive = l'activer pour de bon (comme avant). + const mailCb = document.getElementById("move-email-cb"); + if (mailCb) mailCb.addEventListener("change", async () => { + emailCeDeplacement = mailCb.checked; + if (mailCb.checked && !MOVE_EMAIL_ENABLED) { + if (!(await _setMoveEmailEnabled(true))) { + mailCb.checked = false; // v2026.5.48 — échec d'écriture + emailCeDeplacement = false; + } + } + }); })(); } @@ -14657,6 +15517,15 @@ async function _applyReschedule(iv, change) { const dateChanged = (state.currentDate || "") !== change.newDateIso; const durationChanged = (oldDuration !== null && newDuration !== null && oldDuration !== newDuration); + // v2026.5.48 — rien n'a changé : aucun appel EV ne partirait, donc ni + // toast « appliqué », ni douchette, ni brouillon email mensonger (« Avant = + // Après »). La modal de confirmation drag & drop a déjà son garde ; celle de + // modification n'en avait pas. + if (!techChanged && !startChanged && !dateChanged && !durationChanged) { + showToast("Aucun changement", "Le déplacement est identique à l'existant"); + return; + } + showToast("Modification en cours", "Application sur EasyVista…"); // Étape 1 : si tech / date / heure début ont changé → Planning_schedule_action_Employee @@ -14742,6 +15611,18 @@ async function _applyReschedule(iv, change) { }); } catch (e) { /* best-effort, le deplacement reste applique */ } + // v2026.5.48 — brouillon d'email aux deux techniciens concernés (option + // Paramètres → Email). Les valeurs « avant » ont été capturées en tête de + // fonction, AVANT la mutation du store. + _emailAfterMove({ + ref: iv.ref || "", + // v2026.5.48 — mêmes replis que la carte (fiche > infobulle > attr XML) + lieu: iv.ficheLocation || (iv.infobulle && iv.infobulle.lieu) || iv.bulleLieu || "", + contact: (iv.infobulle && iv.infobulle.contact) || iv.bulleContact || "", + oldTechId: douchOldTechId, oldTechName: douchOldTechName, + oldStart, oldEnd, sourceIso: douchSourceIso, change + }); + // Refresh local pour refléter (le fresh EV confirme — ou la fenêtre // _recentlyMovedAway neutralise son cache serveur en retard). if (typeof loadForDate === "function") { @@ -14752,6 +15633,209 @@ async function _applyReschedule(iv, change) { if (iv && iv._fromDock) _ivDockPendingRestore = null; } +// ============================================================================ +// v2026.5.48 — emails des techniciens + brouillon d'email au déplacement +// ============================================================================ + +// v2026.5.48 — email déduit du nom EV « Nom, Prénom » → prenom.nom@vd.ch (minuscules, +// accents et espaces retirés, traits d'union conservés). Sert de repli quand +// aucune adresse n'est saisie/récupérée dans Paramètres → Email. +function _emailDeduitDuNom(name) { + const m = /^([^,]+),\s*(.+)$/.exec(String(name || "").trim()); + if (!m) return ""; + const slug = (s) => normalizeName(s).replace(/['’\s]+/g, ""); + const nom = slug(m[1]), prenom = slug(m[2]); + const domaine = EMAIL_DOMAIN || DEFAULT_EMAIL_DOMAIN; // v2026.5.48 — configurable + return (nom && prenom) ? `${prenom}.${nom}@${domaine}` : ""; +} + +// v2026.5.48 — adresse d'un technicien : la saisie (Paramètres → Email) prime, +// sinon déduction depuis le nom. Chaîne vide si introuvable. +function _emailForTech(techId) { + const id = String(techId || ""); + return TEAM_EMAILS[id] || _emailDeduitDuNom(TEAM[id]) || ""; +} + +// v2026.5.48 — récupération de l'adresse réelle depuis l'annuaire EV : recherche du +// nom (basicAutoComplete), ouverture de la fiche employé (redirectHeader → +// fetchFiche, même mécanique que les fiches d'intervention : le checksum est +// servi par la session), extraction du champ AM_EMPLOYEE.E_MAIL du formData +// embarqué. Best-effort : null si la recherche ne mène pas à une fiche employé. +// v2026.5.48 — renvoie { email, fullName } au lieu de la seule adresse : +// le nom config (venu du planning EV) peut être TRONQUÉ (« Martins, Solange » +// pour « De Almeida Martins, Solange ») et c'est LUI qui s'affiche sur les +// 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) { + const origin = (state.session && state.session.origin) || state.lastKnownOrigin; + const sid = state.session && state.session.phpsessid; + if (!origin || !sid || !techName) return null; + try { + const r1 = await fetch(`${origin}/api/v1/internal/search/basicAutoComplete?search=${encodeURIComponent(techName)}&PHPSESSID=${encodeURIComponent(sid)}`, { credentials: "include" }); + const j1 = await r1.json(); + // seuls les résultats de l'annuaire (TABLE_NAME AM_EMPLOYEE) nous + // intéressent — la recherche EV renvoie aussi interventions, matériel, etc. + const employes = []; + for (const mo of ((j1 && j1.data && j1.data.METAOBJECTS) || [])) { + if (mo.TABLE_NAME !== "AM_EMPLOYEE") continue; + for (const line of (mo.LINES || [])) { + 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() + }); + } + } + } + const dec = (s) => ((typeof decodeJsonString === "function") ? decodeJsonString(s) : s).trim(); + for (const hit of employes.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 }); + const html = (resp && resp.ok && resp.html) || ""; + const m = /"dbFieldName"\s*:\s*"E_MAIL"[^}]*?"value"\s*:\s*"([^"]+@[^"]+)"/.exec(html); + if (m) { + const email = dec(m[1]); + if (!email.includes("@")) continue; + const mNom = /"dbFieldName"\s*:\s*"LAST_NAME"[^}]*?"value"\s*:\s*"([^"]{2,80})"/i.exec(html); + 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 }; + } + } + } catch (e) { + LOG.info("email", `annuaire EV : recherche échouée pour « ${techName} »`, { err: e && e.message }); + } + return null; +} + +// v2026.5.48 — le nom complet renvoyé par l'annuaire peut-il remplacer le nom affiché +// actuel ? Garde anti-homonyme : même prénom, et chaque mot du nom actuel doit +// se retrouver dans le nom complet (« Martins, Solange » est complété par +// « De Almeida Martins, Solange », mais jamais remplacé par un autre nom). +// Les deux doivent être au format « Nom, Prénom ». +function _nomCompletCompatible(actuel, complet) { + const pa = /^([^,]+),\s*(.+)$/.exec(String(actuel || "").trim()); + const pc = /^([^,]+),\s*(.+)$/.exec(String(complet || "").trim()); + if (!pa || !pc) return false; + if (normalizeName(pa[2]) !== normalizeName(pc[2])) return false; + const motsComplet = normalizeName(pc[1]).split(/\s+/).filter(Boolean); + return normalizeName(pa[1]).split(/\s+/).filter(Boolean) + .every((mot) => motsComplet.includes(mot)); +} + +// v2026.5.48 — remplit un modèle d'email : chaque {variable} est remplacée par sa +// valeur ; les lignes de liste restées sans valeur (« - Lieu : ») sont +// retirées ; les espaces doublés par une variable vide sont nettoyés. +function _moveEmailRempli(modele, valeurs) { + let txt = String(modele || ""); + for (const [cle, val] of Object.entries(valeurs)) { + txt = txt.split("{" + cle + "}").join(val || ""); + } + // v2026.5.48 — les puces « • » comptent comme les tirets pour la + // suppression des lignes restées vides. + txt = txt.split("\n").filter(l => !/^\s*[-•][^:]*:\s*$/.test(l)).join("\n"); + // v2026.5.48 — ne resserrer que les espaces doublés PAR une variable + // vide (au milieu d'une ligne) : l'indentation en tête de ligne du modèle + // (« • … ») est de la mise en forme voulue, on n'y touche pas. + return txt.split("\n").map(l => l.replace(/(\S)[ \t]{2,}/g, "$1 ")).join("\n").trim(); +} + +// v2026.5.48 — bascule de l'option « email après déplacement » pour les fenêtres de +// déplacement (modification + confirmation drag & drop) : même état partout, +// persistant (admin_config) tant qu'on ne la décoche pas. La case du panneau +// Équipe, elle, écrit sa copie cfg partagée (un load-modify-save d'ici pendant +// que le bouton « Enregistrer » du panneau sauve la sienne perdrait ses modifs). +// v2026.5.48 — renvoie true si l'option a bien été persistée : l'appelant +// peut remettre sa case dans l'état réel en cas d'échec d'écriture. +async function _setMoveEmailEnabled(actif) { + MOVE_EMAIL_ENABLED = !!actif; + const cfg = await loadAdminConfig(); + cfg.moveEmailEnabled = MOVE_EMAIL_ENABLED; + const ok = await saveAdminConfig(cfg); + if (!ok) { + LOG.warn("email", "enregistrement de l'option email échoué"); + showToast("Option non enregistrée", "L'écriture des paramètres a échoué — réessaie"); + } + return ok; +} + +// v2026.5.48 — après un déplacement APPLIQUÉ, si l'option est active (Paramètres → +// Équipe), ouvre un brouillon d'email (mailto → Outlook) pré-adressé aux deux +// techniciens concernés avec le récapitulatif. L'ENVOI reste un geste manuel +// 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). +function _emailAfterMove(opts) { + if (!MOVE_EMAIL_ENABLED) return; + try { + const { ref, lieu, contact, oldTechId, oldTechName, oldStart, oldEnd, sourceIso, change } = opts; + // v2026.5.48 — case décochée dans la fenêtre de déplacement : pas + // d'email pour CE déplacement uniquement (l'option globale reste active, + // la prochaine fenêtre revient cochée). + if (change && change.sansEmail) return; + const newTechId = String(change.newTechId || ""); + const newTechName = change.newTechName || (TEAM && TEAM[newTechId]) || ("tech " + newTechId); + const targetIso = change.newDateIso || sourceIso; + // v2026.5.48 — consigne Quentin : 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))]; + // 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 (manquants.length) { + showToast(manquants.length > 1 ? "Adresses manquantes" : "Adresse manquante", + manquants.join(" et ") + (manquants.length > 1 ? " n'ont" : " n'a") + + " pas d'adresse — complète le destinataire dans l'email"); + } + const valeurs = { + numero: ref || "", + ancien_tech: oldTechName || ("tech " + oldTechId), + nouveau_tech: newTechName, + ancienne_date: _isoToDDMMYYYY(sourceIso), + nouvelle_date: _isoToDDMMYYYY(targetIso), + ancienne_heure_debut: oldStart || "", + ancienne_heure_fin: oldEnd || "", + nouvelle_heure_debut: change.newStartTime || "", + nouvelle_heure_fin: change.newEndTime || "", + contact: contact || "", + lieu: lieu || "" + }; + 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 Quentin, + // « 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 }); + } catch (e) { + LOG.warn("email", "ouverture du brouillon é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 @@ -18745,6 +19829,7 @@ function _triggerDeleteItem(actionId, kind, triggerBtn) { label: "Supprimer", variant: "danger", action: async () => { + const labelBtnAvant = triggerBtn ? triggerBtn.textContent : ""; if (triggerBtn) { triggerBtn.disabled = true; triggerBtn.textContent = "Suppression…"; @@ -18776,7 +19861,9 @@ function _triggerDeleteItem(actionId, kind, triggerBtn) { }); if (triggerBtn) { triggerBtn.disabled = false; - triggerBtn.textContent = "🗑 Supprimer l'absence"; + // v2026.5.48 — libellé d'origine (une réservation redevenait + // « Supprimer l'absence » après un échec). + triggerBtn.textContent = labelBtnAvant; } } } @@ -19975,6 +21062,9 @@ const SEARCH_MIN_CHARS = 2; let _searchSeq = 0; let _searchInflight = null; let _searchDebTimer = null; +// v2026.5.48 — vrai pendant une recherche (index + fetch + rendu lancé) : +// bloque la relance au survol/focus qui tuait la recherche en vol. +let _srSearchEnCours = false; let _searchCacheByRef = new Map(); let _searchCacheList = []; let _searchCacheBuiltAt = 0; @@ -19982,7 +21072,7 @@ let _searchCacheSource = null; // "dossier" | "local" | null let _searchCacheDate = null; // v2026.5.46 — jour indexé (rebuild si changé) let _searchInited = false; -function _srNorm(s) { return String(s == null ? "" : s).toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").trim(); } +function _srNorm(s) { return String(s == null ? "" : s).toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim(); } function _srCleanLabel(key) { if (typeof key !== "string") return key; if (key.startsWith("@@hidden_")) return key.slice(9); @@ -20054,7 +21144,19 @@ function _srPushDay(day, dayLabel, seen) { if (e.ref) _searchCacheByRef.set(String(e.ref), e); } } -async function _srBuildCacheIndex(force) { +// v2026.5.48 — construction à VOL UNIQUE : en réseau interne, l'index +// relit tous les jours du dossier partagé (lent). Deux appels concurrents +// (focus + recherche, ou deux recherches rapprochées) se réinitialisaient +// mutuellement les listes en cours de remplissage : le second se greffe +// désormais sur la construction en vol. +let _srCacheBuildInflight = null; +function _srBuildCacheIndex(force) { + if (_srCacheBuildInflight) return _srCacheBuildInflight; + const p = _srBuildCacheIndexInterne(force); + _srCacheBuildInflight = p.finally(() => { _srCacheBuildInflight = null; }); + return _srCacheBuildInflight; +} +async function _srBuildCacheIndexInterne(force) { const FRESH_MS = 120000; const curDate = (state && state.currentDate) || null; // v2026.5.46 — reconstruit si périmé (2 min) OU si on a changé de jour @@ -20300,8 +21402,23 @@ function _srIsLocationQuery(q) { async function _srDoSearch(query) { const mySeq = ++_searchSeq; // jeton anti-périmé (toutes branches) - await _srBuildCacheIndex(false); - if (mySeq !== _searchSeq) return; + // v2026.5.48 — marquer la recherche EN COURS dès l'entrée : la relance + // au survol/focus (_srReopenIfQuery) tuait la recherche en vol via le jeton + // et repartait de zéro — souris sur la barre = recherches qui s'entretuent, + // « je ne tape plus et rien ne part ». La roue tourne AUSSI pendant la + // construction de l'index (phase lente en réseau interne), pas seulement + // pendant le fetch EV. + _srSearchEnCours = true; + _srSpin(true); + try { + await _srBuildCacheIndex(false); + if (mySeq !== _searchSeq) return; + await _srDoSearchSuite(query, mySeq); + } finally { + if (mySeq === _searchSeq) _srSearchEnCours = false; + } +} +async function _srDoSearchSuite(query, mySeq) { // v2026.5.46 — EV reçoit la requête NETTOYÉE (sans opérateurs % " - champ:) const evQuery = _srCleanQueryForEV(query); if (_srIsLocationQuery(evQuery)) { @@ -20355,8 +21472,15 @@ function _srBuildCard(it) { // (le badge À venir/Aujourd'hui/Passé reste calé sur la date réelle). const _hereIso = _srFrToIso(it.day); const _onShownDay = !!(_hereIso && state.currentDate && _hereIso === state.currentDate); - const badge = `
${_srStatusIcon(sk)} ${escapeHtml(it.statusLabel)}${w ? `${_srWhenLabel(w)}` : ""}${_onShownDay ? `📍 Jour affiché` : ""}
`; - main.insertAdjacentHTML("afterbegin", badge); + // 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 Quentin). Le liseré de couleur (acc-*) reste. + const pillHtml = (sk === "clos" || sk === "fait") + ? `${_srStatusIcon(sk)} ${escapeHtml(it.statusLabel)}` : ""; + if (pillHtml || w || _onShownDay) { + const badge = `
${pillHtml}${w ? `${_srWhenLabel(w)}` : ""}${_onShownDay ? `📍 Jour affiché` : ""}
`; + main.insertAdjacentHTML("afterbegin", badge); + } } const actions = document.createElement("div"); actions.className = "sr-actions"; const evBtn = document.createElement("button"); evBtn.className = "sr-btn"; evBtn.textContent = "EasyVista ↗"; @@ -20827,6 +21951,11 @@ function _srClear() { function _srReopenIfQuery() { const box = document.getElementById("ev-search-results"); if (box && !box.classList.contains("hidden")) return; // déjà affiché + // v2026.5.48 — une recherche est déjà en vol (ou programmée par le + // debounce) : ne PAS la tuer en la relançant — c'était la cause du + // « je ne tape plus et la recherche ne part pas » (chaque passage de souris + // sur la barre repartait de zéro pendant la lecture lente de l'index). + if (_srSearchEnCours || _searchDebTimer) return; const inp = document.getElementById("ev-search"); const v = inp && inp.value.trim(); if (v && v.length >= SEARCH_MIN_CHARS) _srDoSearch(v); @@ -20892,13 +22021,17 @@ function initSearchBar() { // qu'après une pause de frappe → « cherche quand j'ai fini d'écrire ». _searchSeq++; if (_searchInflight) { try { _searchInflight.abort(); } catch (e) {} _searchInflight = null; } - if (_searchDebTimer) clearTimeout(_searchDebTimer); + if (_searchDebTimer) { clearTimeout(_searchDebTimer); _searchDebTimer = null; } + _srSearchEnCours = false; // v2026.5.48 — la frappe invalide la recherche en vol _srSpin(false); if (v.length < SEARCH_MIN_CHARS) { _srShowResults(false); return; } - _searchDebTimer = setTimeout(() => _srDoSearch(v), SEARCH_DEBOUNCE_MS); + // v2026.5.48 — le timer est remis à null au déclenchement : un id + // périmé resté truthy bloquerait la relance au survol (_srReopenIfQuery). + _searchDebTimer = setTimeout(() => { _searchDebTimer = null; _srDoSearch(v); }, SEARCH_DEBOUNCE_MS); }); inp.addEventListener("keydown", (e) => { - if (e.key === "Enter") { if (_searchDebTimer) clearTimeout(_searchDebTimer); const v = inp.value.trim(); if (v.length >= SEARCH_MIN_CHARS) _srDoSearch(v); } + // v2026.5.48 — Entrée : recherche immédiate, sans attendre le debounce. + if (e.key === "Enter") { if (_searchDebTimer) { clearTimeout(_searchDebTimer); _searchDebTimer = null; } const v = inp.value.trim(); if (v.length >= SEARCH_MIN_CHARS) _srDoSearch(v); } // v2026.5.46 — Échap : ferme le popup de recherche (sans vider le texte, // pour pouvoir le rouvrir) + ferme les popups non épinglés. else if (e.key === "Escape") { _srShowResults(false); _srCloseTransientPopups(); } @@ -20972,6 +22105,13 @@ function initSearchBar() { }); } document.addEventListener("click", (e) => { + // v2026.5.48 — un clic traité DANS les résultats peut retirer + // l'élément cliqué du DOM avant que l'événement remonte ici (bascule + // « Planifiée (passé) » qui réécrit son innerHTML, pied « N de plus » + // retiré au chargement) : l'élément détaché n'est plus contenu par la + // zone → la recherche se fermait à tort. Un clic sur un élément détaché + // n'est jamais un clic « à l'extérieur ». + if (e.target && e.target.isConnected === false) return; const box = document.getElementById("ev-search-results"); if (!zone.contains(e.target) && !(box && box.contains(e.target))) _srShowResults(false); });