Compare commits

..

18 Commits

Author SHA1 Message Date
FroSteel 6ae440cbf1 Version 5.0.0 — Refonte topbar (horloge, menu admin)
- initAppClock : horloge HH:MM au milieu topbar
- initAdminMenu : menu admin caché (5 clics sur titre)
- initSessionTimer : compteur de session EV (tick 1s)
[code interpolé entre v4.3.0 et v5.0.9]
2026-04-20 09:00:00 +02:00
FroSteel f6d549d522 Version 4.3.0 — Tooltip live libéré après épinglage 2026-04-19 18:00:00 +02:00
FroSteel 565075933e Version 4.2.8 — Corrections cumulées 4.2.4-8 2026-04-19 15:00:00 +02:00
FroSteel 7f78493859 Version 4.2.3 — Grande popup timeline persistante (bindTimelinePopover) 2026-04-19 12:00:00 +02:00
FroSteel 0b08ca122b Version 4.2.1 — Démarrage série 4.2 2026-04-19 09:00:00 +02:00
FroSteel 87f561ae10 Version 4.1.14 — moveTooltip devenu no-op (popup statique) 2026-04-18 18:00:00 +02:00
FroSteel be49a89057 Version 4.1.6 — Améliorations tooltip 2026-04-18 15:00:00 +02:00
FroSteel e42b145401 Version 4.1.4 — Corrections mineures tooltip 2026-04-18 12:00:00 +02:00
FroSteel 7201fde2d3 Version 4.1.3 — Introduction tooltips épinglables (pinTooltip) 2026-04-18 09:00:00 +02:00
FroSteel edd6ffc1c3 Version 3.3.0 — Corrections + raffinements
(manifest.json corrigé : était resté à 3.2.0 par oubli)
2026-04-17 18:00:00 +02:00
FroSteel 23244fc4db Version 3.2.0 — Stabilisation 3.2 2026-04-17 16:00:00 +02:00
FroSteel f52095dc4d Version 3.2.0 (pre-release) — Travail en cours sur la 3.2 2026-04-17 14:00:00 +02:00
FroSteel 94877cb816 Version 3.1.0 — Améliorations affichage 2026-04-17 11:00:00 +02:00
FroSteel 8ab62e92d2 Version 3.0.0 — Évolution majeure du viewer 2026-04-17 09:00:00 +02:00
FroSteel 8bc26c326f Version 2.0.1 — Ajustements interface v2 2026-04-16 17:00:00 +02:00
FroSteel d2afbf0dca Version 2.0.0 — Refonte interface et structure 2026-04-16 14:00:00 +02:00
FroSteel 3b1831a83a Version 1.0.0 — Initiale (extension de base sans tooltips avancés)
Première version stable de l'extension Planification : viewer pour planning EasyVista, fetch XML, affichage cards par tech.
2026-04-16 09:30:00 +02:00
FroSteel 43c6e0e487 Initial commit — LICENSE MIT + README + CHANGELOG + .gitignore 2026-04-16 09:00:00 +02:00
9 changed files with 471 additions and 1710 deletions
+39
View File
@@ -0,0 +1,39 @@
# OS
.DS_Store
Thumbs.db
desktop.ini
# Editors
.vscode/
.idea/
*.swp
*.swo
*~
# Backups
*.bak
*.bak-*
*.orig
*.old
# Build artifacts (les ZIP/XPI livrés ne sont pas dans le repo, ils sont buildés à la demande)
*.zip
*.xpi
*.crx
# Node (si jamais utilisé pour build)
node_modules/
package-lock.json
npm-debug.log*
# Logs
*.log
rebuild.log
# Dossiers de travail temporaires
extracted/
temp/
tmp/
# Tests
test-output/
+179
View File
@@ -0,0 +1,179 @@
# CHANGELOG — Extension Planification EasyVista Canton de Vaud
> Ce changelog documente l'évolution de l'extension Chrome/Firefox "Planification"
> développée par Quentin Rouiller pour les techniciens IT du Canton de Vaud.
>
> Les versions documentées ci-dessous sont celles dont les détails sont connus.
> Pour les versions plus anciennes, Claude Code se basera sur l'analyse du code
> source pour déterminer un message de commit pertinent.
---
## v2026.5.37 — Refonte vue horizontale (sidebar complète)
**Branche** : current
- Topbar en haut supprimée en vue horizontale
- User-badge + titre déplacés tout en haut de la sidebar
- Bouton "Aujourd'hui" pleine largeur avec icône ↺
- Date + heure centrés sous le bouton
- Séparateur visuel
- Sélecteur de date pleine largeur
- Flèches ◀ ▶ côte à côte (wrapper #sidebar-arrows)
- Stats empilées
- Synchronisé à HH:MM
- Espace vide intentionnel
- Boutons du bas vers le haut (margin-top: auto sur Absence)
- Barre de rafraîchissement en overlay top-left
- Banderole pompier masquée en vue horizontale (badge + barre rouge à gauche conservés)
## v2026.5.36 — Sidebar verticale en vue horizontale
- Création wrapper flex-row #horizontal-wrapper contenant [sidebar] + [main]
- Sidebar 200px (170px sur <1400px), sticky, bg-muted
- Déplacement physique des éléments via JS (ELEMENTS_TO_RELOCATE)
- Mémorisation parents d'origine (data-orig-parent + data-orig-index)
- Restauration propre en vue classique
- Zone nom tech : 140px → 120px
## v2026.5.35 — Fix popup épinglé position vue horizontale + stats gauche
- Fix popup épinglé qui partait en haut à gauche en vue horizontale
- Cause : rows .intervention-v2 cachées (display: none) → getBoundingClientRect (0,0,0,0)
- Solution : priorité 1 tooltip visible, priorité 2 segment timeline, fallback srcEl
- Stats globales en colonne verticale 200px à gauche en vue horizontale
- Position sticky, fond bg-muted, séparateurs · masqués
- Zone nom tech 200px → 140px (vue horizontale)
## v2026.5.34 — Bouton 📌 restauré + badge user cliquable
- HTML : badge user toujours visible avec "?" par défaut (retiré class hidden)
- _softUnpinPopup refait en 8 étapes loggées
- Popup reste visible après désépinglage (plus de suppression auto au mouseleave)
- Restauration du bouton 📌 dans .tooltip-actions
- Handler click ré-attaché : clic 📌 = ré-épingle, clic ↻ = recharge
- _ensureSoftUnpinnedCleanupHandler : handler global clic hors popup
- _maybeRetryFetchUser : relance opportuniste après succès planning et reconnexion session
- Logs abondants : [currentUser], [softUnpin], [positionTooltip], [persistentTimeline], [showTooltip]
- Fonction positionTooltipAnchored unifiée (4 candidats droite/gauche/dessous/dessus)
- popup._linkedIv stocké pour ré-épinglage
## v2026.5.33 — Interactions vue horizontale différenciées
- Hover segment timeline en vue horizontale → grande popup directement (openPersistentTimelinePopup)
- Clic segment timeline en vue horizontale → ouvre fiche EasyVista
- Popup absence en vue horizontale : hover uniquement sur badge .card-tech-badge (pas sur carte entière)
- Vue classique : comportement inchangé
## v2026.5.32 — Vue horizontale togglable
- Bouton ⊞ "Vue" dans popup user-badge (à côté ⚙ Paramètres)
- Toggle Vue classique ↔ Vue horizontale persisté localStorage "view_mode"
- HTML class "view-classic" ou "view-horizontal" sur <html>
- Chaque tech = 1 ligne horizontale compacte en mode horizontal
- Card header devient barre latérale gauche fixe 200px
- Interventions détaillées masquées (display: none)
- Timeline horizontale pleine largeur
- Stats rapides .tech-row-stats ajoutés au header (nb interv, Xm · Ya)
## v2026.5.31 — Sarcelle pour absence récurrente (REJETÉ par utilisateur)
- Couleur Pillonel vendredi : sarcelle foncée #0f766e / soft #ccfbf1
- Variables --c-recurring, --c-recurring-soft
- Layout 4 colonnes forcées + scroll interne cartes (REJETÉ : "scroll en continu")
## v2026.5.30 — Absence récurrente cyan + mode compact 24"
- Absence récurrente Pillonel vendredi en cyan
- Mode compact @media (max-width: 1920px) avec grid-template-columns: repeat(4, 1fr)
## v2026.5.29 — Contraste++ + footer
- Contrastes encore plus forts (text-muted #d0d5de dark, #2e3642 light)
- Footer QRO/version : 13px badge avec fond bg-muted + bordure
- Fix highlight row : selector .intervention-v2[data-iv-idx]
- Scroll-into-view automatique au hover segment timeline
## v2026.5.28 — Ajustements visuels absences
- Retrait pastille ronde (.tech-name-dot supprimée) — barre gauche + badge suffisent
- "Maladie" → "Maladie/Accident"
- Contraste textes secondaires +30%
- Popups épinglés width fixe 520px (ne rétrécit plus au resize fenêtre)
- _clampPopupInSafeArea ne rétrécit plus si popup > zone dispo
## v2026.5.27 — Classification absences (Maladie/Congé/Pompier)
- Topbar une ligne : "Jeudi 23.04.26 • 21:55" (gros point •, même taille 22px)
- Fermeture auto popups non-épinglés au survol autre popup/carte
- Texte +20% topbar/stats/boutons
- Icône thème ☀/🌙 plus contrastée (bordure 1.5px, fond bg-muted, ombre)
- Classification absences (ABSENCE_LABELS) + absenceCategory : "maladie"|"conge"|"pompier"|null
- Couleurs : Maladie #4338ca indigo foncé, Congé #06b6d4 cyan, Pompier #b03030 rouge
- Badge + barre gauche + dégradé fond pour catégorie
- Libellé "Absent du DD.MM au DD.MM — Maladie/Accident"
- Suffixe `s` adaptatif (Congé/Congés)
## v2026.5.26 — Badge user inconnu cliquable + retry
- En cas d'échec fetch user, afficher rond gris "?" cliquable
- Bouton ⚙ Paramètres accessible même quand user inconnu
- Retry automatique 60s (max 10 essais = 10 min)
- Reset compteur au succès
## v2026.5.25 — Bouton Paramètres dans popup user-badge
- Remplace les 5 clics sur le titre pour ouvrir admin
- Bouton ⚙ Paramètres explicite dans le popup user-badge
## v2026.5.16-v2026.5.24 — Évolutions diverses (à compléter)
- v2026.5.17 : popup user-badge avec ligne session (MM:SS), couleur selon seuil
- v2026.5.18 : dock pastilles popups épinglés avec couleur catégorie
- v2026.5.19 : drag popup épinglé
- v2026.5.20 : safe area popups (topbar + dock)
- v2026.5.22 : régénération tooltip hover après softUnpin
- v2026.5.23 : reset bulleState.pinned + iv._reloading
---
## Versions antérieures (v5.x et v4.x)
> Ces versions sont à analyser par Claude Code à partir des fichiers source.
> Indices clés à chercher dans le viewer.js :
>
> - Présence de `pinTooltip` → version >= v4.x
> - Présence de `_softUnpinPopup` → version >= v4.3.3
> - Présence de `initSessionTimer` → version >= v5.0.9
> - Présence de `initAppClock` → version >= v5.0.0
> - Présence de `_applyViewMode` → version >= v2026.5.32
> - Présence de `bindTimelinePopover` → version >= v4.2.3
> - Présence de `openPersistentTimelinePopup` → version >= v4.2.3
> - Commentaires `// vX.Y.Z` au-dessus des fonctions = version d'introduction
### v5.0.0 — Refonte topbar (horloge, menu admin)
- initAppClock : horloge HH:MM au milieu topbar
- initAdminMenu : menu admin caché (5 clics sur titre)
- initSessionTimer : compteur de session EV (tick 1s)
### v4.x — Fonctions tooltip avancées
- v4.1.12 : moveTooltip devenu no-op (popup statique)
- v4.1.15 : pendant épinglage, ne pas remplacer contenu sur hover autre iv
- v4.2.3 : grande popup timeline persistante (clic), suit-souris (hover)
- v4.2.3 : bindTimelinePopover, showTimelinePopover, moveTimelineTooltip
- v4.2.4 : setTooltipViewportPosition (détection auto fixed/abs)
- v4.2.9 : pied de page discret QRO/version
- v4.2.9 : initModalScrollLock (bloquer scroll arrière modal)
- v4.3.0 : tooltip live libéré après épinglage (réutilisable autres survols)
- v4.3.3 : _softUnpinPopup (désépinglage mou)
### v3.x et antérieures — Versions de base
- À analyser par Claude Code
---
## Notes techniques persistantes (toutes versions)
- 8 techs hardcodés : "76272,83725,66635,92235,90070,40944,72485,86874"
- Pillonel Olivier (ID 40944) absent tous les vendredis (hardcodé)
- Group ID EasyVista : 191
- Domaines cibles : itsma.etat-de-vaud.ch (interne), itsma.vd.ch (externe)
- SSO : Canton ForgeRock OpenAM
- ABSENCE_LABELS = /^(cong[ée]s|maladie|pompier)$/i
- ADMIN_CONFIG_KEY = "admin_config"
- VIEW_MODE_KEY = "view_mode" (depuis v2026.5.32)
- DAY_NAMES_FULL = ["Dimanche", "Lundi", ..., "Samedi"]
- GUIDs forms EV : S={C99ECD05-3D48-4C62-ABF0-66292053AED6} demande, I={07ED9C68-6172-48EA-8A58-90912B0A283E} incident
- Couleurs catégories : livraison #2563eb, recup #16a34a, remplacement #ea580c, incident #8b5cf6, rollout #92400e, reservation #f59e0b, autre #6b7280
## Auteur
**Quentin Rouiller** (QRO)
Canton de Vaud — Service IT
Email pour commits Git : `quentin.rouiller@ikmail.com`
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Quentin Rouiller
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+156 -84
View File
@@ -1,110 +1,182 @@
# Planning techniciens — Vue claire (v4.1.2) # Planification — Extension EasyVista Canton de Vaud
Extension Chrome/Brave/Edge pour afficher le planning techniciens EasyVista Extension Chrome / Firefox pour visualiser de manière claire et rapide le planning des techniciens IT du Canton de Vaud dans EasyVista.
(`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) dans une vue plus lisible.
## Nouveautés v4.1.2 ## Aperçu rapide
- **Vraies infos contact/lieu dans les cartes** : les attributs attr1/attr2 du - **Auteur** : Quentin Rouiller (QRO)
XML contiennent les infos saisies à la *planification*, qui ne sont pas - **Cible** : techniciens IT Canton de Vaud, EasyVista (`itsma.etat-de-vaud.ch` / `itsma.vd.ch`)
toujours à jour (le tech a pu corriger le contact/lieu avant intervention). - **Démarrage projet** : jeudi 16 avril 2026
Désormais, pour chaque intervention, on fetch AUSSI le xhr2 en arrière-plan - **Version actuelle** : `v2026.5.37`
(en plus de la fiche), ce qui apporte les **vraies** infos validées. La - **Manifest** : V3 (Chrome/Edge/Firefox)
carte se met à jour automatiquement quand elles arrivent. - **Format** : `.zip` (Chromium) + `.xpi` signé (Firefox)
- **Clic ouverture restauré** : retour à la logique v4 (fetch fiche à la volée
+ extraction checksum + construction URL avec sender adéquat). Le checksum
est pré-rempli pendant le fetch arrière-plan, donc au clic l'ouverture est
instantanée dans la plupart des cas.
## Nouveautés v4.1 ## Fonctionnalités principales
- **Fetch des fiches séquentiel (1 par 1)** au lieu de 5 workers en parallèle. ### Vue planning
Le serveur EasyVista sérialise les requêtes de toute façon, donc le parallélisme - Affichage des interventions et réservations groupées par technicien
n'apporte rien. Et surtout : quand tu changes de date pendant le fetch, l'abort - Horaires, contact, lieu, catégorie, statut visibles d'un coup d'œil
est **instantané** car il n'y a qu'une seule requête en vol au maximum. - 8 techniciens hardcodés (équipe IT canton)
- **Cache incrémental** : le cache est sauvé toutes les 5 fiches pendant le fetch, - Cache local pour réduire les requêtes serveur
pas juste à la fin. Si tu changes de date avant que tout soit fini, les statuts
déjà récupérés sont conservés.
## Nouveautés v4 ### Modes d'affichage
- **Vue classique** (depuis v1.0.0) : cards en grille, mode compact écran 24" (depuis v2026.5.30)
- **Vue horizontale** (depuis v2026.5.32) : timeline par tech, sidebar verticale (depuis v2026.5.36)
- Toggle Vue classique ↔ Vue horizontale via bouton ⊞ dans popup user-badge
- Persistance localStorage (`view_mode`)
**Chargement ~50× plus rapide.** Le nombre de requêtes au serveur EasyVista passe ### Tooltips et popups
de ~100 par chargement à **1 seule requête** pour l'affichage principal. - Tooltips au survol (hover) sur chaque intervention
- Popups épinglables (📌) pour garder ouvert (depuis v4.1.3)
- Popups timeline persistantes au clic (depuis v4.2.3)
- Drag-and-drop des popups épinglés (depuis v2026.5.19)
- Safe area : popups jamais cachés sous topbar/dock (depuis v2026.5.20)
- Position auto adaptative (4 candidats : droite/gauche/dessous/dessus)
Concrètement, en v3 un chargement initial faisait : ### Classification des absences (depuis v2026.5.27)
- 1 fetch XML planning (`calendar_block`) - **Maladie/Accident** : indigo `#4338ca`
- ~40 fetches `planning_xhr_2.php` pour les lieux/contacts - **Congé / Congés** : cyan `#06b6d4` (suffixe `s` adaptatif)
- ~40 fetches de fiches HTML pour les catégories/refs/statuts - **Pompier** : rouge `#b03030`
- jusqu'à ~40 fetches de l'API timeline - Badge + barre gauche colorée + dégradé fond
- Absence récurrente Pillonel vendredi : cyan (depuis v2026.5.30)
Total : ~120 requêtes, 10+ Mo, 8 à 15 secondes selon la charge serveur. ### 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
- Retry automatique en cas d'échec fetch user (60s, max 10 essais)
- Compteur de session EasyVista (tick 1s, depuis v5.0.0)
- Reconnexion automatique
En v4, on a découvert que le XML initial `calendar_block` contient **déjà** ### Admin et configuration
dans ses attributs `attr1`/`attr2`/`attr3` le contact, le lieu et la catégorie - Mode admin caché : bouton ⚙ Paramètres dans popup user-badge (depuis v2026.5.25, remplace les 5 clics secrets sur le titre)
complète de chaque intervention, et la ref dans le textContent du nœud. - Configuration persistée dans `localStorage` (`admin_config`)
Toutes ces infos qu'on allait chercher ailleurs étaient en fait dans la toute - Catégories interventions personnalisables (livraison/recup/remplacement/incident/rollout/reservation/autre)
première réponse, ignorées par le code.
Résultat : le premier rendu complet arrive en **moins d'une seconde**. Les ## Versionning — historique et conventions
fiches individuelles ne sont plus fetchées qu'en arrière-plan, uniquement
pour le statut "Clôturé/Résolu" et le commentaire technicien.
**Lazy-load au survol.** Le texte détaillé d'une intervention (Problème, À faire, L'extension a connu **3 systèmes de versionning successifs** :
Matériel, TFS ancien/nouveau poste...) n'est chargé qu'au premier survol de la
ligne, seulement pour l'intervention survolée. Imperceptible pour l'utilisateur,
énorme pour le serveur.
**Concurrence réduite.** Le pic de requêtes parallèles passe de 15 à 5 workers, | Période | Format | Exemple |
pour ménager le serveur EasyVista qui a tendance à saturer sous les rafales. |---|---|---|
| 16-17 avril 2026 | Versions de base | `1.0.0`, `2.0.0`, `3.0.0` |
| 18-20 avril 2026 | SemVer classique | `4.1.3`, `4.2.8`, `5.0.12` |
| 21 avril 2026 → maintenant | **Année + mois + patch** | `2026.5.16``2026.5.37` |
Toute l'interface utilisateur est **strictement identique** à la v3 — on n'a ### Pourquoi le passage à `YYYY.M.PATCH` ?
changé que ce qu'il y a sous le capot.
## Hérité des versions précédentes À partir de la **v2026.5.16** (21 avril 2026), l'extension est passée au versionning par année :
- Plus lisible pour les utilisateurs (l'année indique immédiatement la fraîcheur)
- Plus de débat sur ce qui constitue un "majeur" vs "mineur"
- Bump du `PATCH` à chaque livraison
- Navigation par date : ◀ ▶ et sélecteur ⚠️ **Important** : `v2026.5.16` succède chronologiquement à `v5.0.12`, malgré le numéro qui semble plus petit. Le préfixe `2026` indique l'année.
- Détection automatique des interventions closes (✓ vert, fond vert)
- Cache persistant 7 jours ## Versions notables
- Ghosts : les interventions disparues d'EasyVista restent visibles dans la vue
- Refresh auto 12h et 15h ### `v2026.5.37` (latest, 25 avril 2026) — Refonte vue horizontale
- Annulation coopérative (bouton "Arrêter") - Topbar supprimée en vue horizontale, tout passe en sidebar
- Thème clair/sombre - User-badge + titre + bouton "Aujourd'hui" + date/heure + sélecteur + flèches + stats dans sidebar
- Banderole pompier masquée (badge + barre rouge gauche conservés)
### `v2026.5.36` — Sidebar verticale
- Wrapper flex-row `#horizontal-wrapper` [sidebar 200px] + [main]
- Déplacement physique des éléments via `ELEMENTS_TO_RELOCATE`
- Restauration propre en vue classique
### `v2026.5.32` — Vue horizontale togglable
- Bouton ⊞ "Vue" dans popup user-badge
- Chaque tech = 1 ligne horizontale compacte
- localStorage `view_mode`
### `v2026.5.27` — Classification absences
- ABSENCE_LABELS : `^(cong[ée]s|maladie|pompier)$`
- Couleurs catégories
- Topbar une ligne : "Jeudi 23.04.26 • 21:55"
### `v4.2.3` — Grande popup timeline persistante
- Clic segment timeline = popup persistante
- Hover = popup qui suit la souris
### `v4.1.3` — Tooltips épinglables
- Introduction de `pinTooltip`
### `v1.0.0` (16 avril 2026) — Initiale
- Premier viewer EasyVista pour le canton
Voir [CHANGELOG.md](CHANGELOG.md) pour l'historique complet (40 versions taggées).
## Architecture technique
```
manifest.json # Manifest V3 (Chrome) + gecko_settings (Firefox)
background.js # Worker fond : fetch planning XML, gestion session, fetch fiches
viewer.html # Interface principale
viewer.js # Logique (~9000 lignes) — voir détail ci-dessous
viewer.css # Styles + thèmes clair/sombre
icons/ # icon16, icon48, icon128
```
### `viewer.js` — fonctions clés
| Fonction | Introduite | Rôle |
|---|---|---|
| `loadForDate` | v1.0.0 | Fetch + parse planning pour une date donnée |
| `buildTooltipHTML` | v1.0.0 | Construction HTML du tooltip d'intervention |
| `pinTooltip` | v4.1.3 | Épingler un tooltip (le rendre permanent) |
| `bindTimelinePopover` | v4.2.3 | Lier popover timeline aux segments |
| `showTimelinePopover` | v4.2.3 | Afficher popover persistante |
| `openPersistentTimelinePopup` | v4.2.3 | Grande popup détaillée |
| `setTooltipViewportPosition` | v4.2.4 | Détection auto fixed/abs |
| `_softUnpinPopup` | v4.3.3 | Désépinglage mou (popup reste visible) |
| `initAppClock` | v5.0.0 | Horloge HH:MM topbar |
| `initSessionTimer` | v5.0.0 | Compteur session EV (tick 1s) |
| `initAdminMenu` | v5.0.0 | Menu admin (5 clics titre) |
| `_applyViewMode` | v2026.5.32 | Toggle vue classique/horizontale |
| `_maybeRetryFetchUser` | v2026.5.34 | Relance opportuniste fetch user |
| `positionTooltipAnchored` | v2026.5.34 | Positionnement unifié (4 candidats) |
### Constantes persistantes (toutes versions)
- 8 techs hardcodés : `76272,83725,66635,92235,90070,40944,72485,86874`
- Pillonel Olivier (ID 40944) : absent tous les vendredis (hardcodé)
- Group ID EasyVista : `191`
- GUIDs forms EV :
- Demande : `S={C99ECD05-3D48-4C62-ABF0-66292053AED6}`
- Incident : `I={07ED9C68-6172-48EA-8A58-90912B0A283E}`
- SSO : Canton ForgeRock OpenAM
- Storage keys : `admin_config`, `view_mode` (depuis v2026.5.32)
- Domaines : `itsma.etat-de-vaud.ch` (interne), `itsma.vd.ch` (externe SSO)
## Installation ## Installation
1. Décompresser le zip ### Firefox
2. Ouvrir Chrome, `chrome://extensions/` Télécharger le `.xpi` signé depuis le serveur de mises à jour interne, ou drag-and-drop dans `about:addons`.
3. Activer **Mode développeur** (en haut à droite)
4. **Charger l'extension non empaquetée** → sélectionner le dossier `planning-extension-v4`
Si tu avais déjà la v3 installée, tu peux la supprimer avant — les caches des ### Chrome / Edge
deux versions sont compatibles (même format). Mode développeur : décompresser le ZIP et charger en tant qu'extension non empaquetée.
## Utilisation ## Développement
1. Se connecter à EasyVista dans un onglet (`itsma.etat-de-vaud.ch` ou `itsma.vd.ch`) ```bash
2. Cliquer sur l'icône de l'extension (depuis n'importe quel onglet) git clone https://gitea.netaplaid.ch/FroSteel/Planification.git
3. La vue claire s'ouvre dans un nouvel onglet cd Planification
## Comment ça marche techniquement # Pour packager une nouvelle version :
# 1. modifier le code
# 2. bump version dans manifest.json
# 3. zip + xpi
git add -A
git commit -m "Version YYYY.M.PATCH — description"
git tag vYYYY.M.PATCH
git push origin main
git push --tags
```
- `background.js` fait les fetches en arrière-plan (via le cookie de session EasyVista). ## Licence
- L'extension détecte automatiquement le `PHPSESSID` depuis un onglet EasyVista ouvert.
- **v4 : le XML `planning_xhr.php?div=calendar_block` suffit à afficher tout
l'essentiel.** Les champs `attr1`/`attr2`/`attr3` contiennent contact, lieu
et catégorie. Le `textContent` du nœud contient la ref (S260.../I260...).
- Les fiches individuelles (`index.php?formEvent=...`) ne sont fetchées que pour
obtenir le statut Clôturé/Résolu et le commentaire technicien.
- Le texte d'action détaillé (Problème/À faire/Matériel/...) est récupéré en
lazy-load via `planning_xhr_2.php?id=ACTIONID` au premier survol.
- Le cache est stocké dans `chrome.storage.local` (local à ta machine).
- Aucune donnée n'est envoyée ailleurs que vers `itsma.etat-de-vaud.ch` et `itsma.vd.ch`.
## Limitations connues [MIT License](LICENSE) — Copyright (c) 2026 Quentin Rouiller
- Nécessite un onglet EasyVista ouvert (même en arrière-plan) pour fonctionner ## Auteur
- Fonctionne uniquement sur l'intranet cantonal (les fetches échoueront en externe)
- Les 8 IDs des techs sont en dur dans le code (si quelqu'un quitte/arrive dans **Quentin Rouiller** (QRO)
l'équipe, il faut mettre à jour `viewer.js` ligne ~22) Canton de Vaud — Service IT
- Le statut "Clôturé/Résolu" met quelques secondes à apparaître après le
chargement initial (fetch des fiches en arrière-plan, concurrence 5)
+3 -508
View File
@@ -192,7 +192,7 @@ async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum) {
`&checksum=${encodeURIComponent(formChecksum)}` + `&checksum=${encodeURIComponent(formChecksum)}` +
`&type=todo&sectionId=1&navigator=&nbRecord=0` + `&type=todo&sectionId=1&navigator=&nbRecord=0` +
`&PHPSESSID=${encodeURIComponent(phpsessid)}`; `&PHPSESSID=${encodeURIComponent(phpsessid)}`;
const r = await evFetch(url, origin); const r = await fetch(url, { credentials: "include" });
if (!r.ok) { if (!r.ok) {
const err = new Error("HTTP " + r.status); const err = new Error("HTTP " + r.status);
err.kind = classifyHttpStatus(r.status); err.kind = classifyHttpStatus(r.status);
@@ -206,173 +206,9 @@ async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum) {
// Détection "session invalide" // Détection "session invalide"
// ============================================================================ // ============================================================================
/**
* v5.0.9 : détecte plusieurs patterns de session invalide :
* 1. Page de login classique EasyVista (customer_login, my.policy)
* 2. Script de redirection court : <script>window.location.href = "..."</script>
* (protection CSRF ou session expirée)
* 3. URL de logout : index.php?...&logout=1
* 4. Redirection vers le portail SSO : portail.etat-de-vaud.ch/sso/
* 5. Réponse JSON avec "isLogged": false
*/
function looksLikeLoginPage(text) { function looksLikeLoginPage(text) {
const t = (text || "").substring(0, 3000); // La page de login EasyVista contient cette chaîne
if (!t) return false; return /customer_login|my\.policy/i.test((text || "").substring(0, 3000));
// Pattern 1 : page de login EV classique
if (/customer_login|my\.policy/i.test(t)) return true;
// Pattern 2 : script de redirection (< 500 chars = probablement juste ça)
if (t.length < 500 && /<script[^>]*>\s*window\.location\.href\s*=/i.test(t)) return true;
// Pattern 3 : URL de logout
if (/[?&]logout=1/i.test(t)) return true;
// Pattern 4 : redirection vers portail SSO
if (/portail\.etat-de-vaud\.ch\/sso\//i.test(t)) return true;
// Pattern 5 : JSON isLogged:false
if (/"isLogged"\s*:\s*false/i.test(t)) return true;
return false;
}
// ============================================================================
// v5.0.9 : surveillance du timeout de session EasyVista
// ============================================================================
/**
* GET /timeout_ajax.php?__AJAX_TIMEOUT_FCT__=session_time
*
* Retourne le nombre de millisecondes restantes avant expiration de la
* session EasyVista (0 à 1 800 000 = 30 min max).
*
* Attention : cette requête EST authentifiée et prolonge probablement la
* session (comme toute requête PHP authentifiée). À utiliser avec parcimonie.
*/
async function fetchSessionTimeRemaining(origin, phpsessid) {
const url = `${origin}/timeout_ajax.php`
+ `?PHPSESSID=${encodeURIComponent(phpsessid)}`
+ `&__AJAX_TIMEOUT_FCT__=session_time`;
console.log("[bg] fetchSessionTimeRemaining →", url.substring(0, 120));
const r = await evFetch(url, origin);
if (!r.ok) {
throw new Error("HTTP " + r.status);
}
const body = (await r.text()).trim();
// Vérifier que c'est bien un nombre (sinon = session morte probable)
if (!/^\d+$/.test(body)) {
console.warn("[bg] réponse session_time anormale :", body.substring(0, 200));
// Si c'est une page de login/redirect → session expirée
if (looksLikeLoginPage(body)) {
throw new Error("session_expired");
}
throw new Error("invalid_response");
}
const ms = parseInt(body, 10);
console.log(`[bg] session_time = ${ms} ms = ${Math.round(ms/60000)} min`);
return ms;
}
/**
* GET /timeout_ajax.php?__AJAX_TIMEOUT_FCT__=keep_connection
*
* Prolonge la session à 30 min. Retourne 1800000.
*/
async function extendSessionKeepAlive(origin, phpsessid) {
const url = `${origin}/timeout_ajax.php`
+ `?PHPSESSID=${encodeURIComponent(phpsessid)}`
+ `&__AJAX_TIMEOUT_FCT__=keep_connection`;
console.log("[bg] extendSessionKeepAlive →", url.substring(0, 120));
const r = await evFetch(url, origin);
if (!r.ok) {
throw new Error("HTTP " + r.status);
}
const body = (await r.text()).trim();
if (!/^\d+$/.test(body)) {
if (looksLikeLoginPage(body)) throw new Error("session_expired");
throw new Error("invalid_response");
}
const ms = parseInt(body, 10);
console.log(`[bg] keep_connection → session prolongée à ${ms} ms`);
return ms;
}
// ============================================================================
// v5.0.11 : détection automatique du contexte réseau (interne / externe)
// ============================================================================
/**
* Détecte si on est sur le réseau interne (itsma.etat-de-vaud.ch accessible)
* ou externe (seul itsma.vd.ch accessible). Fait un fetch avec mode "no-cors"
* sur l'URL interne : si ça répond (même redirection SSO), on est interne.
* Si ça échoue (DNS unreachable, timeout), on est externe.
*
* v5.0.11 (fix) : mode "no-cors" pour éviter que l'erreur CORS de la
* redirection SSO (itsma.etat-de-vaud.ch → portail.etat-de-vaud.ch/sso/)
* soit interprétée comme un échec de connectivité. Au bureau, la redirection
* SSO passe → le fetch termine → on sait qu'on est interne.
*
* Le résultat est mis en cache dans chrome.storage.local pendant 1h pour
* éviter de refaire le test à chaque démarrage.
*
* @param {boolean} force - si true, ignore le cache et refait le test
* @returns {Promise<"internal"|"external">}
*/
async function detectNetworkContext(force = false) {
const CACHE_KEY = "network_context_v2"; // v5.0.12 : nouvelle clé pour invalider le cache fautif v5.0.11
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1h
if (!force) {
try {
const data = await chrome.storage.local.get([CACHE_KEY]);
const cached = data[CACHE_KEY];
if (cached && cached.detectedAt && (Date.now() - cached.detectedAt) < CACHE_MAX_AGE_MS) {
console.log("[bg] detectNetworkContext : cache hit =", cached.networkContext);
return cached.networkContext;
}
} catch (e) {}
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2500);
let context = "external";
try {
console.log("[bg] detectNetworkContext : test de itsma.etat-de-vaud.ch (mode no-cors)...");
// v5.0.11 (fix) : no-cors évite l'erreur CORS de la redirection SSO.
// Si le fetch termine sans throw, le serveur est joignable = interne.
// Si timeout ou DNS fail, throw → externe.
await fetch("https://itsma.etat-de-vaud.ch/", {
method: "GET",
mode: "no-cors",
signal: controller.signal,
credentials: "omit",
cache: "no-store"
});
clearTimeout(timer);
console.log("[bg] detectNetworkContext : interne accessible");
context = "internal";
} catch (err) {
clearTimeout(timer);
// AbortError (timeout) ou TypeError (DNS unreachable / réseau coupé)
console.log("[bg] detectNetworkContext : interne inaccessible → externe (" + err.name + ": " + err.message + ")");
context = "external";
}
// Mettre en cache
try {
await chrome.storage.local.set({
[CACHE_KEY]: {
networkContext: context,
detectedAt: Date.now()
}
});
} catch (e) {}
return context;
}
/**
* Retourne l'origine EV à utiliser selon le contexte réseau détecté.
*/
function originForContext(context) {
return context === "internal"
? "https://itsma.etat-de-vaud.ch"
: "https://itsma.vd.ch";
} }
// ============================================================================ // ============================================================================
@@ -616,242 +452,6 @@ async function submitDouchette(origin, phpsessid, opts) {
} }
} }
// ============================================================================
// v5.0.0 : Suppression d'une absence ou d'une réservation
// ============================================================================
/**
* Supprime un item du planning (absence ou réservation) côté EasyVista.
*
* v5.0.1 : l'endpoint exact n'est pas totalement certain selon les versions
* EasyVista. On essaye plusieurs `function_name` jusqu'à trouver celui qui
* marche. Un "status 200" ne garantit pas que ça a été supprimé (l'API peut
* répondre 200 même sur un nom de fonction inconnu), mais ça + le reload
* post-suppression donne un bon signal : si le ticket est toujours là après
* reload, on réessaye avec le nom suivant.
*
* Pour l'absence, dans le HTML le bouton "Supprimer" appelle :
* onclick="g_arr_player[N].delete_absence();"
* qui fait probablement un GET /planning_updator_xhr.php?function_name=...
* mais le nom exact varie (peut être "delete_absence", "Planning_delete_absence",
* "fc_delete_absence", etc.)
*
* @param {string} origin
* @param {string} phpsessid
* @param {string} actionId - ID de l'action à supprimer
* @param {string} kind - "absence" ou "reservation"
*/
async function deletePlanningItem(origin, phpsessid, actionId, kind) {
if (!actionId) throw new Error("actionId manquant");
// v5.0.1 : plusieurs function_name à tester dans l'ordre (du plus probable
// au moins probable). Le premier qui renvoie 200 ET non-login est considéré OK.
const fnNames = kind === "reservation"
? [
"Planning_delete_reservation",
"delete_reservation",
"fc_delete_reservation",
"delete_act_reservation",
"delete_planning_reservation",
"remove_reservation",
// v5.0.2 : réservations sont parfois traitées comme absences côté API
"Planning_delete_absence",
"delete_absence",
"fc_delete_absence"
]
: [
// v5.0.2 : élargir la liste, on a essayé 3 sans succès. Les variantes
// plausibles vues dans les API EasyVista :
"Planning_delete_absence", // le plus "officiel"
"delete_absence", // le nom JS dans le onclick
"fc_delete_absence", // pattern fc_*
"delete_act_absence", // parfois "act_" dans les noms
"Planning_delete_holiday", // en anglais
"delete_holiday",
"fc_delete_holiday",
"delete_planning_absence", // variation complète
"remove_absence"
];
let lastErr = null;
let lastBody = null;
for (const fn of fnNames) {
const url = `${origin}/planning_updator_xhr.php`
+ `?PHPSESSID=${encodeURIComponent(phpsessid)}`
+ `&function_name=${encodeURIComponent(fn)}`
+ `&action_id=${encodeURIComponent(actionId)}`;
console.log(`[bg] deletePlanningItem → tente function_name=${fn}`, url.substring(0, 180));
try {
// v5.0.13 : utiliser evFetch() au lieu de fetch() brut pour que les
// headers Referer + X-Requested-With soient envoyés — sinon EV renvoie
// un <script> de redirection CSRF qui ne ressemble pas à une erreur et
// notre heuristique le prenait à tort pour un succès.
const r = await evFetch(url, origin, { method: "GET" });
const body = await r.text();
console.log(`[bg] status=${r.status} | body (200 premiers chars) : ${(body || "").substring(0, 200)}`);
if (r.status === 401 || r.status === 403) {
throw new Error("session_expired");
}
if (!r.ok) {
lastErr = new Error("HTTP " + r.status);
continue; // tente le prochain
}
if (looksLikeLoginPage(body)) {
throw new Error("session_expired");
}
// v5.0.1 : heuristique pour détecter si la suppression a marché.
// v5.0.13 : élargie pour détecter aussi le script de redirection CSRF
// (si evFetch n'a pas suffi) et les réponses vides.
const trimmed = (body || "").trim().toLowerCase();
const looksLikeError = trimmed.includes("error")
|| trimmed.includes("erreur")
|| trimmed.includes("unknown function")
|| trimmed.includes("fonction inconnue")
|| trimmed.includes("<html")
|| trimmed.includes("window.location.href"); // CSRF
if (!looksLikeError) {
console.log(`[bg] → suppression OK avec function_name=${fn}`);
return { status: r.status, functionName: fn, body: body.substring(0, 200) };
}
console.log(`[bg] → réponse ressemble à une erreur, on tente le prochain nom`);
lastBody = body;
} catch (err) {
if (err.message === "session_expired") throw err;
console.warn(`[bg] erreur avec ${fn}:`, err);
lastErr = err;
}
}
// Aucun n'a fonctionné
throw new Error("Aucun endpoint de suppression n'a fonctionné. "
+ (lastBody ? "Dernière réponse : " + lastBody.substring(0, 100) : "")
+ (lastErr ? " | " + lastErr.message : ""));
}
// ============================================================================
// v5.0.0 : Détection de la liste des techniciens depuis la page planning EV
// ============================================================================
/**
* v5.0.1 : Détection de la liste complète des membres du groupe EasyVista.
*
* Stratégie :
* 1) On part des valeurs connues (group_id=191 et support_ids par défaut).
* Pas besoin de fetcher la page planning HTML (qui souvent ne contient
* pas ces valeurs accessibles en fetch direct, car EasyVista utilise
* des redirections JS).
* 2) Fetch direct /include/components/staff/planning/plan_view_group_supports.php
* qui retourne le HTML d'une popup listant tous les membres du groupe.
* 3) Parser ce HTML pour extraire les paires (id, nom).
*
* Retourne { ids: [{id, name, alreadyInTeam}], groupId }.
*/
async function detectTeamFromEV(origin, phpsessid) {
// v5.0.1 : valeurs par défaut (correspondent au groupe actuel).
// À terme elles devraient venir de la config admin.
const DEFAULT_GROUP_ID = "191";
const DEFAULT_SUPPORT_IDS = "76272,83725,66635,92235,90070,40944,72485,86874";
const groupId = DEFAULT_GROUP_ID;
const supportIds = DEFAULT_SUPPORT_IDS;
console.log("[bg] detectTeamFromEV : group_id =", groupId, "| support_ids =", supportIds);
// Fetch la popup de sélection des intervenants du groupe
const popupUrl = origin + "/include/components/staff/planning/plan_view_group_supports.php"
+ "?PHPSESSID=" + encodeURIComponent(phpsessid)
+ "&eventName="
+ "&theme="
+ "&support_ids=" + encodeURIComponent(supportIds)
+ "&group_id=" + encodeURIComponent(groupId);
console.log("[bg] detectTeamFromEV → popup group_supports");
console.log("[bg] URL =", popupUrl.substring(0, 240));
let popupHtml = "";
try {
const r = await fetch(popupUrl, { method: "GET", credentials: "include" });
console.log("[bg] popup status =", r.status);
if (!r.ok) throw new Error("HTTP " + r.status + " sur popup group");
popupHtml = await r.text();
console.log("[bg] popup taille HTML =", popupHtml.length);
if (looksLikeLoginPage(popupHtml)) throw new Error("session_expired");
} catch (e) {
console.warn("[bg] detectTeam: fetch popup failed:", e);
// Fallback : au moins on retourne les IDs connus avec noms vides
const ids = DEFAULT_SUPPORT_IDS.split(",").filter(Boolean);
return {
ids: ids.map(id => ({ id, name: "? (" + id + ")", alreadyInTeam: true })),
groupId
};
}
// Parser le HTML. Différents patterns possibles.
const results = [];
const currentIdsSet = new Set(supportIds.split(",").filter(Boolean));
// v5.0.1 : log le début du HTML pour diagnostic si parsing échoue
console.log("[bg] popup HTML (début) =", popupHtml.substring(0, 500));
// Pattern 1 : checkboxes + texte voisin
const rxCheckbox = /<input[^>]*type=["']checkbox["'][^>]*value=["'](\d{4,7})["'][^>]*>([\s\S]{0,400}?)(?=<input|<\/tr|<\/table|$)/gi;
let mC;
while ((mC = rxCheckbox.exec(popupHtml)) !== null) {
const id = mC[1];
const context = mC[2];
const nameMatch = context.match(/([A-ZÀ-ÿ][A-Za-zÀ-ÿ\-']+,\s*[A-ZÀ-ÿ][A-Za-zÀ-ÿ\-' \.]+)/);
const name = nameMatch ? nameMatch[1].trim() : null;
if (!results.some(r => r.id === id)) {
results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) });
}
}
console.log("[bg] parsing pattern 1 (checkbox) :", results.length, "résultats");
// Pattern 2 : fallback <option value="76272">Nom...</option>
if (results.length === 0) {
const rxOption = /<option[^>]*value=["'](\d{4,7})["'][^>]*>([^<]+)<\/option>/gi;
let mO;
while ((mO = rxOption.exec(popupHtml)) !== null) {
const id = mO[1];
const name = (mO[2] || "").trim();
if (!results.some(r => r.id === id)) {
results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) });
}
}
console.log("[bg] parsing pattern 2 (option) :", results.length, "résultats");
}
// Pattern 3 : fallback brut tags HTML contenant ID à proximité d'un nom
if (results.length === 0) {
// Chercher chaque ID 4-7 chiffres et regarder les 200 caractères qui suivent
const rxAnyId = /\b(\d{5,7})\b([\s\S]{0,200})/g;
let mA;
while ((mA = rxAnyId.exec(popupHtml)) !== null) {
const id = mA[1];
// Ignorer les IDs qui ressemblent à des timestamps / hash
if (id.length > 6 && parseInt(id, 10) > 1000000000) continue;
const context = mA[2];
const nameMatch = context.match(/([A-ZÀ-ÿ][A-Za-zÀ-ÿ\-']+,\s*[A-ZÀ-ÿ][A-Za-zÀ-ÿ\-' \.]{2,30})/);
if (nameMatch && !results.some(r => r.id === id)) {
results.push({ id, name: nameMatch[1].trim(), alreadyInTeam: currentIdsSet.has(id) });
}
}
console.log("[bg] parsing pattern 3 (brut) :", results.length, "résultats");
}
// Ajouter les IDs actuels manquants (sans nom)
for (const id of currentIdsSet) {
if (!results.some(r => r.id === id)) {
results.push({ id, name: "? (" + id + ")", alreadyInTeam: true });
}
}
console.log("[bg] " + results.length + " personnes retournées");
return { ids: results, groupId: groupId };
}
// ============================================================================ // ============================================================================
// Messages du viewer // Messages du viewer
// ============================================================================ // ============================================================================
@@ -1014,111 +614,6 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
return; return;
} }
if (msg.type === "deletePlanningItem") {
// v5.0.0 : supprime une absence ou réservation côté EasyVista.
// Endpoint : /planning_updator_xhr.php?function_name=...&action_id=...
// Exemples de function_name :
// - Planning_delete_absence
// - Planning_delete_reservation
const session = await findEasyVistaSession();
if (!session) {
sendResponse({ ok: false, error: "no_session" });
return;
}
try {
const result = await deletePlanningItem(
session.origin, session.phpsessid, msg.actionId, msg.kind
);
sendResponse({ ok: true, result });
} catch (err) {
sendResponse({ ok: false, error: err.message || String(err) });
}
return;
}
if (msg.type === "detectTeam") {
// v5.0.0 : détecte la liste des IDs de techniciens depuis le HTML
// v5.0.1 : retourne aussi les noms via la popup group_supports
const session = await findEasyVistaSession();
if (!session) {
sendResponse({ ok: false, error: "no_session" });
return;
}
try {
const result = await detectTeamFromEV(session.origin, session.phpsessid);
// result = { ids: [{id,name,alreadyInTeam}, ...], groupId }
sendResponse({ ok: true, members: result.ids, groupId: result.groupId });
} catch (err) {
sendResponse({ ok: false, error: err.message || String(err) });
}
return;
}
if (msg.type === "getSessionRemaining") {
// v5.0.9 : récupère le temps restant avant expiration de la session EV
const session = await findEasyVistaSession();
if (!session) {
sendResponse({ ok: false, error: "no_session" });
return;
}
try {
const remainingMs = await fetchSessionTimeRemaining(session.origin, session.phpsessid);
sendResponse({ ok: true, remainingMs, phpsessid: session.phpsessid });
} catch (err) {
sendResponse({ ok: false, error: err.message || String(err) });
}
return;
}
if (msg.type === "extendSession") {
// v5.0.9 : prolonge la session EV à 30 min via keep_connection
const session = await findEasyVistaSession();
if (!session) {
sendResponse({ ok: false, error: "no_session" });
return;
}
try {
const remainingMs = await extendSessionKeepAlive(session.origin, session.phpsessid);
sendResponse({ ok: true, remainingMs });
} catch (err) {
sendResponse({ ok: false, error: err.message || String(err) });
}
return;
}
if (msg.type === "openEasyVistaLogin") {
// v5.0.9 : ouvre EasyVista dans un nouvel onglet pour provoquer
// le SSO Windows automatique (reconnexion transparente).
// v5.0.11 : URL simplifiée (racine domaine au lieu de eventName=...),
// et utilise le contexte réseau détecté si l'origine n'est pas fournie.
let origin = msg.origin;
if (!origin) {
const context = await detectNetworkContext();
origin = originForContext(context);
}
try {
const tab = await chrome.tabs.create({
url: `${origin}/`, // racine → EV redirige vers SSO si besoin
active: true
});
sendResponse({ ok: true, tabId: tab.id, origin });
} catch (err) {
sendResponse({ ok: false, error: err.message || String(err) });
}
return;
}
if (msg.type === "detectNetwork") {
// v5.0.11 : détecte si on est en interne ou externe.
const context = await detectNetworkContext(!!msg.force);
sendResponse({
ok: true,
context, // "internal" | "external"
origin: originForContext(context) // URL correspondante
});
return;
}
if (msg.type === "cleanupOldCaches") { if (msg.type === "cleanupOldCaches") {
const removed = await cleanupOldCaches(msg.daysToKeep || 7); const removed = await cleanupOldCaches(msg.daysToKeep || 7);
sendResponse({ ok: true, removed }); sendResponse({ ok: true, removed });
+14 -4
View File
@@ -1,9 +1,15 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Planification", "name": "Planification",
"version": "5.0.13", "version": "5.0.0",
"description": "Vue claire et rapide du planning des techniciens EasyVista. Regroupe interventions et réservations par tech, affiche horaires, contact, lieu, catégorie et statut en un coup d'œil.", "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.3.0 : (1) conflits horaires entre interventions d'un même tech affichés en rouge + ⚠. (2) Réservations disparues retirées directement (pas de re-fetch inutile). (3) Popups épinglés détachés : plusieurs peuvent coexister, ancrés au contenu (scrollent avec la page), auto-positionnés sans se marcher dessus (toast si pas de place), Échap pour tout fermer, Ctrl×2 pour fermer si un seul épinglé. Inclut v4.2.9.",
"permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"], "permissions": [
"activeTab",
"scripting",
"storage",
"tabs",
"alarms"
],
"host_permissions": [ "host_permissions": [
"https://itsma.etat-de-vaud.ch/*", "https://itsma.etat-de-vaud.ch/*",
"https://itsma.vd.ch/*" "https://itsma.vd.ch/*"
@@ -21,7 +27,11 @@
}, },
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["viewer.html", "viewer.js", "viewer.css"], "resources": [
"viewer.html",
"viewer.js",
"viewer.css"
],
"matches": [ "matches": [
"https://itsma.etat-de-vaud.ch/*", "https://itsma.etat-de-vaud.ch/*",
"https://itsma.vd.ch/*" "https://itsma.vd.ch/*"
+1 -547
View File
@@ -1816,87 +1816,16 @@ body.modal-open {
box-shadow: 0 8px 24px rgba(0,0,0,0.18); box-shadow: 0 8px 24px rgba(0,0,0,0.18);
/* Pas de contain: layout (hérité) car ça limite le rendu ; on laisse */ /* Pas de contain: layout (hérité) car ça limite le rendu ; on laisse */
animation: pinned-popup-in 0.15s ease-out; animation: pinned-popup-in 0.15s ease-out;
/* Le padding-top est augmenté pour accueillir la barre de drag. */
padding-top: 28px !important;
} }
@keyframes pinned-popup-in { @keyframes pinned-popup-in {
from { opacity: 0; transform: scale(0.96); } from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); } to { opacity: 1; transform: scale(1); }
} }
/* v4.3.3 : animation de sortie (symétrique à l'apparition) quand on
désépingle. Appliquée par la classe .unpinning. */
.tooltip.pinned-popup.unpinning,
.tooltip.soft-unpinned.unpinning {
animation: pinned-popup-out 0.18s ease-in forwards !important;
}
@keyframes pinned-popup-out {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.94); }
}
/* v4.3.3 corr : quand une popup est désépinglée "mou", elle perd son look
"épinglé" et redevient un tooltip normal visuellement, tout en gardant
sa position absolute (pour ne pas sauter). */
.tooltip.soft-unpinned {
position: absolute !important;
z-index: 5 !important;
opacity: 1 !important;
pointer-events: auto !important;
/* Pas de bordure bleue, pas de padding-top (plus de dragbar), juste les
styles de base du tooltip (hérités de .tooltip). */
border: 1px solid var(--border-strong) !important;
box-shadow: var(--shadow-hover) !important;
padding-top: 12px !important;
animation: none !important;
}
/* v4.3.3 : Barre de drag en haut de la popup épinglée, permet de la
déplacer (le contenu lui-même garde la sélection de texte possible). */
.pinned-popup-dragbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
to bottom,
var(--bg-muted, rgba(128,128,128,0.08)) 0%,
transparent 100%
);
border-bottom: 1px solid var(--border, rgba(128,128,128,0.15));
border-radius: 6px 6px 0 0;
cursor: grab;
user-select: none;
-webkit-user-select: none;
}
.pinned-popup-dragbar:active,
.pinned-popup.dragging .pinned-popup-dragbar {
cursor: grabbing;
}
/* Petite grippe visuelle au milieu pour signaler que c'est déplaçable */
.pinned-popup-dragbar::before {
content: "";
width: 32px;
height: 3px;
border-radius: 3px;
background: var(--border-strong, rgba(128,128,128,0.35));
}
/* Pendant le drag, on fige l'animation pour éviter les tremblements */
.pinned-popup.dragging {
animation: none !important;
transition: none !important;
cursor: grabbing !important;
box-shadow: 0 12px 32px rgba(0,0,0,0.28);
}
/* Bouton × de fermeture du popup épinglé */ /* Bouton × de fermeture du popup épinglé */
.pinned-popup-close { .pinned-popup-close {
position: absolute; position: absolute;
top: 3px; top: 4px;
right: 6px; right: 6px;
width: 22px; width: 22px;
height: 22px; height: 22px;
@@ -1910,483 +1839,8 @@ body.modal-open {
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: background 0.1s, color 0.1s; transition: background 0.1s, color 0.1s;
z-index: 2; /* au-dessus de la dragbar */
} }
.pinned-popup-close:hover { .pinned-popup-close:hover {
background: var(--danger-soft, #fbe6e6); background: var(--danger-soft, #fbe6e6);
color: var(--danger, #b03030); color: var(--danger, #b03030);
} }
/* ─────────────────────────────────────────────────────────────────────────
v5.0.0 : horloge au milieu de la topbar (HH:MM, pas de secondes)
───────────────────────────────────────────────────────────────────────── */
.app-clock {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 22px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text);
letter-spacing: 1px;
pointer-events: none;
user-select: none;
}
.topbar { position: sticky; /* déja défini plus haut */ }
/* topbar doit être en position: relative parent pour que .app-clock absolute
se positionne par rapport à elle */
header.topbar { position: sticky !important; }
header.topbar::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
}
/* ─────────────────────────────────────────────────────────────────────────
v5.0.0 : ligne rouge "heure actuelle" sur la timeline (uniquement si on
affiche la date d'aujourd'hui). v5.0.1 : plus visible.
───────────────────────────────────────────────────────────────────────── */
.timeline-now-line {
position: absolute;
top: -2px;
bottom: -2px;
width: 4px;
background: #ff3030;
z-index: 5;
pointer-events: none;
box-shadow: 0 0 6px rgba(255, 48, 48, 0.8),
0 0 2px rgba(255, 48, 48, 1);
border-radius: 2px;
margin-left: -2px; /* centre la barre sur la position exacte */
}
.timeline-now-line::after {
content: "";
position: absolute;
top: -4px;
left: 50%;
transform: translateX(-50%);
width: 12px;
height: 12px;
background: #ff3030;
border-radius: 50%;
box-shadow: 0 0 8px rgba(255, 48, 48, 0.9);
}
/* ─────────────────────────────────────────────────────────────────────────
v5.0.0 : Panel admin (menu caché 5 clics sur titre)
───────────────────────────────────────────────────────────────────────── */
.admin-overlay {
/* hérite de .modal-overlay */
align-items: flex-start;
padding: 30px 20px;
}
.admin-panel-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 8px;
width: 100%;
max-width: 1100px;
height: calc(100vh - 60px);
display: flex;
flex-direction: column;
box-shadow: 0 12px 40px rgba(0,0,0,0.3);
overflow: hidden;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.admin-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.admin-close-btn {
background: transparent;
border: none;
font-size: 24px;
line-height: 1;
cursor: pointer;
padding: 4px 10px;
color: var(--text-muted);
border-radius: 4px;
}
.admin-close-btn:hover {
background: var(--danger-soft);
color: var(--danger);
}
.admin-body {
display: flex;
flex: 1;
min-height: 0;
}
.admin-sidebar {
width: 180px;
background: var(--bg);
border-right: 1px solid var(--border);
padding: 10px 0;
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.admin-nav-btn {
text-align: left;
padding: 10px 18px;
background: transparent;
border: none;
cursor: pointer;
font-size: 14px;
color: var(--text);
border-left: 3px solid transparent;
transition: background 0.12s, border-color 0.12s;
}
.admin-nav-btn:hover {
background: var(--bg-hover);
}
.admin-nav-btn.active {
background: var(--bg-elevated);
border-left-color: var(--accent);
font-weight: 600;
}
.admin-content {
flex: 1;
padding: 20px 24px;
overflow-y: auto;
}
.admin-section-title {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
}
.admin-section-desc {
margin: 0 0 16px 0;
color: var(--text-muted);
font-size: 13px;
}
.admin-team-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.admin-team-table th,
.admin-team-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: middle;
}
.admin-team-table th {
background: var(--bg);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
color: var(--text-muted);
}
.admin-input {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 13px;
box-sizing: border-box;
}
.admin-input-id {
font-family: var(--mono);
max-width: 100px;
}
.admin-day-cb {
display: inline-flex;
align-items: center;
gap: 2px;
margin-right: 6px;
font-size: 11px;
cursor: pointer;
user-select: none;
}
.admin-day-cb input[type="checkbox"] {
margin: 0 2px 0 0;
}
.admin-del-btn {
background: transparent;
border: none;
cursor: pointer;
font-size: 16px;
color: var(--text-muted);
padding: 4px 8px;
border-radius: 4px;
}
.admin-del-btn:hover {
background: var(--danger-soft);
color: var(--danger);
}
.admin-readonly {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px;
font-family: var(--mono);
font-size: 12px;
overflow-x: auto;
}
.admin-diag-grid {
display: grid;
grid-template-columns: 200px 1fr;
gap: 8px 16px;
margin: 16px 0;
font-size: 13px;
}
.admin-diag-grid > div {
padding: 4px 0;
}
/* ─────────────────────────────────────────────────────────────────────────
v5.0.0 : bouton supprimer dans le tooltip (absence / réservation)
───────────────────────────────────────────────────────────────────────── */
.tooltip-delete-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: var(--danger-soft, #fbe6e6);
border: 1px solid var(--danger, #b03030);
color: var(--danger, #b03030);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
margin-top: 4px;
}
.tooltip-delete-btn:hover:not(:disabled) {
background: var(--danger, #b03030);
color: #fff;
}
.tooltip-delete-btn:disabled {
opacity: 0.6;
cursor: wait;
}
/* Bouton danger dans les modals */
.btn-danger,
.modal-btn-danger {
background: var(--danger, #b03030);
color: #fff;
border: 1px solid var(--danger, #b03030);
}
.btn-danger:hover,
.modal-btn-danger:hover {
background: #8e2020;
}
/* v5.0.1 : ligne d'équipe exclue (pas cochée) - apparaît grisée */
.admin-team-table tr.admin-row-excluded {
opacity: 0.45;
}
.admin-team-table tr.admin-row-excluded input[type="text"] {
background: var(--bg);
}
/* v5.0.1 : bouton supprimer sur la carte "Absent toute la journée" */
.absence-delete-wrap {
margin-top: 8px;
text-align: center;
}
.absence-delete-wrap .tooltip-delete-btn {
font-size: 11px;
padding: 4px 8px;
}
/* v5.0.4 : boutons preset matin / après-midi / journée dans modal absence */
.modal-preset-row {
gap: 8px;
flex-wrap: wrap;
}
.modal-preset-btn {
flex: 1;
min-width: 100px;
padding: 8px 10px;
font-size: 13px;
cursor: pointer;
}
/* ==========================================================================
v5.0.9 : Compteur de session EasyVista (topbar)
========================================================================== */
.app-session {
position: absolute;
top: 50%;
left: calc(50% + 60px); /* à droite de l'horloge (~60px de décalage) */
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
border-radius: 14px;
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
z-index: 9;
background: rgba(0, 0, 0, 0.05);
transition: background 0.3s, color 0.3s;
}
.app-session.hidden {
display: none;
}
.app-session .session-icon {
font-size: 14px;
}
.app-session .session-time {
font-weight: 600;
}
.app-session .session-extend-btn {
margin-left: 4px;
padding: 3px 8px;
font-size: 11px;
border-radius: 10px;
border: 1px solid currentColor;
background: transparent;
color: inherit;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
.app-session .session-extend-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.app-session .session-extend-btn:disabled {
opacity: 0.6;
cursor: default;
}
/* État warning (2-5 min) : jaune */
.app-session.session-warn {
background: #f5c518;
color: #2a2100;
}
.app-session.session-warn .session-extend-btn {
border-color: #2a2100;
}
/* État critical (< 2 min) : rouge + pulse */
.app-session.session-critical {
background: #e74c3c;
color: #fff;
animation: session-pulse 1s infinite;
}
.app-session.session-critical .session-extend-btn {
border-color: #fff;
background: rgba(255, 255, 255, 0.15);
font-weight: 600;
}
.app-session.session-critical .session-extend-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
@keyframes session-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.5); }
50% { box-shadow: 0 0 0 6px rgba(231, 76, 60, 0); }
}
/* Bouton "Me reconnecter" dans la bannière session expirée */
.session-expired-reconnect-btn {
margin-left: 12px;
padding: 6px 14px;
border-radius: 4px;
background: #fff;
color: #c0392b;
border: none;
font-weight: 600;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
}
.session-expired-reconnect-btn:hover {
background: #f8d7da;
}
/* Bannière "Reconnexion en cours" */
.banner-reconnecting {
background: #3498db;
color: #fff;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 500;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.banner-reconnecting.hidden {
display: none;
}
.banner-reconnecting .banner-spinner {
font-size: 16px;
animation: spin-slow 2s linear infinite;
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ==========================================================================
v5.0.11 : bannières reconnect avec boutons Annuler + choix réseau
========================================================================== */
.banner-reconnecting .banner-cancel-btn,
.banner-reconnect-failed .banner-cancel-btn {
margin-left: auto;
padding: 5px 12px;
background: transparent;
color: inherit;
border: 1px solid currentColor;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
.banner-reconnecting .banner-cancel-btn:hover,
.banner-reconnect-failed .banner-cancel-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.banner-reconnect-failed {
background: #e67e22;
color: #fff;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
font-weight: 500;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.banner-reconnect-failed.hidden {
display: none;
}
.banner-reconnect-failed .banner-icon {
font-size: 18px;
}
.banner-reconnect-failed .banner-btn-primary {
padding: 6px 14px;
border-radius: 4px;
background: #fff;
color: #c0392b;
border: none;
font-weight: 600;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
}
.banner-reconnect-failed .banner-btn-primary:hover {
background: #f8d7da;
}
-4
View File
@@ -23,10 +23,6 @@
<span id="capture-info" class="capture-info"></span> <span id="capture-info" class="capture-info"></span>
<span id="refresh-check" class="refresh-check hidden" title="Mise à jour terminée"></span> <span id="refresh-check" class="refresh-check hidden" title="Mise à jour terminée"></span>
</div> </div>
<!-- v5.0.0 : horloge au milieu, format HH:MM, mise à jour toutes les min -->
<div id="app-clock" class="app-clock" title="Heure actuelle"></div>
<!-- v5.0.9 : compteur de session EasyVista (visible < 5 min restantes) -->
<div id="app-session" class="app-session hidden"></div>
<div class="topbar-right"> <div class="topbar-right">
<!-- v4.2.6 : bouton créer une absence pour un ou plusieurs techs --> <!-- v4.2.6 : bouton créer une absence pour un ou plusieurs techs -->
<button id="absence-btn" class="btn btn-action" title="Créer une absence pour un ou plusieurs techniciens"> <button id="absence-btn" class="btn btn-action" title="Créer une absence pour un ou plusieurs techniciens">
+58 -563
View File
@@ -152,23 +152,16 @@ let state = {
currentData: null, // résultat parsé (techs, stats, ...) currentData: null, // résultat parsé (techs, stats, ...)
loading: false, loading: false,
// v5.0.9 : timestamp (ms) auquel la session EV va expirer. // v5.0.9 : timestamp (ms) auquel la session EV va expirer.
// On suppose une durée de 30 min à chaque requête EV réussie.
// null = inconnu (pas encore récupéré).
sessionExpireAt: null, sessionExpireAt: null,
// v5.0.9 : true pendant une reconnexion en cours // v5.0.9 : true pendant une reconnexion en cours (après clic sur "Me
// reconnecter" tant que la nouvelle session n'est pas détectée)
reconnecting: false, reconnecting: false,
// v5.0.9 : true si la session est expirée (bannière rouge affichée) // v5.0.9 : true si la session est expirée (bannière rouge affichée)
sessionExpired: false, sessionExpired: false,
// v5.0.9 : true si on a déjà fait le ping de confirmation < 5 min // v5.0.9 : true si on a déjà fait le ping de confirmation < 5 min
sessionPingDone: false, sessionPingDone: false
// v5.0.10 : dernière origine EV connue comme fonctionnelle (itsma.vd.ch
// ou itsma.etat-de-vaud.ch selon qu'on est en externe ou interne).
// Conservée même quand state.session est null, pour savoir où rediriger
// lors de la reconnexion.
lastKnownOrigin: null,
// v5.0.11 : contexte réseau détecté ("internal" ou "external" ou null).
// Détecté automatiquement au démarrage par un HEAD test sur l'URL interne.
networkContext: null,
// v5.0.11 : timer (setTimeout id) pour le timeout de reconnexion 90 sec
reconnectTimeoutId: null
}; };
// v5.0.9 : constantes session // v5.0.9 : constantes session
@@ -176,10 +169,6 @@ const SESSION_DURATION_MS = 30 * 60 * 1000; // 30 min
const SESSION_WARN_THRESHOLD_MS = 5 * 60 * 1000; // 5 min → affichage compteur const SESSION_WARN_THRESHOLD_MS = 5 * 60 * 1000; // 5 min → affichage compteur
const SESSION_CRITICAL_THRESHOLD_MS = 2 * 60 * 1000; // 2 min → rouge + modal const SESSION_CRITICAL_THRESHOLD_MS = 2 * 60 * 1000; // 2 min → rouge + modal
// v5.0.11 : timeout de la reconnexion. Si l'user n'est pas reconnecté
// dans ce délai, on bascule en état "Reconnexion échouée" avec choix du réseau.
const RECONNECT_TIMEOUT_MS = 90 * 1000; // 90 sec
// ─── Annulation coopérative d'un refresh manuel (v3.1) ────────────────────── // ─── Annulation coopérative d'un refresh manuel (v3.1) ──────────────────────
// Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent // Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent
// isRefreshAborted() avant chaque fetch : si le jeton a changé ou si // isRefreshAborted() avant chaque fetch : si le jeton a changé ou si
@@ -246,34 +235,14 @@ async function init() {
state.currentDate = todayISO(); state.currentDate = todayISO();
document.getElementById("date-picker").value = state.currentDate; document.getElementById("date-picker").value = state.currentDate;
// v5.0.11 : détecter le contexte réseau en arrière-plan (non bloquant) // v4.2 : l'auto-refresh 12h/15h a été supprimé. Les rafraîchissements sont
detectNetworkContextAsync(); // désormais soit manuels (boutons Actualiser / Tout recharger), soit au
// premier chargement si aucun cache n'existe pour la date.
// Charger la sesson puis le planning // Charger la sesson puis le planning
await refreshSessionAndLoad(); await refreshSessionAndLoad();
} }
/**
* 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) {
try {
const resp = await sendMessage({ type: "detectNetwork", force });
if (resp && resp.ok) {
state.networkContext = resp.context;
// Si on n'a pas encore de lastKnownOrigin, on prend celui du contexte détecté
if (!state.lastKnownOrigin) {
state.lastKnownOrigin = resp.origin;
}
console.log("[viewer] réseau détecté :", resp.context, "→", resp.origin);
}
} catch (e) {
console.warn("[viewer] détection réseau échouée", e);
}
}
async function refreshSessionAndLoad() { async function refreshSessionAndLoad() {
const resp = await sendMessage({ type: "getSession" }); const resp = await sendMessage({ type: "getSession" });
if (!resp.ok || !resp.session) { if (!resp.ok || !resp.session) {
@@ -294,10 +263,6 @@ async function refreshSessionAndLoad() {
return; return;
} }
state.session = resp.session; state.session = resp.session;
// v5.0.10 : mémoriser l'origine courante pour la reconnexion si besoin
if (resp.session && resp.session.origin) {
state.lastKnownOrigin = resp.session.origin;
}
hideSessionNeeded(); hideSessionNeeded();
hideEvUnreachable(); hideEvUnreachable();
hideSessionExpiredBanner(); hideSessionExpiredBanner();
@@ -531,12 +496,7 @@ function bindTopbar() {
} }
}); });
// v5.0.10 : clic "Ouvrir EasyVista" sur l'écran plein → déclenche la document.getElementById("open-ev-btn").addEventListener("click", openEasyVista);
// reconnexion SSO + l'auto-reload du viewer dès que la nouvelle session
// est détectée (au lieu d'ouvrir juste un onglet).
document.getElementById("open-ev-btn").addEventListener("click", () => {
triggerReconnect();
});
// v4.2 : écran "EasyVista inaccessible" // v4.2 : écran "EasyVista inaccessible"
const openEvBtn2 = document.getElementById("open-ev-btn-2"); const openEvBtn2 = document.getElementById("open-ev-btn-2");
@@ -567,16 +527,11 @@ function bindTopbar() {
} }
async function openEasyVista() { async function openEasyVista() {
// v5.0.10 : ouvrir sur le domaine le plus approprié : // Ouvrir sur le domaine externe (accessible depuis l'extérieur).
// - lastKnownOrigin si on a déjà eu une session fonctionnelle (respecte // Le domaine interne (itsma.etat-de-vaud.ch) n'est accessible que depuis le réseau VD.
// interne vs externe selon le réseau) // Une fois connecté, l'extension détectera automatiquement le PHPSESSID quel que
// - session.origin si on a encore la session // soit le domaine où tu es connecté.
// - itsma.vd.ch en fallback (domaine externe accessible de partout, await chrome.tabs.create({ url: "https://itsma.vd.ch/" });
// même depuis le réseau VD il redirige vers l'interne transparent)
const origin = state.lastKnownOrigin
|| (state.session && state.session.origin)
|| "https://itsma.vd.ch";
await chrome.tabs.create({ url: origin + "/" });
} }
// Navigation ±1 jour en sautant les week-ends // Navigation ±1 jour en sautant les week-ends
@@ -880,18 +835,11 @@ function initSessionTimer() {
const oldPhpsessid = state.session ? state.session.phpsessid : null; const oldPhpsessid = state.session ? state.session.phpsessid : null;
if (resp.session.phpsessid !== oldPhpsessid) { if (resp.session.phpsessid !== oldPhpsessid) {
console.log("[session] nouvelle session détectée après reconnexion :", resp.session.phpsessid); console.log("[session] nouvelle session détectée après reconnexion :", resp.session.phpsessid);
// v5.0.11 : annuler le timeout de reconnexion puisque ça a marché
if (state.reconnectTimeoutId) {
clearTimeout(state.reconnectTimeoutId);
state.reconnectTimeoutId = null;
}
state.session = resp.session; state.session = resp.session;
if (resp.session.origin) state.lastKnownOrigin = resp.session.origin;
state.reconnecting = false; state.reconnecting = false;
state.sessionExpired = false; state.sessionExpired = false;
hideReconnectingBanner(); hideReconnectingBanner();
hideSessionExpiredBanner(); hideSessionExpiredBanner();
hideReconnectFailedBanner();
markSessionActivity(); markSessionActivity();
showToast("Reconnecté", "Session EasyVista renouvelée"); showToast("Reconnecté", "Session EasyVista renouvelée");
// Recharger le planning à la date courante sans perdre la position // Recharger le planning à la date courante sans perdre la position
@@ -1053,48 +1001,14 @@ function showSessionCriticalModal() {
* Appelé au clic "Me reconnecter" dans la bannière. Ouvre EasyVista dans un * Appelé au clic "Me reconnecter" dans la bannière. Ouvre EasyVista dans un
* nouvel onglet (déclenche Windows SSO Kerberos automatique). Le polling * nouvel onglet (déclenche Windows SSO Kerberos automatique). Le polling
* dans initSessionTimer détectera la nouvelle session et rechargera le viewer. * dans initSessionTimer détectera la nouvelle session et rechargera le viewer.
*
* v5.0.10 : utilise l'origine dynamique (interne ou externe selon le réseau).
* v5.0.11 : détecte le contexte réseau avant d'ouvrir (si pas déjà connu) +
* timeout 90s : si pas reconnecté après ce délai, propose choix manuel.
*
* @param {string} [forcedOrigin] - origine à forcer (pour le choix manuel
* dans le fallback après timeout). Si absent : détection auto.
*/ */
async function triggerReconnect(forcedOrigin) { async function triggerReconnect() {
state.reconnecting = true; state.reconnecting = true;
hideSessionExpiredBanner(); hideSessionExpiredBanner();
hideReconnectFailedBanner();
showReconnectingBanner(); showReconnectingBanner();
// Annuler tout timeout précédent
if (state.reconnectTimeoutId) {
clearTimeout(state.reconnectTimeoutId);
state.reconnectTimeoutId = null;
}
try { try {
let origin = forcedOrigin; const origin = (state.session && state.session.origin) || "https://itsma.etat-de-vaud.ch";
if (!origin) {
// v5.0.11 : re-détecter le réseau à chaque expiration pour gérer le
// cas où on a changé de contexte (bureau → TT) pendant la session.
await detectNetworkContextAsync(true);
origin = state.lastKnownOrigin
|| (state.session && state.session.origin)
|| "https://itsma.vd.ch";
}
console.log("[session] triggerReconnect → ouverture de", origin);
await sendMessage({ type: "openEasyVistaLogin", origin }); await sendMessage({ type: "openEasyVistaLogin", origin });
// Démarrer le timeout 90s : si pas reconnecté, basculer en mode "Échec"
state.reconnectTimeoutId = setTimeout(() => {
if (state.reconnecting && !state.session) {
console.warn("[session] reconnexion timeout 90s → bannière échec");
state.reconnecting = false;
hideReconnectingBanner();
showReconnectFailedBanner();
}
}, RECONNECT_TIMEOUT_MS);
} catch (err) { } catch (err) {
console.warn("[session] openEasyVistaLogin failed:", err); console.warn("[session] openEasyVistaLogin failed:", err);
state.reconnecting = false; state.reconnecting = false;
@@ -1103,21 +1017,6 @@ async function triggerReconnect(forcedOrigin) {
} }
} }
/**
* v5.0.11 : l'user clique "Annuler" pendant la reconnexion. On arrête le
* polling/timeout et on revient à l'état "Session expirée" normal.
*/
function cancelReconnect() {
if (state.reconnectTimeoutId) {
clearTimeout(state.reconnectTimeoutId);
state.reconnectTimeoutId = null;
}
state.reconnecting = false;
hideReconnectingBanner();
hideReconnectFailedBanner();
showSessionExpiredBanner();
}
// v5.0.0 : stockage des paramètres admin dans chrome.storage.local. // v5.0.0 : stockage des paramètres admin dans chrome.storage.local.
// Clé unique : "admin_config". Contient la config éditable (équipe, // Clé unique : "admin_config". Contient la config éditable (équipe,
@@ -3168,7 +3067,7 @@ async function refreshStatuses(techs, isoDate, opts = {}) {
updateProgressBar(i + 1, toFetch.length); updateProgressBar(i + 1, toFetch.length);
} }
// Sauvegarde périodique du cache pdt le fetch // Sauvegarde périodique du cache pendant le fetch
if (sinceLastCacheWrite >= CACHE_WRITE_EVERY) { if (sinceLastCacheWrite >= CACHE_WRITE_EVERY) {
try { try {
await writeCache(isoDate, { techs }); await writeCache(isoDate, { techs });
@@ -3224,7 +3123,7 @@ async function fetchAndUpdateIntervention(iv, myToken) {
return; return;
} }
// v4.1.2 : pour chaque interventoin on fait xhr2 PUIS fiche. // v4.1.2 : pour chaque intervention on fait xhr2 PUIS fiche.
// - xhr2 : récupère les VRAIES infos contact/lieu (attr1/attr2 du XML // - xhr2 : récupère les VRAIES infos contact/lieu (attr1/attr2 du XML
// sont parfois erronées si le tech a corrigé après planif). // sont parfois erronées si le tech a corrigé après planif).
// On met à jour la carte tout de suite avec les vraies infos. // On met à jour la carte tout de suite avec les vraies infos.
@@ -3304,7 +3203,7 @@ async function fetchAndUpdateIntervention(iv, myToken) {
// ─── Étape 3 : API timeline → texte complet de l'action ───────────── // ─── Étape 3 : API timeline → texte complet de l'action ─────────────
// Le HTML brut de la fiche ne contient PAS les valeurs d'action (elles // Le HTML brut de la fiche ne contient PAS les valeurs d'action (elles
// sont injectées côté client par Angular via un apel REST). On appelle // sont injectées côté client par Angular via un appel REST). On appelle
// donc le même endpoint REST qu'Angular pour récupérer la description // donc le même endpoint REST qu'Angular pour récupérer la description
// complète, match par ACTION_ID === iv.actionId (fiable, numérique). // complète, match par ACTION_ID === iv.actionId (fiable, numérique).
// //
@@ -3398,61 +3297,6 @@ async function fetchAndUpdateIntervention(iv, myToken) {
* (venant du texte d'action validé par le tech) sont plus fiables que * (venant du texte d'action validé par le tech) sont plus fiables que
* attr1/attr2 (planification initiale parfois erronée). * attr1/attr2 (planification initiale parfois erronée).
*/ */
// v4.3.2 : pré-fetch de tous les xhr2 en parallèle (batch).
// Objectif : avoir les VRAIES infos contact/lieu pour toutes les interventions
// AVANT que l'utilisateur se mette à les survoler. Comme le xhr2 est léger
// (2-5 KB), on peut en faire plusieurs en parallèle sans écrouler EasyVista.
//
// Params :
// techs : liste des techs avec leurs interventions
// myToken : jeton d'annulation (si l'user change de date, on s'arrête)
// forceAll : si true, re-fait le xhr2 même pour les inter déjà xhr2Fetched
// (utilisé par "Tout recharger")
async function prefetchAllXhr2(techs, myToken, forceAll) {
if (!techs) return;
// Lister les iv qui ont besoin d'un xhr2
const needed = [];
for (const tech of techs) {
for (const iv of tech.interventions || []) {
if (iv.type !== "AL-Intervention") continue;
if (!iv.actionId || iv.ghost) continue;
if (iv.xhr2Fetching) continue;
if (iv.xhr2Fetched && !forceAll) continue;
needed.push(iv);
}
}
if (needed.length === 0) return;
console.log(`[load] pré-fetch xhr2 batch : ${needed.length} interventoin(s)…`);
const t0 = performance.now();
// Si forceAll, reset le flag pour que ensureBulleDescription re-fetch
if (forceAll) {
for (const iv of needed) iv.xhr2Fetched = false;
}
// Batch en parallèle avec concurrency limitée (6) — assez rapide, pas trop
// aggressif sur EasyVista.
const concurrency = 6;
const queue = [...needed];
const workers = [];
for (let w = 0; w < concurrency; w++) {
workers.push((async () => {
while (queue.length > 0) {
if (isRefreshAborted(myToken)) return;
const iv = queue.shift();
try {
await ensureBulleDescription(iv);
} catch (err) {
console.warn("[prefetch xhr2] iv", iv.actionId, err);
}
}
})());
}
await Promise.all(workers);
console.log(`[load] pré-fetch xhr2 fini en ${Math.round(performance.now() - t0)} ms`);
}
async function ensureBulleDescription(iv) { async function ensureBulleDescription(iv) {
// Déjà chargé : rien à faire // Déjà chargé : rien à faire
if (iv.xhr2Fetched) return true; if (iv.xhr2Fetched) return true;
@@ -3891,7 +3735,7 @@ function setRefreshing(on) {
refreshCounter++; refreshCounter++;
if (targetIcon) targetIcon.classList.add("spinning"); if (targetIcon) targetIcon.classList.add("spinning");
clearCheckMark(); clearCheckMark();
// Afficher "rafraichissement en cours…" si on n'a pas déjà les données // Afficher "Rafraîchissement en cours…" si on n'a pas déjà les données
updateCaptureInfoText(); updateCaptureInfoText();
} else { } else {
refreshCounter = Math.max(0, refreshCounter - 1); refreshCounter = Math.max(0, refreshCounter - 1);
@@ -3904,7 +3748,7 @@ function setRefreshing(on) {
} }
} }
// Force le rafraichissement du texte "MAJ HH:MM" ou "rafraichissement en cours…" // Force le rafraîchissement du texte "MAJ HH:MM" ou "Rafraîchissement en cours…"
// selon refreshCounter. // selon refreshCounter.
function updateCaptureInfoText() { function updateCaptureInfoText() {
if (state.currentData) { if (state.currentData) {
@@ -3972,7 +3816,7 @@ function updateProgressBar(done, total) {
label.textContent = `${prefix}${done} / ${total}`; label.textContent = `${prefix}${done} / ${total}`;
} }
// Affiche/masque le bouton "Arrêter". N'est montré que pdt un refresh // Affiche/masque le bouton "Arrêter". N'est montré que pendant un refresh
// manuel (clic utilisateur), pas pendant les chargements normaux ni les // manuel (clic utilisateur), pas pendant les chargements normaux ni les
// refresh auto 12h/15h. // refresh auto 12h/15h.
function showAbortButton(on) { function showAbortButton(on) {
@@ -3988,7 +3832,7 @@ function showAbortButton(on) {
/** /**
* Toast de feedback quand l'user clique Arrêter. Les fetches en cours peuvent * Toast de feedback quand l'user clique Arrêter. Les fetches en cours peuvent
* encore prendre 1-2 secondes avant de se terminer (on ne peut pas vriament * encore prendre 1-2 secondes avant de se terminer (on ne peut pas vraiment
* annuler un fetch() en cours), mais du point de vue de l'interface tout * annuler un fetch() en cours), mais du point de vue de l'interface tout
* est arrêté : plus de mise à jour, plus de cache, plus rien. * est arrêté : plus de mise à jour, plus de cache, plus rien.
*/ */
@@ -4025,12 +3869,7 @@ function detectOverlaps(techs) {
const ivs = (tech.interventions || []).filter(iv => const ivs = (tech.interventions || []).filter(iv =>
iv && iv.startTime && iv.endTime && iv && iv.startTime && iv.endTime &&
!iv._disappearRemove && !iv._disappearRemove &&
iv.type !== "AL-Reservation" && iv.type !== "AL-Reservation"
// v4.3.2 : le pompier est une absence "tolérée" qui chevauche par
// nature les heures de travail (garde volontaire) — on l'exclut des
// conflits. En revanche les congés/maladies/formations restent
// détectés car une inter planifiée pdt une absence, c'est un vrai pb.
!iv.isPompier
); );
// Reset flag sur toutes les inters du tech (y compris celles ignorées) // Reset flag sur toutes les inters du tech (y compris celles ignorées)
for (const iv of (tech.interventions || [])) { for (const iv of (tech.interventions || [])) {
@@ -4069,7 +3908,7 @@ function renderCaptureInfo(data, stats) {
if (refreshCounter > 0) { if (refreshCounter > 0) {
// v4.1.20 : message différencié selon le type de refresh actif // v4.1.20 : message différencié selon le type de refresh actif
// - partial (Actualiser) → "Actualisation en cours…" // - partial (Actualiser) → "Actualisation en cours…"
// - total (Tout recharger) → "rafraichissement en cours…" // - total (Tout recharger) → "Rafraîchissement en cours…"
if (activeRefreshButton === "partial") { if (activeRefreshButton === "partial") {
info.textContent = "Actualisation en cours…"; info.textContent = "Actualisation en cours…";
} else { } else {
@@ -4188,12 +4027,6 @@ function compareTechs(a, b, targetDate) {
return aLast.localeCompare(bLast, "fr"); return aLast.localeCompare(bLast, "fr");
} }
// v5.0.13 : un tech est considéré "absent toute la journée" uniquement si une
// absence couvre RÉELLEMENT du matin au soir (ou quasi), pas juste s'il a des
// absences (éventuellement partielles). Avant, une absence matin 08-12 seule
// faisait passer le tech en "absent toute la journée" car il n'avait QUE des
// absences. Maintenant on check explicitement que l'absence couvre ≥ 90% de
// la plage 08:00-18:00.
function isTechAbsent(tech, isoDate) { function isTechAbsent(tech, isoDate) {
const recurring = RECURRING_ABSENCES[tech.id]; const recurring = RECURRING_ABSENCES[tech.id];
if (recurring) { if (recurring) {
@@ -4201,26 +4034,7 @@ function isTechAbsent(tech, isoDate) {
if (recurring.includes(day)) return true; if (recurring.includes(day)) return true;
} }
if (tech.interventions.length === 0) return false; if (tech.interventions.length === 0) return false;
// Parmi les absences (hors pompier), est-ce qu'une seule couvre la journée ? return tech.interventions.every(iv => iv.type === "AL-Absence" && !iv.isPompier);
const fullDayAbsences = tech.interventions.filter(iv => {
if (iv.type !== "AL-Absence" || iv.isPompier) return false;
const startMin = timeToMinutes(iv.startTime);
const endMin = timeToMinutes(iv.endTime);
if (startMin == null || endMin == null) {
// Si on n'a pas d'horaires, on considère que c'est toute la journée
// (cas des absences multi-jours sans horaires précis)
return true;
}
// Absence couvre toute la journée si son créneau déborde largement
// la plage affichée (≥ 90%). Une demi-journée (4h) sur 10h = 40% → ne
// passera pas, donc on ne marquera pas le tech comme absent toute la journée.
const DAY_LEN_MIN = 10 * 60; // 08:00 → 18:00 = 10h
const clampedStart = Math.max(startMin, 8 * 60);
const clampedEnd = Math.min(endMin, 18 * 60);
const coveredMin = Math.max(0, clampedEnd - clampedStart);
return coveredMin >= 0.9 * DAY_LEN_MIN;
});
return fullDayAbsences.length > 0;
} }
// ============================================================================ // ============================================================================
@@ -4309,24 +4123,6 @@ function buildCard(tech, isoDate) {
note.textContent = "Absent toute la journée"; note.textContent = "Absent toute la journée";
} }
body.appendChild(note); body.appendChild(note);
// v5.0.4 : tooltip au hover sur toute la carte absent (pas juste un
// bouton visible). Contient : détail période + bouton supprimer si
// c'est une absence supprimable (actionId réel, pas pompier récurrent).
if (ab.actionId && !ab.isPompier && !ab._recurring) {
// On attache le tooltip sur la CARD ENTIÈRE (card) — comme ça
// survoler n'importe où sur la zone grisée "absent" le déclenche.
const ivCopy = {
...ab,
type: "AL-Absence" // force pour buildTooltipHTML
};
card.addEventListener("mouseenter", (e) => {
showTooltip(e, ivCopy, card);
});
card.addEventListener("mouseleave", () => {
hideTooltip();
});
}
} }
// v4.1.20 : cas spécifique Pillonel Olivier, absent tous les vendredis. // v4.1.20 : cas spécifique Pillonel Olivier, absent tous les vendredis.
@@ -4406,13 +4202,10 @@ function buildCard(tech, isoDate) {
// Timeline // Timeline
// ============================================================================ // ============================================================================
// v5.0.0 : constantes timeline globales (avant : locales à buildTimeline),
// pour que updateNowLine puisse les utiliser aussi.
const DAY_START = 8 * 60; // 08:00 en minutes
const DAY_END = 18 * 60; // 18:00 en minutes
const DAY_LEN = DAY_END - DAY_START;
function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) { function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) {
const DAY_START = 8 * 60;
const DAY_END = 18 * 60;
const DAY_LEN = DAY_END - DAY_START;
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = "timeline"; wrap.className = "timeline";
@@ -4553,7 +4346,7 @@ function bindTimelinePopover(el) {
// - Ctrl+clic (Cmd+clic sur Mac) : ouvre dans un onglet en arrière-plan // - Ctrl+clic (Cmd+clic sur Mac) : ouvre dans un onglet en arrière-plan
const kind = el.dataset.kind; const kind = el.dataset.kind;
const ivIdxStr = el.dataset.ivIdx; const ivIdxStr = el.dataset.ivIdx;
// Seulement sur les segments avec une interventoin (pas les "hole" libres // Seulement sur les segments avec une intervention (pas les "hole" libres
// ni certaines absences sans ivIdx) // ni certaines absences sans ivIdx)
if (ivIdxStr === undefined) return; if (ivIdxStr === undefined) return;
@@ -4617,7 +4410,7 @@ function openInterventionFromTimeline(el, opts) {
if (!row) return; if (!row) return;
const actionId = row.dataset.actionId; const actionId = row.dataset.actionId;
if (!actionId) return; if (!actionId) return;
// recupere l'iv depuis state // cupère l'iv depuis state
const iv = findIvByActionId(actionId); const iv = findIvByActionId(actionId);
if (!iv) return; if (!iv) return;
openInterventionInNewTab(iv, opts || {}); openInterventionInNewTab(iv, opts || {});
@@ -4734,7 +4527,7 @@ function showTimelinePopover(e, el) {
} }
// ============================================================================ // ============================================================================
// Ligne d'interventoin // Ligne d'intervention
// ============================================================================ // ============================================================================
function buildInterventionRow(iv, cardEl) { function buildInterventionRow(iv, cardEl) {
@@ -4742,9 +4535,7 @@ function buildInterventionRow(iv, cardEl) {
row.className = "intervention-v2"; row.className = "intervention-v2";
row.dataset.actionId = iv.actionId; row.dataset.actionId = iv.actionId;
if (iv.isPompier) row.classList.add("is-pompier-line"); if (iv.isPompier) row.classList.add("is-pompier-line");
// v4.3.3 : on ne marque plus les ghosts visuellement (classe is-ghost if (iv.ghost) row.classList.add("is-ghost");
// retirée). Les tickets disparus sont soit retirés (_disappearRemove),
// soit affichés en vert (_disappearStatus). Plus de barrage.
// v4.2.5 : indicateur "en cours d'analyse" (ticket disparu, on re-fetch // v4.2.5 : indicateur "en cours d'analyse" (ticket disparu, on re-fetch
// la fiche pour décider de le garder en vert ou le retirer). // la fiche pour décider de le garder en vert ou le retirer).
if (iv._disappearChecking) row.classList.add("_checking"); if (iv._disappearChecking) row.classList.add("_checking");
@@ -5540,7 +5331,7 @@ async function copyRef(ref, btn) {
} }
// ─── Rendu incrémental (v3.1) ─────────────────────────────────────────────── // ─── Rendu incrémental (v3.1) ───────────────────────────────────────────────
// Met à jour UNE ligne d'interventoin dans le DOM (après qu'un fetch fiche // Met à jour UNE ligne d'intervention dans le DOM (après qu'un fetch fiche
// ait enrichi l'objet iv avec sa ref, son statut, etc.). Appelée par // ait enrichi l'objet iv avec sa ref, son statut, etc.). Appelée par
// fetchAndUpdateIntervention pour afficher la ref dès qu'elle arrive, sans // fetchAndUpdateIntervention pour afficher la ref dès qu'elle arrive, sans
// attendre que tous les workers aient fini ni re-rendre toute la vue. // attendre que tous les workers aient fini ni re-rendre toute la vue.
@@ -5788,7 +5579,7 @@ function showTooltip(e, iv, rowEl) {
} }
bulleState.hoveredInRow = true; bulleState.hoveredInRow = true;
// v4.1.12 : positionner la bulle UNE SEULE FOIS au mouseenter, près de la // v4.1.12 : positionner la bulle UNE SEULE FOIS au mouseenter, près de la
// carte (row) et pas du curseur. Elle ne bouge plus pdt le survol. // carte (row) et pas du curseur. Elle ne bouge plus pendant le survol.
// v4.1.15 : si pinned, NE PAS repositionner (la bulle doit rester fixe). // v4.1.15 : si pinned, NE PAS repositionner (la bulle doit rester fixe).
if (!bulleState.pinned) { if (!bulleState.pinned) {
positionTooltipAnchored(rowEl || (e && e.currentTarget)); positionTooltipAnchored(rowEl || (e && e.currentTarget));
@@ -5805,7 +5596,7 @@ function showTooltip(e, iv, rowEl) {
if (!ok) return; if (!ok) return;
const tip = tooltipEl(); const tip = tooltipEl();
if (!tip.classList.contains("visible")) return; if (!tip.classList.contains("visible")) return;
// Vérifie qu'on affiche toujours la même interventoin (pas un autre hover // Vérifie qu'on affiche toujours la même intervention (pas un autre hover
// intervenu entretemps) // intervenu entretemps)
if (state.currentTooltipIv === iv) { if (state.currentTooltipIv === iv) {
tip.innerHTML = buildTooltipHTML(iv); tip.innerHTML = buildTooltipHTML(iv);
@@ -5862,19 +5653,24 @@ function hasTextSelectionInTooltip() {
} }
function moveTooltip(e) { function moveTooltip(e) {
// Historique : avant on suivait la souris. Maintenant la bulle est fixe // v4.1.12 : la bulle est FIXE (positionnée une fois au mouseenter). Cette
// (placée une seule fois au mouseenter). Cette fonction est là juste pour // fonction est conservée pour compat mais ne fait plus rien.
// pas casser les appels existants.
} }
// ============================================================================ // ============================================================================
// Positionnement du tooltip // v4.2.4 : Positionnement du tooltip — refonte complète
// ============================================================================ //
// On positionne avec style.left/top en coords VIEWPORT (comme position:fixed). // Stratégie :
// Si un ancêtre casse position:fixed (transform, filter, backdrop-filter ou // 1. On positionne TOUJOURS avec style.left/top en coordonnées VIEWPORT
// contain), on détecte ça empiriquement au 1er placement via // (comme un élément position:fixed).
// getBoundingClientRect — et on bascule en "abs" : mêmes coords mais on // 2. Au 1er positionnement, on mesure si `position: fixed` marche vraiment
// compense le scroll manuellement pour garder la bulle stable à l'écran. // sur ce tooltip (grâce à getBoundingClientRect). Si un ancêtre le
// casse (transform / filter / backdrop-filter / contain), le tooltip
// tombe en position:absolute calculée depuis le containing block.
// 3. Si `position: fixed` est cassée, on active un listener scroll qui
// recalcule la position pour qu'elle reste STABLE à l'écran (on traite
// alors style.left/top comme des coordonnées document et on ajoute
// window.scrollX/Y pour compenser).
// ============================================================================ // ============================================================================
// Position stockée : targetLeft / targetTop = coordonnées VIEWPORT désirées // Position stockée : targetLeft / targetTop = coordonnées VIEWPORT désirées
@@ -6113,41 +5909,13 @@ function pinTooltip() {
closeBtn.type = "button"; closeBtn.type = "button";
closeBtn.className = "pinned-popup-close"; closeBtn.className = "pinned-popup-close";
closeBtn.innerHTML = "×"; closeBtn.innerHTML = "×";
closeBtn.title = "Désépingler (reste visible tant que la souris est dessus)"; closeBtn.title = "Fermer";
closeBtn.addEventListener("click", (e) => { closeBtn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
// Désépinglage "mou" : on marque la popup comme non épinglée mais on la _closePinnedPopup(popup);
// laisse visible tant que la souris est dessus. Elle disparaît quand la
// souris sort.
_softUnpinPopup(popup);
}); });
popup.appendChild(closeBtn); popup.appendChild(closeBtn);
// v4.3.3 : barre de drag en haut, pour déplacer la popup à la souris.
// Ancrée en haut à 22px de haut ; le padding-top de la popup est augmenté
// côté CSS pour ne pas que le contenu soit caché derrière.
const dragbar = document.createElement("div");
dragbar.className = "pinned-popup-dragbar";
dragbar.title = "Glissez pour déplacer";
popup.appendChild(dragbar);
_attachPopupDragHandler(popup, dragbar);
// v4.3.0 : le popup contient un clone du tooltip live, qui inclut le
// bouton 📌. Dans un popup déjà épinglé, ce bouton devient "désépingler".
// On intercepte le clic ici, avant qu'il remonte.
popup.addEventListener("click", (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === "pin") {
e.stopPropagation();
e.preventDefault();
_softUnpinPopup(popup);
}
// Les autres actions (reload, copy-ref, etc.) ne sont pas gérées ici ;
// on pourrait les ajouter plus tard si besoin.
});
// Placer en (0,0) temporairement pour mesurer la taille // Placer en (0,0) temporairement pour mesurer la taille
popup.style.position = "absolute"; popup.style.position = "absolute";
popup.style.left = "-9999px"; popup.style.left = "-9999px";
@@ -6201,167 +5969,12 @@ function _closePinnedPopup(el) {
el.remove(); el.remove();
} }
/** /** Ferme tous les popups épinglés. */
* Désépinglage "mou" : la popup n'est plus considérée épinglée (elle n'est
* plus dans pinnedPopups, donc le comptage pour Ctrl×2 etc. ignore) mais on
* la laisse visible. Elle disparait quand la souris sort.
*/
function _softUnpinPopup(el) {
// Retirer de la liste (pour le comptage Ctrl×2) mais garder le DOM
const idx = pinnedPopups.findIndex(p => p.el === el);
if (idx >= 0) pinnedPopups.splice(idx, 1);
// v4.3.3 corr : basculer visuellement en tooltip normal (retirer tous les
// attributs visuels du mode épinglé : bordure bleue, dragbar, bouton ×,
// padding-top, etc.). La classe .soft-unpinned fait ça côté CSS.
// On retire .pinned-popup pour que les règles visuelles lourdes
// disparaissent, tout en gardant la popup au même endroit (position
// absolute conservée).
el.classList.remove("pinned-popup");
el.classList.add("soft-unpinned");
// Icône 📌 → 📍 pour le clin d'œil (même si elle va bientôt disparaitre)
const pinBtn = el.querySelector('[data-action="pin"]');
if (pinBtn) pinBtn.textContent = "📍";
// Supprimer les éléments propres au mode épinglé : barre de drag et ×
const dragbar = el.querySelector(".pinned-popup-dragbar");
if (dragbar) dragbar.remove();
const closeBtn = el.querySelector(".pinned-popup-close");
if (closeBtn) closeBtn.remove();
// Helper qui joue l'animation de sortie puis supprime le DOM
const animateAndRemove = () => {
el.classList.add("unpinning");
setTimeout(() => el.remove(), 180);
};
if (!el.matches(":hover")) {
animateAndRemove();
return;
}
// Souris dessus : on ne supprime pas tout de suite. On attend mouseleave
// et à ce moment on joue l'animation de sortie et on supprime.
el.addEventListener("mouseleave", animateAndRemove, { once: true });
}
/** Ferme toutes les popups épinglées (appelé par Échap ou changement de date). */
/**
* v5.0.1 : helper pour déclencher la suppression d'une absence ou réservation.
* Affiche la modal de confirmation, puis appelle le background.
*/
function _triggerDeleteItem(actionId, kind, triggerBtn) {
if (!actionId) return;
const label = kind === "reservation" ? "cette réservation" : "cette absence";
showAlertModal({
title: "Confirmer la suppression",
message: `Voulez-vous vraiment supprimer ${label} ? Cette action est irréversible.`,
buttons: [
{ label: "Annuler", variant: "secondary", action: () => {} },
{
label: "Supprimer",
variant: "danger",
action: async () => {
if (triggerBtn) {
triggerBtn.disabled = true;
triggerBtn.textContent = "Suppression…";
}
try {
const resp = await sendMessage({
type: "deletePlanningItem",
actionId: actionId,
kind: kind
});
if (!resp || !resp.ok) {
throw new Error(resp && resp.error ? resp.error : "erreur inconnue");
}
showToast("Supprimé", "L'élément a été retiré du planning.");
unpinTooltip();
closeAllPinnedPopups();
if (state.session) {
await loadForDate(state.currentDate, { forceRefetch: true });
}
} catch (err) {
showAlertModal({
title: "Erreur lors de la suppression",
message: "Impossible de supprimer : " + (err.message || err),
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
if (triggerBtn) {
triggerBtn.disabled = false;
triggerBtn.textContent = "🗑 Supprimer l'absence";
}
}
}
}
]
});
}
function closeAllPinnedPopups() { function closeAllPinnedPopups() {
for (const p of pinnedPopups.slice()) { for (const p of pinnedPopups.slice()) {
p.el.remove(); p.el.remove();
} }
pinnedPopups.length = 0; pinnedPopups.length = 0;
// Fermer aussi les popups en état soft-unpinned qui trainent encore
document.querySelectorAll(".pinned-popup.soft-unpinned").forEach(el => el.remove());
}
/**
* v4.3.3 : permet de déplacer une popup épinglée à la souris via sa barre
* de drag. Met à jour les coords document (position absolute) et le rect
* mémorisé dans pinnedPopups pour que les nouvelles popups évitent bien
* la nouvelle position.
*/
function _attachPopupDragHandler(popup, dragbar) {
let dragging = false;
let startMouseX = 0, startMouseY = 0;
let startLeft = 0, startTop = 0;
const onMouseMove = (e) => {
if (!dragging) return;
const dx = e.clientX - startMouseX;
const dy = e.clientY - startMouseY;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
// Clamper dans le document (pas sortir trop à gauche/haut)
if (newLeft < 4) newLeft = 4;
if (newTop < 4) newTop = 4;
popup.style.left = newLeft + "px";
popup.style.top = newTop + "px";
};
const onMouseUp = () => {
if (!dragging) return;
dragging = false;
popup.classList.remove("dragging");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
// Mettre à jour le rect mémorisé pour la détection de chevauchement
const entry = pinnedPopups.find(p => p.el === popup);
if (entry) {
const l = parseFloat(popup.style.left) || 0;
const t = parseFloat(popup.style.top) || 0;
const w = popup.offsetWidth;
const h = popup.offsetHeight;
entry.rect = { left: l, top: t, right: l + w, bottom: t + h };
}
};
dragbar.addEventListener("mousedown", (e) => {
// Seulement bouton gauche
if (e.button !== 0) return;
e.preventDefault();
dragging = true;
startMouseX = e.clientX;
startMouseY = e.clientY;
startLeft = parseFloat(popup.style.left) || 0;
startTop = parseFloat(popup.style.top) || 0;
popup.classList.add("dragging");
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
} }
// v4.1.14/19 : recharger UNIQUEMENT cette intervention. Fetch DIRECT sans // v4.1.14/19 : recharger UNIQUEMENT cette intervention. Fetch DIRECT sans
@@ -6624,24 +6237,19 @@ function bindTooltipInteractions() {
}, 1200); }, 1200);
}).catch(() => {}); }).catch(() => {});
} }
} else if (action === "delete-item") {
// v5.0.0 : supprimer absence/réservation (depuis tooltip)
const actionId = btn.dataset.actionId;
const kind = btn.dataset.kind || "absence";
_triggerDeleteItem(actionId, kind, btn);
} }
}); });
// Clic hors bulle : unpin si épinglé. // Clic hors bulle : unpin si épinglé.
// Attention : ne pas déclencher sur clic DANS la bulle (elle contient du // Attention : ne pas déclencher sur clic DANS la bulle (elle contient du
// texte sélectionnable), ni sur clic sur une interventoin (qui ouvre la // texte sélectionnable), ni sur clic sur une intervention (qui ouvre la
// fiche — le user n'attend pas que la bulle reste épinglée dans ce cas // fiche — le user n'attend pas que la bulle reste épinglée dans ce cas
// mais le comportement "ouvrir la fiche" reste prioritaire). // mais le comportement "ouvrir la fiche" reste prioritaire).
document.addEventListener("mousedown", (e) => { document.addEventListener("mousedown", (e) => {
if (!bulleState.pinned) return; if (!bulleState.pinned) return;
// Clic dans la bulle → on laisse (sélection de texte) // Clic dans la bulle → on laisse (sélection de texte)
if (el.contains(e.target)) return; if (el.contains(e.target)) return;
// Dans tous les autres cas (y compris clic sur une autre interventoin), // Dans tous les autres cas (y compris clic sur une autre intervention),
// on désépingle. Si c'était un clic sur intervention, le handler // on désépingle. Si c'était un clic sur intervention, le handler
// d'ouverture de la fiche s'exécutera ensuite normalement. // d'ouverture de la fiche s'exécutera ensuite normalement.
unpinTooltip(); unpinTooltip();
@@ -6660,23 +6268,6 @@ function buildTooltipHTML(iv) {
} }
if (iv.reservationLabel) rows.push(row("Sujet", iv.reservationLabel)); if (iv.reservationLabel) rows.push(row("Sujet", iv.reservationLabel));
if (iv.reservationCreator) rows.push(row("Par", iv.reservationCreator)); if (iv.reservationCreator) rows.push(row("Par", iv.reservationCreator));
// v5.0.0 : bouton supprimer pour les réservations (avec confirmation)
rows.push(`<dt></dt><dd><button type="button" class="tooltip-delete-btn" data-action="delete-item" data-action-id="${escapeHtml(iv.actionId || "")}" data-kind="reservation">🗑 Supprimer cette réservation</button></dd>`);
return `<dl>${rows.join("")}</dl>`;
}
// v5.0.0 : cas spécial absence (congé, maladie, formation, pompier, ...)
if (iv.type === "AL-Absence") {
const label = iv.label || "Absence";
rows.push(`<dt>Type</dt><dd><span class="status-pill other">${escapeHtml(label)}</span></dd>`);
if (iv.startTime && iv.endTime) {
rows.push(row("Horaire", `${iv.startTime}${iv.endTime}`));
}
// Pour les absences récurrentes (Pillonel vendredi), pas d'actionId réel
// → pas de bouton supprimer. Pour les autres → oui.
if (iv.actionId) {
rows.push(`<dt></dt><dd><button type="button" class="tooltip-delete-btn" data-action="delete-item" data-action-id="${escapeHtml(iv.actionId)}" data-kind="absence">🗑 Supprimer cette absence</button></dd>`);
}
return `<dl>${rows.join("")}</dl>`; return `<dl>${rows.join("")}</dl>`;
} }
@@ -6897,111 +6488,15 @@ function hideEvUnreachable() {
// que les mises à jour sont arrêtées. // que les mises à jour sont arrêtées.
function showSessionExpiredBanner() { function showSessionExpiredBanner() {
const b = document.getElementById("session-expired-banner"); const b = document.getElementById("session-expired-banner");
if (b) { if (b) b.classList.remove("hidden");
b.classList.remove("hidden"); // Masquer la bannière EV si présente (on ne montre qu'une bannière à la fois)
// v5.0.10 : rebrancher le bouton "Ouvrir EasyVista" natif pour qu'il
// appelle triggerReconnect() au lieu de juste ouvrir un onglet. Ça
// déclenche la reconnexion SSO ET l'auto-reload du viewer quand la
// nouvelle session est détectée.
// On renomme aussi le bouton pour être explicite.
const btn = b.querySelector("#session-banner-reconnect");
if (btn && !btn.dataset.boundReconnect) {
btn.dataset.boundReconnect = "1";
btn.textContent = "🔄 Me reconnecter";
// Retirer d'éventuels anciens listeners en clonant le bouton
const clone = btn.cloneNode(true);
btn.parentNode.replaceChild(clone, btn);
clone.addEventListener("click", () => triggerReconnect());
}
}
hideEvUnreachableBanner(); hideEvUnreachableBanner();
hideReconnectingBanner();
} }
function hideSessionExpiredBanner() { function hideSessionExpiredBanner() {
const b = document.getElementById("session-expired-banner"); const b = document.getElementById("session-expired-banner");
if (b) b.classList.add("hidden"); if (b) b.classList.add("hidden");
} }
// v5.0.9 : bannière affichée pendant la reconnexion (remplace la bannière
// expirée après clic sur "Me reconnecter")
// v5.0.11 : ajoute un bouton "Annuler" pour interrompre le processus.
function showReconnectingBanner() {
let b = document.getElementById("session-reconnecting-banner");
if (!b) {
b = document.createElement("div");
b.id = "session-reconnecting-banner";
b.className = "banner-reconnecting";
const topbar = document.querySelector(".topbar") || document.querySelector("header") || document.body;
if (topbar.nextSibling) {
topbar.parentNode.insertBefore(b, topbar.nextSibling);
} else {
document.body.insertBefore(b, document.body.firstChild);
}
}
b.innerHTML = `
<span class="banner-spinner"></span>
<span class="banner-text">Reconnexion à EasyVista en cours Connectez-vous dans l'onglet qui vient de s'ouvrir.</span>
<button type="button" class="banner-cancel-btn">Annuler</button>
`;
const cancelBtn = b.querySelector(".banner-cancel-btn");
if (cancelBtn) cancelBtn.addEventListener("click", () => cancelReconnect());
b.classList.remove("hidden");
hideSessionExpiredBanner();
hideReconnectFailedBanner();
}
function hideReconnectingBanner() {
const b = document.getElementById("session-reconnecting-banner");
if (b) b.classList.add("hidden");
}
// v5.0.11 : bannière "Reconnexion échouée" avec choix manuel du réseau
// (Bureau/Télétravail). Affichée après timeout 90s de reconnexion.
function showReconnectFailedBanner() {
let b = document.getElementById("session-reconnect-failed-banner");
if (!b) {
b = document.createElement("div");
b.id = "session-reconnect-failed-banner";
b.className = "banner-reconnect-failed";
const topbar = document.querySelector(".topbar") || document.querySelector("header") || document.body;
if (topbar.nextSibling) {
topbar.parentNode.insertBefore(b, topbar.nextSibling);
} else {
document.body.insertBefore(b, document.body.firstChild);
}
}
b.innerHTML = `
<span class="banner-icon"></span>
<span class="banner-text">
<strong>Reconnexion échouée.</strong>
Indiquez vous êtes pour réessayer :
</span>
<button type="button" class="banner-btn-primary" data-origin="https://itsma.etat-de-vaud.ch">🏢 Au bureau</button>
<button type="button" class="banner-btn-primary" data-origin="https://itsma.vd.ch">🏠 En télétravail</button>
<button type="button" class="banner-cancel-btn">Annuler</button>
`;
// Boutons de choix de réseau : retry avec origine forcée
b.querySelectorAll(".banner-btn-primary").forEach(btn => {
btn.addEventListener("click", () => {
const origin = btn.dataset.origin;
hideReconnectFailedBanner();
triggerReconnect(origin);
});
});
// Bouton Annuler : retour à la bannière "Session expirée" simple
const cancelBtn = b.querySelector(".banner-cancel-btn");
if (cancelBtn) cancelBtn.addEventListener("click", () => {
hideReconnectFailedBanner();
showSessionExpiredBanner();
});
b.classList.remove("hidden");
hideSessionExpiredBanner();
hideReconnectingBanner();
}
function hideReconnectFailedBanner() {
const b = document.getElementById("session-reconnect-failed-banner");
if (b) b.classList.add("hidden");
}
// v4.2.5 : bannière non bloquante "EasyVista inaccessible" // v4.2.5 : bannière non bloquante "EasyVista inaccessible"
function showEvUnreachableBanner() { function showEvUnreachableBanner() {
const b = document.getElementById("ev-unreachable-banner"); const b = document.getElementById("ev-unreachable-banner");