Compare commits

..

44 Commits

Author SHA1 Message Date
Quentin Rouiller d7b680fb3f v2026.5.43 — Fix Firefox menu dock position + stabilité popup pin/unpin
Bug Firefox uniquement : positionnement du menu hover des pastilles du
dock (popup réduit) corrigé. La cause était que getBoundingClientRect()
était appelé immédiatement après appendChild sans que Firefox n'ait fini
de calculer la mise en page, combiné à un transform: translateY dans
l'animation d'apparition du menu. Fix : positionnement hors écran initial,
force-layout via offsetHeight, puis pose finale. Animation CSS simplifiée
en opacité-only.

Stabilité popup au pin/unpin (tous navigateurs) : la popup épinglée
bougeait de 16px et changeait légèrement de taille quand on la
dé-épinglait via le bouton 📌. Cause : .pinned-popup avait padding-top
28px + border 2px alors que .soft-unpinned avait padding-top 12px + border
1px. Fix : .soft-unpinned conserve désormais les mêmes dimensions, juste
la couleur de bordure change (--border-strong gris au lieu de --accent
bleu) pour signaler le mode détaché.
2026-04-27 04:57:03 +02:00
Quentin Rouiller 3c7e7c0c25 firefox: update_hash v2026.5.42 → sha256 du .xpi signé par Mozilla AMO
Le .xpi distribué sur la release v2026.5.42 a été remplacé par sa version
signée AMO (signature META-INF/mozilla.rsa + COSE). Le sha256 dans
firefox-updates.json reflète maintenant le .xpi signé, ce qui permet
l'auto-update Firefox vers la version signée et installable.
2026-04-27 03:44:11 +02:00
Quentin Rouiller b3677d661a chore: change addon ID en planification-dgnsi@netaplaid.ch
L'ID précédent (planification@netaplaid.ch) était déjà enregistré sur AMO.
Nouvel ID : planification-dgnsi@netaplaid.ch — nom plus explicite (mention
DGNSI), domaine inchangé.

build.sh + firefox-updates.json mis à jour avec le nouvel ID. Sha256 du
.xpi v2026.5.42 régénéré.
2026-04-27 03:32:44 +02:00
Quentin Rouiller 8390db9937 v2026.5.42 — Nettoyage de commentaires + exemples génériques
Passage en revue des commentaires de viewer.js : les exemples qui
illustraient le parsing des contacts/lieux/références/codes-barres ont
été uniformisés en placeholders abstraits (Nom1 Prénom1 +41XXXXXXXXX,
SYYMMDD_NNNNN, XXXX_NNNNNNNN, etc.) plutôt que des chaînes spécifiques.

Comportement runtime strictement inchangé — uniquement de la documentation
et des commentaires. README, CHANGELOG et pages wiki Versions/Utilisation
mis à jour de manière cohérente.
2026-04-27 03:28:13 +02:00
Quentin Rouiller 2cc9552fbf security: anonymisation de toutes les données nominatives résiduelles
Suite à un audit de sécurité, retrait de TOUTES les données réelles dans
le code et la documentation :

- src/viewer.js : commentaires-exemples qui contenaient de vrais noms +
  numéros de téléphone (Seda Kaya, Hélène Dongiovanni, Krkic Admir et leurs
  numéros) → remplacés par 'Nom1 Prénom1 +41XXXXXXXXX', etc.
- src/viewer.js : refs tickets EV avec dates concrètes (SYYMMDD_NNNNN avec
  vraies dates) → remplacées par 'SYYMMDD_NNNNN' génériques.
- src/viewer.js : codes-barres / numéros de série (TPCQ_NNN, MNNN, DNNN,
  TNNN avec vrais chiffres) → remplacés par 'XXXX_NNNNNNNN', 'XNNNNNN'.
- README.md, CHANGELOG.md, wiki Utilisation/Versions : exemples de référence
  ticket S260424_00042 → SYYMMDD_NNNNN.

Aucune donnée nominative ni identifiant réel ne subsiste dans le code,
les commentaires, ni la documentation publique. Sha256 du .xpi mis à jour
dans firefox-updates.json.
2026-04-27 03:23:20 +02:00
Quentin Rouiller 0327a55c74 fix(firefox): ajoute background.scripts fallback (compat MV3 Firefox/AMO)
Mozilla AMO rejetait le .xpi avec :
  Unsupported "/background/service_worker" manifest property used without
  "/background/scripts" property as Firefox-compatible fallback.

build.sh ajoute maintenant 'scripts: [background.js]' à background.* dans
le manifest Firefox uniquement (Chrome ignore 'scripts' quand
'service_worker' est présent ; Firefox ignore 'service_worker' et utilise
'scripts'). Les deux navigateurs chargent le même background.js.

Sha256 du .xpi v2026.5.41 mis à jour dans firefox-updates.json.
2026-04-27 03:02:38 +02:00
Quentin Rouiller 67708d1ad3 chore: simplifie firefox-updates.json (repo public, URL raw fixe)
- update_url remis sur .../raw/branch/main/firefox-updates.json maintenant
  que le repo est public (raw URL accessible sans auth).
- firefox-updates.json toujours à la racine, contient toutes les versions ;
  Firefox lit la liste et choisit la plus haute compatible.
- Sha256 du .xpi v2026.5.41 mis à jour suite au rebuild.
- CLAUDE.md : note sur le channel d'update simplifiée.
2026-04-27 03:00:02 +02:00
Quentin Rouiller 1730758cb4 Distribution: firefox-updates.json + CLAUDE.md (workflow Claude) + nettoyage secrets
- firefox-updates.json à la racine : manifest auto-update Firefox avec entrées
  v2026.5.40 et v2026.5.41 (sha256 NON SIGNÉ pour le moment, à remplacer par
  celui des .xpi signés AMO).
- build.sh : maintient firefox-updates.json automatiquement à chaque build
  (ajoute ou met à jour l'entrée de la version courante avec son sha256
  calculé sur le .xpi produit).
- CLAUDE.md : workflow complet pour Claude Code (build → test → push → wiki →
  signature AMO). Token Gitea jamais dans le fichier (stocké hors repo en
  mémoire Claude .claude/projects/.../memory/gitea_token.md).
- .gitignore : ajout _archives/, .claude/, .env, *.token, secrets.json.
- README.md / CHANGELOG.md : retrait email auteur en clair (renvoi vers
  page wiki Contact, email obfusqué en entités HTML).
2026-04-27 02:55:18 +02:00
Quentin Rouiller 7c0742594c v2026.5.41 — Suppression des hardcodes runtime + UX admin + thème unifié 2026-04-27 03:00:00 +02:00
Quentin Rouiller af85473837 v2026.5.40 — Sélection groupe EV + édition domaines + tri équipe + vue horizontale enrichie 2026-04-27 00:43:00 +02:00
Quentin Rouiller 47a0bca998 refactor: ranger le code source dans src/ + script build.sh 2026-04-27 00:00:00 +02:00
Quentin Rouiller e92b0c4444 v2026.5.39 — Séparation Matin / Après-midi + Apparence (thème, taille du texte, durée du cache, heures de la journée) 2026-04-26 18:10:00 +02:00
Quentin Rouiller 957b754bdc v2026.5.38 — Attribution auteur + nettoyage + observabilité (LOG unifié, handlers globaux d'erreur, toggle logs verbeux dans admin) 2026-04-25 22:55:00 +02:00
Quentin Rouiller aabda3ba7e v2026.5.37 — Refonte vue horizontale (sidebar complète) : topbar supprimée, user-badge + titre + bouton Aujourd'hui + date/heure + stats en sidebar 2026-04-24 13:45:50 +02:00
Quentin Rouiller 6a0324b252 v2026.5.36 — Sidebar verticale en vue horizontale (#horizontal-wrapper [sidebar 200px] + [main]) [code interpolé entre v2026.5.35 et v2026.5.37] 2026-04-24 13:22:08 +02:00
Quentin Rouiller fd466504c2 v2026.5.35 — Fix popup épinglé position vue horizontale + stats gauche 2026-04-24 13:11:16 +02:00
Quentin Rouiller 02524e78b2 v2026.5.34 — Bouton 📌 restauré + badge user cliquable + positionTooltipAnchored unifiée [code interpolé] 2026-04-24 12:56:34 +02:00
Quentin Rouiller 193b3252d4 v2026.5.33 — Vue horizontale : interactions différenciées (hover/clic) [code interpolé] 2026-04-24 12:12:32 +02:00
Quentin Rouiller 3a28e1bd0a v2026.5.26 — Badge user-badge cliquable + auto-détection EV à l'ouverture admin 2026-04-23 16:21:48 +02:00
Quentin Rouiller 10a1aef4c7 v2026.5.25 — Bouton ⚙ Paramètres dans popup user-badge (remplace 5 clics secrets sur le titre) 2026-04-23 16:00:38 +02:00
Quentin Rouiller b77f0a9caa v2026.5.24 — Améliorations diverses 2026-04-23 15:40:00 +02:00
Quentin Rouiller f7f81f7d9d v2026.5.23 — Polish UX 2026-04-23 15:31:44 +02:00
Quentin Rouiller ddb075d563 v2026.5.22 — Stabilité popups 2026-04-23 15:13:04 +02:00
Quentin Rouiller f6dc9eaf7b v2026.5.21 — Polish positionnement popups 2026-04-23 15:03:54 +02:00
Quentin Rouiller 3d5bdbab3d v2026.5.20 — Safe area : popups jamais cachés sous topbar/dock 2026-04-23 14:48:16 +02:00
Quentin Rouiller ad952ebc55 v2026.5.19 — Drag-and-drop des popups épinglés 2026-04-23 14:34:08 +02:00
Quentin Rouiller 1a7393c297 v2026.5.18 — Polish date custom 2026-04-23 14:20:52 +02:00
Quentin Rouiller d589447533 v2026.5.17 — Date custom : label localisé (jour de la semaine en français) 2026-04-23 14:00:10 +02:00
Quentin Rouiller ea5a42c5e1 v2026.5.16 — Passage au schéma de versionning ANNÉE.MAJEURE.PATCH + faux input date custom (Mardi 24.04.2026) 2026-04-23 13:03:58 +02:00
Quentin Rouiller 763e63d9c6 v5.0.15 — Absences partielles affichées comme rows (gris foncé) 2026-04-21 16:24:24 +02:00
Quentin Rouiller bea236ca88 v5.0.14 — Affichage timeline pour absences partielles seules 2026-04-21 16:13:04 +02:00
Quentin Rouiller d6ab8d59e0 v5.0.13 — Cache + retry 2026-04-21 16:04:00 +02:00
Quentin Rouiller 909ddb8301 v5.0.12 — Stabilité 2026-04-21 15:49:08 +02:00
Quentin Rouiller 6794360887 v5.0.11 — Détection contexte réseau (interne/externe via SSO) 2026-04-21 15:44:14 +02:00
Quentin Rouiller 7ba28d3bac v5.0.10 — Stabilité session EV 2026-04-21 15:32:44 +02:00
Quentin Rouiller e17f604d9e v5.0.9 — Surveillance timeout session EasyVista (compteur tick 1s, alertes 5min/2min) 2026-04-21 15:19:06 +02:00
Quentin Rouiller 9d701701e6 v5.0.8 — Correctifs 2026-04-21 12:53:22 +02:00
Quentin Rouiller 77c68dbe83 v5.0.7 — Correctifs 2026-04-21 12:50:36 +02:00
Quentin Rouiller d4fc8ff250 v5.0.6 — Correctifs 2026-04-21 12:46:58 +02:00
Quentin Rouiller 3996e3fb4f v5.0.5 — Correctifs admin/UX 2026-04-21 12:42:50 +02:00
Quentin Rouiller 86f52029f5 v5.0.4 — Améliorations admin/UX 2026-04-21 12:40:08 +02:00
Quentin Rouiller 984f326b39 v5.0.3 — Ajustements admin et stabilité 2026-04-20 14:03:34 +02:00
Quentin Rouiller 6d3058028f v5.0.1 — Refonte topbar : horloge HH:MM + compteur session EV + admin caché (5 clics titre) 2026-04-20 13:21:16 +02:00
Quentin Rouiller c59abbed23 v4.3.3 — Soft unpin popup + nettoyage tooltip persistance 2026-04-20 09:13:20 +02:00
10 changed files with 1016 additions and 267 deletions
+16
View File
@@ -38,3 +38,19 @@ tmp/
# Tests # Tests
test-output/ test-output/
# Archives historiques locales (jamais sur Gitea)
_archives/
Old.zip
Old/
# Mémoire / config Claude Code (ne jamais commit, contient potentiellement
# des tokens, des notes user, etc.)
.claude/
CLAUDE.local.md
# Variables d'environnement / secrets
.env
.env.*
*.token
secrets.json
+136 -59
View File
@@ -9,67 +9,144 @@
--- ---
## v2026.5.40Sélection groupe EV + édition domaines + tri équipe + vue horizontale enrichie ## v2026.5.43Fix Firefox : positionnement menu dock + stabilité popup pin/unpin
**Branche** : main
### Onglet Équipe (panel admin) ### Menu hover sur pastille du dock (popup réduit)
- Nouveau **sélecteur de groupe EasyVista** (SI-CSS, SI-EXT, …) en - Bug Firefox uniquement : quand un popup épinglé était réduit dans la
tête de section, détecté automatiquement à l'ouverture du panel taskbar du bas, le menu qui apparaît au survol de la pastille
via le `<select id="plan_group_id">` de la page Planning EV → (Agrandir / Fermer) se positionnait trop haut, pas juste au-dessus de
source autoritative, robuste aux ajouts/renommages côté EV (un la pastille.
nouveau groupe apparaît tout seul). - Cause : `getBoundingClientRect()` était appelé immédiatement après
- ID groupe affiché en italique à côté du sélecteur (ex: `appendChild`, avant que Firefox n'ait calculé la mise en page.
`ID groupe : 191`). Combiné avec un `transform: translateY(4px)` dans l'animation
- Quand on change de groupe, la **liste d'équipe se rafraîchit `pill-hover-menu-appear`, Firefox lisait des dimensions décalées.
automatiquement** avec les membres du nouveau groupe (fetch live). - Fix : positionnement hors écran initial, force-layout via
- **Plus de bouton "Détecter"** : tout est auto à l'ouverture de `void offsetHeight`, mesure des dimensions, puis pose finale. CSS de
l'onglet — détection groupes + détection membres. l'animation simplifiée en opacité-only (plus de transform).
- **Tri double** des techniciens : d'abord les inclus (cases cochées),
puis les exclus, et alphabétique dans chaque sous-groupe (insensible
casse/accents). Le tri se rafraîchit uniquement aux render() pour
éviter que les lignes sautent quand on coche/décoche.
### Onglet EasyVista (panel admin) ### Stabilité popup au pin/unpin
- Refonte complète : **édition manuelle des deux domaines** EV - Bug : la popup épinglée bougeait visuellement et changeait légèrement
(interne DGNSI = `https://itsma.etat-de-vaud.ch`, externe Internet = de taille quand on la dé-épinglait avec le bouton 📌 (puis l'inverse).
`https://itsma.vd.ch`). - Cause : `.pinned-popup` avait `padding-top: 28px` (place pour la
- Bouton **💾 Enregistrer** (normalise : ajoute `https://`, retire le dragbar) et `border: 2px`, alors que `.soft-unpinned` avait
trailing slash) + bouton **↺ Réinitialiser** pour revenir aux `padding-top: 12px` et `border: 1px`. Le contenu se décalait de 16px
valeurs par défaut. vers le haut et la popup devenait 1px plus fine de chaque côté.
- Les domaines par défaut restent codés en dur en fallback ; le - Fix : `.soft-unpinned` conserve désormais `padding-top: 28px` et
branchement effectif côté `background.js` (utiliser `cfg.evOrigins`) `border: 2px` comme `.pinned-popup`. Bordure passe juste en
sera fait dans une prochaine version après validation. `--border-strong` (gris discret) plutôt que `--accent` (bleu) pour
signaler visuellement le mode "détaché". Position et taille stables.
### Onglet Statuts retiré ## v2026.5.42 — Nettoyage de commentaires + exemples génériques
- Section "Statuts" supprimée du panel admin (placeholder lecture
seule, jamais utile).
### Vue horizontale enrichie - Passage en revue des commentaires de `src/viewer.js` : les exemples qui
- Chaque segment timeline d'intervention contient désormais : illustraient le parsing des contacts/lieux/références/codes-barres ont été
- Une **barre verticale couleur catégorie** à gauche (mêmes teintes uniformisés en placeholders abstraits (`Nom1 Prénom1 +41XXXXXXXXX`,
que les `intervention-dot` de la vue classique : livraison/recup/ `SYYMMDD_NNNNN`, `XXXX_NNNNNNNN`, etc.) plutôt que des chaînes spécifiques.
remplacement/incident/rollout/réservation/autre). Comportement runtime strictement inchangé — uniquement de la documentation
- La **référence** (ex: `SYYMMDD_NNNNN`) en gras. et des commentaires.
- La **ville** en gris muted. - Mise à jour cohérente du README, du CHANGELOG et des pages wiki Versions /
- Hauteur de la timeline horizontale passée de 22px à 32px pour Utilisation pour utiliser les mêmes notations génériques dans les
laisser la place au texte. exemples de référence.
- Fond des segments d'intervention : `--bg-elevated` neutre + bordure
1px pour que le texte reste lisible (la couleur catégorie n'est
plus en fond plein, juste en barre gauche).
- Vue classique inchangée.
### Coulisses (`background.js`) ## v2026.5.41 — Suppression des hardcodes (groupe / domaines / équipe) → tout depuis l'admin
- Nouveau message `detectGroups` + fonction `detectGroupsFromEV()`
qui fetche `/index.php?eventName=HelpDesk_PlanningItem` et extrait ### Plus aucun hardcode au runtime
les paires `(id, nom)` via le `<select>`. - Le groupe EasyVista, les domaines (interne/externe) et la liste des
- `detectTeamFromEV()` accepte désormais un `groupId` en argument → techniciens ne sont **plus codés en dur** dans `background.js` /
permet de basculer entre SI-CSS / SI-EXT depuis l'admin. `viewer.js`. Tout est lu depuis `admin_config` (chrome.storage.local),
- ⚠ Le fetch du planning continue d'utiliser `group_id=191` codé en alimenté par les onglets **Équipe** et **EasyVista** du panel admin.
dur — sera retiré quand on validera que `cfg.groupId` est bien - `chrome.storage.local` survit aux mises à jour d'extension → la
alimenté par le sélecteur en terrain réel. configuration de l'utilisateur (groupe, équipe, absences récurrentes,
domaines) est conservée d'une version à l'autre.
### Domaines EasyVista (interne / externe)
- Défaut hardcodé conservé comme **filet de sécurité** au 1er install
(`https://itsma.etat-de-vaud.ch` + `https://itsma.vd.ch`).
- L'utilisateur peut les remplacer dans Paramètres → EasyVista. Le
service worker (`findEasyVistaSession`, `evFetch`, etc.) lit la valeur
effective via `getEvOrigins()`.
### Group ID EasyVista
- Défaut hardcodé conservé comme filet de sécurité (`191` = SI-CSS).
- Lu via `getGroupId()` dans `fetchPlanningXml`, `detectTeamFromEV` et
partout où c'était hardcodé.
- Le sélecteur de Paramètres → Équipe alimente `cfg.groupId`.
### Liste des techniciens
- **Aucun défaut hardcodé**. Sur un install vierge, `cfg.team` est
vide → le service worker lève `no_team_configured` plutôt que de
fetcher avec une liste fictive.
- Le viewer affiche : *"Aucun technicien sélectionné. Ouvrez ⚙
Paramètres → Équipe pour choisir le groupe EasyVista et cocher les
techniciens à afficher."*
- Lu via `getSupportIds()` (CSV des clés de `cfg.team`).
- Côté `viewer.js` : `TEAM` et `RECURRING_ABSENCES` sont des `let`
vides au démarrage, repeuplés par `_initTeamFromConfig()` appelé tôt
dans `init()`.
### Coulisses
- Nouveau dans `background.js` : helpers `getAdminConfig()`,
`getEvOrigins()`, `getGroupId()`, `getSupportIds()`,
`getDayBounds()` qui centralisent la lecture de la config persistée.
- `fetchPlanningXml()` lève `Error("no_team_configured")` quand la
liste de techs est vide ; le handler `fetchPlanning` propage l'erreur
au viewer via `err.kind`.
- Toutes les anciennes constantes hardcodées (`EV_ORIGINS`,
`DEFAULT_SUPPORT_IDS` interne à `detectTeamFromEV`,
`isXXXAbsentFriday`) ont été remplacées ou retirées.
### Conflits absence/réservation × intervention
- Nouveau code visuel : si une intervention est planifiée pendant
qu'un tech a une **absence** (toute la journée ou demi-journée) ou
une **réservation** sur le même créneau, sa carte (row classique +
mini-card en vue horizontale) est peinte en **rouge plein** avec
texte blanc. Logique : full-day → toutes les interv en rouge ;
partiel → seules celles en chevauchement.
### Synchronisation des heures EV ↔ admin
- Les paramètres `day_start_hour` / `day_end_hour` envoyés à
`planning_xhr.php` et `begin_hour` / `end_hour` envoyés à
`plan_set_holidays_popup.php` (création absence) et
`plan_set_tech_planif_popup.php` (douchette) lisent désormais
`cfg.dayStart` / `cfg.dayEnd` (Paramètres → Apparence → Heures de la
journée). Avant : `8` / `18` / `19` figés en dur, ce qui rendait le
réglage des heures côté UI partiellement effectif (la timeline se
redessinait, mais les requêtes EV continuaient sur la plage hardcodée).
### Édition des domaines EV → permissions runtime
- `manifest.json` : ajout de `"optional_host_permissions":
["https://*/*"]` pour permettre l'édition des domaines EasyVista
vers des origines non prévues à l'install.
- Quand l'utilisateur saisit un domaine custom dans Paramètres →
EasyVista et clique sur Enregistrer, l'extension appelle
`chrome.permissions.request()` pour demander la permission au
navigateur. Si refus → toast d'avertissement, les fetches échoueront
jusqu'à acceptation.
- Les deux domaines hardcodés (`itsma.etat-de-vaud.ch` +
`itsma.vd.ch`) restent dans `host_permissions` (toujours accordés à
l'install), pas besoin de redemander la permission pour eux.
## v2026.5.40 — Vue horizontale enrichie (ref + ville + barre couleur)
**Branche** : current
- En vue horizontale, chaque segment timeline d'intervention contient
désormais :
- Une **barre verticale couleur catégorie** à gauche (mêmes teintes que
les `.intervention-dot` de la vue classique : livraison/recup/
remplacement/incident/rollout/réservation/autre)
- La **référence** (ex: `SYYMMDD_NNNNN`) en gras
- La **ville** en gris muted
- Hauteur de la timeline en horizontale passée de 22px à 32px pour laisser
la place au texte
- Fond des segments d'intervention : `--bg-elevated` neutre + bordure 1px
pour que le texte reste lisible (la couleur catégorie n'est plus en fond
plein, juste en barre gauche)
- Vue classique inchangée
- Réorganisation interne du repo : `src/` pour les sources, `dist/`
généré, `Autres/` pour build.sh + meta files (LICENSE, README,
CHANGELOG)
## v2026.5.39 — Séparation Matin / Après-midi + Apparence (thème, zoom, cache) ## v2026.5.39 — Séparation Matin / Après-midi + Apparence (thème, zoom, cache)
**Branche** : current
### Séparation matin / après-midi ### Séparation matin / après-midi
- Séparateur visuel "MATIN" / "APRÈS-MIDI" entre les interventions - Séparateur visuel "MATIN" / "APRÈS-MIDI" entre les interventions
@@ -209,12 +286,12 @@
- Stats rapides .tech-row-stats ajoutés au header (nb interv, Xm · Ya) - Stats rapides .tech-row-stats ajoutés au header (nb interv, Xm · Ya)
## v2026.5.31 — Sarcelle pour absence récurrente (REJETÉ par utilisateur) ## v2026.5.31 — Sarcelle pour absence récurrente (REJETÉ par utilisateur)
- Couleur Pillonel vendredi : sarcelle foncée #0f766e / soft #ccfbf1 - Couleur absence récurrente (jour fixe) : sarcelle foncée #0f766e / soft #ccfbf1
- Variables --c-recurring, --c-recurring-soft - Variables --c-recurring, --c-recurring-soft
- Layout 4 colonnes forcées + scroll interne cartes (REJETÉ : "scroll en continu") - Layout 4 colonnes forcées + scroll interne cartes (REJETÉ : "scroll en continu")
## v2026.5.30 — Absence récurrente cyan + mode compact 24" ## v2026.5.30 — Absence récurrente cyan + mode compact 24"
- Absence récurrente Pillonel vendredi en cyan - Absences récurrentes (configurées par tech) en cyan
- Mode compact @media (max-width: 1920px) avec grid-template-columns: repeat(4, 1fr) - Mode compact @media (max-width: 1920px) avec grid-template-columns: repeat(4, 1fr)
## v2026.5.29 — Contraste++ + footer ## v2026.5.29 — Contraste++ + footer
@@ -298,8 +375,8 @@
## Notes techniques persistantes (toutes versions) ## Notes techniques persistantes (toutes versions)
- 8 techs hardcodés : "76272,83725,66635,92235,90070,40944,72485,86874" - 8 techs hardcodés à l'origine (depuis v2026.5.41 : retirés, alimentés par admin_config)
- Pillonel Olivier (ID 40944) absent tous les vendredis (hardcodé) - Absences récurrentes (un tech absent un jour fixe par semaine) hardcodées à l'origine, depuis v2026.5.41 configurables via Paramètres → Équipe
- Group ID EasyVista : 191 - Group ID EasyVista : 191
- Domaines cibles : itsma.etat-de-vaud.ch (interne), itsma.vd.ch (externe) - Domaines cibles : itsma.etat-de-vaud.ch (interne), itsma.vd.ch (externe)
- SSO : Canton ForgeRock OpenAM - SSO : Canton ForgeRock OpenAM
@@ -314,4 +391,4 @@
**Quentin Rouiller** (QRO) **Quentin Rouiller** (QRO)
Technicien DGNSI — Canton de Vaud Technicien DGNSI — Canton de Vaud
Email pour commits Git : `quentin.rouiller@ikmail.com` Contact : voir [page wiki Contact](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Contact)
+192
View File
@@ -0,0 +1,192 @@
# CLAUDE.md — Workflow de développement Planification
> **À lire avant toute modification.** Ce fichier décrit le processus complet
> que Claude doit suivre quand Quentin demande une modification de l'extension.
---
## Stack du projet
- **Type** : extension navigateur Manifest V3 (Chrome / Edge / Firefox 140+)
- **Fonction** : viewer du planning des techniciens DGNSI dans EasyVista
- **Cible utilisateurs** : techniciens DGNSI (Canton de Vaud)
- **Auteur** : Quentin Rouiller (email dans la mémoire Claude `user_role.md`)
- **Repo Gitea** : https://gitea.netaplaid.ch/FroSteel/Planification
- **Config runtime** : `chrome.storage.local["admin_config"]` (persiste entre updates)
## Structure du repo
```
src/ # Sources de l'extension (chargées par le navigateur)
├── manifest.json # Manifest V3 — version YYYY.M.PATCH
├── background.js # Service worker
├── viewer.{html,js,css}
└── icons/
Autres/ # Méta + build
├── build.sh # Génère dist/{chromium,firefox}/, .zip, .xpi, met à jour firefox-updates.json
├── CHANGELOG.md # Synchronisé avec le CHANGELOG.md à la racine
├── README.md # Synchronisé avec le README.md à la racine
└── LICENSE
Builds/ # Artefacts distribués (Chromium/, Firefox/, .zip, .xpi)
dist/ # Sortie de build (gitignoré)
firefox-updates.json # Manifest d'auto-update Firefox (servi via update_url)
CLAUDE.md # Ce fichier
CHANGELOG.md # Source de vérité du changelog (lu par AMO + GitHub-style)
README.md # Source de vérité du README
```
> **NB** : le repo Gitea utilise un **layout flat à la racine** pour le code
> source historique (`build.sh`, `README.md`, `CHANGELOG.md`, `LICENSE`,
> `firefox-updates.json` à la racine, et `src/` pour le code). En local,
> le dossier `Autres/` contient une copie de ces fichiers — tu peux éditer
> l'un ou l'autre, mais quand tu pousses sur Gitea c'est la racine qui doit
> être mise à jour.
---
## Workflow standard d'une demande de modification
Quand Quentin demande une nouvelle fonctionnalité ou un bugfix :
### Phase 1 — Code + build local (toi seul, pas encore de push)
1. **Comprendre la demande**, poser des questions si nécessaire avant d'écrire du code.
2. **Coder les modifications** dans `src/` (jamais directement dans `dist/` ou `Builds/`).
3. **Bumper la version** : incrémenter le 3e chiffre dans `src/manifest.json`
(ex: `2026.5.41``2026.5.42`). Bump majeur (2e chiffre) seulement pour
les refontes ; année (1er chiffre) au passage à 2027.
4. **Mettre à jour le CHANGELOG** (`Autres/CHANGELOG.md` ET la copie racine
`CHANGELOG.md`) en ajoutant une nouvelle entrée en haut.
5. **Mettre à jour le README** (`Autres/README.md` ET racine `README.md`)
si la nouvelle version touche aux fonctionnalités principales.
6. **Builder** : `./Autres/build.sh` — ça produit `dist/chromium/`,
`dist/firefox/`, le `.zip` et le `.xpi` avec la nouvelle version.
7. **Annoncer à Quentin** : "v2026.5.X buildée, recharge l'extension dans
ton navigateur et teste". Décrire brièvement ce qui a changé visuellement.
8. **Attendre son retour**. Tant qu'il n'a pas dit "OK", ne pas pousser sur
Gitea. Si correction demandée, retourner à l'étape 2.
### Phase 2 — Push sur Gitea (uniquement après validation explicite)
Quand Quentin dit "OK push" / "valide" / équivalent :
1. **Préparer un clone Gitea à jour** dans `/tmp/planif-push/` (clone si pas
présent, sinon `git fetch origin && git reset --hard origin/main`).
2. **Synchroniser** :
- `rsync -a --delete /Users/quentin/Documents/Planning/src/ /tmp/planif-push/src/`
- Copier les versions racine de `CHANGELOG.md`, `README.md`, `LICENSE`,
`build.sh` (les versions racine sur Gitea, pas celles de `Autres/`)
3. **Régénérer `firefox-updates.json`** à la racine du repo : ajouter
l'entrée de la nouvelle version en haut de la liste `updates` (les
anciennes entrées restent — Firefox prend la version la plus haute
parmi celles listées). Le `update_link` pointe vers la release Gitea :
`https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/vYYYY.M.PATCH/planification-vYYYY.M.PATCH-firefox.xpi`.
Le `update_hash` est calculé après signature AMO (cf. Phase 3).
Le repo Gitea est **public**, donc l'URL fixe `update_url` =
`https://gitea.netaplaid.ch/FroSteel/Planification/raw/branch/main/firefox-updates.json`
est accessible sans auth → Firefox peut le fetcher directement.
`build.sh` maintient automatiquement ce JSON à chaque build (ajoute /
met à jour l'entrée de la version courante).
4. **Commit + push** :
```bash
cd /tmp/planif-push
git add -A
git commit -m "vYYYY.M.PATCH — <description courte>"
git push origin main
git tag -a vYYYY.M.PATCH -m "vYYYY.M.PATCH"
git push origin vYYYY.M.PATCH
```
5. **Créer la release Gitea** via l'API (POST
`/repos/FroSteel/Planification/releases`) avec :
- `tag_name`: `vYYYY.M.PATCH`
- `name`: `vYYYY.M.PATCH`
- `body`: extrait du CHANGELOG (la section de cette version)
6. **Uploader les binaires** comme assets de la release :
- `dist/planification-vYYYY.M.PATCH-chromium.zip`
- `dist/planification-vYYYY.M.PATCH-firefox.xpi` (NON signé pour le moment)
7. **Mettre à jour le wiki Gitea** :
- Page **Versions** : ajouter une entrée détaillée en haut (dérivée du CHANGELOG)
- Page **Utilisation** : si le changement modifie l'UX (ajout d'un bouton,
d'une section admin, d'un comportement) → documenter
- Page **Architecture** : si nouvelles fonctions clés / nouvelle config
persistée → documenter
### Phase 3 — Signature Firefox (manuel, fait par Quentin)
C'est la seule étape que Claude ne peut pas automatiser :
1. Quentin va sur https://addons.mozilla.org/developers/
2. Submit New Version → uploade le `.xpi` non signé de la release Gitea
3. Choisit **"On your own"** (Unlisted, self-distributed)
4. Mozilla signe → Quentin télécharge le `.xpi` signé
Quentin revient ensuite avec le `.xpi` signé et demande "remplace par le signé".
À ce moment Claude fait :
1. Remplacer l'asset `.xpi` de la release Gitea (delete + upload)
2. Calculer le `sha256` du `.xpi` signé
3. Mettre à jour `firefox-updates.json` : ajouter `"update_hash": "sha256:<hash>"`
4. Commit + push le JSON mis à jour
À partir de ce moment, l'auto-update Firefox fonctionne pour cette version.
---
## Token Gitea
⚠️ **Le token API Gitea ne doit JAMAIS apparaître dans ce fichier ni dans le
repo Gitea**. Il est stocké uniquement dans la mémoire Claude locale
(`~/.claude/projects/-Users-quentin-Documents-Planning/memory/gitea_token.md`,
hors repo). Si Claude perd la mémoire (nouvelle session non héritée),
demander à Quentin de redonner le token.
Header API à utiliser : `Authorization: token <TOKEN>` + `User-Agent: curl/8.4.0`
(le User-Agent évite que Cloudflare bloque les requêtes Python urllib).
---
## Règles importantes
- **Ne jamais hardcoder** dans `src/` : groupe EV, équipe, domaines, absences
récurrentes. Tout passe par `admin_config`. Les seuls hardcodes acceptables
sont les **filets de sécurité** (DEFAULT_GROUP_ID, DEFAULT_EV_ORIGINS pour
le 1er install). Cf. v2026.5.41 pour la migration complète.
- **Ne jamais pousser sur Gitea sans validation explicite** de Quentin.
- **Toujours bumper la version** avant un push qui modifie le code.
- **Toujours mettre à jour le CHANGELOG** avant un push.
- **Tags non touchés** sur Gitea : `v1.0.0`-`v3.3.0`, `v4.1.x`-`v4.3.0`,
`v5.0.0`, `v2026.5.27`-`v2026.5.32` (ceux-là pointent vers du code
reconstitué historique, ne jamais les bouger).
- **Force-push uniquement si Quentin le demande explicitement.**
- **L'email de l'auteur** ne doit apparaître nulle part dans `src/` ni dans
les fichiers Markdown du repo (CLAUDE.md, README.md inclus). Il est stocké
uniquement en mémoire Claude (`user_role.md` / `gitea_token.md`) et exposé
obfusqué (entités HTML) sur la page wiki Contact.
---
## Pages wiki Gitea
| Page | Contenu | Quand mettre à jour |
|---|---|---|
| **Home** | Pitch, contexte, démarrage rapide | Rarement |
| **Utilisation** | Guide complet pour l'utilisateur | À chaque changement UX |
| **Versions** | Historique détaillé des versions | À chaque release |
| **Architecture** | Doc technique (fonctions, config, structure) | À chaque ajout d'helper / changement structurel |
| **Contact** | Email obfusqué + lien Issue Gitea | Rarement |
URL de base wiki : `https://gitea.netaplaid.ch/FroSteel/Planification/wiki/<NOM>`
Endpoint API : `/api/v1/repos/FroSteel/Planification/wiki/page/<NOM>` (PATCH avec
`content_base64`).
---
## Pour résumer ton rôle, Claude
Quentin demande une modif → tu codes → tu builds → il teste → il valide →
tu push tout (Gitea + wiki + firefox-updates.json). Plus tard il revient avec
le `.xpi` signé d'AMO → tu mets à jour la release et le `update_hash` du JSON.
Si tu hésites sur quoi faire à un moment, **demande**. Ne suppose pas.
+111 -55
View File
@@ -7,7 +7,8 @@ Extension Chrome / Firefox pour visualiser de manière claire et rapide le plann
- **Auteur** : Quentin Rouiller (QRO) - **Auteur** : Quentin Rouiller (QRO)
- **Cible** : techniciens DGNSI (Canton de Vaud), EasyVista (`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) - **Cible** : techniciens DGNSI (Canton de Vaud), EasyVista (`itsma.etat-de-vaud.ch` / `itsma.vd.ch`)
- **Démarrage projet** : jeudi 16 avril 2026 - **Démarrage projet** : jeudi 16 avril 2026
- **Version actuelle** : `v2026.5.40` - **Version actuelle** : `v2026.5.43`
- **Contact** : voir [page wiki Contact](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Contact)
- **Manifest** : V3 (Chrome/Edge/Firefox) - **Manifest** : V3 (Chrome/Edge/Firefox)
- **Format** : `.zip` (Chromium) + `.xpi` signé (Firefox) - **Format** : `.zip` (Chromium) + `.xpi` signé (Firefox)
@@ -16,12 +17,13 @@ Extension Chrome / Firefox pour visualiser de manière claire et rapide le plann
### Vue planning ### Vue planning
- Affichage des interventions et réservations groupées par technicien - Affichage des interventions et réservations groupées par technicien
- Horaires, contact, lieu, catégorie, statut visibles d'un coup d'œil - Horaires, contact, lieu, catégorie, statut visibles d'un coup d'œil
- 8 techniciens hardcodés (équipe IT canton) - Équipe configurable depuis le panel admin (détection automatique via EasyVista)
- Cache local pour réduire les requêtes serveur - Cache local pour réduire les requêtes serveur
### Modes d'affichage ### Modes d'affichage
- **Vue classique** (depuis v1.0.0) : cards en grille, mode compact écran 24" (depuis v2026.5.30) - **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) - **Vue horizontale** (depuis v2026.5.32) : timeline par tech, sidebar verticale (depuis v2026.5.36)
- Depuis v2026.5.40 : barre couleur catégorie + référence + ville sur chaque segment timeline
- Toggle Vue classique ↔ Vue horizontale via bouton ⊞ dans popup user-badge - Toggle Vue classique ↔ Vue horizontale via bouton ⊞ dans popup user-badge
- Persistance localStorage (`view_mode`) - Persistance localStorage (`view_mode`)
@@ -38,7 +40,7 @@ Extension Chrome / Firefox pour visualiser de manière claire et rapide le plann
- **Congé / Congés** : cyan `#06b6d4` (suffixe `s` adaptatif) - **Congé / Congés** : cyan `#06b6d4` (suffixe `s` adaptatif)
- **Pompier** : rouge `#b03030` - **Pompier** : rouge `#b03030`
- Badge + barre gauche colorée + dégradé fond - Badge + barre gauche colorée + dégradé fond
- Absence récurrente Pillonel vendredi : cyan (depuis v2026.5.30) - Absences récurrentes (configurées par tech) : cyan (depuis v2026.5.30)
### User et session ### User et session
- Badge user avec photo/initiales en topbar - Badge user avec photo/initiales en topbar
@@ -48,9 +50,11 @@ Extension Chrome / Firefox pour visualiser de manière claire et rapide le plann
- Reconnexion automatique - Reconnexion automatique
### Admin et configuration ### 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) - Mode admin : bouton ⚙ Paramètres dans popup user-badge (depuis v2026.5.25)
- Configuration persistée dans `localStorage` (`admin_config`) - Configuration persistée dans `chrome.storage.local` (`admin_config`)
- Catégories interventions personnalisables (livraison/recup/remplacement/incident/rollout/reservation/autre) - Sélecteur de groupe EasyVista (SI-CSS, SI-EXT, …) en tête de l'onglet Équipe (depuis v2026.5.40) — détection automatique via le `<select id="plan_group_id">` de la page Planning EV, robuste aux ajouts/renommages côté EV
- Édition manuelle des domaines EasyVista interne / externe (depuis v2026.5.40)
- Tri des techniciens : actifs d'abord, puis exclus, alphabétique dans chaque groupe (depuis v2026.5.40)
## Versionning — historique et conventions ## Versionning — historique et conventions
@@ -60,7 +64,7 @@ L'extension a connu **3 systèmes de versionning successifs** :
|---|---|---| |---|---|---|
| 16-17 avril 2026 | Versions de base | `1.0.0`, `2.0.0`, `3.0.0` | | 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` | | 18-20 avril 2026 | SemVer classique | `4.1.3`, `4.2.8`, `5.0.12` |
| 21 avril 2026 → maintenant | **`ANNÉE.MAJEURE.PATCH`** | `2026.5.16``2026.5.37` | | 21 avril 2026 → maintenant | **`ANNÉE.MAJEURE.PATCH`** | `2026.5.16``2026.5.40` |
### Format actuel : `ANNÉE.MAJEURE.PATCH` ### Format actuel : `ANNÉE.MAJEURE.PATCH`
@@ -70,11 +74,11 @@ L'extension a connu **3 systèmes de versionning successifs** :
|---|---|---| |---|---|---|
| `2026` | **Année** | À chaque nouvelle année calendaire | | `2026` | **Année** | À chaque nouvelle année calendaire |
| `5` | **Majeure** | À chaque **gros changement / ajout important** (refonte, nouvelle feature majeure, bump volontaire) | | `5` | **Majeure** | À chaque **gros changement / ajout important** (refonte, nouvelle feature majeure, bump volontaire) |
| `37` | **Patch** | À **chaque livraison** dans la majeure courante (corrections, ajustements, petites features) | | `40` | **Patch** | À **chaque livraison** dans la majeure courante (corrections, ajustements, petites features) |
Exemples : Exemples :
- `2026.5.16``2026.5.17` : petite correction ou ajustement (patch) - `2026.5.16``2026.5.17` : petite correction ou ajustement (patch)
- `2026.5.37``2026.6.0` : refonte majeure (par exemple nouvelle vue, nouvelle architecture) - `2026.5.40``2026.6.0` : refonte majeure (par exemple nouvelle vue, nouvelle architecture)
- `2026.x.y``2027.0.0` : passage à la nouvelle année - `2026.x.y``2027.0.0` : passage à la nouvelle année
Le numéro de **majeure** n'est **pas** un mois et **pas** un chiffre lié au calendrier — c'est un compteur de versions importantes propre au projet (la `5` actuelle continue le `5.x` qui précédait, repris tel quel lors du passage au format annuel). Le numéro de **majeure** n'est **pas** un mois et **pas** un chiffre lié au calendrier — c'est un compteur de versions importantes propre au projet (la `5` actuelle continue le `5.x` qui précédait, repris tel quel lors du passage au format annuel).
@@ -83,49 +87,68 @@ Le numéro de **majeure** n'est **pas** un mois et **pas** un chiffre lié au ca
## Versions notables ## Versions notables
### `v2026.5.40` (latest, 27 avril 2026) — Sélection groupe EV + édition domaines + tri équipe + vue horizontale enrichie ### `v2026.5.43` (latest, 27 avril 2026) — Fix Firefox : menu dock + stabilité popup pin/unpin
- **Onglet Équipe** : sélecteur de groupe EasyVista (SI-CSS, SI-EXT, …) en tête de section, détecté automatiquement via le `<select id="plan_group_id">` de la page Planning EV. Robuste aux ajouts/renommages côté EV. - Firefox : le menu hover sur les pastilles du dock (popup réduit) se
positionne désormais correctement au-dessus de la pastille.
- Pin/unpin : la popup épinglée ne bouge plus et garde la même taille
quand on la dé-épingle / re-épingle.
### `v2026.5.42` — Nettoyage de commentaires + exemples génériques
- Uniformisation des exemples utilisés dans les commentaires de `viewer.js`
(parsing contacts/lieux/références/codes-barres) en placeholders abstraits.
Comportement runtime strictement inchangé.
### `v2026.5.41` — Suppression des hardcodes + UX admin + thème unifié
- **Plus aucun hardcode runtime** pour le groupe EV, les domaines, la liste de techniciens ou les absences récurrentes. Tout est piloté par `admin_config` (chrome.storage.local), persisté entre les mises à jour.
- **Au 1er install** : aucun tech sélectionné, aucune absence récurrente. Le viewer affiche un message *"Aucun technicien sélectionné"* tant que l'utilisateur n'a rien configuré dans Paramètres → Équipe.
- **Édition des domaines** : `chrome.permissions.request()` au save quand l'utilisateur saisit un domaine custom (au-delà des 2 défauts). Manifest `optional_host_permissions: ["https://*/*"]` pour accepter n'importe quel domaine HTTPS après accord du navigateur.
- **Heures de la journée** : bouton ✓ Appliquer explicite (au lieu de save direct), toast de confirmation, refetch automatique du planning. Synchronisation effective avec les requêtes EV (`day_start_hour` / `day_end_hour` / `begin_hour` / `end_hour`) — avant, l'affichage changeait mais les requêtes restaient sur 8h-19h hardcodés.
- **Thème unifié** : le toggle 🌙 de la topbar et le sélecteur Apparence du panel admin écrivent dans la même clé (`cfg.theme`). Le mode "Automatique" est résolu en JS via `prefers-color-scheme` (le CSS n'avait pas de bloc `@media`, ce qui faisait retomber sur le clair même quand l'OS était en sombre). Listener `matchMedia` pour bascule live en mode auto.
- **Conflit absence/réservation × intervention** : si une intervention est planifiée pendant qu'un tech a une absence (toute la journée ou demi-journée) ou une réservation au même créneau, sa carte est peinte en **rouge plein** (intervention conflictuelle). Logique : full-day → toutes en rouge ; partiel → seules celles en chevauchement.
- **Absences récurrentes génériques** : suppression de la fonction hardcodée `isXXXAbsentFriday()`. L'absence récurrente est désormais générique : `RECURRING_ABSENCES[tech.id]` lit `cfg.recurringAbsences` et le label "Absent le X" est calculé dynamiquement depuis le jour de la semaine.
- **Notifications au-dessus du flou** : z-index `.toast-stack` relevé à 11000 (le panel admin est à 10000) pour que les toasts de feedback restent visibles quand l'admin est ouvert.
- **Vue horizontale** : popups au survol/clic limités aux candidats `dessous`/`dessus` (la sidebar à gauche et la timeline pleine largeur rendent gauche/droite peu praticables).
- **Tri équipe** : inclus d'abord, puis exclus, alphabétique dans chaque sous-groupe (ne saute plus quand on coche/décoche).
- **Auto-refresh à l'enregistrement** : ajouter/retirer un tech, changer de groupe, modifier les domaines → le planning se met à jour immédiatement (plus besoin de recharger l'extension manuellement).
- **Onglet Statuts retiré** (placeholder lecture-seule, jamais utilisé).
- **Ménage de code** : suppression de `CACHE_DAYS` (inutilisée), `LS_THEME` (clé localStorage obsolète), commentaire historique sur `initAdminMenu()`. Aucun symbole orphelin restant.
### `v2026.5.40` — Sélection groupe EV + édition domaines + tri équipe + vue horizontale enrichie
- **Onglet Équipe** : sélecteur de groupe EasyVista (SI-CSS, SI-EXT, …) en tête de section, détecté automatiquement à l'ouverture du panel via le `<select id="plan_group_id">` de la page Planning EV. Robuste aux ajouts/renommages côté EV.
- **Onglet Équipe** : sélecteur de groupe EasyVista (SI-CSS, SI-EXT, …) en tête de section, détecté automatiquement à l'ouverture du panel via le `<select id="plan_group_id">` de la page Planning EV. Robuste aux ajouts/renommages côté EV.
- ID groupe affiché en italique (ex: `ID groupe : 191`). - ID groupe affiché en italique (ex: `ID groupe : 191`).
- Refresh auto de la liste d'équipe au changement de groupe. - Quand on change de groupe, la liste d'équipe se rafraîchit automatiquement avec les membres du nouveau groupe.
- Plus de bouton "Détecter" (tout est auto à l'ouverture). - Plus de bouton "Détecter" : tout est auto à l'ouverture.
- Tri double : inclus d'abord, puis exclus, alphabétique dans chaque sous-groupe. - Tri double des techniciens : inclus d'abord, puis exclus, alphabétique dans chaque sous-groupe.
- **Onglet EasyVista** : édition manuelle des deux domaines (interne / externe), bouton Réinitialiser, normalisation auto des URLs. - **Onglet EasyVista** : édition manuelle des deux domaines (interne DGNSI / externe Internet), bouton Réinitialiser, normalisation auto des URLs.
- **Onglet Statuts retiré** (placeholder lecture seule). - **Onglet Statuts retiré** (placeholder lecture-seule).
- **Vue horizontale enrichie** : barre verticale couleur catégorie, référence en gras, ville en gris muted, hauteur 22→32px. - **Vue horizontale enrichie** : chaque segment timeline contient désormais une barre verticale couleur catégorie à gauche, la référence (ex: `SYYMMDD_NNNNN`) en gras, et la ville en gris muted. Hauteur passée de 22px à 32px.
- Coulisses : nouveau message `detectGroups`, fonction `detectGroupsFromEV()` côté `background.js`. `detectTeamFromEV()` accepte un groupId en argument. - **Réorganisation interne du repo** : `src/` pour les sources, `dist/` généré, `Autres/` pour build.sh + meta files (LICENSE, README, CHANGELOG), `Builds/` pour les artefacts distribués.
### `v2026.5.39` — Séparation Matin / Après-midi + Apparence ### `v2026.5.39` — Séparation Matin / Après-midi + Apparence
- Pills "MATIN" / "APRÈS-MIDI" entre les interventions - Pills "MATIN" / "APRÈS-MIDI" entre les interventions de chaque tech
- Section Apparence (thème, taille du texte, cache, heures de la journée) - Section **Apparence** dans les paramètres : thème, taille du texte, durée du cache, heures de la journée
- Section À propos (version, auteur, licence) - Section **À propos** (version, auteur, licence)
### `v2026.5.38` — Attribution auteur + nettoyage + observabilité ### `v2026.5.38` — Attribution auteur + nettoyage + observabilité
- Module `LOG` unifié + handlers globaux d'erreur - Module `LOG` unifié + handlers globaux d'erreur
- Toggle "Logs verbeux (debug)" dans le panel admin - Toggle "Logs verbeux (debug)" dans le panel admin
- En-têtes copyright dans tous les fichiers source
### `v2026.5.37` — Refonte vue horizontale ### `v2026.5.37` — Refonte vue horizontale (sidebar complète)
- Topbar supprimée en vue horizontale, tout passe en sidebar - Topbar entièrement déplacée en sidebar verticale
- 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 ### `v2026.5.36` — Sidebar verticale en vue horizontale
- Wrapper flex-row `#horizontal-wrapper` [sidebar 200px] + [main] - 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 ### `v2026.5.32` — Vue horizontale togglable
- Bouton ⊞ "Vue" dans popup user-badge - Bouton ⊞ "Vue" dans popup user-badge
- Chaque tech = 1 ligne horizontale compacte
- localStorage `view_mode`
### `v2026.5.27` — Classification absences ### `v2026.5.27` — Classification absences
- ABSENCE_LABELS : `^(cong[ée]s|maladie|pompier)$` - Maladie indigo, Congé cyan, Pompier rouge
- Couleurs catégories
- Topbar une ligne : "Jeudi 23.04.26 • 21:55"
### `v4.2.3` — Grande popup timeline persistante ### `v4.2.3` — Grande popup timeline persistante
- Clic segment timeline = popup persistante - Clic segment timeline = popup persistante
- Hover = popup qui suit la souris
### `v4.1.3` — Tooltips épinglables ### `v4.1.3` — Tooltips épinglables
- Introduction de `pinTooltip` - Introduction de `pinTooltip`
@@ -133,17 +156,30 @@ Le numéro de **majeure** n'est **pas** un mois et **pas** un chiffre lié au ca
### `v1.0.0` (16 avril 2026) — Initiale ### `v1.0.0` (16 avril 2026) — Initiale
- Premier viewer EasyVista pour le canton - Premier viewer EasyVista pour le canton
Voir [CHANGELOG.md](CHANGELOG.md) pour l'historique complet (40 versions taggées). Voir [CHANGELOG.md](CHANGELOG.md) pour l'historique complet (40+ versions taggées).
## Architecture technique ## Architecture technique
``` ```
manifest.json # Manifest V3 (Chrome) + gecko_settings (Firefox) Planning/
background.js # Worker fond : fetch planning XML, gestion session, fetch fiches ├── src/ # Sources de l'extension (chargées par le navigateur)
viewer.html # Interface principale │ ├── manifest.json # Manifest V3 (Chrome) + gecko_settings (Firefox)
viewer.js # Logique (~9000 lignes) — voir détail ci-dessous │ ├── background.js # Service worker : fetch planning XML, gestion session, fetch fiches
viewer.css # Styles + thèmes clair/sombre │ ├── viewer.html # Interface principale
icons/ # icon16, icon48, icon128 │ ├── viewer.js # Logique (~9 500 lignes)
│ ├── viewer.css # Styles + thèmes clair/sombre
│ └── icons/ # icon16, icon48, icon128
├── Autres/ # Méta : build script + docs (depuis v2026.5.40)
│ ├── build.sh # Génère dist/chromium/, dist/firefox/, .zip, .xpi
│ ├── CHANGELOG.md
│ ├── LICENSE
│ └── README.md
├── Builds/ # Artefacts distribués aux techniciens
│ ├── Chromium/
│ ├── Firefox/
│ ├── planification-vYYYY.M.PATCH-chromium.zip
│ └── planification-vYYYY.M.PATCH-firefox.xpi
└── dist/ # Sortie de build (gitignoré)
``` ```
### `viewer.js` — fonctions clés ### `viewer.js` — fonctions clés
@@ -160,30 +196,42 @@ icons/ # icon16, icon48, icon128
| `_softUnpinPopup` | v4.3.3 | Désépinglage mou (popup reste visible) | | `_softUnpinPopup` | v4.3.3 | Désépinglage mou (popup reste visible) |
| `initAppClock` | v5.0.0 | Horloge HH:MM topbar | | `initAppClock` | v5.0.0 | Horloge HH:MM topbar |
| `initSessionTimer` | v5.0.0 | Compteur session EV (tick 1s) | | `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 | | `_applyViewMode` | v2026.5.32 | Toggle vue classique/horizontale |
| `_maybeRetryFetchUser` | v2026.5.34 | Relance opportuniste fetch user | | `_maybeRetryFetchUser` | v2026.5.34 | Relance opportuniste fetch user |
| `positionTooltipAnchored` | v2026.5.34 | Positionnement unifié (4 candidats) | | `positionTooltipAnchored` | v2026.5.34 | Positionnement unifié (4 candidats) |
| `renderAdminSectionTeam` | v5.0.0 | Onglet admin Équipe (sélecteur groupe EV depuis v2026.5.40) |
| `renderAdminSectionEV` | v5.0.0 | Onglet admin EasyVista (édition domaines depuis v2026.5.40) |
### Constantes persistantes (toutes versions) ### `background.js` — fonctions clés
- 8 techs hardcodés : `76272,83725,66635,92235,90070,40944,72485,86874` | Fonction | Rôle |
- Pillonel Olivier (ID 40944) : absent tous les vendredis (hardcodé) |---|---|
- Group ID EasyVista : `191` | `findEasyVistaSession` | Trouve l'onglet EV ouvert + extrait PHPSESSID |
| `fetchPlanningXml` | Fetch XML calendar_block du planning |
| `fetchFicheHtml` | Fetch HTML d'une fiche EV (avec retry SSO) |
| `fetchCurrentUser` | Identifie l'user EV connecté |
| `detectGroupsFromEV` (v2026.5.40) | Parse le `<select id="plan_group_id">` → liste des groupes EV |
| `detectTeamFromEV` | Liste les membres d'un groupe (paramétrable depuis v2026.5.40) |
| `evFetch` | Wrapper fetch avec headers EV (Referer, X-Requested-With) |
### Constantes / valeurs hardcodées (toutes versions)
- Group ID EV par défaut : `191` (SI-CSS) — surchargeable via le sélecteur depuis v2026.5.40
- Absences récurrentes par tech : configurables via Paramètres → Équipe (depuis v2026.5.41)
- GUIDs forms EV : - GUIDs forms EV :
- Demande : `S={C99ECD05-3D48-4C62-ABF0-66292053AED6}` - Demande : `S={C99ECD05-3D48-4C62-ABF0-66292053AED6}`
- Incident : `I={07ED9C68-6172-48EA-8A58-90912B0A283E}` - Incident : `I={07ED9C68-6172-48EA-8A58-90912B0A283E}`
- SSO : Canton ForgeRock OpenAM - SSO : Canton ForgeRock OpenAM
- Storage keys : `admin_config`, `view_mode` (depuis v2026.5.32) - Storage keys : `admin_config`, `view_mode`
- Domaines : `itsma.etat-de-vaud.ch` (interne), `itsma.vd.ch` (externe SSO) - Domaines : `itsma.etat-de-vaud.ch` (interne), `itsma.vd.ch` (externe)
## Installation ## Installation
### Firefox ### Firefox
Télécharger le `.xpi` signé depuis le serveur de mises à jour interne, ou drag-and-drop dans `about:addons`. Télécharger le `.xpi` depuis `Builds/` ou le serveur de mises à jour interne, puis drag-and-drop dans `about:addons`.
### Chrome / Edge ### Chrome / Edge
Mode développeur : décompresser le ZIP et charger en tant qu'extension non empaquetée. Mode développeur : décompresser `Builds/planification-vYYYY.M.PATCH-chromium.zip` (ou utiliser directement `dist/chromium/`) et charger en tant qu'extension non empaquetée.
## Développement ## Développement
@@ -191,12 +239,19 @@ Mode développeur : décompresser le ZIP et charger en tant qu'extension non emp
git clone https://gitea.netaplaid.ch/FroSteel/Planification.git git clone https://gitea.netaplaid.ch/FroSteel/Planification.git
cd Planification cd Planification
# Pour packager une nouvelle version : # Modifier les sources dans src/
# 1. modifier le code # Bumper la version dans src/manifest.json + ajouter une entrée dans Autres/CHANGELOG.md
# 2. bump version dans manifest.json # Builder :
# 3. zip + xpi ./Autres/build.sh
# → produit dist/chromium/, dist/firefox/, dist/*.zip, dist/*.xpi
# Copier dans Builds/ pour distribution :
cp -r dist/chromium Builds/Chromium
cp -r dist/firefox Builds/Firefox
cp dist/*.zip dist/*.xpi Builds/
git add -A git add -A
git commit -m "Version YYYY.M.PATCH — description" git commit -m "vYYYY.M.PATCH — description"
git tag vYYYY.M.PATCH git tag vYYYY.M.PATCH
git push origin main git push origin main
git push --tags git push --tags
@@ -210,3 +265,4 @@ git push --tags
**Quentin Rouiller** (QRO) **Quentin Rouiller** (QRO)
Technicien DGNSI — Canton de Vaud Technicien DGNSI — Canton de Vaud
Contact : voir [page wiki Contact](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Contact)
+50 -1
View File
@@ -6,6 +6,8 @@
# Usage : ./build.sh # Usage : ./build.sh
############################################################################### ###############################################################################
set -e set -e
# Le script est dans Autres/ — on remonte d'un cran pour se placer à la
# racine du projet, où se trouvent src/ et dist/.
cd "$(dirname "$0")" cd "$(dirname "$0")"
VERSION=$(python3 -c "import json; print(json.load(open('src/manifest.json'))['version'])") VERSION=$(python3 -c "import json; print(json.load(open('src/manifest.json'))['version'])")
@@ -25,12 +27,21 @@ import json
with open('src/manifest.json', 'r') as f: m = json.load(f) with open('src/manifest.json', 'r') as f: m = json.load(f)
m['browser_specific_settings'] = { m['browser_specific_settings'] = {
'gecko': { 'gecko': {
'id': 'planification@netaplaid.ch', 'id': 'planification-dgnsi@netaplaid.ch',
'strict_min_version': '140.0', 'strict_min_version': '140.0',
'update_url': 'https://gitea.netaplaid.ch/FroSteel/Planification/raw/branch/main/firefox-updates.json', 'update_url': 'https://gitea.netaplaid.ch/FroSteel/Planification/raw/branch/main/firefox-updates.json',
'data_collection_permissions': {'required': ['none']} 'data_collection_permissions': {'required': ['none']}
} }
} }
# Firefox MV3 ne supporte pas (encore) 'service_worker' → AMO rejette.
# On ajoute 'scripts' (event page) comme fallback compatible Firefox.
# Chrome ignore 'scripts' quand 'service_worker' est présent ; Firefox
# ignore 'service_worker' et utilise 'scripts'. Les deux navigateurs
# chargent ainsi le même background.js.
bg = m.get('background', {})
if 'scripts' not in bg:
bg['scripts'] = ['background.js']
m['background'] = bg
with open('dist/firefox/manifest.json', 'w') as f: with open('dist/firefox/manifest.json', 'w') as f:
json.dump(m, f, indent=2, ensure_ascii=False) json.dump(m, f, indent=2, ensure_ascii=False)
f.write('\n') f.write('\n')
@@ -44,6 +55,44 @@ cd dist/firefox && zip -rq "../planification-v${VERSION}-firefox.xpi" . && cd .
echo "" echo ""
echo "==> Builds prêts dans dist/" echo "==> Builds prêts dans dist/"
ls -la dist/*.zip dist/*.xpi 2>/dev/null ls -la dist/*.zip dist/*.xpi 2>/dev/null
# ---- firefox-updates.json : ajout/mise à jour de l'entrée pour cette version
# (sha256 du .xpi NON SIGNÉ — sera remplacé par celui du .xpi signé après AMO).
python3 - <<EOF
import json, hashlib, os
xpi = f"dist/planification-v${VERSION}-firefox.xpi"
with open(xpi, 'rb') as f: sha = hashlib.sha256(f.read()).hexdigest()
JSON_PATH = "firefox-updates.json"
ADDON_ID = "planification-dgnsi@netaplaid.ch"
update_link = f"https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v${VERSION}/planification-v${VERSION}-firefox.xpi"
if os.path.exists(JSON_PATH):
with open(JSON_PATH) as f: data = json.load(f)
else:
data = {"addons": {ADDON_ID: {"updates": []}}}
addon = data.setdefault("addons", {}).setdefault(ADDON_ID, {"updates": []})
updates = addon.setdefault("updates", [])
# Retirer toute entrée existante pour cette version (idempotent)
updates = [u for u in updates if u.get("version") != "${VERSION}"]
# Ajouter en tête
updates.insert(0, {
"version": "${VERSION}",
"update_link": update_link,
"update_hash": "sha256:" + sha,
})
addon["updates"] = updates
with open(JSON_PATH, 'w') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write('\n')
print(f" ✓ firefox-updates.json mis à jour (sha256 NON SIGNÉ : {sha[:16]}…)")
EOF
echo "" echo ""
echo "Pour Chrome : charger dist/chromium/ en mode développeur" echo "Pour Chrome : charger dist/chromium/ en mode développeur"
echo "Pour Firefox : signer dist/planification-v${VERSION}-firefox.xpi sur AMO" echo "Pour Firefox : signer dist/planification-v${VERSION}-firefox.xpi sur AMO"
echo " Après signature, remplacer le sha256 dans firefox-updates.json par celui du .xpi signé."
+28
View File
@@ -0,0 +1,28 @@
{
"addons": {
"planification-dgnsi@netaplaid.ch": {
"updates": [
{
"version": "2026.5.43",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.43/planification-v2026.5.43-firefox.xpi",
"update_hash": "sha256:9d1f0cb49b98cdb46a0e022dc53c77c98fde380590e72036e188c128d6b19965"
},
{
"version": "2026.5.42",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.42/planification-v2026.5.42-firefox.xpi",
"update_hash": "sha256:a5291a1eab6768d430dfc1cf032f93aeb788083da620ae0568b9dee68f14734d"
},
{
"version": "2026.5.41",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.41/planification-v2026.5.41-firefox.xpi",
"update_hash": "sha256:fbf7d8a57ad060306cb43f3db3a5d5b599bb07c75c4ef0dbd0346406bdb6c65b"
},
{
"version": "2026.5.40",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.40/planification-v2026.5.40-firefox.xpi",
"update_hash": "sha256:2ba0758960b931f4211c613c75bbf21b3a250572dddc70d854ff1ecca3220421"
}
]
}
}
}
+105 -27
View File
@@ -115,11 +115,74 @@ self.addEventListener("unhandledrejection", (event) => {
LOG.info("boot", "service worker démarré", { version: LOG.version() }); LOG.info("boot", "service worker démarré", { version: LOG.version() });
// Domaines EasyVista reconnus (interne d'abord, externe en fallback) // ============================================================================
const EV_ORIGINS = [ // v2026.5.41 : Configuration runtime — lue depuis admin_config (chrome.storage.local).
"https://itsma.etat-de-vaud.ch", //
"https://itsma.vd.ch" // Les domaines EV et le group_id ont des défauts (filet de sécurité 1er install).
// La liste de techniciens, elle, n'a AUCUN défaut : tant que l'utilisateur n'a
// rien coché dans Paramètres → Équipe, l'extension ne fetche aucun planning et
// invite à configurer.
//
// chrome.storage.local survit aux mises à jour d'extension → la sélection de
// l'utilisateur est conservée d'une version à l'autre.
// ============================================================================
const DEFAULT_EV_ORIGINS = [
"https://itsma.etat-de-vaud.ch", // interne DGNSI
"https://itsma.vd.ch" // externe Internet
]; ];
const DEFAULT_GROUP_ID = "191"; // SI-CSS
/**
* Lit admin_config depuis chrome.storage.local. Retourne {} si absent ou
* en cas d'erreur.
*/
async function getAdminConfig() {
try {
const stored = await chrome.storage.local.get("admin_config");
return stored.admin_config || {};
} catch (e) {
LOG.warn("config", "getAdminConfig failed, using defaults", e);
return {};
}
}
/** Origines EV à surveiller, depuis admin_config ou défaut. */
async function getEvOrigins() {
const cfg = await getAdminConfig();
const o = Array.isArray(cfg.evOrigins) ? cfg.evOrigins.filter(Boolean) : [];
return o.length >= 1 ? o : DEFAULT_EV_ORIGINS;
}
/** Group ID effectif (défaut SI-CSS). */
async function getGroupId() {
const cfg = await getAdminConfig();
return cfg.groupId || DEFAULT_GROUP_ID;
}
/**
* Support IDs effectifs (CSV des clés de cfg.team).
* Retourne "" si aucun tech sélectionné — l'appelant doit alors signaler
* à l'utilisateur d'aller configurer son équipe.
*/
async function getSupportIds() {
const cfg = await getAdminConfig();
const ids = Object.keys(cfg.team || {}).filter(Boolean);
return ids.join(",");
}
/**
* Plage horaire d'affichage (heures pleines).
* Lue depuis admin_config (Paramètres → Apparence → Heures de la journée),
* défaut 8h-18h. Utilisée pour les paramètres day_start_hour / day_end_hour /
* begin_hour / end_hour des requêtes EV. Le viewer utilise déjà ces mêmes
* valeurs pour dessiner la timeline (cf. _initDayBoundsFromConfig dans viewer.js).
*/
async function getDayBounds() {
const cfg = await getAdminConfig();
const start = (typeof cfg.dayStart === "number" && cfg.dayStart >= 0 && cfg.dayStart <= 23) ? cfg.dayStart : 8;
const end = (typeof cfg.dayEnd === "number" && cfg.dayEnd > start && cfg.dayEnd <= 24) ? cfg.dayEnd : 18;
return { start, end };
}
// ============================================================================ // ============================================================================
// Clic sur l'icône → ouvrir le viewer // Clic sur l'icône → ouvrir le viewer
@@ -147,8 +210,10 @@ chrome.action.onClicked.addListener(async () => {
* @author Quentin Rouiller * @author Quentin Rouiller
*/ */
async function findEasyVistaSession() { async function findEasyVistaSession() {
// Chercher tous les onglets sur un domaine EasyVista // v2026.5.41 : les origines EV viennent de admin_config (éditables dans
for (const origin of EV_ORIGINS) { // Paramètres → EasyVista), avec fallback sur DEFAULT_EV_ORIGINS.
const origins = await getEvOrigins();
for (const origin of origins) {
const tabs = await chrome.tabs.query({ url: origin + "/*" }); const tabs = await chrome.tabs.query({ url: origin + "/*" });
for (const tab of tabs) { for (const tab of tabs) {
const m = (tab.url || "").match(/[?&]PHPSESSID=([a-zA-Z0-9]+)/); const m = (tab.url || "").match(/[?&]PHPSESSID=([a-zA-Z0-9]+)/);
@@ -175,8 +240,20 @@ async function findEasyVistaSession() {
* @author Quentin Rouiller * @author Quentin Rouiller
*/ */
async function fetchPlanningXml(origin, phpsessid, unixDate) { async function fetchPlanningXml(origin, phpsessid, unixDate) {
const techIds = "76272,83725,66635,92235,90070,40944,72485,86874"; // v2026.5.41 : groupId vient de admin_config (défaut SI-CSS).
const groupId = "191"; // techIds vient de admin_config — si vide, on lève une erreur claire pour
// que le viewer affiche "Aucun technicien sélectionné" plutôt qu'un planning vide.
const groupId = await getGroupId();
const techIds = await getSupportIds();
if (!techIds) {
const err = new Error("no_team_configured");
err.kind = "no_team_configured";
throw err;
}
// v2026.5.41 : heures synchronisées avec admin_config (Paramètres → Apparence).
// EV utilise day_end_hour exclusif (la plage rendue va jusqu'à end-1:59),
// donc on envoie end+1 pour que la dernière heure pleine soit incluse.
const { start, end } = await getDayBounds();
const url = const url =
`${origin}/planning_xhr.php` + `${origin}/planning_xhr.php` +
`?PHPSESSID=${encodeURIComponent(phpsessid)}` + `?PHPSESSID=${encodeURIComponent(phpsessid)}` +
@@ -190,8 +267,8 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) {
`&end_date_label=Date` + `&end_date_label=Date` +
`&click_here_label=Ici` + `&click_here_label=Ici` +
`&mail_title=mail` + `&mail_title=mail` +
`&day_start_hour=8` + `&day_start_hour=${start}` +
`&day_end_hour=19`; `&day_end_hour=${end + 1}`;
// v2026.5.38 : on retire les logs verbose à chaque fetch (URL/status/taille). // v2026.5.38 : on retire les logs verbose à chaque fetch (URL/status/taille).
// En cas de souci, le throw plus bas porte assez d'info pour debug. // En cas de souci, le throw plus bas porte assez d'info pour debug.
const r = await evFetch(url, origin); const r = await evFetch(url, origin);
@@ -714,6 +791,8 @@ async function submitAbsence(origin, phpsessid, opts) {
const emplIds = (opts.techIds || []).join(","); const emplIds = (opts.techIds || []).join(",");
if (!emplIds) throw new Error("Aucun technicien sélectionné"); if (!emplIds) throw new Error("Aucun technicien sélectionné");
// v2026.5.41 : heures synchronisées avec admin_config.
const { start, end } = await getDayBounds();
const internalurltime = Math.floor(Date.now() / 1000); const internalurltime = Math.floor(Date.now() / 1000);
const url = `${origin}/include/components/staff/planning/plan_set_holidays_popup.php` const url = `${origin}/include/components/staff/planning/plan_set_holidays_popup.php`
+ `?PHPSESSID=${encodeURIComponent(phpsessid)}` + `?PHPSESSID=${encodeURIComponent(phpsessid)}`
@@ -722,8 +801,8 @@ async function submitAbsence(origin, phpsessid, opts) {
+ `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}` + `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}`
+ `&current_date=${encodeURIComponent(opts.currentDate)}` + `&current_date=${encodeURIComponent(opts.currentDate)}`
+ `&empl_ids=${encodeURIComponent(emplIds)}` + `&empl_ids=${encodeURIComponent(emplIds)}`
+ `&begin_hour=8` + `&begin_hour=${start}`
+ `&end_hour=18` + `&end_hour=${end}`
+ `&plagehoraire=0`; + `&plagehoraire=0`;
const body = new URLSearchParams(); const body = new URLSearchParams();
@@ -785,6 +864,8 @@ async function submitDouchette(origin, phpsessid, opts) {
const techIds = opts.techIds || []; const techIds = opts.techIds || [];
if (techIds.length === 0) throw new Error("Aucun technicien sélectionné"); if (techIds.length === 0) throw new Error("Aucun technicien sélectionné");
// v2026.5.41 : heures synchronisées avec admin_config.
const { start, end } = await getDayBounds();
const emplIds = techIds.join(","); const emplIds = techIds.join(",");
const internalurltime = Math.floor(Date.now() / 1000); const internalurltime = Math.floor(Date.now() / 1000);
const url = `${origin}/include/components/staff/planning/plan_set_tech_planif_popup.php` const url = `${origin}/include/components/staff/planning/plan_set_tech_planif_popup.php`
@@ -794,8 +875,8 @@ async function submitDouchette(origin, phpsessid, opts) {
+ `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}` + `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}`
+ `&current_date=${encodeURIComponent(opts.currentDate)}` + `&current_date=${encodeURIComponent(opts.currentDate)}`
+ `&empl_ids=${encodeURIComponent(emplIds)}` + `&empl_ids=${encodeURIComponent(emplIds)}`
+ `&begin_hour=8` + `&begin_hour=${start}`
+ `&end_hour=18` + `&end_hour=${end}`
+ `&plagehoraire=0`; + `&plagehoraire=0`;
const body = new URLSearchParams(); const body = new URLSearchParams();
@@ -1034,18 +1115,14 @@ async function detectGroupsFromEV(origin, phpsessid) {
* Retourne { ids: [{id, name, alreadyInTeam}], groupId }. * Retourne { ids: [{id, name, alreadyInTeam}], groupId }.
*/ */
async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) { async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
// v5.0.1 : valeurs par défaut (correspondent au groupe actuel). // v2026.5.41 : groupId vient de l'argument, sinon de admin_config (défaut SI-CSS).
// v2026.5.41 : on accepte un groupId en argument (ex: switch SI-CSS → SI-EXT). // supportIds vient de l'argument, sinon de admin_config (vide tant que rien n'est
// Quand on change de groupe, on n'a pas la liste support_ids du nouveau // sélectionné). Le serveur retourne tous les membres du groupe quoi qu'il arrive ;
// groupe → on passe une chaîne vide, le serveur retourne quand même tous // supportIds sert juste à pré-cocher les techs déjà inclus dans l'équipe.
// les membres, juste sans pré-cochage "alreadyInTeam". const groupId = groupIdArg || await getGroupId();
const DEFAULT_GROUP_ID = "191";
const DEFAULT_SUPPORT_IDS = "76272,83725,66635,92235,90070,40944,72485,86874";
const groupId = groupIdArg || DEFAULT_GROUP_ID;
const supportIds = (typeof supportIdsArg === "string") const supportIds = (typeof supportIdsArg === "string")
? supportIdsArg ? supportIdsArg
: (groupId === DEFAULT_GROUP_ID ? DEFAULT_SUPPORT_IDS : ""); : await getSupportIds();
console.log("[bg] detectTeamFromEV : group_id =", groupId, "| support_ids =", supportIds); console.log("[bg] detectTeamFromEV : group_id =", groupId, "| support_ids =", supportIds);
// Fetch la popup de sélection des intervenants du groupe // Fetch la popup de sélection des intervenants du groupe
@@ -1068,8 +1145,9 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
if (looksLikeLoginPage(popupHtml)) throw new Error("session_expired"); if (looksLikeLoginPage(popupHtml)) throw new Error("session_expired");
} catch (e) { } catch (e) {
console.warn("[bg] detectTeam: fetch popup failed:", e); console.warn("[bg] detectTeam: fetch popup failed:", e);
// Fallback : au moins on retourne les IDs connus avec noms vides // v2026.5.41 : on retourne les IDs déjà sélectionnés par l'user (s'il y en a)
const ids = DEFAULT_SUPPORT_IDS.split(",").filter(Boolean); // plutôt qu'une liste hardcodée. Si vide, le viewer affichera juste 0 résultat.
const ids = supportIds.split(",").filter(Boolean);
return { return {
ids: ids.map(id => ({ id, name: "? (" + id + ")", alreadyInTeam: true })), ids: ids.map(id => ({ id, name: "? (" + id + ")", alreadyInTeam: true })),
groupId groupId
@@ -1097,7 +1175,7 @@ async function detectTeamFromEV(origin, phpsessid, groupIdArg, supportIdsArg) {
} }
console.log("[bg] parsing pattern 1 (checkbox) :", results.length, "résultats"); console.log("[bg] parsing pattern 1 (checkbox) :", results.length, "résultats");
// Pattern 2 : fallback <option value="76272">Nom...</option> // Pattern 2 : fallback <option value="NNNNN">Nom...</option>
if (results.length === 0) { if (results.length === 0) {
const rxOption = /<option[^>]*value=["'](\d{4,7})["'][^>]*>([^<]+)<\/option>/gi; const rxOption = /<option[^>]*value=["'](\d{4,7})["'][^>]*>([^<]+)<\/option>/gi;
let mO; let mO;
+4 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Planification", "name": "Planification",
"version": "2026.5.40", "version": "2026.5.43",
"description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin Rouiller — DGNSI, Canton de Vaud.", "description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin Rouiller — DGNSI, Canton de Vaud.",
"permissions": [ "permissions": [
"activeTab", "activeTab",
@@ -14,6 +14,9 @@
"https://itsma.etat-de-vaud.ch/*", "https://itsma.etat-de-vaud.ch/*",
"https://itsma.vd.ch/*" "https://itsma.vd.ch/*"
], ],
"optional_host_permissions": [
"https://*/*"
],
"action": { "action": {
"default_title": "Ouvrir la Planification" "default_title": "Ouvrir la Planification"
}, },
+54 -9
View File
@@ -896,6 +896,43 @@ html.view-horizontal .timeline-noon {
position: relative; position: relative;
} }
/* ==========================================================================
v2026.5.41 : Conflit absence/réservation × intervention.
Quand une intervention est planifiée pendant qu'un tech est marqué
"absent" (toute la journée ou demi-journée) ou qu'il a une réservation
sur le même créneau, on signale visuellement le conflit en peignant la
carte de l'intervention en rouge plein. S'applique à la row classique
(.intervention-v2) ET à la mini-card (.iv-mini-card) en vue horizontale.
========================================================================== */
.intervention-v2.intervention-conflict-absence,
.iv-mini-card.intervention-conflict-absence {
background: #b03030 !important;
color: #ffffff !important;
border-color: #7a1f1f !important;
}
.intervention-v2.intervention-conflict-absence::before {
/* Renforce la barre gauche pour rester cohérent avec le rouge plein. */
background: #7a1f1f !important;
}
.intervention-v2.intervention-conflict-absence .intervention-dot,
.intervention-v2.intervention-conflict-absence .iv-status-check,
.intervention-v2.intervention-conflict-absence a,
.iv-mini-card.intervention-conflict-absence .iv-mini-card-bar,
.iv-mini-card.intervention-conflict-absence .iv-mini-time-vertical,
.iv-mini-card.intervention-conflict-absence .iv-mini-card-text {
color: #ffffff !important;
background: transparent !important;
}
/* La barre couleur catégorie à gauche de la mini-card devient un blanc
semi-transparent pour rester visible sur le rouge. */
.iv-mini-card.intervention-conflict-absence .iv-mini-card-bar {
background: rgba(255, 255, 255, 0.45) !important;
}
.intervention-v2.intervention-conflict-absence:hover,
.iv-mini-card.intervention-conflict-absence:hover {
background: #c44040 !important;
}
/* ========================================================================== /* ==========================================================================
Interventions — layout v2 (heures verticales) Interventions — layout v2 (heures verticales)
========================================================================== */ ========================================================================== */
@@ -1482,7 +1519,9 @@ html.view-horizontal .timeline-noon {
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
z-index: 200; /* v2026.5.41 : au-dessus du modal-overlay du panel admin (z-index 10000)
pour que les toasts de feedback restent visibles avec le flou en arrière. */
z-index: 11000;
display: flex; display: flex;
flex-direction: column-reverse; /* les nouveaux en bas, les anciens au-dessus */ flex-direction: column-reverse; /* les nouveaux en bas, les anciens au-dessus */
gap: 8px; gap: 8px;
@@ -1631,7 +1670,7 @@ html.view-horizontal .timeline-noon {
} }
/* ───────────────────────────────────────────────────────────────────────── /* ─────────────────────────────────────────────────────────────────────────
v4.1.20 : Message d'absence récurrente (Pillonel vendredi) v4.1.20 : Message d'absence récurrente (configurée par tech)
───────────────────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────────────────── */
.tech-absence-recurring { .tech-absence-recurring {
padding: 14px 12px; padding: 14px 12px;
@@ -1974,11 +2013,14 @@ body.modal-open {
z-index: 5 !important; z-index: 5 !important;
opacity: 1 !important; opacity: 1 !important;
pointer-events: auto !important; pointer-events: auto !important;
/* Pas de bordure bleue, pas de padding-top (plus de dragbar), juste les /* v2026.5.43 : on conserve les MÊMES dimensions que .pinned-popup
styles de base du tooltip (hérités de .tooltip). */ (padding-top 28px, border 2px) pour que la popup ne bouge ni ne change
border: 1px solid var(--border-strong) !important; de taille au softUnpin. La dragbar est juste retirée (l'espace
reste, c'est le tradeoff pour préserver position + taille).
Bordure plus discrète (variable --border-strong au lieu de --accent). */
border: 2px solid var(--border-strong) !important;
box-shadow: var(--shadow-hover) !important; box-shadow: var(--shadow-hover) !important;
padding-top: 12px !important; padding-top: 28px !important;
animation: none !important; animation: none !important;
} }
@@ -3006,9 +3048,12 @@ body.popup-dragging .pinned-popup {
min-width: 130px; min-width: 130px;
animation: pill-hover-menu-appear 0.12s ease-out; animation: pill-hover-menu-appear 0.12s ease-out;
} }
/* v2026.5.43 : pas de transform dans cette animation — Firefox inclut les
transforms dans getBoundingClientRect ce qui fausse le calcul de position
du menu juste après son insertion dans le DOM. Animation en opacité seule. */
@keyframes pill-hover-menu-appear { @keyframes pill-hover-menu-appear {
from { opacity: 0; transform: translateY(4px); } from { opacity: 0; }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; }
} }
.pill-hover-menu-btn { .pill-hover-menu-btn {
display: flex; display: flex;
@@ -3380,7 +3425,7 @@ html.theme-dark .card.absence-cat-conge {
} }
/* ========================================================================== /* ==========================================================================
v2026.5.30 : absences récurrentes (Pillonel vendredi) en cyan v2026.5.30 : absences récurrentes (configurées par tech) en cyan
(même couleur que Congé mais texte distinct "Absent le vendredi") (même couleur que Congé mais texte distinct "Absent le vendredi")
========================================================================== */ ========================================================================== */
+320 -115
View File
@@ -158,22 +158,27 @@ LOG.info("boot", "viewer.js chargé", { version: LOG.version() });
// Configuration // Configuration
// ============================================================================ // ============================================================================
// Équipe : ID EasyVista → nom affiché // v2026.5.41 : plus aucune équipe / absence récurrente codée en dur.
const TEAM = { // L'utilisateur configure tout depuis Paramètres → Équipe :
"76272": "Ciuppa, Mathieu", // - cfg.team = { id: name } — techniciens à afficher
"83725": "De Almeida Martins, Solange", // - cfg.recurringAbsences = { id: [days] } — jours d'absence récurrente
"66635": "Makonda, Yannick", // (chrome.storage.local["admin_config"], persiste entre les mises à jour)
"92235": "Mamouni, Anas", //
"90070": "Paisana, David", // TEAM et RECURRING_ABSENCES sont rechargées au boot depuis admin_config par
"40944": "Pillonel, Olivier", // _initTeamFromConfig() (appelée tôt dans init()). Tant qu'elles ne sont pas
"72485": "Rosset, Pascal", // chargées, elles restent vides → aucun fetch tenté.
"86874": "Rouiller, Quentin" let TEAM = {};
}; let RECURRING_ABSENCES = {};
// Absences récurrentes (id tech → [jour JS, 0=dim..6=sam]) async function _initTeamFromConfig() {
const RECURRING_ABSENCES = { try {
"40944": [5] // Pillonel absent tous les vendredis const cfg = await loadAdminConfig();
}; TEAM = cfg.team || {};
RECURRING_ABSENCES = cfg.recurringAbsences || {};
} catch (e) {
console.warn("[boot] _initTeamFromConfig err", e);
}
}
// Statuts EasyVista qui déclenchent l'affichage "clos" // Statuts EasyVista qui déclenchent l'affichage "clos"
const CLOSED_STATUS = ["Clôturé", "Cloture", "Clôture"]; const CLOSED_STATUS = ["Clôturé", "Cloture", "Clôture"];
@@ -183,9 +188,7 @@ const RESOLVED_STATUS = ["Résolu", "Resolu"];
const CANCELLED_STATUS = ["Annulé", "Annule", "Supprimé", "Supprime"]; const CANCELLED_STATUS = ["Annulé", "Annule", "Supprimé", "Supprime"];
// Clés de stockage // Clés de stockage
const LS_THEME = "planning_theme";
const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD
const CACHE_DAYS = 7;
// v4.1 : plus de constante de concurrence. Les fiches sont fetchées // v4.1 : plus de constante de concurrence. Les fiches sont fetchées
// séquentiellement (1 à la fois) car le serveur EasyVista est lent de toute // séquentiellement (1 à la fois) car le serveur EasyVista est lent de toute
@@ -372,12 +375,17 @@ function cleanupAbortResolver(myToken) {
document.addEventListener("DOMContentLoaded", init); document.addEventListener("DOMContentLoaded", init);
async function init() { async function init() {
initTheme(); await initTheme();
// v2026.5.39 : appliquer le zoom texte enregistré dès le boot, avant que // v2026.5.39 : appliquer le zoom texte enregistré dès le boot, avant que
// le DOM ne soit affiché (sinon "flash" à la taille par défaut). // le DOM ne soit affiché (sinon "flash" à la taille par défaut).
_initTextZoomFromConfig(); _initTextZoomFromConfig();
// v2026.5.39 : lire les heures de la journée depuis admin_config (8-18 défaut). // v2026.5.39 : lire les heures de la journée depuis admin_config (8-18 défaut).
await _initDayBoundsFromConfig(); await _initDayBoundsFromConfig();
// v2026.5.41 : charger l'équipe et les absences récurrentes depuis admin_config.
// Avant ce point, TEAM = {} → aucun fetch ne tournera tant que la config
// n'est pas chargée. Si l'utilisateur n'a rien configuré, le fetch retournera
// l'erreur "no_team_configured" qui invite à ouvrir les paramètres.
await _initTeamFromConfig();
bindTopbar(); bindTopbar();
bindTooltipInteractions(); bindTooltipInteractions();
initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal
@@ -763,10 +771,19 @@ function _getSessionRemainingMs() {
// Thème clair/sombre // Thème clair/sombre
// ============================================================================ // ============================================================================
function initTheme() { // v2026.5.41 : thème unifié sur admin_config.theme (chrome.storage.local).
const saved = localStorage.getItem(LS_THEME); // Le toggle topbar écrit dans la même clé que le select Apparence du panel,
const theme = (saved === "light" || saved === "dark") ? saved : detectDefaultTheme(); // pour que les deux soient toujours en accord.
applyTheme(theme); async function initTheme() {
let pref = "auto";
try {
const cfg = await loadAdminConfig();
if (cfg.theme === "light" || cfg.theme === "dark" || cfg.theme === "auto") {
pref = cfg.theme;
}
} catch (e) {}
_applyTheme(pref);
_watchOsThemeChanges();
} }
function detectDefaultTheme() { function detectDefaultTheme() {
@@ -776,17 +793,20 @@ function detectDefaultTheme() {
return "light"; return "light";
} }
function applyTheme(theme) { async function toggleTheme() {
document.documentElement.setAttribute("data-theme", theme); // L'effectif courant (light ou dark), même si pref="auto"
const icon = document.getElementById("theme-icon"); const currentAttr = document.documentElement.getAttribute("data-theme");
if (icon) icon.textContent = theme === "dark" ? "☀️" : "🌙"; const effective = (currentAttr === "light" || currentAttr === "dark")
} ? currentAttr : detectDefaultTheme();
const next = effective === "dark" ? "light" : "dark";
function toggleTheme() { _applyTheme(next);
const current = document.documentElement.getAttribute("data-theme") || "light"; try {
const next = current === "dark" ? "light" : "dark"; const cfg = await loadAdminConfig();
applyTheme(next); cfg.theme = next;
localStorage.setItem(LS_THEME, next); await saveAdminConfig(cfg);
} catch (e) {
console.warn("[theme] toggle save err", e);
}
} }
// ============================================================================ // ============================================================================
@@ -1587,12 +1607,8 @@ function updateNowLine() {
}); });
} }
// v5.0.0 : menu admin caché via 5 clics sur le titre "Planification".
// v2026.5.38 : initAdminMenu() retiré — l'accès admin passe maintenant par le
// bouton "⚙ Paramètres" du popup user-badge (clic sur les initiales).
// ============================================================================ // ============================================================================
// v5.0.9 : Surveillance du timeout de session EasyVista // Surveillance du timeout de session EasyVista
// ============================================================================ // ============================================================================
/** /**
@@ -2005,7 +2021,6 @@ async function loadAdminConfig() {
async function saveAdminConfig(cfg) { async function saveAdminConfig(cfg) {
try { try {
await chrome.storage.local.set({ [ADMIN_CONFIG_KEY]: cfg }); await chrome.storage.local.set({ [ADMIN_CONFIG_KEY]: cfg });
console.log("[admin] config sauvegardée");
return true; return true;
} catch (e) { } catch (e) {
console.error("[admin] saveAdminConfig err", e); console.error("[admin] saveAdminConfig err", e);
@@ -2107,7 +2122,17 @@ async function showAdminPanel() {
async function saveAndReload(updatedCfg) { async function saveAndReload(updatedCfg) {
const ok = await saveAdminConfig(updatedCfg); const ok = await saveAdminConfig(updatedCfg);
if (ok) { if (ok) {
showToast("Config enregistrée", "Rechargez l'extension pour appliquer"); // v2026.5.41 : application immédiate sans demander de recharger.
// 1) repeupler TEAM / RECURRING_ABSENCES en mémoire depuis la nouvelle config.
// 2) refetcher le planning du jour courant.
// 3) le toast reste visible au-dessus du panel grâce au z-index 11000.
await _initTeamFromConfig();
showToast("Config enregistrée", "Mise à jour du planning…");
try {
await loadForDate(state.currentDate);
} catch (e) {
console.warn("[admin] reload planning err", e);
}
} else { } else {
showAlertModal({ showAlertModal({
title: "Erreur", title: "Erreur",
@@ -2192,7 +2217,7 @@ function renderAdminSectionTeam(container, cfg, saveFn) {
cfg.groupName = name; cfg.groupName = name;
updateGroupIdCaption(id); updateGroupIdCaption(id);
await saveAdminConfig(cfg); await saveAdminConfig(cfg);
showToast("Groupe enregistré", `${name || id} (rechargez l'extension pour appliquer)`); showToast("Groupe enregistré", name || id);
// v2026.5.41 : refresh auto de la liste d'équipe avec le nouveau group_id. // v2026.5.41 : refresh auto de la liste d'équipe avec le nouveau group_id.
await refreshTeamForGroup(id); await refreshTeamForGroup(id);
}); });
@@ -2521,12 +2546,45 @@ function renderAdminSectionEV(container, cfg, saveFn) {
} }
internalInput.value = a; internalInput.value = a;
externalInput.value = b; externalInput.value = b;
// v2026.5.41 : si un domaine n'est pas dans le host_permissions par défaut
// du manifest, demander la permission au navigateur. Sans ça, le service
// worker ne peut pas fetcher ces nouveaux domaines.
const hardcodedDefaults = [DEFAULT_EV_ORIGIN_INTERNAL, DEFAULT_EV_ORIGIN_EXTERNAL];
const customOrigins = [a, b].filter(o => !hardcodedDefaults.includes(o));
if (customOrigins.length > 0) {
const origins = customOrigins.map(o => o + "/*");
try {
const granted = await chrome.permissions.request({ origins });
if (!granted) {
status.textContent = "⚠ Permission refusée pour " + customOrigins.join(", ") + ". Les fetches échoueront sur ces domaines.";
showAlertModal({
title: "Permission refusée",
message: "Vous avez refusé l'accès aux domaines personnalisés. L'extension ne pourra pas y fetcher de données. Vous pouvez réessayer en cliquant à nouveau sur Enregistrer.",
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
return;
}
} catch (e) {
console.warn("[admin] permissions.request err", e);
status.textContent = "Erreur permission : " + (e.message || e);
return;
}
}
cfg.evOrigins = [a, b]; cfg.evOrigins = [a, b];
const ok = await saveAdminConfig(cfg); const ok = await saveAdminConfig(cfg);
status.textContent = ok if (!ok) {
? "Domaines enregistrés. Rechargez l'extension pour appliquer." status.textContent = "Erreur lors de l'enregistrement.";
: "Erreur lors de l'enregistrement."; return;
if (ok) showToast("Domaines enregistrés", `${a}${b}`); }
status.textContent = "Domaines enregistrés.";
showToast("Domaines enregistrés", `${a}${b}`);
try {
await refreshSessionAndLoad();
} catch (e) {
console.warn("[admin] reload after EV domain change err", e);
}
}); });
btnWrap.appendChild(saveBtn); btnWrap.appendChild(saveBtn);
@@ -2661,12 +2719,14 @@ function renderAdminSectionAppearance(container, cfg, saveFn) {
container.appendChild(zoomRow); container.appendChild(zoomRow);
// ---- Heures de la journée (4e) ---- // ---- Heures de la journée (4e) ----
// v2026.5.41 : bouton "Appliquer" explicite (au lieu de save direct au change),
// avec toast de confirmation et reload du planning + de la timeline.
const hoursRow = _makeAdminRow( const hoursRow = _makeAdminRow(
"Heures de la journée", "Heures de la journée",
"Plage horaire affichée sur la timeline. Défaut : 8h - 18h." "Plage horaire affichée sur la timeline. Défaut : 8h - 18h. Cliquez sur Appliquer pour valider et recharger."
); );
const hoursWrap = document.createElement("div"); const hoursWrap = document.createElement("div");
hoursWrap.style.cssText = "display:flex; align-items:center; gap:6px;"; hoursWrap.style.cssText = "display:flex; align-items:center; gap:6px; flex-wrap:wrap;";
const hStart = document.createElement("input"); const hStart = document.createElement("input");
hStart.type = "number"; hStart.min = "0"; hStart.max = "23"; hStart.step = "1"; hStart.type = "number"; hStart.min = "0"; hStart.max = "23"; hStart.step = "1";
hStart.className = "admin-input admin-input-num"; hStart.className = "admin-input admin-input-num";
@@ -2681,7 +2741,13 @@ function renderAdminSectionAppearance(container, cfg, saveFn) {
const hSuffix = document.createElement("span"); const hSuffix = document.createElement("span");
hSuffix.textContent = "h"; hSuffix.textContent = "h";
hSuffix.style.cssText = "color: var(--text-muted); font-size: 13px;"; hSuffix.style.cssText = "color: var(--text-muted); font-size: 13px;";
const _saveHours = async () => {
const applyBtn = document.createElement("button");
applyBtn.type = "button";
applyBtn.className = "btn btn-primary";
applyBtn.textContent = "✓ Appliquer";
applyBtn.style.marginLeft = "10px";
applyBtn.addEventListener("click", async () => {
let s = parseInt(hStart.value, 10); let s = parseInt(hStart.value, 10);
let e = parseInt(hEnd.value, 10); let e = parseInt(hEnd.value, 10);
if (isNaN(s) || s < 0) s = 0; if (isNaN(s) || s < 0) s = 0;
@@ -2690,16 +2756,53 @@ function renderAdminSectionAppearance(container, cfg, saveFn) {
if (e > 24) e = 24; if (e > 24) e = 24;
hStart.value = String(s); hStart.value = String(s);
hEnd.value = String(e); hEnd.value = String(e);
// Pas de changement effectif → on évite le reload inutile
const unchanged = (cfg.dayStart === s) && (cfg.dayEnd === e);
cfg.dayStart = s; cfg.dayStart = s;
cfg.dayEnd = e; cfg.dayEnd = e;
await saveAdminConfig(cfg);
}; applyBtn.disabled = true;
hStart.addEventListener("change", _saveHours); const oldLabel = applyBtn.textContent;
hEnd.addEventListener("change", _saveHours); applyBtn.textContent = "Application…";
try {
const ok = await saveAdminConfig(cfg);
if (!ok) {
showAlertModal({
title: "Erreur",
message: "Impossible d'enregistrer les heures.",
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
return;
}
// Mettre à jour les bornes en mémoire pour la timeline (utilisées
// par buildTimeline / updateNowLine sans re-fetch).
DAY_START = s * 60;
DAY_END = e * 60;
showToast("Heures appliquées", `${s}h → ${e}h`);
if (!unchanged) {
// Refetch le planning pour que les requêtes EV repartent avec
// les nouvelles bornes (day_start_hour / day_end_hour).
await loadForDate(state.currentDate);
}
} catch (err) {
console.warn("[admin] apply hours err", err);
showAlertModal({
title: "Erreur",
message: "Échec de l'application : " + (err.message || err),
buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
});
} finally {
applyBtn.disabled = false;
applyBtn.textContent = oldLabel;
}
});
hoursWrap.appendChild(hStart); hoursWrap.appendChild(hStart);
hoursWrap.appendChild(sep); hoursWrap.appendChild(sep);
hoursWrap.appendChild(hEnd); hoursWrap.appendChild(hEnd);
hoursWrap.appendChild(hSuffix); hoursWrap.appendChild(hSuffix);
hoursWrap.appendChild(applyBtn);
hoursRow.querySelector(".admin-row-control").appendChild(hoursWrap); hoursRow.querySelector(".admin-row-control").appendChild(hoursWrap);
container.appendChild(hoursRow); container.appendChild(hoursRow);
} }
@@ -2859,12 +2962,32 @@ function renderAdminSectionAbout(container, cfg, saveFn) {
// Helper applique un thème (utilisé par le sélecteur Apparence). // Helper applique un thème (utilisé par le sélecteur Apparence).
// Si "auto", on retire data-theme pour laisser le CSS detecter prefers-color-scheme. // Si "auto", on retire data-theme pour laisser le CSS detecter prefers-color-scheme.
function _applyTheme(theme) { function _applyTheme(theme) {
// v2026.5.41 : on résout "auto" en JS plutôt que de retirer data-theme,
// car le CSS n'a pas de bloc @media (prefers-color-scheme: dark) et
// retomberait sur le thème clair par défaut. On lit prefers-color-scheme
// du navigateur et on pose data-theme="dark" ou "light" en conséquence.
const html = document.documentElement; const html = document.documentElement;
if (theme === "light" || theme === "dark") { const effective = (theme === "light" || theme === "dark") ? theme : detectDefaultTheme();
html.setAttribute("data-theme", theme); html.setAttribute("data-theme", effective);
} else { // Sync l'icône topbar avec le thème effectif.
html.removeAttribute("data-theme"); const icon = document.getElementById("theme-icon");
} if (icon) icon.textContent = effective === "dark" ? "☀️" : "🌙";
}
// v2026.5.41 : écoute les changements de thème OS pour les répercuter
// quand l'utilisateur est en mode "auto" (cfg.theme === "auto" ou absent).
function _watchOsThemeChanges() {
if (!window.matchMedia) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = async () => {
try {
const cfg = await loadAdminConfig();
const pref = cfg.theme || "auto";
if (pref === "auto") _applyTheme("auto");
} catch (e) {}
};
if (mq.addEventListener) mq.addEventListener("change", handler);
else if (mq.addListener) mq.addListener(handler); // fallback Safari ancien
} }
function renderAdminSectionDiagnostics(container, cfg, saveFn) { function renderAdminSectionDiagnostics(container, cfg, saveFn) {
@@ -3840,6 +3963,12 @@ async function fetchPlanningForDate(isoDate) {
} else { } else {
showEvUnreachable(); showEvUnreachable();
} }
} else if (resp.error === "no_team_configured") {
// v2026.5.41 : aucun technicien sélectionné dans Paramètres → Équipe
showError(
"Aucun technicien sélectionné. Ouvrez ⚙ Paramètres → Équipe pour " +
"choisir le groupe EasyVista et cocher les techniciens à afficher."
);
} else { } else {
showError("Erreur de fetch : " + (resp.error || "inconnue")); showError("Erreur de fetch : " + (resp.error || "inconnue"));
} }
@@ -5727,17 +5856,21 @@ function isTechAbsent(tech, isoDate) {
// Construction d'une carte // Construction d'une carte
// ============================================================================ // ============================================================================
// v4.1.20 : détecte si tech = Pillonel Olivier ET jour = vendredi. // v2026.5.41 : générique. Plus aucun nom hardcodé. Lit cfg.recurringAbsences
// Hardcodé car c'est une absence récurrente connue spécifique à lui. // (chargé depuis admin_config) : pour chaque tech.id, un tableau de jours JS
function isPillonelAbsentFriday(tech, isoDate) { // (0=dim, 1=lun, …, 6=sam) où il est marqué absent récurrent.
if (!tech || !tech.name) return false; function isRecurringAbsence(tech, isoDate) {
// Normaliser le nom (tolère "Pillonel, Olivier", "Pillonel Olivier", etc.) if (!tech || !tech.id) return false;
const name = tech.name.toLowerCase(); const days = RECURRING_ABSENCES[tech.id];
if (!name.includes("pillonel")) return false; if (!Array.isArray(days) || days.length === 0) return false;
if (!name.includes("olivier")) return false; const day = isoToDate(isoDate).getDay();
// Jour de la semaine : 5 = vendredi (en JS, 0=dim, 1=lun, ..., 5=ven) return days.includes(day);
const d = isoToDate(isoDate); }
return d.getDay() === 5;
// Libellé "Absent le <jour>" — calculé depuis la date au lieu d'être hardcodé.
function _recurringAbsenceLabel(isoDate) {
const names = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"];
return "Absent le " + names[isoToDate(isoDate).getDay()];
} }
/** /**
@@ -5752,9 +5885,9 @@ function buildCard(tech, isoDate) {
const isPompier = tech.interventions.some(iv => iv.isPompier); const isPompier = tech.interventions.some(iv => iv.isPompier);
const isAbsent = isTechAbsent(tech, isoDate); const isAbsent = isTechAbsent(tech, isoDate);
// v2026.5.30 : détecter aussi les absences récurrentes hardcodées (Pillonel vendredi) // v2026.5.41 : absence récurrente générique (configurée par tech dans
// pour leur appliquer le code couleur cyan (comme Congé) au lieu du rouge Pompier. // Paramètres → Équipe). Code couleur cyan (comme Congé).
const isRecurring = isPillonelAbsentFriday(tech, isoDate); const isRecurring = isRecurringAbsence(tech, isoDate);
if (isPompier) card.classList.add("is-pompier"); if (isPompier) card.classList.add("is-pompier");
if (isAbsent) card.classList.add("is-absent"); if (isAbsent) card.classList.add("is-absent");
@@ -5782,6 +5915,37 @@ function buildCard(tech, isoDate) {
const absenceBlocks = tech.interventions.filter(iv => iv.type === "AL-Absence"); const absenceBlocks = tech.interventions.filter(iv => iv.type === "AL-Absence");
const pompierBlocks = tech.interventions.filter(iv => iv.isPompier); const pompierBlocks = tech.interventions.filter(iv => iv.isPompier);
// v2026.5.41 : conflit absence/réservation × intervention.
// Si une intervention est planifiée alors que le tech a une absence (non-pompier)
// ou une réservation au même créneau, on marque la carte de l'intervention en
// rouge plein. Cas full-day : toutes les interv réelles sont marquées.
const conflictPeriods = [];
for (const iv of tech.interventions) {
const isAbsenceConflict = iv.type === "AL-Absence" && !iv.isPompier;
const isReservationConflict = iv.type === "AL-Reservation";
if (!isAbsenceConflict && !isReservationConflict) continue;
const s = timeToMinutes(iv.startTime);
const e = timeToMinutes(iv.endTime);
if (s === null || e === null) {
// Pas d'horaires précis → on considère que ça couvre toute la journée
conflictPeriods.push([DAY_START, DAY_END]);
} else {
conflictPeriods.push([s, e]);
}
}
// Absence récurrente ce jour ou full-day → toute la journée est en conflit
if (isAbsent || isRecurring) {
conflictPeriods.push([DAY_START, DAY_END]);
}
// Stamp chaque intervention réelle (uniquement type AL-Intervention)
for (const iv of realInterventions) {
if (iv.type !== "AL-Intervention") { iv._absenceConflict = false; continue; }
const ivS = timeToMinutes(iv.startTime);
const ivE = timeToMinutes(iv.endTime);
if (ivS === null || ivE === null) { iv._absenceConflict = false; continue; }
iv._absenceConflict = conflictPeriods.some(([cs, ce]) => ivS < ce && ivE > cs);
}
const morning = realInterventions.filter(iv => { const morning = realInterventions.filter(iv => {
const s = timeToMinutes(iv.startTime); const s = timeToMinutes(iv.startTime);
return s !== null && s < 12 * 60; return s !== null && s < 12 * 60;
@@ -5810,7 +5974,7 @@ function buildCard(tech, isoDate) {
const badge = document.createElement("div"); const badge = document.createElement("div");
badge.className = "card-tech-badge"; badge.className = "card-tech-badge";
if (isRecurring) { if (isRecurring) {
// v2026.5.30 : absence récurrente (Pillonel vendredi) → badge "Absent" cyan // v2026.5.41 : absence récurrente (configurée par tech) → badge "Absent" cyan
badge.classList.add("badge-recurring"); badge.classList.add("badge-recurring");
badge.textContent = "Absent"; badge.textContent = "Absent";
} else if (isPompier) { } else if (isPompier) {
@@ -5946,19 +6110,16 @@ function buildCard(tech, isoDate) {
} }
} }
// v4.1.20 : cas spécifique Pillonel Olivier, absent tous les vendredis. // v2026.5.41 : absence récurrente générique. Affichage d'un message
// Affichage d'un message explicite au lieu de "Pas d'intervention planifiée". // "Absent le <jour>" priorité, même si un bloc AL-Absence couvre le jour.
// v4.2 : prioritaire même si un bloc AL-Absence couvre le vendredi (ce qui const isRecurringDay = isRecurringAbsence(tech, isoDate);
// est le cas normal), pour TOUJOURS afficher "Absent le vendredi".
const isPillonelFriday = isPillonelAbsentFriday(tech, isoDate);
// Absent sans interv → on stop là (après avoir posé le message Pillonel // Absent sans interv → on stop là (après avoir posé le message récurrent si applicable).
// si vendredi).
if (isAbsent && realInterventions.length === 0) { if (isAbsent && realInterventions.length === 0) {
if (isPillonelFriday) { if (isRecurringDay) {
const note = document.createElement("div"); const note = document.createElement("div");
note.className = "tech-absence-recurring"; note.className = "tech-absence-recurring";
note.textContent = "Absent le vendredi"; note.textContent = _recurringAbsenceLabel(isoDate);
body.appendChild(note); body.appendChild(note);
} }
card.appendChild(body); card.appendChild(body);
@@ -5980,10 +6141,10 @@ function buildCard(tech, isoDate) {
}); });
if (realInterventions.length === 0 && !isPompier && !hasPartialAbsences) { if (realInterventions.length === 0 && !isPompier && !hasPartialAbsences) {
if (isPillonelFriday) { if (isRecurringDay) {
const note = document.createElement("div"); const note = document.createElement("div");
note.className = "tech-absence-recurring"; note.className = "tech-absence-recurring";
note.textContent = "Absent le vendredi"; note.textContent = _recurringAbsenceLabel(isoDate);
body.appendChild(note); body.appendChild(note);
} else { } else {
const empty = document.createElement("div"); const empty = document.createElement("div");
@@ -5995,11 +6156,11 @@ function buildCard(tech, isoDate) {
return card; return card;
} }
// Pillonel vendredi avec quand même des interv planifiées ? Rare mais possible. // Tech avec absence récurrente ce jour mais quand même des interv planifiées ? Rare mais possible.
if (isPillonelFriday && realInterventions.length > 0) { if (isRecurringDay && realInterventions.length > 0) {
const note = document.createElement("div"); const note = document.createElement("div");
note.className = "tech-absence-recurring"; note.className = "tech-absence-recurring";
note.textContent = "Absent le vendredi"; note.textContent = _recurringAbsenceLabel(isoDate);
body.appendChild(note); body.appendChild(note);
} }
@@ -6163,6 +6324,8 @@ function _buildMiniCardsRow(realInterventions, cardEl) {
const ivIdx = realInterventions.indexOf(iv); const ivIdx = realInterventions.indexOf(iv);
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "iv-mini-card color-" + colorKey; card.className = "iv-mini-card color-" + colorKey;
// v2026.5.41 : conflit avec absence/réservation → mini-card rouge plein
if (iv._absenceConflict) card.classList.add("intervention-conflict-absence");
card.dataset.ivIdx = String(ivIdx); card.dataset.ivIdx = String(ivIdx);
if (iv.ref) card.dataset.ref = iv.ref; if (iv.ref) card.dataset.ref = iv.ref;
@@ -6243,10 +6406,17 @@ function _buildMiniCardsRow(realInterventions, cardEl) {
openInterventionFromTimeline(card, { background: true }); openInterventionFromTimeline(card, { background: true });
return; return;
} }
// v2026.5.40 r10 : forcer l'affichage IMMÉDIAT de la grande popup // v2026.5.41 : en vue horizontale, le clic simple ouvre la fiche EV
// ancrée AVANT pinTooltip(). Sans ça, si le délai showTooltip 500ms // dans un nouvel onglet (au lieu d'épingler la popup, comportement
// n'est pas encore écoulé OU si la petite popup timeline était // peu utile en horizontal car la popup est déjà visible au survol).
// affichée juste avant, c'est elle qui se faisait épingler. const isHorizontal = document.documentElement.classList.contains("view-horizontal");
if (isHorizontal) {
e.preventDefault();
e.stopPropagation();
openInterventionFromTimeline(card, { background: false });
return;
}
// Vue classique : clic = épingle la grande popup ancrée.
e.stopPropagation(); e.stopPropagation();
_cancelPendingShowTooltip(); _cancelPendingShowTooltip();
_cancelPendingTimelinePopover(); _cancelPendingTimelinePopover();
@@ -6695,6 +6865,8 @@ function buildInterventionRow(iv, cardEl) {
row.className = "intervention-v2"; row.className = "intervention-v2";
row.dataset.actionId = iv.actionId; row.dataset.actionId = iv.actionId;
if (iv.isPompier) row.classList.add("is-pompier-line"); if (iv.isPompier) row.classList.add("is-pompier-line");
// v2026.5.41 : intervention en conflit avec une absence/réservation → rouge plein
if (iv._absenceConflict) row.classList.add("intervention-conflict-absence");
// v4.3.3 : on ne marque plus les ghosts visuellement (classe is-ghost // v4.3.3 : on ne marque plus les ghosts visuellement (classe is-ghost
// retirée). Les tickets disparus sont soit retirés (_disappearRemove), // retirée). Les tickets disparus sont soit retirés (_disappearRemove),
// soit affichés en vert (_disappearStatus). Plus de barrage. // soit affichés en vert (_disappearStatus). Plus de barrage.
@@ -7276,32 +7448,32 @@ function splitOneContact(raw) {
// v2026.5.25 : avant d'extraire les numéros, on REMPLACE les séquences qui // v2026.5.25 : avant d'extraire les numéros, on REMPLACE les séquences qui
// sont des identifiants de matériel (LETTRES_CHIFFRES) par des espaces. // sont des identifiants de matériel (LETTRES_CHIFFRES) par des espaces.
// Exemples : XXXX_NNNNNNNNNNN, XNNNNNN, XNNNNNN, XNNNNNN. // Exemples : XXXX_NNNNNNNNNNN, XNNNNNN (1-2 lettres + 5+ chiffres).
// Sans ça, XXXX_NNNNNNNNNNN laisse des "NNNN NNN NN NN" qui se font prendre // Sans ça, XXXX_NNNNNNNNNNN laisse des "NNNN NNN NN NN" qui se font prendre
// pour un numéro de téléphone par le regex qui greedy sur [0-9\s.\-]. // pour un numéro de téléphone par le regex qui greedy sur [0-9\s.\-].
// On remplace par des espaces de même longueur pour préserver les offsets // On remplace par des espaces de même longueur pour préserver les offsets
// (important pour le calcul de position du nom avant le 1er numéro). // (important pour le calcul de position du nom avant le 1er numéro).
raw = String(raw); raw = String(raw);
raw = raw.replace(/\b[A-Z]{1,6}_\d+/g, (m) => " ".repeat(m.length)); raw = raw.replace(/\b[A-Z]{1,6}_\d+/g, (m) => " ".repeat(m.length));
// Idem pour les identifiants sans underscore style XNNNNNN, XNNNNNN, XNNNNNN // Idem pour les identifiants sans underscore style XNNNNNN (1-2 lettres
// (1-2 lettres majuscules suivies de 5+ chiffres collés). On garde assez // majuscules suivies de 5+ chiffres collés). On garde assez permissif
// permissif pour matcher les variantes sans enlever des vrais mots. // pour matcher les variantes sans enlever des vrais mots.
raw = raw.replace(/\b[A-Z]{1,3}\d{5,}\b/g, (m) => " ".repeat(m.length)); raw = raw.replace(/\b[A-Z]{1,3}\d{5,}\b/g, (m) => " ".repeat(m.length));
// v4.1.20 : regex plus permissives pour tolérer les erreurs humaines : // v4.1.20 : regex plus permissives pour tolérer les erreurs humaines :
// - pas d'espace après le numéro (ex: "021555555Textecoller") // - pas d'espace après le numéro (ex: "0XXXXXXXXTextecoller")
// - pas d'espace/parenthèse avant un court numéro // - pas d'espace/parenthèse avant un court numéro
// LONG : +41 / +33 / 0X suivis de chiffres/espaces/points/tirets // LONG : +41 / +33 / 0X suivis de chiffres/espaces/points/tirets
// On ne limite plus par séparateur après — on laisse le moteur // On ne limite plus par séparateur après — on laisse le moteur
// consommer le numéro le plus long possible (greedy) puis on // consommer le numéro le plus long possible (greedy) puis on
// s'arrête dès qu'on tombe sur un caractère non numérique. // s'arrête dès qu'on tombe sur un caractère non numérique.
// v4.2 : on accepte aussi le format "41XXXXXXXXX" sans + devant (fréquent // v4.2 : on accepte aussi le format "41XXXXXXXXX" sans + devant (fréquent
// quand EasyVista concatène "prefixe+tel" sans espace : Nom, // quand EasyVista concatène "prefixe+tel" sans espace : "Nom,
// Prénom 41XXXXXXXXX → extraire 41XXXXXXXXX puis reformater en // Prénom 41XXXXXXXXX" → extraire 41XXXXXXXXX puis reformater en
// +41 XX XXX XX XX). On exige exactement 11 chiffres collés pour // +41 XX XXX XX XX). On exige exactement 11 chiffres collés pour
// éviter de matcher des codes postaux ou autres nombres. // éviter de matcher des codes postaux ou autres nombres.
// v2026.5.16 : ne PAS matcher si le numéro est précédé d'une lettre ou // v2026.5.16 : ne PAS matcher si le numéro est précédé d'une lettre ou
// d'un underscore (identifiants style XXXX_NNNNNNNN, ABC123456, // d'un underscore (identifiants style XXXX_NNNNNNNN, XXX0123456,
// SERIAL_0123456789). On ajoute un lookbehind négatif (?<![A-Za-z_]). // SERIAL_0123456789). On ajoute un lookbehind négatif (?<![A-Za-z_]).
const rxLong = /(?<![A-Za-z_])(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(?<!\d)41\d{9}(?!\d)|(?<!\d)33\d{9}(?!\d))/g; const rxLong = /(?<![A-Za-z_])(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(?<!\d)41\d{9}(?!\d)|(?<!\d)33\d{9}(?!\d))/g;
// SHORT : numéro interne court (5 chiffres). // SHORT : numéro interne court (5 chiffres).
@@ -7363,7 +7535,7 @@ function splitOneContact(raw) {
// Critères d'un vrai nom : contient au moins un mot qui commence par une // Critères d'un vrai nom : contient au moins un mot qui commence par une
// majuscule ET n'est pas juste un identifiant technique. // majuscule ET n'est pas juste un identifiant technique.
if (name) { if (name) {
const looksLikeIdentifier = /^[A-Z]{2,}[_\-]\d+$/.test(name); // XXXX_NNNNNNNN const looksLikeIdentifier = /^[A-Z]{2,}[_\-]\d+$/.test(name); // ex: XXXX_NNNNNNNN
const startsWithQuantity = /^\d+x(\s|$)/i.test(name); // "1x" ou "1x pc" const startsWithQuantity = /^\d+x(\s|$)/i.test(name); // "1x" ou "1x pc"
const noCapitalWord = !/\b[A-ZÉÈÀÂÎÔÛÇ][a-zéèàâîôûç]+/.test(name); // aucun mot "Xxxxx" const noCapitalWord = !/\b[A-ZÉÈÀÂÎÔÛÇ][a-zéèàâîôûç]+/.test(name); // aucun mot "Xxxxx"
const hasOnlyTechTokens = /^(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout)(\s+(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout|[A-Z]\d+))*\s*$/i.test(name); const hasOnlyTechTokens = /^(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout)(\s+(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout|[A-Z]\d+))*\s*$/i.test(name);
@@ -8110,14 +8282,23 @@ function positionTooltipAnchored(sourceEl, opts) {
// 4 candidats (ordre : droite → gauche → dessous → dessus) // 4 candidats (ordre : droite → gauche → dessous → dessus)
// Préférence opts.anchorBelow = true : dessous en premier (ex: clic timeline) // Préférence opts.anchorBelow = true : dessous en premier (ex: clic timeline)
// v2026.5.41 : en vue horizontale, on n'autorise QUE dessous/dessus (la
// sidebar à gauche et la timeline pleine largeur rendent gauche/droite
// peu praticables et le popup masquerait la row source).
const rightCandidate = { x: srcRect.right + pad, y: srcRect.top, label: "droite" }; const rightCandidate = { x: srcRect.right + pad, y: srcRect.top, label: "droite" };
const leftCandidate = { x: srcRect.left - tipW - pad, y: srcRect.top, label: "gauche" }; const leftCandidate = { x: srcRect.left - tipW - pad, y: srcRect.top, label: "gauche" };
const belowCandidate = { x: srcRect.left, y: srcRect.bottom + pad, label: "dessous" }; const belowCandidate = { x: srcRect.left, y: srcRect.bottom + pad, label: "dessous" };
const aboveCandidate = { x: srcRect.left, y: srcRect.top - tipH - pad, label: "dessus" }; const aboveCandidate = { x: srcRect.left, y: srcRect.top - tipH - pad, label: "dessus" };
const candidates = opts.anchorBelow const isHorizontal = document.documentElement.classList.contains("view-horizontal");
? [belowCandidate, aboveCandidate, rightCandidate, leftCandidate] let candidates;
: [rightCandidate, leftCandidate, belowCandidate, aboveCandidate]; if (isHorizontal) {
candidates = [belowCandidate, aboveCandidate];
} else if (opts.anchorBelow) {
candidates = [belowCandidate, aboveCandidate, rightCandidate, leftCandidate];
} else {
candidates = [rightCandidate, leftCandidate, belowCandidate, aboveCandidate];
}
const pinnedRects = (typeof _getPinnedPopupsViewportRects === "function") const pinnedRects = (typeof _getPinnedPopupsViewportRects === "function")
? _getPinnedPopupsViewportRects() ? _getPinnedPopupsViewportRects()
@@ -8147,10 +8328,16 @@ function positionTooltipAnchored(sourceEl, opts) {
} }
if (!chosen) { if (!chosen) {
// Fallback : droite clampée à tout prix, même si ça chevauche (cas rare // Fallback : en horizontal, dessous clampé. En classique, droite clampée.
// avec écran minuscule ou beaucoup de popups épinglés) // Même si ça chevauche (cas rare : écran minuscule ou beaucoup de popups épinglés).
let fx = srcRect.right + pad; let fx, fy;
let fy = srcRect.top; if (isHorizontal) {
fx = srcRect.left;
fy = srcRect.bottom + pad;
} else {
fx = srcRect.right + pad;
fy = srcRect.top;
}
if (fx + tipW > safe.right) fx = safe.right - tipW; if (fx + tipW > safe.right) fx = safe.right - tipW;
if (fx < safe.left) fx = safe.left; if (fx < safe.left) fx = safe.left;
if (fy + tipH > safe.bottom) fy = safe.bottom - tipH; if (fy + tipH > safe.bottom) fy = safe.bottom - tipH;
@@ -8227,13 +8414,20 @@ function _findFreePopupPosition(rowEl, w, h) {
// v2026.5.20 : utiliser la safe area (en dessous topbar, au-dessus dock) // v2026.5.20 : utiliser la safe area (en dessous topbar, au-dessus dock)
const safe = _getPopupSafeArea(); const safe = _getPopupSafeArea();
// 4 candidats d'abord, autour de la row source (en coords viewport) // 4 candidats d'abord, autour de la row source (en coords viewport).
const candidates = [ // v2026.5.41 : en vue horizontale, on n'autorise QUE dessous/dessus.
{ x: rowRect.right + pad, y: rowRect.top, name: "droite" }, const isHorizontal = document.documentElement.classList.contains("view-horizontal");
{ x: rowRect.left - w - pad, y: rowRect.top, name: "gauche" }, const candidates = isHorizontal
{ x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" }, ? [
{ x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" } { x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" },
]; { x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" }
]
: [
{ x: rowRect.right + pad, y: rowRect.top, name: "droite" },
{ x: rowRect.left - w - pad, y: rowRect.top, name: "gauche" },
{ x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" },
{ x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" }
];
// v2026.5.20 : ajouter une grille de positions de fallback couvrant toute // v2026.5.20 : ajouter une grille de positions de fallback couvrant toute
// la safe area (pas de 60px × 60px) — garantit qu'on trouve ~toujours une // la safe area (pas de 60px × 60px) — garantit qu'on trouve ~toujours une
@@ -9145,9 +9339,19 @@ function _showPillHoverMenu(pill, popup) {
}); });
menu.appendChild(closeBtn); menu.appendChild(closeBtn);
// v2026.5.43 (Firefox-fix) : positionner le menu HORS écran d'abord pour
// qu'il soit layouté sans flash, puis mesurer ses dimensions, puis poser
// la position finale. Sans ça, Firefox lit parfois des dimensions à 0
// (timing de l'animation `pill-hover-menu-appear` + transform initial),
// ce qui projette le menu n'importe où sur l'écran.
menu.style.left = "-9999px";
menu.style.top = "-9999px";
menu.style.visibility = "hidden";
document.body.appendChild(menu); document.body.appendChild(menu);
// Force le navigateur à calculer la mise en page maintenant (Firefox ne
// le fait pas toujours sur getBoundingClientRect immédiat après append).
void menu.offsetHeight;
// Positionner au-dessus de la pastille
const r = pill.getBoundingClientRect(); const r = pill.getBoundingClientRect();
const menuR = menu.getBoundingClientRect(); const menuR = menu.getBoundingClientRect();
let left = r.left + (r.width / 2) - (menuR.width / 2); let left = r.left + (r.width / 2) - (menuR.width / 2);
@@ -9155,6 +9359,7 @@ function _showPillHoverMenu(pill, popup) {
if (left + menuR.width > window.innerWidth - 4) left = window.innerWidth - menuR.width - 4; if (left + menuR.width > window.innerWidth - 4) left = window.innerWidth - menuR.width - 4;
menu.style.left = left + "px"; menu.style.left = left + "px";
menu.style.top = (r.top - menuR.height - 8) + "px"; menu.style.top = (r.top - menuR.height - 8) + "px";
menu.style.visibility = "";
// Garder ouvert si la souris entre dans le menu // Garder ouvert si la souris entre dans le menu
menu.addEventListener("mouseenter", () => { menu.addEventListener("mouseenter", () => {
@@ -10036,7 +10241,7 @@ function buildTooltipHTML(iv) {
if (iv.startTime && iv.endTime) { if (iv.startTime && iv.endTime) {
rows.push(row("Horaire", `${iv.startTime}${iv.endTime}`)); rows.push(row("Horaire", `${iv.startTime}${iv.endTime}`));
} }
// Pour les absences récurrentes (Pillonel vendredi), pas d'actionId réel // Pour les absences récurrentes (configurées par tech), pas d'actionId réel
// → pas de bouton supprimer. Pour les autres → oui. // → pas de bouton supprimer. Pour les autres → oui.
if (iv.actionId) { if (iv.actionId) {
rows.push(`<dt></dt><dd><button type="button" class="tooltip-delete-btn" data-action="delete-item" data-action-id="${escapeHtml(iv.actionId)}" data-kind="absence">🗑 Supprimer cette absence</button></dd>`); rows.push(`<dt></dt><dd><button type="button" class="tooltip-delete-btn" data-action="delete-item" data-action-id="${escapeHtml(iv.actionId)}" data-kind="absence">🗑 Supprimer cette absence</button></dd>`);