forked from FroSteel/Planification
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 565075933e | |||
| 7f78493859 | |||
| 0b08ca122b | |||
| 87f561ae10 | |||
| be49a89057 | |||
| e42b145401 | |||
| 7201fde2d3 | |||
| edd6ffc1c3 | |||
| 23244fc4db | |||
| f52095dc4d | |||
| 94877cb816 | |||
| 8ab62e92d2 | |||
| 8bc26c326f | |||
| d2afbf0dca | |||
| 3b1831a83a | |||
| 43c6e0e487 |
+39
@@ -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
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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
|
||||
(`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) dans une vue plus lisible.
|
||||
Extension Chrome / Firefox pour visualiser de manière claire et rapide le planning des techniciens IT du Canton de Vaud dans EasyVista.
|
||||
|
||||
## Nouveautés v4.1.2
|
||||
## Aperçu rapide
|
||||
|
||||
- **Vraies infos contact/lieu dans les cartes** : les attributs attr1/attr2 du
|
||||
XML contiennent les infos saisies à la *planification*, qui ne sont pas
|
||||
toujours à jour (le tech a pu corriger le contact/lieu avant intervention).
|
||||
Désormais, pour chaque intervention, on fetch AUSSI le xhr2 en arrière-plan
|
||||
(en plus de la fiche), ce qui apporte les **vraies** infos validées. La
|
||||
carte se met à jour automatiquement quand elles arrivent.
|
||||
- **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.
|
||||
- **Auteur** : Quentin Rouiller (QRO)
|
||||
- **Cible** : techniciens IT Canton de Vaud, EasyVista (`itsma.etat-de-vaud.ch` / `itsma.vd.ch`)
|
||||
- **Démarrage projet** : jeudi 16 avril 2026
|
||||
- **Version actuelle** : `v2026.5.37`
|
||||
- **Manifest** : V3 (Chrome/Edge/Firefox)
|
||||
- **Format** : `.zip` (Chromium) + `.xpi` signé (Firefox)
|
||||
|
||||
## Nouveautés v4.1
|
||||
## Fonctionnalités principales
|
||||
|
||||
- **Fetch des fiches séquentiel (1 par 1)** au lieu de 5 workers en parallèle.
|
||||
Le serveur EasyVista sérialise les requêtes de toute façon, donc le parallélisme
|
||||
n'apporte rien. Et surtout : quand tu changes de date pendant le fetch, l'abort
|
||||
est **instantané** car il n'y a qu'une seule requête en vol au maximum.
|
||||
- **Cache incrémental** : le cache est sauvé toutes les 5 fiches pendant le fetch,
|
||||
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.
|
||||
### Vue planning
|
||||
- Affichage des interventions et réservations groupées par technicien
|
||||
- Horaires, contact, lieu, catégorie, statut visibles d'un coup d'œil
|
||||
- 8 techniciens hardcodés (équipe IT canton)
|
||||
- Cache local pour réduire les requêtes serveur
|
||||
|
||||
## 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
|
||||
de ~100 par chargement à **1 seule requête** pour l'affichage principal.
|
||||
### Tooltips et popups
|
||||
- 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 :
|
||||
- 1 fetch XML planning (`calendar_block`)
|
||||
- ~40 fetches `planning_xhr_2.php` pour les lieux/contacts
|
||||
- ~40 fetches de fiches HTML pour les catégories/refs/statuts
|
||||
- jusqu'à ~40 fetches de l'API timeline
|
||||
### Classification des absences (depuis v2026.5.27)
|
||||
- **Maladie/Accident** : indigo `#4338ca`
|
||||
- **Congé / Congés** : cyan `#06b6d4` (suffixe `s` adaptatif)
|
||||
- **Pompier** : rouge `#b03030`
|
||||
- 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à**
|
||||
dans ses attributs `attr1`/`attr2`/`attr3` le contact, le lieu et la catégorie
|
||||
complète de chaque intervention, et la ref dans le textContent du nœud.
|
||||
Toutes ces infos qu'on allait chercher ailleurs étaient en fait dans la toute
|
||||
première réponse, ignorées par le code.
|
||||
### Admin et configuration
|
||||
- Mode admin caché : bouton ⚙ Paramètres dans popup user-badge (depuis v2026.5.25, remplace les 5 clics secrets sur le titre)
|
||||
- Configuration persistée dans `localStorage` (`admin_config`)
|
||||
- Catégories interventions personnalisables (livraison/recup/remplacement/incident/rollout/reservation/autre)
|
||||
|
||||
Résultat : le premier rendu complet arrive en **moins d'une seconde**. Les
|
||||
fiches individuelles ne sont plus fetchées qu'en arrière-plan, uniquement
|
||||
pour le statut "Clôturé/Résolu" et le commentaire technicien.
|
||||
## Versionning — historique et conventions
|
||||
|
||||
**Lazy-load au survol.** Le texte détaillé d'une intervention (Problème, À faire,
|
||||
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.
|
||||
L'extension a connu **3 systèmes de versionning successifs** :
|
||||
|
||||
**Concurrence réduite.** Le pic de requêtes parallèles passe de 15 à 5 workers,
|
||||
pour ménager le serveur EasyVista qui a tendance à saturer sous les rafales.
|
||||
| Période | Format | Exemple |
|
||||
|---|---|---|
|
||||
| 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
|
||||
changé que ce qu'il y a sous le capot.
|
||||
### Pourquoi le passage à `YYYY.M.PATCH` ?
|
||||
|
||||
## 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
|
||||
- Détection automatique des interventions closes (✓ vert, fond vert)
|
||||
- Cache persistant 7 jours
|
||||
- Ghosts : les interventions disparues d'EasyVista restent visibles dans la vue
|
||||
- Refresh auto 12h et 15h
|
||||
- Annulation coopérative (bouton "Arrêter")
|
||||
- Thème clair/sombre
|
||||
⚠️ **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.
|
||||
|
||||
## Versions notables
|
||||
|
||||
### `v2026.5.37` (latest, 25 avril 2026) — Refonte vue horizontale
|
||||
- Topbar supprimée en vue horizontale, tout passe en sidebar
|
||||
- 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
|
||||
|
||||
1. Décompresser le zip
|
||||
2. Ouvrir Chrome, `chrome://extensions/`
|
||||
3. Activer **Mode développeur** (en haut à droite)
|
||||
4. **Charger l'extension non empaquetée** → sélectionner le dossier `planning-extension-v4`
|
||||
### Firefox
|
||||
Télécharger le `.xpi` signé depuis le serveur de mises à jour interne, ou drag-and-drop dans `about:addons`.
|
||||
|
||||
Si tu avais déjà la v3 installée, tu peux la supprimer avant — les caches des
|
||||
deux versions sont compatibles (même format).
|
||||
### Chrome / Edge
|
||||
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`)
|
||||
2. Cliquer sur l'icône de l'extension (depuis n'importe quel onglet)
|
||||
3. La vue claire s'ouvre dans un nouvel onglet
|
||||
```bash
|
||||
git clone https://gitea.netaplaid.ch/FroSteel/Planification.git
|
||||
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).
|
||||
- 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`.
|
||||
## Licence
|
||||
|
||||
## Limitations connues
|
||||
[MIT License](LICENSE) — Copyright (c) 2026 Quentin Rouiller
|
||||
|
||||
- Nécessite un onglet EasyVista ouvert (même en arrière-plan) pour fonctionner
|
||||
- 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
|
||||
l'équipe, il faut mettre à jour `viewer.js` ligne ~22)
|
||||
- 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)
|
||||
## Auteur
|
||||
|
||||
**Quentin Rouiller** (QRO)
|
||||
Canton de Vaud — Service IT
|
||||
|
||||
+14
-4
@@ -1,9 +1,15 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Planification",
|
||||
"version": "4.3.3",
|
||||
"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.",
|
||||
"permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"],
|
||||
"version": "4.2.8",
|
||||
"description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.2.8 : liste de techniciens dans les modals Absence/Douchette entièrement visible sans scroll. Inclut v4.2.7 (URL exacte douchette).",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"scripting",
|
||||
"storage",
|
||||
"tabs",
|
||||
"alarms"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://itsma.etat-de-vaud.ch/*",
|
||||
"https://itsma.vd.ch/*"
|
||||
@@ -21,7 +27,11 @@
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["viewer.html", "viewer.js", "viewer.css"],
|
||||
"resources": [
|
||||
"viewer.html",
|
||||
"viewer.js",
|
||||
"viewer.css"
|
||||
],
|
||||
"matches": [
|
||||
"https://itsma.etat-de-vaud.ch/*",
|
||||
"https://itsma.vd.ch/*"
|
||||
|
||||
+6
-171
@@ -262,7 +262,7 @@ html, body {
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
/* Barre de progression pendant le rafraichissement — v4.1.12 : texte
|
||||
/* Barre de progression pendant le rafraîchissement — v4.1.12 : texte
|
||||
toujours lisible, que la zone verte l'ait atteint ou non (utilise
|
||||
mix-blend-mode:difference pour inverser la couleur du texte là où la
|
||||
barre verte est dessous). */
|
||||
@@ -438,7 +438,7 @@ html, body {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Bouton "Arrêter" (apparaît pdt un refresh manuel) */
|
||||
/* Bouton "Arrêter" (apparaît pendant un refresh manuel) */
|
||||
.btn-abort {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
@@ -909,9 +909,10 @@ html, body {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* .intervention-v2.is-ghost : retirée en v4.3.3 — on ne barre plus les
|
||||
cartes. La gestion des tickets disparus se fait via _disappearStatus
|
||||
(vert ✓/✓✓) ou _disappearRemove (retrait total). */
|
||||
.intervention-v2.is-ghost {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Ligne 1 : REF en titre centré gros gras */
|
||||
.iv-ref-header {
|
||||
@@ -1750,169 +1751,3 @@ html, body {
|
||||
.modal-actions.horizontal .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
v4.2.9 : blocage du scroll arrière quand une modal est ouverte.
|
||||
La classe body.modal-open est ajoutée/retirée automatiquement par
|
||||
initModalScrollLock() dans viewer.js dès qu'un .modal-overlay existe.
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* v4.2.9 : pied de page discret en bas à droite — affiche auteur + date + version */
|
||||
.app-footer {
|
||||
position: fixed;
|
||||
right: 8px;
|
||||
bottom: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--text-faint, #8892a0);
|
||||
opacity: 0.55;
|
||||
pointer-events: none; /* ne capture pas les clics */
|
||||
user-select: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.2px;
|
||||
z-index: 1; /* sous les modals (qui sont à 10000) */
|
||||
}
|
||||
.app-footer:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
v4.3.0 : conflit d'horaire entre 2 interventions d'un même tech.
|
||||
Les heures s'affichent en rouge + icône ⚠ à côté.
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
.iv-time-vertical.iv-time-overlap .iv-time-start,
|
||||
.iv-time-vertical.iv-time-overlap .iv-time-end,
|
||||
.iv-time-vertical.iv-time-overlap .iv-time-arrow {
|
||||
color: var(--danger, #b03030) !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
.iv-time-overlap-warn {
|
||||
color: var(--danger, #b03030);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
cursor: help;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
v4.3.0 : popups épinglés détachés
|
||||
Ancrés au contenu (position:absolute coord document) → scrollent avec
|
||||
la page. Persistent jusqu'à fermeture explicite.
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
.tooltip.pinned-popup {
|
||||
position: absolute !important; /* override le fixed du .tooltip */
|
||||
/* v4.3.3 corr : les popups épinglées doivent passer DERRIÈRE la topbar
|
||||
quand on scrolle (topbar sticky z-index 10). Donc on met 5 : au-dessus
|
||||
du contenu normal, mais sous la topbar / bannières / modals. */
|
||||
z-index: 5 !important;
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
/* Bordure plus visible pour distinguer du tooltip live */
|
||||
border: 2px solid var(--accent, #0f4f8b);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.18);
|
||||
/* Pas de contain: layout (hérité) car ça limite le rendu ; on laisse */
|
||||
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 {
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
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é */
|
||||
.pinned-popup-close {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted, #888);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
z-index: 2; /* au-dessus de la dragbar */
|
||||
}
|
||||
.pinned-popup-close:hover {
|
||||
background: var(--danger-soft, #fbe6e6);
|
||||
color: var(--danger, #b03030);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
// ============================================================================
|
||||
// viewer.js — vue claire du planning techniciens
|
||||
// viewer.js v4.1 — vue claire du planning techniciens
|
||||
// ============================================================================
|
||||
// Idée de base : on récupère tout depuis le XML EasyVista (calendar_block) en
|
||||
// 1 seule requête. attr1/attr2/attr3 + textContent contiennent déjà ref,
|
||||
// contact, lieu, catégorie, formLink, deadline. Plus besoin de faire 74
|
||||
// requêtes xhr2 au chargement comme la v3. Le texte complet de l'action
|
||||
// (Problème / À faire / Matériel) est lazy-load au hover, seulement si
|
||||
// l'user survole la ligne.
|
||||
// Différences clés avec v3 :
|
||||
// 1. Une SEULE requête initiale (calendar_block) pour TOUT récupérer :
|
||||
// ref, contact, lieu, catégorie, formLink, deadline — tout est déjà dans
|
||||
// les attributs attr1/attr2/attr3/textContent du XML EasyVista.
|
||||
// 2. Suppression du fetch xhr2 en masse au chargement (74 requêtes éliminées)
|
||||
// 3. Suppression du fetch timeline (plus nécessaire)
|
||||
// 4. Lazy-load du texte d'action détaillé : on fetch xhr2 UNIQUEMENT sur hover,
|
||||
// et seulement pour l'intervention survolée (pour enrichir le tooltip avec
|
||||
// Problème/À faire/Matériel/etc.)
|
||||
// 5. Rendu utilisateur IDENTIQUE à v3 (même UI, mêmes infos au tooltip).
|
||||
//
|
||||
// Fetch des fiches : séquentiel (1 par 1) au lieu d'en paralléliser. Le
|
||||
// serveur EasyVista sérialise de toute façon, et ça rend l'abort instantané
|
||||
// si l'user change de date en cours.
|
||||
// Le cache est écrit toutes les 5 fiches (incrémental), pas juste à la fin.
|
||||
// Comme ça si l'user change de date au milieu, ce qu'on a déjà fetché est
|
||||
// pas perdu.
|
||||
// Différences v4 → v4.1 :
|
||||
// - Fetch des fiches SÉQUENTIEL (1 par 1) au lieu de 5 workers en parallèle.
|
||||
// Raison : le serveur EasyVista sérialise de toute façon, et le séquentiel
|
||||
// rend l'abort instantané quand l'user change de date.
|
||||
// - Cache INCRÉMENTAL : écrit toutes les 5 fiches pendant le fetch, pas juste
|
||||
// à la fin. Si l'user change de date en cours, les statuts déjà récupérés
|
||||
// ne sont pas perdus.
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
@@ -209,8 +214,6 @@ async function init() {
|
||||
initTheme();
|
||||
bindTopbar();
|
||||
bindTooltipInteractions();
|
||||
initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal
|
||||
initAppFooter(); // v4.2.9 : pied de page discret bas-droite
|
||||
|
||||
// Initialiser la date = aujourd'hui
|
||||
state.currentDate = todayISO();
|
||||
@@ -220,7 +223,7 @@ async function init() {
|
||||
// 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 session puis le planning
|
||||
await refreshSessionAndLoad();
|
||||
}
|
||||
|
||||
@@ -380,7 +383,7 @@ function toggleTheme() {
|
||||
|
||||
function bindTopbar() {
|
||||
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
|
||||
// v4.1.10 : 2 boutons de rafraichissement.
|
||||
// v4.1.10 : 2 boutons de rafraîchissement.
|
||||
// - refresh-btn (Total) : force le re-fetch de toutes les fiches (même celles
|
||||
// déjà enrichies), utile pour voir les statuts évoluer.
|
||||
// - refresh-partial-btn (Partiel) : re-fetch juste le XML planning pour
|
||||
@@ -437,7 +440,7 @@ function bindTopbar() {
|
||||
hideUserNamePopup();
|
||||
}
|
||||
}
|
||||
// v4.2.4 : clic ailleurs ferme aussi la grande bulle d'interventoin
|
||||
// v4.2.4 : clic ailleurs ferme aussi la grande bulle d'intervention
|
||||
// quand elle est ouverte via clic timeline (mode "anchored"). Clic sur
|
||||
// la bulle elle-même ou sur une timeline-slot ne ferme pas.
|
||||
const tip = tooltipEl();
|
||||
@@ -455,10 +458,6 @@ function bindTopbar() {
|
||||
if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
|
||||
hideTooltip({ force: true });
|
||||
}
|
||||
// v4.3.0 : Échap ferme TOUS les popups épinglés (le user veut tout fermer)
|
||||
if (typeof closeAllPinnedPopups === "function") {
|
||||
closeAllPinnedPopups();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -676,44 +675,6 @@ function showAlertModal(opts) {
|
||||
document.addEventListener("keydown", escHandler);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// v4.2.9 : blocage du scroll en arrière-plan quand un modal est ouvert
|
||||
// ============================================================================
|
||||
//
|
||||
// Un MutationObserver surveille l'apparition/disparition de tout élément
|
||||
// .modal-overlay dans le body. Dès qu'il y en a au moins un, on ajoute la
|
||||
// classe `modal-open` sur body → CSS bloque le scroll. Quand le dernier
|
||||
// modal disparaît, la classe est retirée.
|
||||
//
|
||||
// Centralisé ici pour que TOUS les modals (existants et futurs) en profitent
|
||||
// sans modification individuelle.
|
||||
|
||||
function initModalScrollLock() {
|
||||
const updateLock = () => {
|
||||
const hasModal = document.querySelector(".modal-overlay") !== null;
|
||||
document.body.classList.toggle("modal-open", hasModal);
|
||||
};
|
||||
const observer = new MutationObserver(updateLock);
|
||||
observer.observe(document.body, { childList: true, subtree: false });
|
||||
updateLock(); // au cas où un modal serait déjà là au boot
|
||||
}
|
||||
|
||||
// v4.2.9 : pied de page discret "QRO / vX.X.X" en bas à droite.
|
||||
// La version est lue depuis le manifest (source unique de vérité).
|
||||
function initAppFooter() {
|
||||
if (document.querySelector(".app-footer")) return;
|
||||
let version = "";
|
||||
try {
|
||||
const manifest = chrome && chrome.runtime && chrome.runtime.getManifest
|
||||
? chrome.runtime.getManifest() : null;
|
||||
if (manifest && manifest.version) version = "v" + manifest.version;
|
||||
} catch (e) {}
|
||||
const el = document.createElement("div");
|
||||
el.className = "app-footer";
|
||||
el.textContent = `QRO${version ? " / " + version : ""}`;
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// v4.2.6 : Modals Absence et Douchette
|
||||
// ============================================================================
|
||||
@@ -1212,13 +1173,6 @@ async function writeCache(isoDate, data) {
|
||||
// ============================================================================
|
||||
|
||||
async function loadForDate(isoDate, opts = {}) {
|
||||
// v4.3.1 : changer de date ferme tous les popups épinglés. Ils réfèrent à
|
||||
// des interventions du jour courant, ils n'ont aucun sens sur un autre jour.
|
||||
const previousDate = state.currentDate;
|
||||
if (previousDate && previousDate !== isoDate && typeof closeAllPinnedPopups === "function") {
|
||||
closeAllPinnedPopups();
|
||||
}
|
||||
|
||||
state.currentDate = isoDate;
|
||||
document.getElementById("date-picker").value = isoDate;
|
||||
|
||||
@@ -1364,20 +1318,11 @@ async function loadForDate(isoDate, opts = {}) {
|
||||
)
|
||||
);
|
||||
|
||||
// v4.3.2 : avant de lancer le fetch des fiches (lourd, ~460 KB chacune),
|
||||
// on fait d'abord un batch xhr2 (léger, ~2-5 KB chacun) pour récupérer
|
||||
// les vraies infos contact/lieu de toutes les interventions en parallèle.
|
||||
// Comme ça les cartes s'enrichissent en 1-3 secondes au lieu d'attendre
|
||||
// que l'utilisateur les survole une par une.
|
||||
if (!isRefreshAborted(myToken)) {
|
||||
await prefetchAllXhr2(merged.techs, myToken, opts.doStatusRefresh);
|
||||
}
|
||||
|
||||
if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted(myToken)) {
|
||||
const tFiches = performance.now();
|
||||
const nFiches = merged.techs.flatMap(t=>t.interventions).filter(i=>i.type==="AL-Intervention").length;
|
||||
console.log(`[load] début fetch des ${nFiches} fiches (statuts)…`);
|
||||
// forceAll : uniquement si refresh manuel (bouton "rafraichir").
|
||||
// forceAll : uniquement si refresh manuel (bouton "Rafraîchir").
|
||||
// À la navigation normale entre dates, on ne refetch que les iv non
|
||||
// encore enrichies (ficheFetched=false) — ça reprend là où on s'était
|
||||
// arrêté si un refresh précédent a été interrompu par un changement de
|
||||
@@ -1466,9 +1411,9 @@ async function fetchPlanningForDate(isoDate) {
|
||||
}
|
||||
|
||||
// Safeguard (v3.1) : le serveur EasyVista répond parfois 200 avec un
|
||||
// corps vide — typiquement quand la sesson vient d'être invalidée, ou
|
||||
// corps vide — typiquement quand la session vient d'être invalidée, ou
|
||||
// quand il soupçonne du scraping (trop de requêtes parallèles). Dans
|
||||
// les deux cas, on traite ça comme une sesson expirée : inutile de
|
||||
// les deux cas, on traite ça comme une session expirée : inutile de
|
||||
// parser (ça ferait "Document is empty") ni de retry en boucle.
|
||||
if (!resp.xml || resp.xml.length < 20) {
|
||||
console.warn("[viewer] XML planning vide — session probablement invalide");
|
||||
@@ -1716,7 +1661,7 @@ function mergeCacheAndFresh(cached, fresh) {
|
||||
// xhr2Fetched.
|
||||
// - Règle générale : fresh wins sur les champs live, cache wins sur les
|
||||
// champs enrichis qui ne sont pas dans le fresh.
|
||||
// - Une interventoin en cache mais plus en fresh → marquée "ghost"
|
||||
// - Une intervention en cache mais plus en fresh → marquée "ghost"
|
||||
|
||||
if (!cached || !cached.techs) {
|
||||
return { techs: fresh.techs };
|
||||
@@ -2035,17 +1980,6 @@ async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken)
|
||||
* et iv._disappearRemove (true si à retirer).
|
||||
*/
|
||||
async function analyzeOneDisappearedIv(tech, iv) {
|
||||
// v4.3.0 : court-circuit pour les réservations (AL-Reservation). Elles n'ont
|
||||
// pas de notion de "terminé par tech" ni de statut clos/résolu à afficher
|
||||
// (pas de fiche à ouvrir). Quand une réservation disparaît du planning,
|
||||
// elle est juste retirée — inutile de re-fetcher sa fiche.
|
||||
if (iv.type === "AL-Reservation") {
|
||||
iv._disappearChecking = false;
|
||||
iv._disappearStatus = "cancelled";
|
||||
iv._disappearRemove = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Étape 1 : re-fetch la fiche
|
||||
const resp = await sendMessage({
|
||||
type: "fetchFiche",
|
||||
@@ -2173,7 +2107,7 @@ async function refreshStatuses(techs, isoDate, opts = {}) {
|
||||
if (statusClosed && iv.ficheFetched) continue;
|
||||
// v4.1.7 : pause/reprise par date. Sans forceAll (= chargement normal
|
||||
// au retour sur une date), on skip les iv déjà enrichies (ficheFetched)
|
||||
// pour ne pas refetcher inutilement. Un clic sur "rafraichir" active
|
||||
// pour ne pas refetcher inutilement. Un clic sur "Rafraîchir" active
|
||||
// forceAll, ce qui refetche les non-closes même si déjà enrichies (pour
|
||||
// voir passer les statuts "En cours" → "Exécution" → "Clôturé").
|
||||
if (!forceAll && iv.ficheFetched) continue;
|
||||
@@ -2186,8 +2120,8 @@ async function refreshStatuses(techs, isoDate, opts = {}) {
|
||||
setRefreshing(true);
|
||||
|
||||
// v4.1.7 : barre de progression visible uniquement si on est en train de
|
||||
// rafraichir la date actuellement affichée. Si l'user change de date
|
||||
// pdt le refresh, isRefreshAborted() deviendra true et on sortira.
|
||||
// rafraîchir la date actuellement affichée. Si l'user change de date
|
||||
// pendant le refresh, isRefreshAborted() deviendra true et on sortira.
|
||||
const showBar = (state.currentDate === isoDate);
|
||||
if (showBar) {
|
||||
updateProgressBar(0, toFetch.length);
|
||||
@@ -2219,7 +2153,7 @@ async function refreshStatuses(techs, isoDate, opts = {}) {
|
||||
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) {
|
||||
try {
|
||||
await writeCache(isoDate, { techs });
|
||||
@@ -2275,7 +2209,7 @@ async function fetchAndUpdateIntervention(iv, myToken) {
|
||||
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
|
||||
// sont parfois erronées si le tech a corrigé après planif).
|
||||
// On met à jour la carte tout de suite avec les vraies infos.
|
||||
@@ -2355,7 +2289,7 @@ async function fetchAndUpdateIntervention(iv, myToken) {
|
||||
|
||||
// ─── Étape 3 : API timeline → texte complet de l'action ─────────────
|
||||
// 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
|
||||
// complète, match par ACTION_ID === iv.actionId (fiable, numérique).
|
||||
//
|
||||
@@ -2449,61 +2383,6 @@ async function fetchAndUpdateIntervention(iv, myToken) {
|
||||
* (venant du texte d'action validé par le tech) sont plus fiables que
|
||||
* 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) {
|
||||
// Déjà chargé : rien à faire
|
||||
if (iv.xhr2Fetched) return true;
|
||||
@@ -2942,7 +2821,7 @@ function setRefreshing(on) {
|
||||
refreshCounter++;
|
||||
if (targetIcon) targetIcon.classList.add("spinning");
|
||||
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();
|
||||
} else {
|
||||
refreshCounter = Math.max(0, refreshCounter - 1);
|
||||
@@ -2955,7 +2834,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.
|
||||
function updateCaptureInfoText() {
|
||||
if (state.currentData) {
|
||||
@@ -3023,7 +2902,7 @@ function updateProgressBar(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
|
||||
// refresh auto 12h/15h.
|
||||
function showAbortButton(on) {
|
||||
@@ -3039,7 +2918,7 @@ function showAbortButton(on) {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* est arrêté : plus de mise à jour, plus de cache, plus rien.
|
||||
*/
|
||||
@@ -3054,10 +2933,6 @@ function renderFromData(data) {
|
||||
document.getElementById("session-needed").classList.add("hidden");
|
||||
document.getElementById("cards").classList.remove("hidden");
|
||||
|
||||
// v4.3.0 : détecter les conflits d'horaire entre interventions d'un même
|
||||
// tech (même heure de début OU chevauchement).
|
||||
detectOverlaps(data.techs);
|
||||
|
||||
// Calculer les stats
|
||||
const stats = computeStats(data.techs, data.targetDate);
|
||||
renderCaptureInfo(data, stats);
|
||||
@@ -3065,62 +2940,12 @@ function renderFromData(data) {
|
||||
renderCards(data);
|
||||
}
|
||||
|
||||
// v4.3.0 : détection des conflits d'horaire entre interventions d'un même tech.
|
||||
// Marque iv._hasOverlap = true pour chaque intervention en conflit avec une
|
||||
// autre (même heure de début OU chevauchement de créneaux).
|
||||
// Les absences récurrentes, tickets fantômes à retirer, et réservations
|
||||
// sont ignorés (pas de conflit pertinent pour eux).
|
||||
function detectOverlaps(techs) {
|
||||
if (!techs) return;
|
||||
for (const tech of techs) {
|
||||
const ivs = (tech.interventions || []).filter(iv =>
|
||||
iv && iv.startTime && iv.endTime &&
|
||||
!iv._disappearRemove &&
|
||||
iv.type !== "AL-Reservation" &&
|
||||
// v4.3.2 : le pompier est une absence "tolérée" qui chevauche par
|
||||
// nature les heures de travail (garde volontaire) — on l'exclut des
|
||||
// conflits. En revanche les congés/maladies/formations restent
|
||||
// détectés car une inter planifiée pdt une absence, c'est un vrai pb.
|
||||
!iv.isPompier
|
||||
);
|
||||
// Reset flag sur toutes les inters du tech (y compris celles ignorées)
|
||||
for (const iv of (tech.interventions || [])) {
|
||||
iv._hasOverlap = false;
|
||||
}
|
||||
// Convertir HH:MM en minutes pour comparaison rapide
|
||||
const toMin = (hhmm) => {
|
||||
if (!hhmm) return null;
|
||||
const parts = hhmm.split(":");
|
||||
if (parts.length < 2) return null;
|
||||
const h = parseInt(parts[0], 10);
|
||||
const m = parseInt(parts[1], 10);
|
||||
if (isNaN(h) || isNaN(m)) return null;
|
||||
return h * 60 + m;
|
||||
};
|
||||
// Comparer chaque paire
|
||||
for (let i = 0; i < ivs.length; i++) {
|
||||
for (let j = i + 1; j < ivs.length; j++) {
|
||||
const a = ivs[i], b = ivs[j];
|
||||
const aStart = toMin(a.startTime), aEnd = toMin(a.endTime);
|
||||
const bStart = toMin(b.startTime), bEnd = toMin(b.endTime);
|
||||
if (aStart === null || aEnd === null || bStart === null || bEnd === null) continue;
|
||||
// Chevauchement = a commence avant que b finisse ET b commence avant que a finisse.
|
||||
// Inclut aussi le cas "même heure de début" (aStart === bStart).
|
||||
if (aStart < bEnd && bStart < aEnd) {
|
||||
a._hasOverlap = true;
|
||||
b._hasOverlap = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderCaptureInfo(data, stats) {
|
||||
const info = document.getElementById("capture-info");
|
||||
if (refreshCounter > 0) {
|
||||
// v4.1.20 : message différencié selon le type de refresh actif
|
||||
// - partial (Actualiser) → "Actualisation en cours…"
|
||||
// - total (Tout recharger) → "rafraichissement en cours…"
|
||||
// - total (Tout recharger) → "Rafraîchissement en cours…"
|
||||
if (activeRefreshButton === "partial") {
|
||||
info.textContent = "Actualisation en cours…";
|
||||
} else {
|
||||
@@ -3558,7 +3383,7 @@ function bindTimelinePopover(el) {
|
||||
// - Ctrl+clic (Cmd+clic sur Mac) : ouvre dans un onglet en arrière-plan
|
||||
const kind = el.dataset.kind;
|
||||
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)
|
||||
if (ivIdxStr === undefined) return;
|
||||
|
||||
@@ -3622,7 +3447,7 @@ function openInterventionFromTimeline(el, opts) {
|
||||
if (!row) return;
|
||||
const actionId = row.dataset.actionId;
|
||||
if (!actionId) return;
|
||||
// recupere l'iv depuis state
|
||||
// Récupère l'iv depuis state
|
||||
const iv = findIvByActionId(actionId);
|
||||
if (!iv) return;
|
||||
openInterventionInNewTab(iv, opts || {});
|
||||
@@ -3739,7 +3564,7 @@ function showTimelinePopover(e, el) {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ligne d'interventoin
|
||||
// Ligne d'intervention
|
||||
// ============================================================================
|
||||
|
||||
function buildInterventionRow(iv, cardEl) {
|
||||
@@ -3747,9 +3572,7 @@ function buildInterventionRow(iv, cardEl) {
|
||||
row.className = "intervention-v2";
|
||||
row.dataset.actionId = iv.actionId;
|
||||
if (iv.isPompier) row.classList.add("is-pompier-line");
|
||||
// v4.3.3 : on ne marque plus les ghosts visuellement (classe is-ghost
|
||||
// retirée). Les tickets disparus sont soit retirés (_disappearRemove),
|
||||
// soit affichés en vert (_disappearStatus). Plus de barrage.
|
||||
if (iv.ghost) row.classList.add("is-ghost");
|
||||
// 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).
|
||||
if (iv._disappearChecking) row.classList.add("_checking");
|
||||
@@ -3840,10 +3663,6 @@ function buildInterventionRow(iv, cardEl) {
|
||||
// ─── Ligne 2 gauche : heures VERTICALES (début / ↓ / fin) ─────────────────
|
||||
const timeEl = document.createElement("div");
|
||||
timeEl.className = "iv-time-vertical";
|
||||
// v4.3.0 : marquer rouge + icône ⚠ si conflit horaire détecté
|
||||
if (iv._hasOverlap) {
|
||||
timeEl.classList.add("iv-time-overlap");
|
||||
}
|
||||
if (iv.startTime && iv.endTime) {
|
||||
const s = document.createElement("div");
|
||||
s.className = "iv-time-start";
|
||||
@@ -3857,14 +3676,6 @@ function buildInterventionRow(iv, cardEl) {
|
||||
timeEl.appendChild(s);
|
||||
timeEl.appendChild(sep);
|
||||
timeEl.appendChild(e);
|
||||
// v4.3.0 : icône d'alerte à côté des heures si conflit
|
||||
if (iv._hasOverlap) {
|
||||
const warn = document.createElement("div");
|
||||
warn.className = "iv-time-overlap-warn";
|
||||
warn.textContent = "⚠";
|
||||
warn.title = "Conflit d'horaire avec une autre intervention";
|
||||
timeEl.appendChild(warn);
|
||||
}
|
||||
} else {
|
||||
timeEl.textContent = "—";
|
||||
}
|
||||
@@ -4545,7 +4356,7 @@ async function copyRef(ref, btn) {
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
// fetchAndUpdateIntervention pour afficher la ref dès qu'elle arrive, sans
|
||||
// attendre que tous les workers aient fini ni re-rendre toute la vue.
|
||||
@@ -4793,7 +4604,7 @@ function showTooltip(e, iv, rowEl) {
|
||||
}
|
||||
bulleState.hoveredInRow = true;
|
||||
// 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).
|
||||
if (!bulleState.pinned) {
|
||||
positionTooltipAnchored(rowEl || (e && e.currentTarget));
|
||||
@@ -4810,7 +4621,7 @@ function showTooltip(e, iv, rowEl) {
|
||||
if (!ok) return;
|
||||
const tip = tooltipEl();
|
||||
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)
|
||||
if (state.currentTooltipIv === iv) {
|
||||
tip.innerHTML = buildTooltipHTML(iv);
|
||||
@@ -4867,19 +4678,24 @@ function hasTextSelectionInTooltip() {
|
||||
}
|
||||
|
||||
function moveTooltip(e) {
|
||||
// Historique : avant on suivait la souris. Maintenant la bulle est fixe
|
||||
// (placée une seule fois au mouseenter). Cette fonction est là juste pour
|
||||
// pas casser les appels existants.
|
||||
// v4.1.12 : la bulle est FIXE (positionnée une fois au mouseenter). Cette
|
||||
// fonction est conservée pour compat mais ne fait plus rien.
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Positionnement du tooltip
|
||||
// ============================================================================
|
||||
// On positionne avec style.left/top en coords VIEWPORT (comme position:fixed).
|
||||
// Si un ancêtre casse position:fixed (transform, filter, backdrop-filter ou
|
||||
// contain), on détecte ça empiriquement au 1er placement via
|
||||
// getBoundingClientRect — et on bascule en "abs" : mêmes coords mais on
|
||||
// compense le scroll manuellement pour garder la bulle stable à l'écran.
|
||||
// v4.2.4 : Positionnement du tooltip — refonte complète
|
||||
//
|
||||
// Stratégie :
|
||||
// 1. On positionne TOUJOURS avec style.left/top en coordonnées VIEWPORT
|
||||
// (comme un élément position:fixed).
|
||||
// 2. Au 1er positionnement, on mesure si `position: fixed` marche vraiment
|
||||
// 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
|
||||
@@ -4983,338 +4799,13 @@ function positionTooltipAnchored(rowEl) {
|
||||
setTooltipViewportPosition(x, y);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// v4.3.0 : système de popups épinglés détachés
|
||||
// ============================================================================
|
||||
//
|
||||
// Au lieu d'épingler le tooltip unique (qui empêchait d'afficher d'autres
|
||||
// infos au survol), on clone son contenu en un popup indépendant :
|
||||
// - Ancré DANS le contenu de la page (position: absolute + coordonnées
|
||||
// document) → scrolle avec le contenu, pas avec le viewport.
|
||||
// - Peut coexister avec d'autres popups épinglés (jusqu'à ce qu'il n'y
|
||||
// ait plus de place disponible).
|
||||
// - Persiste jusqu'à fermeture explicite (bouton ×, Échap, ou Ctrl×2 si 1 seul).
|
||||
//
|
||||
// Le tooltip live (#tooltip) garde son rôle initial : il se ferme au mouseleave.
|
||||
|
||||
const pinnedPopups = []; // [{el, iv, rect}]
|
||||
|
||||
/**
|
||||
* Ancre la popup au contenu : ajoute le scrollY actuel au top viewport pour
|
||||
* obtenir une position absolute document, qui scrolle avec le contenu.
|
||||
*/
|
||||
function _viewportToDocumentY(y) {
|
||||
return y + (window.scrollY || window.pageYOffset || 0);
|
||||
}
|
||||
function _viewportToDocumentX(x) {
|
||||
return x + (window.scrollX || window.pageXOffset || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste si un rectangle {left, top, right, bottom} (en coords document)
|
||||
* chevauche avec un popup déjà épinglé.
|
||||
*/
|
||||
function _rectsOverlap(a, b) {
|
||||
return !(a.right <= b.left || a.left >= b.right ||
|
||||
a.bottom <= b.top || a.top >= b.bottom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cherche une position libre pour un popup de dimensions {w, h} près de la
|
||||
* ligne source `rowEl`. Essaie dans l'ordre : droite, gauche, dessous, dessus.
|
||||
* Retourne {x, y} en coordonnées document, ou null si aucune position libre.
|
||||
*/
|
||||
function _findFreePopupPosition(rowEl, w, h) {
|
||||
const pad = 14;
|
||||
const rowRect = rowEl.getBoundingClientRect();
|
||||
const viewportW = window.innerWidth;
|
||||
const viewportH = window.innerHeight;
|
||||
|
||||
// 4 candidats, en coords viewport
|
||||
const candidates = [
|
||||
// Droite
|
||||
{ x: rowRect.right + pad, y: rowRect.top, name: "droite" },
|
||||
// Gauche
|
||||
{ x: rowRect.left - w - pad, y: rowRect.top, name: "gauche" },
|
||||
// Dessous
|
||||
{ x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" },
|
||||
// Dessus
|
||||
{ x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" }
|
||||
];
|
||||
|
||||
// Pour chaque candidat, clamper dans le viewport (marge 8px) et convertir
|
||||
// en coord document, puis tester le chevauchement
|
||||
for (const c of candidates) {
|
||||
let x = c.x, y = c.y;
|
||||
// Clamp horizontal dans le viewport
|
||||
if (x < 4) x = 4;
|
||||
if (x + w > viewportW - 8) x = viewportW - 8 - w;
|
||||
// Clamp vertical dans le viewport
|
||||
if (y < 4) y = 4;
|
||||
if (y + h > viewportH - 8) y = viewportH - 8 - h;
|
||||
// Si, après clamp, la popup chevaucherait la ligne source elle-même,
|
||||
// on ignore ce candidat (on préfère une direction qui la laisse visible).
|
||||
const rowRectClamped = {
|
||||
left: rowRect.left, top: rowRect.top,
|
||||
right: rowRect.right, bottom: rowRect.bottom
|
||||
};
|
||||
const candRect = { left: x, top: y, right: x + w, bottom: y + h };
|
||||
if (_rectsOverlap(candRect, rowRectClamped)) continue;
|
||||
|
||||
// Test chevauchement avec les popups déjà épinglés
|
||||
const docRect = {
|
||||
left: _viewportToDocumentX(x),
|
||||
top: _viewportToDocumentY(y),
|
||||
right: _viewportToDocumentX(x + w),
|
||||
bottom: _viewportToDocumentY(y + h)
|
||||
};
|
||||
let overlapsOther = false;
|
||||
for (const p of pinnedPopups) {
|
||||
if (_rectsOverlap(docRect, p.rect)) {
|
||||
overlapsOther = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!overlapsOther) {
|
||||
// Position libre trouvée
|
||||
return {
|
||||
viewportX: x, viewportY: y,
|
||||
docX: docRect.left, docY: docRect.top,
|
||||
rect: docRect
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v4.3.0 : épingle la bulle courante en la clonant dans un popup détaché
|
||||
* ancré au contenu. Le tooltip live redevient disponible.
|
||||
*/
|
||||
// v4.1.10 : pin/unpin la bulle. Quand pin, on ajoute la classe CSS "pinned"
|
||||
// qui change le curseur (text) et autorise la sélection.
|
||||
function pinTooltip() {
|
||||
if (!state.currentTooltipIv) return;
|
||||
const srcEl = tooltipEl();
|
||||
if (!srcEl) return;
|
||||
const iv = state.currentTooltipIv;
|
||||
|
||||
// Chercher la ligne source (row iv-v2)
|
||||
let rowEl = null;
|
||||
if (iv.actionId) {
|
||||
rowEl = document.querySelector(`.intervention-v2[data-action-id="${iv.actionId}"]`);
|
||||
}
|
||||
if (!rowEl) {
|
||||
// Fallback : utiliser la position actuelle du tooltip live
|
||||
rowEl = srcEl;
|
||||
}
|
||||
|
||||
// Cloner le contenu du tooltip actuel en popup détaché
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "tooltip pinned-popup visible";
|
||||
popup.dataset.actionId = iv.actionId || "";
|
||||
popup.innerHTML = srcEl.innerHTML;
|
||||
|
||||
// Ajouter un bouton × de fermeture (en plus du 📌)
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.type = "button";
|
||||
closeBtn.className = "pinned-popup-close";
|
||||
closeBtn.innerHTML = "×";
|
||||
closeBtn.title = "Désépingler (reste visible tant que la souris est dessus)";
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
// Désépinglage "mou" : on marque la popup comme non épinglée mais on la
|
||||
// laisse visible tant que la souris est dessus. Elle disparaît quand la
|
||||
// souris sort.
|
||||
_softUnpinPopup(popup);
|
||||
});
|
||||
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
|
||||
popup.style.position = "absolute";
|
||||
popup.style.left = "-9999px";
|
||||
popup.style.top = "-9999px";
|
||||
popup.style.visibility = "hidden";
|
||||
document.body.appendChild(popup);
|
||||
|
||||
// Mesurer après rendu
|
||||
const pRect = popup.getBoundingClientRect();
|
||||
const w = pRect.width;
|
||||
const h = pRect.height;
|
||||
|
||||
// Chercher une position libre
|
||||
const pos = _findFreePopupPosition(rowEl, w, h);
|
||||
|
||||
if (!pos) {
|
||||
// Pas de place : retirer et afficher un toast
|
||||
popup.remove();
|
||||
showToast("Pas de place", "Fermez une popup épinglée");
|
||||
return;
|
||||
}
|
||||
|
||||
// Appliquer la position (coords document = position: absolute)
|
||||
popup.style.left = pos.docX + "px";
|
||||
popup.style.top = pos.docY + "px";
|
||||
popup.style.visibility = "visible";
|
||||
|
||||
// Enregistrer dans la liste
|
||||
pinnedPopups.push({ el: popup, iv: iv, rect: pos.rect });
|
||||
|
||||
// v4.3.0 : libérer le tooltip live (il redevient utilisable pour d'autres survols)
|
||||
bulleState.pinned = false;
|
||||
bulleState.hoveredInRow = false;
|
||||
bulleState.hoveredInBulle = false;
|
||||
srcEl.classList.remove("visible", "pinned");
|
||||
srcEl.classList.add("hidden");
|
||||
if (srcEl.dataset) delete srcEl.dataset.mode;
|
||||
state.currentTooltipIv = null;
|
||||
currentTooltipPos = null;
|
||||
tooltipPositionMode = null;
|
||||
if (bulleState.hideTimer) {
|
||||
clearTimeout(bulleState.hideTimer);
|
||||
bulleState.hideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ferme un popup épinglé donné. */
|
||||
function _closePinnedPopup(el) {
|
||||
const idx = pinnedPopups.findIndex(p => p.el === el);
|
||||
if (idx >= 0) pinnedPopups.splice(idx, 1);
|
||||
el.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). */
|
||||
function closeAllPinnedPopups() {
|
||||
for (const p of pinnedPopups.slice()) {
|
||||
p.el.remove();
|
||||
}
|
||||
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);
|
||||
});
|
||||
bulleState.pinned = true;
|
||||
const el = tooltipEl();
|
||||
el.classList.add("pinned");
|
||||
}
|
||||
|
||||
// v4.1.14/19 : recharger UNIQUEMENT cette intervention. Fetch DIRECT sans
|
||||
@@ -5517,29 +5008,22 @@ function bindTooltipInteractions() {
|
||||
}
|
||||
});
|
||||
|
||||
// Double-Ctrl : v4.3.0
|
||||
// - Si 0 popup épinglé ET un tooltip live visible : épingler
|
||||
// - Si EXACTEMENT 1 popup épinglé ET souris pas dessus : le fermer
|
||||
// - Si 2+ popups épinglés : ne fait rien (ambigu, user doit utiliser Échap)
|
||||
// Double-Ctrl : pin/unpin
|
||||
// On détecte 2 keydown Control dans une fenêtre de 400 ms.
|
||||
let lastCtrlTs = 0;
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Control") return;
|
||||
// Ignorer si la touche est répétée (hold)
|
||||
if (e.repeat) return;
|
||||
const now = performance.now();
|
||||
if (now - lastCtrlTs < 400) {
|
||||
// Double-Ctrl détecté
|
||||
lastCtrlTs = 0;
|
||||
if (pinnedPopups.length === 0) {
|
||||
// Aucun popup épinglé : épingler le tooltip live s'il y en a un
|
||||
if (state.currentTooltipIv) pinTooltip();
|
||||
} else if (pinnedPopups.length === 1) {
|
||||
// 1 popup épinglé : le fermer si la souris n'est pas dessus
|
||||
const p = pinnedPopups[0];
|
||||
if (!p.el.matches(":hover")) {
|
||||
_closePinnedPopup(p.el);
|
||||
}
|
||||
if (bulleState.pinned) {
|
||||
unpinTooltip();
|
||||
} else if (state.currentTooltipIv) {
|
||||
pinTooltip();
|
||||
}
|
||||
// 2+ popups : rien faire (Échap pour tout fermer)
|
||||
} else {
|
||||
lastCtrlTs = now;
|
||||
}
|
||||
@@ -5553,9 +5037,9 @@ function bindTooltipInteractions() {
|
||||
e.preventDefault();
|
||||
const action = btn.dataset.action;
|
||||
if (action === "pin") {
|
||||
// v4.3.0 : toujours épingler (le tooltip live clone son contenu en popup
|
||||
// détaché). Pour désépingler, l'user utilise × sur le popup, ou Échap.
|
||||
if (state.currentTooltipIv) {
|
||||
if (bulleState.pinned) {
|
||||
unpinTooltip();
|
||||
} else if (state.currentTooltipIv) {
|
||||
pinTooltip();
|
||||
}
|
||||
} else if (action === "reload") {
|
||||
@@ -5582,14 +5066,14 @@ function bindTooltipInteractions() {
|
||||
|
||||
// Clic hors bulle : unpin si épinglé.
|
||||
// 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
|
||||
// mais le comportement "ouvrir la fiche" reste prioritaire).
|
||||
document.addEventListener("mousedown", (e) => {
|
||||
if (!bulleState.pinned) return;
|
||||
// Clic dans la bulle → on laisse (sélection de texte)
|
||||
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
|
||||
// d'ouverture de la fiche s'exécutera ensuite normalement.
|
||||
unpinTooltip();
|
||||
|
||||
Reference in New Issue
Block a user