Compare commits

..

1 Commits

Author SHA1 Message Date
Quentin Rouiller c59abbed23 v4.3.3 — Soft unpin popup + nettoyage tooltip persistance 2026-04-20 09:13:20 +02:00
9 changed files with 4749 additions and 811 deletions
-39
View File
@@ -1,39 +0,0 @@
# 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
@@ -1,179 +0,0 @@
# 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
@@ -1,21 +0,0 @@
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.
+84 -156
View File
@@ -1,182 +1,110 @@
# Planification — Extension EasyVista Canton de Vaud # Planning techniciens — Vue claire (v4.1.2)
Extension Chrome / Firefox pour visualiser de manière claire et rapide le planning des techniciens IT du Canton de Vaud dans EasyVista. Extension Chrome/Brave/Edge pour afficher le planning techniciens EasyVista
(`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) dans une vue plus lisible.
## Aperçu rapide ## Nouveautés v4.1.2
- **Auteur** : Quentin Rouiller (QRO) - **Vraies infos contact/lieu dans les cartes** : les attributs attr1/attr2 du
- **Cible** : techniciens IT Canton de Vaud, EasyVista (`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) XML contiennent les infos saisies à la *planification*, qui ne sont pas
- **Démarrage projet** : jeudi 16 avril 2026 toujours à jour (le tech a pu corriger le contact/lieu avant intervention).
- **Version actuelle** : `v2026.5.37` Désormais, pour chaque intervention, on fetch AUSSI le xhr2 en arrière-plan
- **Manifest** : V3 (Chrome/Edge/Firefox) (en plus de la fiche), ce qui apporte les **vraies** infos validées. La
- **Format** : `.zip` (Chromium) + `.xpi` signé (Firefox) 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.
## Fonctionnalités principales ## Nouveautés v4.1
### Vue planning - **Fetch des fiches séquentiel (1 par 1)** au lieu de 5 workers en parallèle.
- Affichage des interventions et réservations groupées par technicien Le serveur EasyVista sérialise les requêtes de toute façon, donc le parallélisme
- Horaires, contact, lieu, catégorie, statut visibles d'un coup d'œil n'apporte rien. Et surtout : quand tu changes de date pendant le fetch, l'abort
- 8 techniciens hardcodés (équipe IT canton) est **instantané** car il n'y a qu'une seule requête en vol au maximum.
- Cache local pour réduire les requêtes serveur - **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.
### Modes d'affichage ## Nouveautés v4
- **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`)
### Tooltips et popups **Chargement ~50× plus rapide.** Le nombre de requêtes au serveur EasyVista passe
- Tooltips au survol (hover) sur chaque intervention de ~100 par chargement à **1 seule requête** pour l'affichage principal.
- 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)
### Classification des absences (depuis v2026.5.27) Concrètement, en v3 un chargement initial faisait :
- **Maladie/Accident** : indigo `#4338ca` - 1 fetch XML planning (`calendar_block`)
- **Congé / Congés** : cyan `#06b6d4` (suffixe `s` adaptatif) - ~40 fetches `planning_xhr_2.php` pour les lieux/contacts
- **Pompier** : rouge `#b03030` - ~40 fetches de fiches HTML pour les catégories/refs/statuts
- Badge + barre gauche colorée + dégradé fond - jusqu'à ~40 fetches de l'API timeline
- Absence récurrente Pillonel vendredi : cyan (depuis v2026.5.30)
### User et session Total : ~120 requêtes, 10+ Mo, 8 à 15 secondes selon la charge serveur.
- 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
### Admin et configuration En v4, on a découvert que le XML initial `calendar_block` contient **déjà**
- Mode admin caché : bouton ⚙ Paramètres dans popup user-badge (depuis v2026.5.25, remplace les 5 clics secrets sur le titre) dans ses attributs `attr1`/`attr2`/`attr3` le contact, le lieu et la catégorie
- Configuration persistée dans `localStorage` (`admin_config`) complète de chaque intervention, et la ref dans le textContent du nœud.
- Catégories interventions personnalisables (livraison/recup/remplacement/incident/rollout/reservation/autre) Toutes ces infos qu'on allait chercher ailleurs étaient en fait dans la toute
première réponse, ignorées par le code.
## Versionning — historique et conventions 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.
L'extension a connu **3 systèmes de versionning successifs** : **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.
| Période | Format | Exemple | **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.
| 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` |
### Pourquoi le passage à `YYYY.M.PATCH` ? Toute l'interface utilisateur est **strictement identique** à la v3 — on n'a
changé que ce qu'il y a sous le capot.
À partir de la **v2026.5.16** (21 avril 2026), l'extension est passée au versionning par année : ## Hérité des versions précédentes
- 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
⚠️ **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. - Navigation par date : ◀ ▶ et sélecteur
- Détection automatique des interventions closes (✓ vert, fond vert)
## Versions notables - Cache persistant 7 jours
- Ghosts : les interventions disparues d'EasyVista restent visibles dans la vue
### `v2026.5.37` (latest, 25 avril 2026) — Refonte vue horizontale - Refresh auto 12h et 15h
- Topbar supprimée en vue horizontale, tout passe en sidebar - Annulation coopérative (bouton "Arrêter")
- User-badge + titre + bouton "Aujourd'hui" + date/heure + sélecteur + flèches + stats dans sidebar - Thème clair/sombre
- 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
### Firefox 1. Décompresser le zip
Télécharger le `.xpi` signé depuis le serveur de mises à jour interne, ou drag-and-drop dans `about:addons`. 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`
### Chrome / Edge Si tu avais déjà la v3 installée, tu peux la supprimer avant — les caches des
Mode développeur : décompresser le ZIP et charger en tant qu'extension non empaquetée. deux versions sont compatibles (même format).
## Développement ## Utilisation
```bash 1. Se connecter à EasyVista dans un onglet (`itsma.etat-de-vaud.ch` ou `itsma.vd.ch`)
git clone https://gitea.netaplaid.ch/FroSteel/Planification.git 2. Cliquer sur l'icône de l'extension (depuis n'importe quel onglet)
cd Planification 3. La vue claire s'ouvre dans un nouvel onglet
# Pour packager une nouvelle version : ## Comment ça marche techniquement
# 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
```
## Licence - `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`.
[MIT License](LICENSE) — Copyright (c) 2026 Quentin Rouiller ## Limitations connues
## Auteur - 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)
**Quentin Rouiller** (QRO) - Les 8 IDs des techs sont en dur dans le code (si quelqu'un quitte/arrive dans
Canton de Vaud — Service IT 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)
+429 -57
View File
@@ -7,8 +7,8 @@
// - fetchPlanning : fetch le XML du planning pour une date (1 requête = tout) // - fetchPlanning : fetch le XML du planning pour une date (1 requête = tout)
// - fetchXhr2 : fetch un texte d'action détaillé (utilisé en lazy-load au survol) // - fetchXhr2 : fetch un texte d'action détaillé (utilisé en lazy-load au survol)
// - fetchFiche : fetch une fiche individuelle (HTML) pour statut + commentaire tech // - fetchFiche : fetch une fiche individuelle (HTML) pour statut + commentaire tech
// 3. Programmer les alarmes de refresh auto (12h, 15h) // 3. Nettoyer les vieux caches (>7 jours)
// 4. Nettoyer les vieux caches (>7 jours) // (v4.2 : l'auto-refresh 12h/15h a été retiré)
// //
// v4 : suppression de fetchTimeline (pu utilisé). Le calendar_block contient // v4 : suppression de fetchTimeline (pu utilisé). Le calendar_block contient
// directement ref/contact/lieu/catégorie dans ses attributs attr1/attr2/attr3, // directement ref/contact/lieu/catégorie dans ses attributs attr1/attr2/attr3,
@@ -87,12 +87,30 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) {
console.log("[bg] fetchPlanningXml →", url.substring(0, 140)); console.log("[bg] fetchPlanningXml →", url.substring(0, 140));
const r = await fetch(url, { credentials: "include" }); const r = await fetch(url, { credentials: "include" });
console.log("[bg] status =", r.status); console.log("[bg] status =", r.status);
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) {
// v4.2 : classifier l'erreur HTTP pour que le viewer affiche le bon
// écran (session expirée vs EV inaccessible).
const err = new Error("HTTP " + r.status);
err.kind = classifyHttpStatus(r.status);
err.status = r.status;
throw err;
}
const xml = await r.text(); const xml = await r.text();
console.log("[bg] taille XML =", xml.length); console.log("[bg] taille XML =", xml.length);
return xml; return xml;
} }
/**
* v4.2 : classifie un statut HTTP comme "session_expired" ou "ev_unreachable".
* - 401, 403, 404 → session_expired (EV renvoie souvent 404 au lieu de rediriger
* vers la page de login quand PHPSESSID n'est plus valide)
* - 5xx, autres → ev_unreachable (service down, surcharge, etc.)
*/
function classifyHttpStatus(status) {
if (status === 401 || status === 403 || status === 404) return "session_expired";
return "ev_unreachable";
}
/** /**
* Fetch planning_xhr_2.php?id=ACTIONID pour UNE intervention. * Fetch planning_xhr_2.php?id=ACTIONID pour UNE intervention.
* Retourne ~400 octets au format custom : * Retourne ~400 octets au format custom :
@@ -101,7 +119,12 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) {
async function fetchXhr2(origin, phpsessid, actionId) { async function fetchXhr2(origin, phpsessid, actionId) {
const url = `${origin}/planning_xhr_2.php?PHPSESSID=${encodeURIComponent(phpsessid)}&id=${encodeURIComponent(actionId)}`; const url = `${origin}/planning_xhr_2.php?PHPSESSID=${encodeURIComponent(phpsessid)}&id=${encodeURIComponent(actionId)}`;
const r = await fetch(url, { credentials: "include" }); const r = await fetch(url, { credentials: "include" });
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) {
const err = new Error("HTTP " + r.status);
err.kind = classifyHttpStatus(r.status);
err.status = r.status;
throw err;
}
return await r.text(); return await r.text();
} }
@@ -109,12 +132,47 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`; const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`;
console.log("[bg] fetchFicheHtml →", url.substring(0, 120)); console.log("[bg] fetchFicheHtml →", url.substring(0, 120));
const r = await fetch(url, { credentials: "include" }); const r = await fetch(url, { credentials: "include" });
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) {
const err = new Error("HTTP " + r.status);
err.kind = classifyHttpStatus(r.status);
err.status = r.status;
throw err;
}
const html = await r.text(); const html = await r.text();
console.log("[bg] fiche status =", r.status, "| taille =", html.length); console.log("[bg] fiche status =", r.status, "| taille =", html.length);
return html; return html;
} }
// v4.1.7 : API timeline EasyVista. Renvoie le JSON des actions d'une fiche,
// avec pour chaque action : intervenant, ACTION_ID, AM_DONE_BY_ID, description
// complète (bien plus riche que le xhr2 tronqué).
// Utilisé pour afficher le texte complet de l'action dans le tooltip.
// v4.1.9 : le GUID du form est passé en paramètre (extrait dynamiquement du
// HTML de la fiche par le viewer). Il est différent pour une demande S...
// ({C99ECD05}) vs un incident I... ({07ED9C68}).
async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum) {
// Sécurité : GUID doit être de la forme %7B...%7D ou {...}
if (!/^(%7B|\{)[A-F0-9\-]{36}(%7D|\})$/i.test(guid)) {
throw new Error("Invalid GUID: " + guid);
}
// S'assurer qu'on a la forme encodée %7B...%7D
const encodedGuid = guid.startsWith("%7B") ? guid : `%7B${guid.replace(/[{}]/g, "")}%7D`;
const url =
`${origin}/api/v1/internal/forms/${encodedGuid}/timeline` +
`?target=${encodeURIComponent(formId)}` +
`&checksum=${encodeURIComponent(formChecksum)}` +
`&type=todo&sectionId=1&navigator=&nbRecord=0` +
`&PHPSESSID=${encodeURIComponent(phpsessid)}`;
const r = await fetch(url, { credentials: "include" });
if (!r.ok) {
const err = new Error("HTTP " + r.status);
err.kind = classifyHttpStatus(r.status);
err.status = r.status;
throw err;
}
return await r.text();
}
// ============================================================================ // ============================================================================
// Détection "session invalide" // Détection "session invalide"
// ============================================================================ // ============================================================================
@@ -124,6 +182,247 @@ function looksLikeLoginPage(text) {
return /customer_login|my\.policy/i.test((text || "").substring(0, 3000)); return /customer_login|my\.policy/i.test((text || "").substring(0, 3000));
} }
// ============================================================================
// v4.2 : récupération de l'utilisateur connecté
// ============================================================================
/**
* Essaie de récupérer le nom de l'utilisateur EasyVista connecté en fetchant
* la page d'accueil avec la session active. EasyVista n'exposant pas
* d'endpoint public simple, on cherche des patterns typiques dans le HTML :
* - <title>...Nom, Prénom...</title>
* - éléments avec data-user-name, data-user-login
* - balises cachées ou variables JS EV.User.name
* - champ "Bienvenue Nom Prénom"
* Retourne { name: "Nom Prénom" | null, login: "..." | null } ou null si
* tout a échoué.
*/
async function fetchCurrentUser(origin, phpsessid) {
const url = `${origin}/index.php?PHPSESSID=${encodeURIComponent(phpsessid)}`;
const resp = await fetch(url, {
method: "GET",
credentials: "include",
headers: { "Accept": "text/html,*/*" }
});
// v4.2 : cette fonction est lancée en tâche de fond au démarrage. Si la
// session est expirée ou EV inaccessible, on retourne juste null — le
// planning lui-même déclenchera l'écran d'erreur approprié.
if (!resp.ok) return null;
const html = await resp.text();
if (looksLikeLoginPage(html)) return null;
// v4.2.2 : patterns spécifiques à la structure EasyVista réelle du Canton
// de Vaud (identifiés à partir du HTML de la page d'accueil). L'user est
// affiché dans un dropdown ".ev-employee-dropdown" avec ces éléments :
// <span class="profile-info">
// <span class="h5" title="Nom, Prénom">Nom, Prénom</span>
// <span class="h6" title="3.3 DGNSI-ServiceDesk">3.3 DGNSI-ServiceDesk</span>
// ...
// </span>
// Le title du <a> parent contient aussi "Nom, Prénom / Service / Société".
const patterns = [
// 1) Le plus fiable : span class="h5" dans profile-info (structure EV 2026)
/<span\s+class=["']profile-info["'][^>]*>\s*<span\s+class=["']h5["'][^>]*title=["']([^"']{2,80})["']/i,
// 2) Fallback : span class="h5" avec title= même hors profile-info
/<span\s+class=["']h5["'][^>]*title=["']([^"']{2,80})["'][^>]*>\s*([^<]{2,80})<\/span>/i,
// 3) Fallback : title= de ev-employee-dropdown (format "Nom, Prénom / Service / Société")
/class=["'][^"']*ev-employee-dropdown[^"']*["'][^>]*title=["']([^"'\/]+?)(?:\s*\/\s*[^"']+)?["']/i,
// 4) Anciens patterns génériques (autres instances EasyVista éventuelles)
/data-user-name\s*=\s*["']([^"']+)["']/i,
/data-username\s*=\s*["']([^"']+)["']/i,
/data-user-fullname\s*=\s*["']([^"']+)["']/i,
/EV\.User\.name\s*=\s*["']([^"']+)["']/,
/EV\.User\.fullname\s*=\s*["']([^"']+)["']/,
/userFullName\s*[:=]\s*["']([^"']+)["']/,
// 5) "Bienvenue" / "Welcome"
/(?:Bienvenue|Welcome)[,\s]+(?:M\.?\s+|Mme\s+)?([A-ZÀÁÂÄÇÉÈÊËÍÎÏÓÔÖÚÛÜÑ][\wÀ-ÿ'.\-]+(?:\s*,?\s+[A-ZÀÁÂÄÇÉÈÊËÍÎÏÓÔÖÚÛÜÑ][\wÀ-ÿ'.\-]+){0,3})/
];
let name = null;
for (const rx of patterns) {
const m = html.match(rx);
if (m && m[1]) {
const candidate = m[1].trim()
.replace(/\s+/g, " ")
.replace(/^(?:EasyVista|EV|Accueil|Home|Planning|ITSMA)[\s\-|•]+/i, "")
.replace(/[\s\-|•]+(?:EasyVista|EV|ITSMA)$/i, "")
.trim();
if (candidate && candidate.length >= 3 && candidate.length <= 80
&& /[A-Za-zÀ-ÿ]/.test(candidate)
&& !/\b(login|connexion|sign\s*in|easyvista|ITSMA)\b/i.test(candidate)) {
name = candidate;
break;
}
}
}
// v4.2.2 : on extrait aussi le service/unité si disponible (h6 à côté du h5)
let service = null;
const serviceMatch = html.match(
/<span\s+class=["']profile-info["'][^>]*>[\s\S]{0,500}?<span\s+class=["']h6["'][^>]*title=["']([^"']{2,80})["']/i
);
if (serviceMatch && serviceMatch[1]) {
service = serviceMatch[1].trim();
}
// Login / identifiant court (optionnel)
let login = null;
const loginPatterns = [
/data-user-login\s*=\s*["']([^"']+)["']/i,
/data-login\s*=\s*["']([^"']+)["']/i,
/EV\.User\.login\s*=\s*["']([^"']+)["']/,
/userLogin\s*[:=]\s*["']([^"']+)["']/
];
for (const rx of loginPatterns) {
const m = html.match(rx);
if (m && m[1]) {
login = m[1].trim();
break;
}
}
if (!name && !login && !service) return null;
return { name, login, service };
}
// ============================================================================
// v4.2.6 : Création d'absence
// ============================================================================
/**
* Envoie un POST vers plan_set_holidays_popup.php pour créer une absence.
* Format attendu (analysé depuis le HTML EasyVista) :
* Query params : PHPSESSID, MAIN_DIRECTORY, ROOT_DIRECTORY, current_date,
* empl_ids, begin_hour, end_hour, plagehoraire
* Body : start_date, start_time, end_date, end_time, label_guid, dialog_action
*
* @param {string} origin - "https://itsma.vd.ch" ou similaire
* @param {string} phpsessid
* @param {Object} opts - { techIds: string[], startDate: "DD/MM/YYYY",
* startTime: "HH:MM:SS", endDate, endTime,
* typeGuid, currentDate }
*/
async function submitAbsence(origin, phpsessid, opts) {
const emplIds = (opts.techIds || []).join(",");
if (!emplIds) throw new Error("Aucun technicien sélectionné");
const internalurltime = Math.floor(Date.now() / 1000);
const url = `${origin}/include/components/staff/planning/plan_set_holidays_popup.php`
+ `?PHPSESSID=${encodeURIComponent(phpsessid)}`
+ `&internalurltime=${internalurltime}`
+ `&MAIN_DIRECTORY=${encodeURIComponent("/")}`
+ `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}`
+ `&current_date=${encodeURIComponent(opts.currentDate)}`
+ `&empl_ids=${encodeURIComponent(emplIds)}`
+ `&begin_hour=8`
+ `&end_hour=18`
+ `&plagehoraire=0`;
const body = new URLSearchParams();
body.set("start_date", opts.startDate);
body.set("start_time", opts.startTime);
body.set("end_date", opts.endDate);
body.set("end_time", opts.endTime);
body.set("label_guid", opts.typeGuid);
body.set("dialog_action", "save_holidays");
console.log("[bg] submitAbsence →", url.substring(0, 140));
console.log("[bg] body:", body.toString());
const r = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString()
});
console.log("[bg] status =", r.status);
if (!r.ok) {
throw new Error("HTTP " + r.status);
}
const responseText = await r.text();
if (looksLikeLoginPage(responseText)) {
throw new Error("session_expired");
}
// Succès : on ne sait pas le format exact de la réponse EasyVista, on
// considère qu'un HTTP 200 non-login signifie succès.
return { status: r.status };
}
// ============================================================================
// v4.2.6 : Envoi sur douchette
// ============================================================================
/**
* Envoie la planification du jour sur la douchette des techs sélectionnés.
*
* Endpoint identifié (via l'inspection de la page EasyVista) :
* POST /include/components/staff/planning/plan_set_tech_planif_popup.php
* Query : PHPSESSID, current_date, empl_ids (CSV), begin_hour, end_hour,
* plagehoraire
* Body : dialog_action=save_planif
*
* Contrairement à l'absence, un seul POST suffit pour tous les techs (empl_ids
* est une CSV), pas besoin de boucler.
*
* @param {string} origin
* @param {string} phpsessid
* @param {Object} opts - { techIds, currentDate }
* @returns {{ okCount, errors }}
*/
async function submitDouchette(origin, phpsessid, opts) {
const techIds = opts.techIds || [];
if (techIds.length === 0) throw new Error("Aucun technicien sélectionné");
const emplIds = techIds.join(",");
const internalurltime = Math.floor(Date.now() / 1000);
const url = `${origin}/include/components/staff/planning/plan_set_tech_planif_popup.php`
+ `?PHPSESSID=${encodeURIComponent(phpsessid)}`
+ `&internalurltime=${internalurltime}`
+ `&MAIN_DIRECTORY=${encodeURIComponent("/")}`
+ `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}`
+ `&current_date=${encodeURIComponent(opts.currentDate)}`
+ `&empl_ids=${encodeURIComponent(emplIds)}`
+ `&begin_hour=8`
+ `&end_hour=18`
+ `&plagehoraire=0`;
const body = new URLSearchParams();
body.set("dialog_action", "save_planif");
console.log("[bg] submitDouchette →", url.substring(0, 160));
console.log("[bg] body:", body.toString());
console.log("[bg] techs:", emplIds);
try {
const r = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString()
});
console.log("[bg] status =", r.status);
if (r.status === 401 || r.status === 403) {
return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) };
}
if (!r.ok) {
return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "HTTP " + r.status })) };
}
const responseText = await r.text();
if (looksLikeLoginPage(responseText)) {
return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) };
}
return { okCount: techIds.length, errors: [] };
} catch (err) {
const msg = err && err.message ? err.message : String(err);
return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: msg })) };
}
}
// ============================================================================ // ============================================================================
// Messages du viewer // Messages du viewer
// ============================================================================ // ============================================================================
@@ -143,13 +442,21 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ ok: false, error: "no_session" }); sendResponse({ ok: false, error: "no_session" });
return; return;
} }
// Fetch XML calendar_block du planning (rapide ~40 ko) try {
const xml = await fetchPlanningXml(session.origin, session.phpsessid, msg.unixDate); // Fetch XML calendar_block du planning (rapide ~40 ko)
if (looksLikeLoginPage(xml)) { const xml = await fetchPlanningXml(session.origin, session.phpsessid, msg.unixDate);
sendResponse({ ok: false, error: "session_expired" }); if (looksLikeLoginPage(xml)) {
return; sendResponse({ ok: false, error: "session_expired" });
return;
}
sendResponse({ ok: true, xml, session });
} catch (err) {
// v4.2 : classification de l'erreur pour afficher le bon écran
const errorCode = err.kind || (
/network|fetch|typeerror/i.test(err.message) ? "ev_unreachable" : "ev_unreachable"
);
sendResponse({ ok: false, error: errorCode, httpStatus: err.status, detail: err.message });
} }
sendResponse({ ok: true, xml, session });
return; return;
} }
@@ -163,7 +470,12 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
const body = await fetchXhr2(session.origin, session.phpsessid, msg.actionId); const body = await fetchXhr2(session.origin, session.phpsessid, msg.actionId);
sendResponse({ ok: true, body }); sendResponse({ ok: true, body });
} catch (err) { } catch (err) {
sendResponse({ ok: false, error: String(err) }); sendResponse({
ok: false,
error: err.kind || "fetch_failed",
httpStatus: err.status,
detail: err.message || String(err)
});
} }
return; return;
} }
@@ -174,18 +486,102 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ ok: false, error: "no_session" }); sendResponse({ ok: false, error: "no_session" });
return; return;
} }
const html = await fetchFicheHtml(session.origin, session.phpsessid, msg.formLink); try {
if (looksLikeLoginPage(html)) { const html = await fetchFicheHtml(session.origin, session.phpsessid, msg.formLink);
sendResponse({ ok: false, error: "session_expired" }); if (looksLikeLoginPage(html)) {
return; sendResponse({ ok: false, error: "session_expired" });
return;
}
sendResponse({ ok: true, html, session });
} catch (err) {
sendResponse({
ok: false,
error: err.kind || "fetch_failed",
httpStatus: err.status,
detail: err.message || String(err)
});
} }
sendResponse({ ok: true, html, session });
return; return;
} }
if (msg.type === "scheduleAutoRefresh") { if (msg.type === "fetchTimelineApi") {
scheduleAutoRefreshAlarms(); const session = await findEasyVistaSession();
sendResponse({ ok: true }); if (!session) {
sendResponse({ ok: false, error: "no_session" });
return;
}
try {
const body = await fetchTimelineApi(
session.origin, session.phpsessid,
msg.guid, msg.formId, msg.formChecksum
);
if (looksLikeLoginPage(body)) {
sendResponse({ ok: false, error: "session_expired" });
return;
}
sendResponse({ ok: true, body });
} catch (err) {
sendResponse({
ok: false,
error: err.kind || "fetch_failed",
httpStatus: err.status,
detail: err.message || String(err)
});
}
return;
}
if (msg.type === "fetchCurrentUser") {
// v4.2 : essaie d'identifier l'utilisateur EasyVista connecté en
// fetchant la page d'accueil et en cherchant dans le HTML un champ
// contenant son nom. Si on trouve rien, on renvoie { ok: true,
// user: null } pour que l'UI sache qu'on n'a pas pu.
const session = await findEasyVistaSession();
if (!session) {
sendResponse({ ok: false, error: "no_session" });
return;
}
try {
const user = await fetchCurrentUser(session.origin, session.phpsessid);
sendResponse({ ok: true, user });
} catch (err) {
sendResponse({ ok: false, error: String(err) });
}
return;
}
if (msg.type === "submitAbsence") {
// v4.2.6 : crée une absence dans EasyVista via POST vers
// /include/components/staff/planning/plan_set_holidays_popup.php
const session = await findEasyVistaSession();
if (!session) {
sendResponse({ ok: false, error: "no_session" });
return;
}
try {
const result = await submitAbsence(session.origin, session.phpsessid, msg);
sendResponse({ ok: true, result });
} catch (err) {
sendResponse({ ok: false, error: err.message || String(err) });
}
return;
}
if (msg.type === "submitDouchette") {
// v4.2.6 : envoie la planification sur la douchette de chaque tech.
// On teste plusieurs URLs possibles (l'endpoint exact n'est pas dans
// le HTML statique que nous avons analysé).
const session = await findEasyVistaSession();
if (!session) {
sendResponse({ ok: false, error: "no_session" });
return;
}
try {
const result = await submitDouchette(session.origin, session.phpsessid, msg);
sendResponse({ ok: true, okCount: result.okCount, errors: result.errors });
} catch (err) {
sendResponse({ ok: false, error: err.message || String(err) });
}
return; return;
} }
@@ -207,45 +603,21 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}); });
// ============================================================================ // ============================================================================
// Alarmes : refresh auto 12h / 15h // v4.2 : les alarmes d'auto-refresh 12h/15h ont été supprimées. Seul le
// nettoyage quotidien des caches > 7 jours reste.
// On supprime aussi activement les anciennes alarmes créées par les
// versions précédentes pour éviter qu'elles restent programmées.
// ============================================================================ // ============================================================================
function scheduleAutoRefreshAlarms() { async function clearLegacyRefreshAlarms() {
// Calculer le prochain 12h et 15h à partir de maintenant try {
const now = new Date(); await chrome.alarms.clear("refresh_12h");
await chrome.alarms.clear("refresh_15h");
function nextAt(hour, minute) { } catch (e) {
const d = new Date(); console.warn("clearLegacyRefreshAlarms:", e);
d.setHours(hour, minute, 0, 0);
if (d <= now) d.setDate(d.getDate() + 1);
return d.getTime();
} }
chrome.alarms.create("refresh_12h", {
when: nextAt(12, 0),
periodInMinutes: 24 * 60 // tous les jours
});
chrome.alarms.create("refresh_15h", {
when: nextAt(15, 0),
periodInMinutes: 24 * 60
});
} }
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === "refresh_12h" || alarm.name === "refresh_15h") {
// Envoyer un message à tous les viewers ouverts pour qu'ils se rafraîchissent
const viewerUrl = chrome.runtime.getURL("viewer.html");
const tabs = await chrome.tabs.query({ url: viewerUrl + "*" });
for (const tab of tabs) {
try {
await chrome.tabs.sendMessage(tab.id, { type: "autoRefresh" });
} catch {
// Onglet fermé ou pas réactif, on ignore
}
}
}
});
// ============================================================================ // ============================================================================
// Nettoyage caches > 7 jours // Nettoyage caches > 7 jours
// ============================================================================ // ============================================================================
@@ -270,13 +642,13 @@ async function cleanupOldCaches(daysToKeep) {
return toRemove.length; return toRemove.length;
} }
// Au démarrage, programmer les alarmes et nettoyer // Au démarrage, nettoyer les anciennes alarmes et les anciens caches
chrome.runtime.onInstalled.addListener(() => { chrome.runtime.onInstalled.addListener(() => {
scheduleAutoRefreshAlarms(); clearLegacyRefreshAlarms();
cleanupOldCaches(7).catch(err => console.warn("cleanup:", err)); cleanupOldCaches(7).catch(err => console.warn("cleanup:", err));
}); });
chrome.runtime.onStartup.addListener(() => { chrome.runtime.onStartup.addListener(() => {
scheduleAutoRefreshAlarms(); clearLegacyRefreshAlarms();
cleanupOldCaches(7).catch(err => console.warn("cleanup:", err)); cleanupOldCaches(7).catch(err => console.warn("cleanup:", err));
}); });
+6 -16
View File
@@ -1,21 +1,15 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Planning Techniciens — Vue claire", "name": "Planification",
"version": "4.1.6", "version": "4.3.3",
"description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.1.6 : parsing fiche corrigé pour trouver l'Intervenant via ng-model=colData.value et ignorer les blocs d'action vides (étapes workflow automatiques).", "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": [ "permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"],
"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/*"
], ],
"action": { "action": {
"default_title": "Ouvrir la vue claire du planning" "default_title": "Ouvrir la Planification"
}, },
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"
@@ -27,11 +21,7 @@
}, },
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": [ "resources": ["viewer.html", "viewer.js", "viewer.css"],
"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/*"
+889 -11
View File
@@ -171,6 +171,150 @@ html, body {
flex-shrink: 0; flex-shrink: 0;
} }
/* Bannière de session expirée (v4.1.12) — sticky sous la topbar, non bloquante */
.session-banner {
position: sticky;
top: 56px;
z-index: 8;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
/* v4.2.5 : rouge plus vif + bord plus épais pour visibilité max */
background: linear-gradient(90deg, #c93030, #d84848);
color: #fff;
border-top: 2px solid #ff6060;
border-bottom: 2px solid #7a1515;
font-size: 14px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
/* petite animation d'apparition pour attirer l'œil */
animation: session-banner-in 0.22s ease-out;
}
@keyframes session-banner-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
/* v4.2.5 : variante ORANGE pour "EV inaccessible" (distinct de session expirée) */
.session-banner.ev-banner {
background: linear-gradient(90deg, #c77920, #e09a3a);
border-top: 2px solid #ffbb60;
border-bottom: 2px solid #7a4a15;
}
.session-banner.ev-banner .btn-primary {
color: #8a4a10;
}
.session-banner.hidden {
display: none;
}
.session-banner-icon {
font-size: 20px;
flex-shrink: 0;
}
.session-banner-text {
flex: 1;
line-height: 1.4;
}
.session-banner-text strong {
font-weight: 600;
}
.session-banner .btn-primary {
background: #fff;
color: #9a2020;
border: 0;
font-weight: 600;
}
.session-banner .btn-primary:hover {
background: #f0f0f0;
}
.session-banner .btn-sm {
padding: 5px 12px;
font-size: 12px;
/* v4.2.5 : btn-sm non-primary dans la bannière = contour blanc */
background: transparent;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.5);
font-weight: 500;
}
.session-banner .btn-sm:hover {
background: rgba(255, 255, 255, 0.12);
}
.session-banner .btn-primary.btn-sm {
/* reset : le primary override le style du btn-sm */
background: #fff;
color: #9a2020;
border: 0;
font-weight: 600;
}
.session-banner.ev-banner .btn-primary.btn-sm {
color: #8a4a10;
}
.session-banner .btn-icon {
background: transparent;
color: #fff;
border: 0;
font-size: 20px;
line-height: 1;
padding: 4px 8px;
cursor: pointer;
}
.session-banner .btn-icon:hover {
background: rgba(255,255,255,0.15);
}
/* Barre de progression pendant le rafraichissement — 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). */
.progress-bar {
position: sticky;
top: 56px;
z-index: 9;
height: 22px;
/* v4.1.17 : backdrop-blur sur toute la barre → ce qui défile derrière
est légèrement flouté sur TOUTE la largeur. Pas d'opacité sombre
ajoutée, transparence préservée. */
background: rgba(128, 128, 128, 0.08);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
border-bottom: 1px solid var(--border, rgba(128, 128, 128, 0.2));
overflow: hidden;
}
.progress-bar.hidden {
display: none;
}
.progress-bar-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: linear-gradient(90deg, #2ea043, #3fb950);
width: 0%;
transition: width 240ms ease-out;
box-shadow: 0 0 8px rgba(63, 185, 80, 0.3);
}
.progress-bar-label {
position: relative;
display: block;
text-align: center;
line-height: 22px;
font-size: 12px;
font-weight: 700;
color: #fff;
pointer-events: none;
letter-spacing: 0.3px;
/* v4.1.14/17 : text-shadow multi-directionnel (halo sombre autour du
texte). Le backdrop-blur est sur toute la barre, plus besoin de pill. */
text-shadow:
0 0 2px rgba(0, 0, 0, 0.95),
0 0 3px rgba(0, 0, 0, 0.85),
0 1px 2px rgba(0, 0, 0, 0.75),
0 -1px 2px rgba(0, 0, 0, 0.75),
1px 0 2px rgba(0, 0, 0, 0.75),
-1px 0 2px rgba(0, 0, 0, 0.75);
z-index: 2;
}
/* Navigation de date */ /* Navigation de date */
.date-nav { .date-nav {
display: flex; display: flex;
@@ -249,6 +393,41 @@ html, body {
opacity: 1; opacity: 1;
} }
/* v4.1.12 : boutons refresh plus clairs visuellement.
- "Vérifier" (partiel) : style discret, icône demi-rotation
- "Tout recharger" (total) : plus affirmé, icône double-flèche circulaire */
.btn-refresh {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
}
.btn-refresh-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
color: currentColor;
}
.btn-refresh-label {
font-size: 12px;
line-height: 1;
}
.btn-refresh-strong {
background: var(--bg-subtle, rgba(63, 185, 80, 0.08));
border-color: var(--border-strong);
}
.btn-refresh-strong:hover {
background: rgba(63, 185, 80, 0.18);
border-color: rgba(63, 185, 80, 0.5);
}
.btn-refresh-icon.spinning {
animation: refresh-spin 0.9s linear infinite;
transform-origin: 50% 50%;
}
@keyframes refresh-spin {
to { transform: rotate(360deg); }
}
.btn-primary { .btn-primary {
background: var(--accent); background: var(--accent);
color: white; color: white;
@@ -259,7 +438,7 @@ html, body {
opacity: 0.9; opacity: 0.9;
} }
/* Bouton "Arrêter" (apparaît pendant un refresh manuel) */ /* Bouton "Arrêter" (apparaît pdt un refresh manuel) */
.btn-abort { .btn-abort {
background: var(--danger-soft); background: var(--danger-soft);
color: var(--danger); color: var(--danger);
@@ -624,9 +803,12 @@ html, body {
display: grid; display: grid;
grid-template-columns: 4px 58px 1fr auto; grid-template-columns: 4px 58px 1fr auto;
grid-template-rows: auto auto; grid-template-rows: auto auto;
/* v4.1.17 : la ligne du bas (right) s'étend maintenant sur les 2 colonnes
droite (right + status) pour que la signature aille vraiment jusqu'au
bord droit. Le ✓ status est positionné en absolute par-dessus. */
grid-template-areas: grid-template-areas:
"dot time ref copy" "dot time ref copy"
"dot time right status"; "dot time right right";
gap: 2px 10px; gap: 2px 10px;
align-items: start; align-items: start;
padding: 10px 12px 12px 8px; padding: 10px 12px 12px 8px;
@@ -684,10 +866,52 @@ html, body {
width: 5px; width: 5px;
} }
.intervention-v2.is-ghost { /* v4.2.5 : statut "terminée par le tech" (commentaire LOGIN: détecté).
opacity: 0.5; Vert PLUS CLAIR que status-closed (distinction visuelle du ✓ simple
text-decoration: line-through; vs ✓✓ double). */
.intervention-v2.status-terminated {
background: var(--c-recup-soft, rgba(63, 185, 80, 0.12));
box-shadow: inset 4px 0 0 var(--c-recup, #3fb950);
} }
.intervention-v2.status-terminated:hover {
background: var(--c-recup-soft, rgba(63, 185, 80, 0.12));
filter: brightness(0.96);
}
.intervention-v2.status-terminated .intervention-dot {
background: var(--c-recup, #3fb950);
width: 5px;
}
.intervention-v2.status-terminated .iv-status-check {
color: var(--c-recup, #3fb950);
}
.timeline-slot.status-terminated { background: var(--c-recup, #3fb950); }
/* v4.2.5 : carte "en cours d'analyse" (ghost juste disparu, on re-fetch la
fiche pour décider du sort). Opacité réduite + petit spinner discret. */
.intervention-v2._checking {
opacity: 0.6;
position: relative;
}
.intervention-v2._checking::after {
content: "";
position: absolute;
right: 10px;
top: 50%;
width: 12px;
height: 12px;
margin-top: -6px;
border: 2px solid var(--border, #ccc);
border-top-color: var(--text-muted, #666);
border-radius: 50%;
animation: iv-check-spin 0.9s linear infinite;
}
@keyframes iv-check-spin {
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). */
/* Ligne 1 : REF en titre centré gros gras */ /* Ligne 1 : REF en titre centré gros gras */
.iv-ref-header { .iv-ref-header {
@@ -710,15 +934,28 @@ html, body {
} }
.iv-status-check { .iv-status-check {
grid-area: status; /* v4.1.17 : absolute en bas à droite (la grid-area "status" a été
align-self: center; fusionnée avec "right" pour étendre la signature jusqu'au bord). */
position: absolute;
right: 10px;
bottom: 10px;
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: var(--c-closed); color: var(--c-closed);
padding-right: 6px; pointer-events: none;
/* Au-dessus de la signature, mais discret */
z-index: 1;
} }
.intervention-v2.status-resolved .iv-status-check { color: var(--c-resolved); } .intervention-v2.status-resolved .iv-status-check { color: var(--c-resolved); }
/* v4.2.5 : ✓✓ double check (clôturé/résolu) — un peu plus petit pour tenir
les 2 caractères. Espacement négatif pour les rapprocher. */
.iv-status-check.double {
font-size: 14px;
letter-spacing: -3px;
padding-right: 3px; /* compenser le letter-spacing côté droit */
}
.intervention-copy { .intervention-copy {
grid-area: copy; grid-area: copy;
align-self: start; align-self: start;
@@ -840,6 +1077,8 @@ html, body {
align-items: baseline; align-items: baseline;
gap: 8px; gap: 8px;
font-size: 12px; font-size: 12px;
/* v4.1.14 : forcer la ligne à occuper 100% de largeur du parent */
width: 100%;
} }
.iv-category { .iv-category {
color: var(--text-muted); color: var(--text-muted);
@@ -847,8 +1086,13 @@ html, body {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
flex: 1; /* v4.1.15 : taille naturelle (pas de flex:1 qui étirait le texte et
rendait la signature juste à côté). Sans flex, la catégorie reste à
son contenu + justify-content:space-between pousse la signature à
l'extrême droite du parent. */
min-width: 0; min-width: 0;
flex: 0 1 auto;
max-width: calc(100% - 70px);
} }
.iv-signature { .iv-signature {
color: var(--text-faint); color: var(--text-faint);
@@ -856,6 +1100,16 @@ html, body {
font-family: var(--mono); font-family: var(--mono);
flex-shrink: 0; flex-shrink: 0;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-align: right;
/* v4.1.15/17 : margin-left: auto pour collage garanti à droite */
margin-left: auto;
white-space: nowrap;
}
/* v4.1.17 : si statut clos/résolu, le ✓ est à droite en absolute → décaler
la signature pour ne pas se chevaucher */
.intervention-v2.status-closed .iv-signature,
.intervention-v2.status-resolved .iv-signature {
padding-right: 22px;
} }
/* Réservation (créneau bloqué par un coordinateur) */ /* Réservation (créneau bloqué par un coordinateur) */
@@ -902,7 +1156,14 @@ html, body {
Tooltip Tooltip
========================================================================== */ ========================================================================== */
.tooltip { .tooltip {
position: fixed; position: fixed !important;
/* v4.2.4 : forcer un stacking context propre et l'isolation pour que le
tooltip ne soit pas affecté par un éventuel filter/transform/contain
sur un ancêtre (qui casserait position:fixed). `contain: layout` et
`will-change: transform` garantissent aussi que le navigateur traite
ce tooltip indépendamment. */
isolation: isolate;
contain: layout;
z-index: 100; z-index: 100;
max-width: 620px; max-width: 620px;
max-height: calc(100vh - 40px); max-height: calc(100vh - 40px);
@@ -918,9 +1179,107 @@ html, body {
pointer-events: none; pointer-events: none;
opacity: 0; opacity: 0;
transition: opacity 0.1s; transition: opacity 0.1s;
user-select: text;
-webkit-user-select: text;
} }
.tooltip.visible { .tooltip.visible {
opacity: 1; opacity: 1;
/* v4.1.10 : permet à la souris d'entrer dans la bulle pour la garder
visible (persistance au hover) et, en mode pinned, pour sélectionner. */
pointer-events: auto;
/* v4.2 : curseur texte par défaut (pour signaler que c'est sélectionnable) */
cursor: text;
}
.tooltip.pinned {
/* Bulle épinglée : bordure verte pour indiquer le mode */
border-color: var(--c-accent, #3fb950);
box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.15), var(--shadow-hover);
}
/* v4.1.13/14 : barre d'actions en haut à droite de la bulle
(recharger cette iv + épingler) */
.tooltip-actions {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 2px;
z-index: 5;
}
.tooltip-actionbtn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
font-size: 13px;
opacity: 0.55;
transition: opacity 0.15s, background 0.15s, transform 0.15s;
user-select: none;
color: var(--text-muted);
}
.tooltip-actionbtn svg {
width: 14px;
height: 14px;
}
.tooltip-actionbtn:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
.tooltip-actionbtn.spinning svg {
animation: refresh-spin 0.8s linear infinite;
transform-origin: 50% 50%;
}
/* L'ancien .tooltip-pinbtn garde ses variantes */
.tooltip-pinbtn {
filter: grayscale(100%);
}
.tooltip-pinbtn:hover {
filter: grayscale(0%);
}
.tooltip.pinned .tooltip-pinbtn {
opacity: 1;
filter: grayscale(0%);
transform: rotate(-30deg);
background: rgba(63, 185, 80, 0.15);
}
/* v4.1.15 : référence dans la bulle avec bouton copier inline */
.tt-ref-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
.tt-ref-val {
font-family: var(--mono, monospace);
}
.tt-copy-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
width: 26px;
height: 22px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.tt-copy-btn:hover {
background: var(--bg-hover);
color: var(--text);
border-color: var(--border-strong);
}
.tt-copy-btn.copied {
background: rgba(63, 185, 80, 0.2);
border-color: #3fb950;
color: #3fb950;
} }
.tooltip dl { .tooltip dl {
@@ -1038,3 +1397,522 @@ html, body {
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
/* ─────────────────────────────────────────────────────────────────────────
v4.1.20 : Modal central de confirmation (vider cache)
───────────────────────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
/* Flou + assombrissement léger de l'arrière-plan */
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
animation: modal-fade-in 0.15s ease-out;
}
@keyframes modal-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-card {
background: var(--bg, #ffffff);
color: var(--text, #111);
border: 1px solid var(--border, rgba(128, 128, 128, 0.25));
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25),
0 2px 8px rgba(0, 0, 0, 0.15);
padding: 24px 24px 20px;
width: min(440px, 92vw);
max-height: 90vh;
overflow-y: auto;
animation: modal-card-in 0.18s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modal-card-in {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal-title {
margin: 0 0 12px 0;
font-size: 18px;
font-weight: 700;
color: var(--text, #111);
}
.modal-message {
margin: 0 0 20px 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted, #555);
}
.modal-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.modal-actions .btn {
width: 100%;
padding: 10px 14px;
font-size: 13px;
font-weight: 600;
text-align: center;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s, transform 0.06s;
border: 1px solid transparent;
}
.modal-actions .btn:active { transform: translateY(1px); }
/* Vider le cache du jour : danger modéré (orange) */
.btn-modal-danger {
background: rgba(234, 128, 38, 0.12);
color: #c85a00;
border-color: rgba(234, 128, 38, 0.3);
}
.btn-modal-danger:hover {
background: rgba(234, 128, 38, 0.22);
}
/* Vider tout le cache : danger fort (rouge) */
.btn-modal-danger-strong {
background: rgba(220, 60, 60, 0.12);
color: #c03030;
border-color: rgba(220, 60, 60, 0.3);
}
.btn-modal-danger-strong:hover {
background: rgba(220, 60, 60, 0.22);
}
/* Annuler : neutre */
.btn-modal-cancel {
background: transparent;
color: var(--text-muted, #666);
border-color: var(--border, rgba(128, 128, 128, 0.3));
margin-top: 4px;
}
.btn-modal-cancel:hover {
background: var(--bg-hover, rgba(128, 128, 128, 0.08));
}
/* v4.2.5 : bouton primaire (action principale) pour modals d'alerte */
.btn-modal-primary {
background: var(--c-accent, #3fb950);
color: #fff;
border-color: var(--c-accent, #3fb950);
}
.btn-modal-primary:hover {
filter: brightness(1.08);
}
/* ─────────────────────────────────────────────────────────────────────────
v4.1.20 : Message d'absence récurrente (Pillonel vendredi)
───────────────────────────────────────────────────────────────────────── */
.tech-absence-recurring {
padding: 14px 12px;
text-align: center;
font-size: 13px;
font-style: italic;
color: var(--text-faint, #888);
background: rgba(128, 128, 128, 0.04);
border-top: 1px solid var(--border, rgba(128, 128, 128, 0.15));
border-bottom: 1px solid var(--border, rgba(128, 128, 128, 0.15));
}
/* v4.2 : contact en rouge quand anomalie détectée (Contact + Personne de
contact présents tous les deux dans l'action = situation suspecte).
On signale visuellement pour que l'user aille vérifier dans la fiche. */
.iv-contact-line.iv-contact-anomalie {
color: #dc3030;
}
.iv-contact-line.iv-contact-anomalie .iv-contact,
.iv-contact-line.iv-contact-anomalie .iv-phone {
color: #dc3030;
}
/* v4.2 : badge utilisateur EasyVista connecté (en haut à droite de la topbar) */
.current-user {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted, #666);
background: rgba(128, 128, 128, 0.08);
border: 1px solid var(--border, rgba(128, 128, 128, 0.2));
border-radius: 999px;
margin-right: 8px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.current-user::before {
content: "👤";
font-size: 11px;
opacity: 0.7;
}
.current-user.hidden {
display: none;
}
/* ─────────────────────────────────────────────────────────────────────────
v4.2.3 : pastille utilisateur (initiales) dans la topbar gauche
───────────────────────────────────────────────────────────────────────── */
.user-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
margin-right: 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
color: #fff;
background: var(--user-badge-color, #5b6372);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 50%;
cursor: pointer;
transition: transform 0.1s, box-shadow 0.12s;
flex-shrink: 0;
user-select: none;
}
.user-badge:hover {
transform: scale(1.06);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.user-badge:active {
transform: scale(0.97);
}
.user-badge.hidden {
display: none;
}
.user-badge.open {
/* Indication visuelle quand la popup nom est ouverte */
box-shadow: 0 0 0 2px var(--user-badge-color, #5b6372) inset,
0 0 0 2px rgba(255, 255, 255, 0.15);
}
/* Popup du nom complet, affichée juste sous la pastille au clic */
.user-name-popup {
position: fixed;
z-index: 10050;
padding: 8px 14px;
background: var(--bg, #ffffff);
color: var(--text, #111);
border: 1px solid var(--border, rgba(128, 128, 128, 0.25));
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18),
0 2px 6px rgba(0, 0, 0, 0.12);
font-size: 13px;
font-weight: 500;
white-space: nowrap;
animation: user-popup-in 0.12s ease-out;
}
.user-name-popup.hidden {
display: none;
}
@keyframes user-popup-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ─────────────────────────────────────────────────────────────────────────
v4.2.6 : boutons d'action topbar (Absence, Douchette)
───────────────────────────────────────────────────────────────────────── */
.btn-action {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
background: transparent;
color: var(--text, #e0e0e0);
border: 1px solid var(--border, rgba(128, 128, 128, 0.3));
border-radius: 6px;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
}
.btn-action:hover {
background: var(--bg-hover, rgba(128, 128, 128, 0.12));
border-color: var(--border-strong, rgba(128, 128, 128, 0.5));
}
.btn-action:active {
transform: translateY(1px);
}
.btn-action-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.btn-action-emoji {
font-size: 14px;
line-height: 1;
}
.btn-action-label {
white-space: nowrap;
}
/* ─────────────────────────────────────────────────────────────────────────
v4.2.6 : modals Absence et Douchette
───────────────────────────────────────────────────────────────────────── */
.modal-card.modal-wide {
width: min(520px, 92vw);
}
.modal-form-group {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.modal-form-row {
display: flex;
gap: 8px;
align-items: center;
}
.modal-form-row > * {
flex: 1;
}
.modal-form-label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.modal-form-input,
.modal-form-select {
padding: 8px 10px;
font-size: 13px;
background: var(--bg, #fff);
color: var(--text, #111);
border: 1px solid var(--border, rgba(128, 128, 128, 0.3));
border-radius: 6px;
font-family: inherit;
}
.modal-form-input:focus,
.modal-form-select:focus {
outline: none;
border-color: var(--c-accent, #3fb950);
box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.15);
}
/* Liste checkboxes techniciens */
.modal-tech-list {
display: flex;
flex-direction: column;
gap: 4px;
/* v4.2.8 : plus de max-height → tous les techs (max 8 + "Tout") visibles
d'un coup sans avoir à scroller dans la liste. */
padding: 6px;
background: var(--bg-muted, rgba(128, 128, 128, 0.06));
border: 1px solid var(--border, rgba(128, 128, 128, 0.2));
border-radius: 6px;
}
.modal-tech-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.1s;
}
.modal-tech-item:hover {
background: var(--bg-hover, rgba(128, 128, 128, 0.12));
}
.modal-tech-item input[type="checkbox"] {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--c-accent, #3fb950);
}
.modal-tech-item.tech-selectall {
font-weight: 600;
border-bottom: 1px solid var(--border, rgba(128, 128, 128, 0.2));
padding-bottom: 8px;
margin-bottom: 2px;
}
.modal-tech-item.tech-selectall:hover {
background: var(--bg-hover, rgba(128, 128, 128, 0.12));
}
/* Boutons Appliquer/Envoyer/Annuler côte à côte */
.modal-actions.horizontal {
flex-direction: row;
gap: 8px;
}
.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);
}
+70 -7
View File
@@ -2,13 +2,18 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Planning techniciens</title> <title>Planification</title>
<link rel="stylesheet" href="viewer.css"> <link rel="stylesheet" href="viewer.css">
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="topbar-left"> <div class="topbar-left">
<h1>Planning techniciens</h1> <!-- v4.2.3 : pastille avec initiales de l'utilisateur connecté, avant
le titre. Clic → popup fixe avec nom complet juste en dessous. -->
<button id="user-badge" class="user-badge hidden"
type="button" aria-label="Utilisateur connecté"
title="Utilisateur connecté"></button>
<h1>Planification</h1>
<div class="date-nav"> <div class="date-nav">
<button id="nav-prev" class="btn btn-nav" title="Jour précédent" aria-label="Jour précédent"></button> <button id="nav-prev" class="btn btn-nav" title="Jour précédent" aria-label="Jour précédent"></button>
<input type="date" id="date-picker" class="date-input"> <input type="date" id="date-picker" class="date-input">
@@ -19,8 +24,28 @@
<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>
<div class="topbar-right"> <div class="topbar-right">
<button id="refresh-btn" class="btn" title="Rafraîchir maintenant"> <!-- v4.2.6 : bouton créer une absence pour un ou plusieurs techs -->
<span id="refresh-icon"></span> Rafraîchir <button id="absence-btn" class="btn btn-action" title="Créer une absence pour un ou plusieurs techniciens">
<svg class="btn-action-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/>
<path d="M3 9h18M8 3v4M16 3v4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<circle cx="12" cy="15" r="4.2" stroke="currentColor" stroke-width="1.8" fill="none"/>
<line x1="9" y1="15" x2="15" y2="15" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" transform="rotate(-45 12 15)"/>
</svg>
<span class="btn-action-label">Absence</span>
</button>
<!-- v4.2.6 : bouton envoyer la planification sur la douchette -->
<button id="douchette-btn" class="btn btn-action" title="Envoyer la planification sur la douchette des techniciens">
<span class="btn-action-emoji">🎯</span>
<span class="btn-action-label">Douchette</span>
</button>
<button id="refresh-partial-btn" class="btn btn-refresh" title="Actualiser : ajoute les nouvelles interventions et retire celles qui ne sont plus dans le planning. Rapide, ne re-télécharge pas les fiches déjà connues.">
<svg id="refresh-partial-icon" class="btn-refresh-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8a6 6 0 0 1 10.2-4.24M14 3v3h-3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span class="btn-refresh-label">Actualiser</span>
</button>
<button id="refresh-btn" class="btn btn-refresh btn-refresh-strong" title="Tout recharger : re-télécharge le planning ET toutes les fiches (y compris celles déjà connues) pour voir évoluer les statuts. Plus lent.">
<svg id="refresh-icon" class="btn-refresh-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8a6 6 0 1 0 1.76-4.24M2 3v3h3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 8a6 6 0 0 1-10.2 4.24M14 13v-3h-3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span class="btn-refresh-label">Tout recharger</span>
</button> </button>
<button id="abort-btn" class="btn btn-abort hidden" title="Arrêter le rafraîchissement en cours"> <button id="abort-btn" class="btn btn-abort hidden" title="Arrêter le rafraîchissement en cours">
✕ Arrêter ✕ Arrêter
@@ -34,14 +59,48 @@
</div> </div>
</header> </header>
<!-- Bannière session expirée (non bloquante, apparaît en haut du contenu) -->
<div id="session-expired-banner" class="session-banner hidden">
<span class="session-banner-icon"></span>
<span class="session-banner-text">
<strong>Session EasyVista expirée.</strong>
Données affichées depuis le cache. Reconnectez-vous à EasyVista pour rafraîchir.
</span>
<button id="session-banner-reconnect" class="btn btn-primary btn-sm">Ouvrir EasyVista</button>
<button id="session-banner-close" class="btn btn-icon" title="Masquer">×</button>
</div>
<!-- v4.2.5 : Bannière EasyVista inaccessible (non bloquante, avec cache) -->
<div id="ev-unreachable-banner" class="session-banner ev-banner hidden">
<span class="session-banner-icon"></span>
<span class="session-banner-text">
<strong>EasyVista est inaccessible.</strong>
Données affichées depuis le cache.
</span>
<button id="ev-unreachable-banner-retry" class="btn btn-primary btn-sm">Réessayer</button>
<button id="ev-unreachable-banner-open" class="btn btn-sm">Ouvrir EasyVista</button>
<button id="ev-unreachable-banner-close" class="btn btn-icon" title="Masquer">×</button>
</div>
<!-- Barre de progression (visible uniquement pendant un refresh actif) -->
<div id="progress-bar" class="progress-bar hidden">
<div class="progress-bar-fill" id="progress-bar-fill"></div>
<span class="progress-bar-label" id="progress-bar-label"></span>
</div>
<main id="main"> <main id="main">
<div id="error-box" class="error-box hidden"></div> <div id="error-box" class="error-box hidden"></div>
<div id="session-needed" class="session-needed hidden"> <div id="session-needed" class="session-needed hidden">
<h2>Connexion à EasyVista requise</h2> <h2>Session EasyVista expirée</h2>
<p>Cette extension doit se connecter à <code>itsma.etat-de-vaud.ch</code> pour fonctionner.</p> <p>Reconnectez-vous à EasyVista pour continuer.</p>
<p>Ouvre EasyVista dans un onglet, connecte-toi, puis <b>reclique sur l'icône de l'extension</b>.</p>
<button id="open-ev-btn" class="btn btn-primary">Ouvrir EasyVista</button> <button id="open-ev-btn" class="btn btn-primary">Ouvrir EasyVista</button>
</div> </div>
<div id="ev-unreachable" class="session-needed hidden">
<h2>EasyVista est inaccessible pour le moment.</h2>
<p>Réessayez dans quelques instants, ou ouvrez EasyVista directement.</p>
<button id="open-ev-btn-2" class="btn btn-primary">Ouvrir EasyVista</button>
<button id="retry-btn" class="btn btn-subtle">Réessayer</button>
</div>
<div id="loading" class="loading">Chargement…</div> <div id="loading" class="loading">Chargement…</div>
<div id="stats" class="stats hidden"></div> <div id="stats" class="stats hidden"></div>
<div id="cards" class="cards"></div> <div id="cards" class="cards"></div>
@@ -49,6 +108,10 @@
<div id="tooltip" class="tooltip hidden" role="tooltip"></div> <div id="tooltip" class="tooltip hidden" role="tooltip"></div>
<!-- v4.2.3 : popup fixe du nom de l'utilisateur connecté. S'ouvre au clic
sur la pastille d'initiales (topbar gauche). -->
<div id="user-name-popup" class="user-name-popup hidden" role="dialog" aria-hidden="true"></div>
<!-- Conteneur des toasts (notifications d'ouverture) --> <!-- Conteneur des toasts (notifications d'ouverture) -->
<div id="toast-stack" class="toast-stack" aria-live="polite"></div> <div id="toast-stack" class="toast-stack" aria-live="polite"></div>
+3271 -325
View File
File diff suppressed because it is too large Load Diff