diff --git a/background.js b/background.js
index 2822f88..86e6345 100644
--- a/background.js
+++ b/background.js
@@ -285,6 +285,144 @@ async function fetchCurrentUser(origin, phpsessid) {
return { name, login, service };
}
+// ============================================================================
+// v4.2.6 : Création d'absence
+// ============================================================================
+
+/**
+ * Envoie un POST vers plan_set_holidays_popup.php pour créer une absence.
+ * Format attendu (analysé depuis le HTML EasyVista) :
+ * Query params : PHPSESSID, MAIN_DIRECTORY, ROOT_DIRECTORY, current_date,
+ * empl_ids, begin_hour, end_hour, plagehoraire
+ * Body : start_date, start_time, end_date, end_time, label_guid, dialog_action
+ *
+ * @param {string} origin - "https://itsma.vd.ch" ou similaire
+ * @param {string} phpsessid
+ * @param {Object} opts - { techIds: string[], startDate: "DD/MM/YYYY",
+ * startTime: "HH:MM:SS", endDate, endTime,
+ * typeGuid, currentDate }
+ */
+async function submitAbsence(origin, phpsessid, opts) {
+ const emplIds = (opts.techIds || []).join(",");
+ if (!emplIds) throw new Error("Aucun technicien sélectionné");
+
+ const internalurltime = Math.floor(Date.now() / 1000);
+ const url = `${origin}/include/components/staff/planning/plan_set_holidays_popup.php`
+ + `?PHPSESSID=${encodeURIComponent(phpsessid)}`
+ + `&internalurltime=${internalurltime}`
+ + `&MAIN_DIRECTORY=${encodeURIComponent("/")}`
+ + `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}`
+ + `¤t_date=${encodeURIComponent(opts.currentDate)}`
+ + `&empl_ids=${encodeURIComponent(emplIds)}`
+ + `&begin_hour=8`
+ + `&end_hour=18`
+ + `&plagehoraire=0`;
+
+ const body = new URLSearchParams();
+ body.set("start_date", opts.startDate);
+ body.set("start_time", opts.startTime);
+ body.set("end_date", opts.endDate);
+ body.set("end_time", opts.endTime);
+ body.set("label_guid", opts.typeGuid);
+ body.set("dialog_action", "save_holidays");
+
+ console.log("[bg] submitAbsence →", url.substring(0, 140));
+ console.log("[bg] body:", body.toString());
+
+ const r = await fetch(url, {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: body.toString()
+ });
+
+ console.log("[bg] status =", r.status);
+
+ if (!r.ok) {
+ throw new Error("HTTP " + r.status);
+ }
+
+ const responseText = await r.text();
+ if (looksLikeLoginPage(responseText)) {
+ throw new Error("session_expired");
+ }
+
+ // Succès : on ne sait pas le format exact de la réponse EasyVista, on
+ // considère qu'un HTTP 200 non-login signifie succès.
+ return { status: r.status };
+}
+
+// ============================================================================
+// v4.2.6 : Envoi sur douchette
+// ============================================================================
+
+/**
+ * Envoie la planification du jour sur la douchette des techs sélectionnés.
+ *
+ * Endpoint identifié (via l'inspection de la page EasyVista) :
+ * POST /include/components/staff/planning/plan_set_tech_planif_popup.php
+ * Query : PHPSESSID, current_date, empl_ids (CSV), begin_hour, end_hour,
+ * plagehoraire
+ * Body : dialog_action=save_planif
+ *
+ * Contrairement à l'absence, un seul POST suffit pour tous les techs (empl_ids
+ * est une CSV), pas besoin de boucler.
+ *
+ * @param {string} origin
+ * @param {string} phpsessid
+ * @param {Object} opts - { techIds, currentDate }
+ * @returns {{ okCount, errors }}
+ */
+async function submitDouchette(origin, phpsessid, opts) {
+ const techIds = opts.techIds || [];
+ if (techIds.length === 0) throw new Error("Aucun technicien sélectionné");
+
+ const emplIds = techIds.join(",");
+ const internalurltime = Math.floor(Date.now() / 1000);
+ const url = `${origin}/include/components/staff/planning/plan_set_tech_planif_popup.php`
+ + `?PHPSESSID=${encodeURIComponent(phpsessid)}`
+ + `&internalurltime=${internalurltime}`
+ + `&MAIN_DIRECTORY=${encodeURIComponent("/")}`
+ + `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}`
+ + `¤t_date=${encodeURIComponent(opts.currentDate)}`
+ + `&empl_ids=${encodeURIComponent(emplIds)}`
+ + `&begin_hour=8`
+ + `&end_hour=18`
+ + `&plagehoraire=0`;
+
+ const body = new URLSearchParams();
+ body.set("dialog_action", "save_planif");
+
+ console.log("[bg] submitDouchette →", url.substring(0, 160));
+ console.log("[bg] body:", body.toString());
+ console.log("[bg] techs:", emplIds);
+
+ try {
+ const r = await fetch(url, {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: body.toString()
+ });
+ console.log("[bg] status =", r.status);
+
+ if (r.status === 401 || r.status === 403) {
+ return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) };
+ }
+ if (!r.ok) {
+ return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "HTTP " + r.status })) };
+ }
+ const responseText = await r.text();
+ if (looksLikeLoginPage(responseText)) {
+ return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) };
+ }
+ return { okCount: techIds.length, errors: [] };
+ } catch (err) {
+ const msg = err && err.message ? err.message : String(err);
+ return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: msg })) };
+ }
+}
+
// ============================================================================
// Messages du viewer
// ============================================================================
@@ -412,6 +550,41 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
return;
}
+ if (msg.type === "submitAbsence") {
+ // v4.2.6 : crée une absence dans EasyVista via POST vers
+ // /include/components/staff/planning/plan_set_holidays_popup.php
+ const session = await findEasyVistaSession();
+ if (!session) {
+ sendResponse({ ok: false, error: "no_session" });
+ return;
+ }
+ try {
+ const result = await submitAbsence(session.origin, session.phpsessid, msg);
+ sendResponse({ ok: true, result });
+ } catch (err) {
+ sendResponse({ ok: false, error: err.message || String(err) });
+ }
+ return;
+ }
+
+ if (msg.type === "submitDouchette") {
+ // v4.2.6 : envoie la planification sur la douchette de chaque tech.
+ // On teste plusieurs URLs possibles (l'endpoint exact n'est pas dans
+ // le HTML statique que nous avons analysé).
+ const session = await findEasyVistaSession();
+ if (!session) {
+ sendResponse({ ok: false, error: "no_session" });
+ return;
+ }
+ try {
+ const result = await submitDouchette(session.origin, session.phpsessid, msg);
+ sendResponse({ ok: true, okCount: result.okCount, errors: result.errors });
+ } catch (err) {
+ sendResponse({ ok: false, error: err.message || String(err) });
+ }
+ return;
+ }
+
if (msg.type === "cleanupOldCaches") {
const removed = await cleanupOldCaches(msg.daysToKeep || 7);
sendResponse({ ok: true, removed });
diff --git a/manifest.json b/manifest.json
index bf0791f..bb207ea 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
- "name": "Planning Techniciens — Vue claire",
- "version": "4.2.3",
- "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.2.3 : titre renommé 'Planification', pastille d'initiales utilisateur à gauche (clic = popup nom complet), timeline petite popup qui suit la souris, clic timeline = grande popup persistante sous la timeline, double-clic = ouvre fiche, Ctrl+clic = fiche en arrière-plan, 2 contacts séparés par 'et' affichés sur 2 lignes, numéros courts 5 chiffres commençant par 6/7/8 avec espaces reconnus.",
+ "name": "Planification",
+ "version": "4.2.8",
+ "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.2.8 : liste de techniciens dans les modals Absence/Douchette entièrement visible sans scroll. Inclut v4.2.7 (URL exacte douchette).",
"permissions": [
"activeTab",
"scripting",
@@ -15,7 +15,7 @@
"https://itsma.vd.ch/*"
],
"action": {
- "default_title": "Ouvrir la vue claire du planning"
+ "default_title": "Ouvrir la Planification"
},
"background": {
"service_worker": "background.js"
diff --git a/viewer.css b/viewer.css
index 77e0c5a..72ad2bd 100644
--- a/viewer.css
+++ b/viewer.css
@@ -179,27 +179,48 @@ html, body {
display: flex;
align-items: center;
gap: 12px;
- padding: 10px 16px;
- background: linear-gradient(90deg, #7a1f1f, #8b2a2a);
+ padding: 12px 18px;
+ /* v4.2.5 : rouge plus vif + bord plus épais pour visibilité max */
+ background: linear-gradient(90deg, #c93030, #d84848);
color: #fff;
- border-bottom: 1px solid #5a1515;
- font-size: 13px;
- box-shadow: 0 2px 6px rgba(0,0,0,0.25);
+ border-top: 2px solid #ff6060;
+ border-bottom: 2px solid #7a1515;
+ font-size: 14px;
+ font-weight: 500;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ /* petite animation d'apparition pour attirer l'œil */
+ animation: session-banner-in 0.22s ease-out;
+}
+@keyframes session-banner-in {
+ from { opacity: 0; transform: translateY(-6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+/* v4.2.5 : variante ORANGE pour "EV inaccessible" (distinct de session expirée) */
+.session-banner.ev-banner {
+ background: linear-gradient(90deg, #c77920, #e09a3a);
+ border-top: 2px solid #ffbb60;
+ border-bottom: 2px solid #7a4a15;
+}
+.session-banner.ev-banner .btn-primary {
+ color: #8a4a10;
}
.session-banner.hidden {
display: none;
}
.session-banner-icon {
- font-size: 18px;
+ font-size: 20px;
flex-shrink: 0;
}
.session-banner-text {
flex: 1;
line-height: 1.4;
}
+.session-banner-text strong {
+ font-weight: 600;
+}
.session-banner .btn-primary {
background: #fff;
- color: #7a1f1f;
+ color: #9a2020;
border: 0;
font-weight: 600;
}
@@ -207,8 +228,26 @@ html, body {
background: #f0f0f0;
}
.session-banner .btn-sm {
- padding: 4px 12px;
+ padding: 5px 12px;
font-size: 12px;
+ /* v4.2.5 : btn-sm non-primary dans la bannière = contour blanc */
+ background: transparent;
+ color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.5);
+ font-weight: 500;
+}
+.session-banner .btn-sm:hover {
+ background: rgba(255, 255, 255, 0.12);
+}
+.session-banner .btn-primary.btn-sm {
+ /* reset : le primary override le style du btn-sm */
+ background: #fff;
+ color: #9a2020;
+ border: 0;
+ font-weight: 600;
+}
+.session-banner.ev-banner .btn-primary.btn-sm {
+ color: #8a4a10;
}
.session-banner .btn-icon {
background: transparent;
@@ -217,6 +256,7 @@ html, body {
font-size: 20px;
line-height: 1;
padding: 4px 8px;
+ cursor: pointer;
}
.session-banner .btn-icon:hover {
background: rgba(255,255,255,0.15);
@@ -826,6 +866,49 @@ html, body {
width: 5px;
}
+/* v4.2.5 : statut "terminée par le tech" (commentaire LOGIN: détecté).
+ Vert PLUS CLAIR que status-closed (distinction visuelle du ✓ simple
+ vs ✓✓ double). */
+.intervention-v2.status-terminated {
+ background: var(--c-recup-soft, rgba(63, 185, 80, 0.12));
+ box-shadow: inset 4px 0 0 var(--c-recup, #3fb950);
+}
+.intervention-v2.status-terminated:hover {
+ background: var(--c-recup-soft, rgba(63, 185, 80, 0.12));
+ filter: brightness(0.96);
+}
+.intervention-v2.status-terminated .intervention-dot {
+ background: var(--c-recup, #3fb950);
+ width: 5px;
+}
+.intervention-v2.status-terminated .iv-status-check {
+ color: var(--c-recup, #3fb950);
+}
+.timeline-slot.status-terminated { background: var(--c-recup, #3fb950); }
+
+/* v4.2.5 : carte "en cours d'analyse" (ghost juste disparu, on re-fetch la
+ fiche pour décider du sort). Opacité réduite + petit spinner discret. */
+.intervention-v2._checking {
+ opacity: 0.6;
+ position: relative;
+}
+.intervention-v2._checking::after {
+ content: "";
+ position: absolute;
+ right: 10px;
+ top: 50%;
+ width: 12px;
+ height: 12px;
+ margin-top: -6px;
+ border: 2px solid var(--border, #ccc);
+ border-top-color: var(--text-muted, #666);
+ border-radius: 50%;
+ animation: iv-check-spin 0.9s linear infinite;
+}
+@keyframes iv-check-spin {
+ to { transform: rotate(360deg); }
+}
+
.intervention-v2.is-ghost {
opacity: 0.5;
text-decoration: line-through;
@@ -866,6 +949,14 @@ html, body {
}
.intervention-v2.status-resolved .iv-status-check { color: var(--c-resolved); }
+/* v4.2.5 : ✓✓ double check (clôturé/résolu) — un peu plus petit pour tenir
+ les 2 caractères. Espacement négatif pour les rapprocher. */
+.iv-status-check.double {
+ font-size: 14px;
+ letter-spacing: -3px;
+ padding-right: 3px; /* compenser le letter-spacing côté droit */
+}
+
.intervention-copy {
grid-area: copy;
align-self: start;
@@ -1067,6 +1158,13 @@ html, body {
========================================================================== */
.tooltip {
position: fixed !important;
+ /* v4.2.4 : forcer un stacking context propre et l'isolation pour que le
+ tooltip ne soit pas affecté par un éventuel filter/transform/contain
+ sur un ancêtre (qui casserait position:fixed). `contain: layout` et
+ `will-change: transform` garantissent aussi que le navigateur traite
+ ce tooltip indépendamment. */
+ isolation: isolate;
+ contain: layout;
z-index: 100;
max-width: 620px;
max-height: calc(100vh - 40px);
@@ -1082,9 +1180,6 @@ html, body {
pointer-events: none;
opacity: 0;
transition: opacity 0.1s;
- /* v4.2 : sélection de texte autorisée en permanence. Avant (v4.1.10) on
- bloquait par défaut et n'activait qu'en mode épinglé, mais c'était
- contre-productif — on veut pouvoir copier un numéro sans pin d'abord. */
user-select: text;
-webkit-user-select: text;
}
@@ -1401,6 +1496,15 @@ html, body {
.btn-modal-cancel:hover {
background: var(--bg-hover, rgba(128, 128, 128, 0.08));
}
+/* v4.2.5 : bouton primaire (action principale) pour modals d'alerte */
+.btn-modal-primary {
+ background: var(--c-accent, #3fb950);
+ color: #fff;
+ border-color: var(--c-accent, #3fb950);
+}
+.btn-modal-primary:hover {
+ filter: brightness(1.08);
+}
/* ─────────────────────────────────────────────────────────────────────────
v4.1.20 : Message d'absence récurrente (Pillonel vendredi)
@@ -1516,3 +1620,134 @@ html, body {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
+
+/* ─────────────────────────────────────────────────────────────────────────
+ v4.2.6 : boutons d'action topbar (Absence, Douchette)
+ ───────────────────────────────────────────────────────────────────────── */
+.btn-action {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ font-size: 13px;
+ font-weight: 500;
+ background: transparent;
+ color: var(--text, #e0e0e0);
+ border: 1px solid var(--border, rgba(128, 128, 128, 0.3));
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.12s, border-color 0.12s;
+}
+.btn-action:hover {
+ background: var(--bg-hover, rgba(128, 128, 128, 0.12));
+ border-color: var(--border-strong, rgba(128, 128, 128, 0.5));
+}
+.btn-action:active {
+ transform: translateY(1px);
+}
+.btn-action-icon {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+}
+.btn-action-emoji {
+ font-size: 14px;
+ line-height: 1;
+}
+.btn-action-label {
+ white-space: nowrap;
+}
+
+/* ─────────────────────────────────────────────────────────────────────────
+ v4.2.6 : modals Absence et Douchette
+ ───────────────────────────────────────────────────────────────────────── */
+.modal-card.modal-wide {
+ width: min(520px, 92vw);
+}
+.modal-form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: 14px;
+}
+.modal-form-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+.modal-form-row > * {
+ flex: 1;
+}
+.modal-form-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-muted, #888);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+.modal-form-input,
+.modal-form-select {
+ padding: 8px 10px;
+ font-size: 13px;
+ background: var(--bg, #fff);
+ color: var(--text, #111);
+ border: 1px solid var(--border, rgba(128, 128, 128, 0.3));
+ border-radius: 6px;
+ font-family: inherit;
+}
+.modal-form-input:focus,
+.modal-form-select:focus {
+ outline: none;
+ border-color: var(--c-accent, #3fb950);
+ box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.15);
+}
+
+/* Liste checkboxes techniciens */
+.modal-tech-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ /* v4.2.8 : plus de max-height → tous les techs (max 8 + "Tout") visibles
+ d'un coup sans avoir à scroller dans la liste. */
+ padding: 6px;
+ background: var(--bg-muted, rgba(128, 128, 128, 0.06));
+ border: 1px solid var(--border, rgba(128, 128, 128, 0.2));
+ border-radius: 6px;
+}
+.modal-tech-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 8px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 13px;
+ transition: background 0.1s;
+}
+.modal-tech-item:hover {
+ background: var(--bg-hover, rgba(128, 128, 128, 0.12));
+}
+.modal-tech-item input[type="checkbox"] {
+ width: 15px;
+ height: 15px;
+ cursor: pointer;
+ accent-color: var(--c-accent, #3fb950);
+}
+.modal-tech-item.tech-selectall {
+ font-weight: 600;
+ border-bottom: 1px solid var(--border, rgba(128, 128, 128, 0.2));
+ padding-bottom: 8px;
+ margin-bottom: 2px;
+}
+.modal-tech-item.tech-selectall:hover {
+ background: var(--bg-hover, rgba(128, 128, 128, 0.12));
+}
+
+/* Boutons Appliquer/Envoyer/Annuler côte à côte */
+.modal-actions.horizontal {
+ flex-direction: row;
+ gap: 8px;
+}
+.modal-actions.horizontal .btn {
+ flex: 1;
+}
diff --git a/viewer.html b/viewer.html
index 7ee753b..bb01067 100644
--- a/viewer.html
+++ b/viewer.html
@@ -24,6 +24,21 @@
✓
+
+
+
+
+
+
+ ⚠
+
+ EasyVista est inaccessible.
+ Données affichées depuis le cache.
+
+ Réessayer
+ Ouvrir EasyVista
+ ×
+
+
diff --git a/viewer.js b/viewer.js
index a249089..f2f1e07 100644
--- a/viewer.js
+++ b/viewer.js
@@ -230,16 +230,27 @@ async function init() {
async function refreshSessionAndLoad() {
const resp = await sendMessage({ type: "getSession" });
if (!resp.ok || !resp.session) {
- showSessionNeeded();
+ // v4.2.5 : si un cache existe pour le jour demandé, on l'affiche avec
+ // une bannière "session expirée" sticky au-dessus. Sinon écran plein.
+ const cached = await readCache(state.currentDate);
+ if (cached) {
+ renderFromData({
+ techs: cached.techs,
+ targetDate: state.currentDate,
+ captureTime: cached.savedAt || null,
+ source: "cache"
+ });
+ showSessionExpiredBanner();
+ } else {
+ showSessionNeeded();
+ }
return;
}
state.session = resp.session;
hideSessionNeeded();
hideEvUnreachable();
hideSessionExpiredBanner();
- // v4.2 : en tâche de fond, identifier l'utilisateur EasyVista connecté et
- // l'afficher dans la topbar. Ne pas bloquer le chargement du planning
- // si ça échoue.
+ hideEvUnreachableBanner();
fetchAndShowCurrentUser();
await loadForDate(state.currentDate);
}
@@ -398,6 +409,12 @@ function bindTopbar() {
});
document.getElementById("clear-cache-btn").addEventListener("click", onClearCache);
+ // v4.2.6 : boutons Absence et Douchette
+ const absenceBtn = document.getElementById("absence-btn");
+ if (absenceBtn) absenceBtn.addEventListener("click", showAbsenceModal);
+ const douchetteBtn = document.getElementById("douchette-btn");
+ if (douchetteBtn) douchetteBtn.addEventListener("click", showDouchetteModal);
+
document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1));
document.getElementById("nav-next").addEventListener("click", () => navigateDate(+1));
document.getElementById("nav-today").addEventListener("click", () => loadForDate(todayISO()));
@@ -414,16 +431,34 @@ function bindTopbar() {
toggleUserNamePopup();
});
}
- // Clic ailleurs ou touche Escape ferme la popup
+ // Clic ailleurs ou touche Escape ferme la popup user
document.addEventListener("click", (e) => {
const popup = document.getElementById("user-name-popup");
- if (!popup || popup.classList.contains("hidden")) return;
- // Ne pas fermer si le clic est dans la popup elle-même ou sur le badge
- if (e.target.closest("#user-name-popup") || e.target.closest("#user-badge")) return;
- hideUserNamePopup();
+ if (popup && !popup.classList.contains("hidden")) {
+ // Ne pas fermer si le clic est dans la popup elle-même ou sur le badge
+ if (!e.target.closest("#user-name-popup") && !e.target.closest("#user-badge")) {
+ hideUserNamePopup();
+ }
+ }
+ // v4.2.4 : clic ailleurs ferme aussi la grande bulle d'intervention
+ // quand elle est ouverte via clic timeline (mode "anchored"). Clic sur
+ // la bulle elle-même ou sur une timeline-slot ne ferme pas.
+ const tip = tooltipEl();
+ if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
+ if (!e.target.closest("#tooltip") && !e.target.closest(".timeline-slot")) {
+ hideTooltip({ force: true });
+ }
+ }
});
document.addEventListener("keydown", (e) => {
- if (e.key === "Escape") hideUserNamePopup();
+ if (e.key === "Escape") {
+ hideUserNamePopup();
+ // v4.2.4 : Échap ferme aussi la grande bulle anchored
+ const tip = tooltipEl();
+ if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
+ hideTooltip({ force: true });
+ }
+ }
});
document.getElementById("open-ev-btn").addEventListener("click", openEasyVista);
@@ -443,6 +478,17 @@ function bindTopbar() {
if (reconnectBtn) reconnectBtn.addEventListener("click", openEasyVista);
const closeBtn = document.getElementById("session-banner-close");
if (closeBtn) closeBtn.addEventListener("click", hideSessionExpiredBanner);
+
+ // v4.2.5 : bindings bannière "EasyVista inaccessible"
+ const evRetryBtn = document.getElementById("ev-unreachable-banner-retry");
+ if (evRetryBtn) evRetryBtn.addEventListener("click", async () => {
+ hideEvUnreachableBanner();
+ await refreshSessionAndLoad();
+ });
+ const evOpenBtn = document.getElementById("ev-unreachable-banner-open");
+ if (evOpenBtn) evOpenBtn.addEventListener("click", openEasyVista);
+ const evCloseBtn = document.getElementById("ev-unreachable-banner-close");
+ if (evCloseBtn) evCloseBtn.addEventListener("click", hideEvUnreachableBanner);
}
async function openEasyVista() {
@@ -546,6 +592,513 @@ function showClearCacheModal() {
document.addEventListener("keydown", escHandler);
}
+// ============================================================================
+// v4.2.5 : modal d'alerte générique (session expirée / EV inaccessible /
+// erreur d'ouverture). Remplace les alert() natives par une vraie popup
+// avec flou autour, titre, message et boutons personnalisables.
+// ============================================================================
+
+/**
+ * Affiche un modal d'alerte.
+ * @param {Object} opts
+ * @param {string} opts.title - Titre
+ * @param {string} opts.message - Message (HTML autorisé si opts.html=true)
+ * @param {boolean} [opts.html=false] - Si true, message interprété comme HTML
+ * @param {Array<{label:string, variant:"primary"|"secondary"|"danger", action:(()=>void|Promise
)}>} opts.buttons
+ * Boutons (en bas du modal). Le 1er = focus par défaut.
+ */
+function showAlertModal(opts) {
+ // Si un alert modal est déjà affiché, l'enlever d'abord
+ const existing = document.getElementById("alert-modal");
+ if (existing) existing.remove();
+
+ const overlay = document.createElement("div");
+ overlay.id = "alert-modal";
+ overlay.className = "modal-overlay";
+
+ const card = document.createElement("div");
+ card.className = "modal-card";
+ card.setAttribute("role", "dialog");
+ card.setAttribute("aria-labelledby", "alert-modal-title");
+
+ const h = document.createElement("h2");
+ h.id = "alert-modal-title";
+ h.className = "modal-title";
+ h.textContent = opts.title || "";
+ card.appendChild(h);
+
+ const p = document.createElement("p");
+ p.className = "modal-message";
+ if (opts.html) {
+ p.innerHTML = opts.message || "";
+ } else {
+ p.textContent = opts.message || "";
+ }
+ card.appendChild(p);
+
+ const actions = document.createElement("div");
+ actions.className = "modal-actions";
+ (opts.buttons || []).forEach((btn, i) => {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "btn";
+ if (btn.variant === "primary") b.classList.add("btn-modal-primary");
+ else if (btn.variant === "danger") b.classList.add("btn-modal-danger-strong");
+ else b.classList.add("btn-modal-cancel");
+ b.textContent = btn.label;
+ b.addEventListener("click", async () => {
+ overlay.remove();
+ if (typeof btn.action === "function") {
+ try { await btn.action(); } catch (e) { console.error("[alert-modal]", e); }
+ }
+ });
+ actions.appendChild(b);
+ if (i === 0) setTimeout(() => b.focus(), 50);
+ });
+ card.appendChild(actions);
+
+ overlay.appendChild(card);
+ document.body.appendChild(overlay);
+
+ // Clic sur le fond (flou) → fermer
+ overlay.addEventListener("click", (e) => {
+ if (e.target === overlay) overlay.remove();
+ });
+
+ // Échap ferme la modale
+ const escHandler = (e) => {
+ if (e.key === "Escape") {
+ overlay.remove();
+ document.removeEventListener("keydown", escHandler);
+ }
+ };
+ document.addEventListener("keydown", escHandler);
+}
+
+// ============================================================================
+// v4.2.6 : Modals Absence et Douchette
+// ============================================================================
+
+// Types d'absence EasyVista (extraits du HTML plan_set_holidays_popup.php)
+const HOLIDAY_TYPES = [
+ { guid: "{EF51F439-441E-4A68-9D1A-A6E0A85F32FE}", label: "Congés" },
+ { guid: "{B5B887A7-DE5D-4CAB-B55E-7D01E5D0DF84}", label: "Déménagement" },
+ { guid: "{8476B26C-DFE4-4256-B2B5-3CE1C9EC3479}", label: "Ecrans" },
+ { guid: "{E7432422-55CB-4DB9-8A26-619D036E2155}", label: "Evènements spéciaux" },
+ { guid: "{F9B8FFC6-5D64-4339-AAAF-166D6D3801DA}", label: "MAC" },
+ { guid: "{0554F45A-9B31-43D7-A1E2-0407D74F3BB5}", label: "Maladie" },
+ { guid: "{E8301A0F-B246-420A-863C-3837F1B581E0}", label: "PC" },
+ { guid: "{60D70502-063D-45AD-9415-25C1C556105F}", label: "Pompier" },
+ { guid: "{B343C590-1446-45BF-9CE6-790C759BA999}", label: "Réunion" },
+ { guid: "{7E63F472-677E-4EFD-B822-1AF4DC163AEC}", label: "Rollout" },
+ { guid: "{D45DEF80-9DDA-46BA-957E-B5B6D7F9D46A}", label: "Téléphones" },
+ { guid: "{06BCAC52-5A8A-4D6D-9BC6-566AAF18666A}", label: "UTP" }
+];
+
+/**
+ * Formate une date ISO YYYY-MM-DD en DD/MM/YYYY (format EasyVista).
+ */
+function isoToEvDate(iso) {
+ if (!iso) return "";
+ const parts = iso.split("-");
+ if (parts.length !== 3) return iso;
+ return `${parts[2]}/${parts[1]}/${parts[0]}`;
+}
+
+/**
+ * Construit un bloc liste de techniciens avec checkboxes.
+ * @param {Object} opts
+ * @param {boolean} [opts.selectAll] - Afficher la case "Tout sélectionner"
+ * @returns {HTMLElement}
+ */
+function buildTechCheckboxList(opts = {}) {
+ const container = document.createElement("div");
+ container.className = "modal-tech-list";
+
+ const techIds = Object.keys(TEAM);
+
+ if (opts.selectAll) {
+ const allRow = document.createElement("label");
+ allRow.className = "modal-tech-item tech-selectall";
+ const allBox = document.createElement("input");
+ allBox.type = "checkbox";
+ allBox.className = "tech-select-all";
+ const allLabel = document.createElement("span");
+ allLabel.textContent = "Tout sélectionner";
+ allRow.appendChild(allBox);
+ allRow.appendChild(allLabel);
+ container.appendChild(allRow);
+
+ allBox.addEventListener("change", () => {
+ container.querySelectorAll(".tech-checkbox").forEach(cb => {
+ cb.checked = allBox.checked;
+ });
+ });
+ }
+
+ for (const id of techIds) {
+ const row = document.createElement("label");
+ row.className = "modal-tech-item";
+ const cb = document.createElement("input");
+ cb.type = "checkbox";
+ cb.className = "tech-checkbox";
+ cb.value = id;
+ const label = document.createElement("span");
+ label.textContent = TEAM[id];
+ row.appendChild(cb);
+ row.appendChild(label);
+ container.appendChild(row);
+
+ // Cocher "Tout" si toutes les cases sont cochées (et décocher sinon)
+ cb.addEventListener("change", () => {
+ const allBox = container.querySelector(".tech-select-all");
+ if (!allBox) return;
+ const boxes = [...container.querySelectorAll(".tech-checkbox")];
+ allBox.checked = boxes.every(b => b.checked);
+ allBox.indeterminate = !allBox.checked && boxes.some(b => b.checked);
+ });
+ }
+
+ return container;
+}
+
+/**
+ * Récupère la liste des techIds cochés dans une liste de checkboxes.
+ */
+function getCheckedTechIds(container) {
+ return [...container.querySelectorAll(".tech-checkbox:checked")].map(cb => cb.value);
+}
+
+/**
+ * Ouvre la modal "Créer une absence".
+ */
+function showAbsenceModal() {
+ const existing = document.getElementById("absence-modal");
+ if (existing) existing.remove();
+
+ const overlay = document.createElement("div");
+ overlay.id = "absence-modal";
+ overlay.className = "modal-overlay";
+
+ const card = document.createElement("div");
+ card.className = "modal-card modal-wide";
+ card.setAttribute("role", "dialog");
+
+ const title = document.createElement("h2");
+ title.className = "modal-title";
+ title.textContent = "Créer une absence";
+ card.appendChild(title);
+
+ // Liste des techs (sans "Tout sélectionner" : on ne met quasi jamais tout
+ // le monde en absence, et c'est trop dangereux par erreur)
+ const techGroup = document.createElement("div");
+ techGroup.className = "modal-form-group";
+ const techLabel = document.createElement("label");
+ techLabel.className = "modal-form-label";
+ techLabel.textContent = "Technicien(s)";
+ techGroup.appendChild(techLabel);
+ const techList = buildTechCheckboxList({ selectAll: false });
+ techGroup.appendChild(techList);
+ card.appendChild(techGroup);
+
+ // Dates et heures : aujourd'hui ou le jour affiché, 08:00-18:00
+ const today = state.currentDate || todayISO();
+
+ const dateGroup = document.createElement("div");
+ dateGroup.className = "modal-form-group";
+ const dateLabel = document.createElement("label");
+ dateLabel.className = "modal-form-label";
+ dateLabel.textContent = "Date et heure de début";
+ dateGroup.appendChild(dateLabel);
+ const dateRow1 = document.createElement("div");
+ dateRow1.className = "modal-form-row";
+ const startDate = document.createElement("input");
+ startDate.type = "date";
+ startDate.className = "modal-form-input";
+ startDate.id = "absence-start-date";
+ startDate.value = today;
+ const startTime = document.createElement("input");
+ startTime.type = "time";
+ startTime.className = "modal-form-input";
+ startTime.id = "absence-start-time";
+ startTime.value = "08:00";
+ dateRow1.appendChild(startDate);
+ dateRow1.appendChild(startTime);
+ dateGroup.appendChild(dateRow1);
+ card.appendChild(dateGroup);
+
+ const endGroup = document.createElement("div");
+ endGroup.className = "modal-form-group";
+ const endLabel = document.createElement("label");
+ endLabel.className = "modal-form-label";
+ endLabel.textContent = "Date et heure de fin";
+ endGroup.appendChild(endLabel);
+ const endRow = document.createElement("div");
+ endRow.className = "modal-form-row";
+ const endDate = document.createElement("input");
+ endDate.type = "date";
+ endDate.className = "modal-form-input";
+ endDate.id = "absence-end-date";
+ endDate.value = today;
+ const endTime = document.createElement("input");
+ endTime.type = "time";
+ endTime.className = "modal-form-input";
+ endTime.id = "absence-end-time";
+ endTime.value = "18:00";
+ endRow.appendChild(endDate);
+ endRow.appendChild(endTime);
+ endGroup.appendChild(endRow);
+ card.appendChild(endGroup);
+
+ // Type d'absence
+ const typeGroup = document.createElement("div");
+ typeGroup.className = "modal-form-group";
+ const typeLabel = document.createElement("label");
+ typeLabel.className = "modal-form-label";
+ typeLabel.textContent = "Type d'absence";
+ typeGroup.appendChild(typeLabel);
+ const typeSelect = document.createElement("select");
+ typeSelect.className = "modal-form-select";
+ typeSelect.id = "absence-type-select";
+ const emptyOpt = document.createElement("option");
+ emptyOpt.value = "";
+ emptyOpt.textContent = "— Choisir un type —";
+ typeSelect.appendChild(emptyOpt);
+ for (const t of HOLIDAY_TYPES) {
+ const opt = document.createElement("option");
+ opt.value = t.guid;
+ opt.textContent = t.label;
+ typeSelect.appendChild(opt);
+ }
+ typeGroup.appendChild(typeSelect);
+ card.appendChild(typeGroup);
+
+ // Boutons Appliquer / Annuler
+ const actions = document.createElement("div");
+ actions.className = "modal-actions horizontal";
+ const cancelBtn = document.createElement("button");
+ cancelBtn.type = "button";
+ cancelBtn.className = "btn btn-modal-cancel";
+ cancelBtn.textContent = "Annuler";
+ cancelBtn.addEventListener("click", () => overlay.remove());
+ const applyBtn = document.createElement("button");
+ applyBtn.type = "button";
+ applyBtn.className = "btn btn-modal-primary";
+ applyBtn.textContent = "Appliquer";
+ applyBtn.addEventListener("click", async () => {
+ // Validation
+ const techIds = getCheckedTechIds(techList);
+ if (techIds.length === 0) {
+ showAlertModal({
+ title: "Sélection manquante",
+ message: "Choisissez au moins un technicien.",
+ buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
+ });
+ return;
+ }
+ if (!typeSelect.value) {
+ showAlertModal({
+ title: "Sélection manquante",
+ message: "Choisissez un type d'absence.",
+ buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
+ });
+ return;
+ }
+ const sd = startDate.value, st = startTime.value;
+ const ed = endDate.value, et = endTime.value;
+ if (!sd || !st || !ed || !et) {
+ showAlertModal({
+ title: "Dates/heures manquantes",
+ message: "Remplissez toutes les dates et heures.",
+ buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
+ });
+ return;
+ }
+ // Désactiver le bouton pendant l'envoi
+ applyBtn.disabled = true;
+ applyBtn.textContent = "Envoi…";
+ try {
+ await submitAbsence({
+ techIds: techIds,
+ startDate: sd,
+ startTime: st,
+ endDate: ed,
+ endTime: et,
+ typeGuid: typeSelect.value
+ });
+ overlay.remove();
+ showToast("Absence créée", techIds.length + " tech" + (techIds.length > 1 ? "s" : ""));
+ // Reload le planning du jour pour voir l'absence
+ if (state.session) {
+ await loadForDate(state.currentDate, { forceRefetch: true });
+ }
+ } catch (err) {
+ applyBtn.disabled = false;
+ applyBtn.textContent = "Appliquer";
+ showAlertModal({
+ title: "Erreur lors de la création",
+ message: "Impossible de créer l'absence : " + (err.message || err),
+ buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
+ });
+ }
+ });
+ actions.appendChild(cancelBtn);
+ actions.appendChild(applyBtn);
+ card.appendChild(actions);
+
+ overlay.appendChild(card);
+ document.body.appendChild(overlay);
+
+ overlay.addEventListener("click", (e) => {
+ if (e.target === overlay) overlay.remove();
+ });
+ const escHandler = (e) => {
+ if (e.key === "Escape") {
+ overlay.remove();
+ document.removeEventListener("keydown", escHandler);
+ }
+ };
+ document.addEventListener("keydown", escHandler);
+}
+
+/**
+ * Envoie la requête de création d'absence à EasyVista.
+ * Appelle le background script qui fait le POST avec la bonne session.
+ */
+async function submitAbsence(opts) {
+ const resp = await sendMessage({
+ type: "submitAbsence",
+ techIds: opts.techIds,
+ startDate: isoToEvDate(opts.startDate),
+ startTime: opts.startTime + ":00", // HH:MM:SS
+ endDate: isoToEvDate(opts.endDate),
+ endTime: opts.endTime + ":00",
+ typeGuid: opts.typeGuid,
+ currentDate: isoToEvDate(opts.startDate)
+ });
+ if (!resp || !resp.ok) {
+ throw new Error(resp && resp.error ? resp.error : "erreur inconnue");
+ }
+ return resp;
+}
+
+/**
+ * Ouvre la modal "Envoyer la planification sur la douchette".
+ */
+function showDouchetteModal() {
+ const existing = document.getElementById("douchette-modal");
+ if (existing) existing.remove();
+
+ const overlay = document.createElement("div");
+ overlay.id = "douchette-modal";
+ overlay.className = "modal-overlay";
+
+ const card = document.createElement("div");
+ card.className = "modal-card";
+ card.setAttribute("role", "dialog");
+
+ const title = document.createElement("h2");
+ title.className = "modal-title";
+ title.textContent = "Envoyer la planification sur la douchette";
+ card.appendChild(title);
+
+ const msg = document.createElement("p");
+ msg.className = "modal-message";
+ msg.textContent = "Choisissez le ou les techniciens qui recevront la planification du jour sur leur douchette.";
+ card.appendChild(msg);
+
+ const techGroup = document.createElement("div");
+ techGroup.className = "modal-form-group";
+ const techList = buildTechCheckboxList({ selectAll: true });
+ techGroup.appendChild(techList);
+ card.appendChild(techGroup);
+
+ // Boutons
+ const actions = document.createElement("div");
+ actions.className = "modal-actions horizontal";
+ const cancelBtn = document.createElement("button");
+ cancelBtn.type = "button";
+ cancelBtn.className = "btn btn-modal-cancel";
+ cancelBtn.textContent = "Annuler";
+ cancelBtn.addEventListener("click", () => overlay.remove());
+ const sendBtn = document.createElement("button");
+ sendBtn.type = "button";
+ sendBtn.className = "btn btn-modal-primary";
+ sendBtn.textContent = "Envoyer";
+ sendBtn.addEventListener("click", async () => {
+ const techIds = getCheckedTechIds(techList);
+ if (techIds.length === 0) {
+ showAlertModal({
+ title: "Sélection manquante",
+ message: "Choisissez au moins un technicien.",
+ buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
+ });
+ return;
+ }
+ sendBtn.disabled = true;
+ sendBtn.textContent = "Envoi…";
+ try {
+ const result = await submitDouchette(techIds);
+ overlay.remove();
+ if (result && result.okCount > 0) {
+ showToast(
+ "Envoyé sur douchette",
+ result.okCount + "/" + techIds.length + " tech" + (techIds.length > 1 ? "s" : "")
+ );
+ }
+ if (result && result.errors && result.errors.length > 0) {
+ showAlertModal({
+ title: "Envoi partiellement échoué",
+ message: result.errors.length + " tech(s) n'ont pas pu recevoir : "
+ + result.errors.map(e => TEAM[e.techId] || e.techId).join(", "),
+ buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
+ });
+ }
+ } catch (err) {
+ sendBtn.disabled = false;
+ sendBtn.textContent = "Envoyer";
+ showAlertModal({
+ title: "Erreur lors de l'envoi",
+ message: "Impossible d'envoyer sur la douchette : " + (err.message || err),
+ buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
+ });
+ }
+ });
+ actions.appendChild(cancelBtn);
+ actions.appendChild(sendBtn);
+ card.appendChild(actions);
+
+ overlay.appendChild(card);
+ document.body.appendChild(overlay);
+
+ overlay.addEventListener("click", (e) => {
+ if (e.target === overlay) overlay.remove();
+ });
+ const escHandler = (e) => {
+ if (e.key === "Escape") {
+ overlay.remove();
+ document.removeEventListener("keydown", escHandler);
+ }
+ };
+ document.addEventListener("keydown", escHandler);
+}
+
+/**
+ * Envoie la planification sur la douchette de plusieurs techniciens.
+ * Retourne { okCount, errors: [{techId, error}] }.
+ */
+async function submitDouchette(techIds) {
+ const resp = await sendMessage({
+ type: "submitDouchette",
+ techIds: techIds,
+ currentDate: isoToEvDate(state.currentDate || todayISO())
+ });
+ if (!resp || !resp.ok) {
+ throw new Error(resp && resp.error ? resp.error : "erreur inconnue");
+ }
+ return resp;
+}
+
// ============================================================================
// Date helpers
// ============================================================================
@@ -624,7 +1177,19 @@ async function loadForDate(isoDate, opts = {}) {
document.getElementById("date-picker").value = isoDate;
if (!state.session) {
- showSessionNeeded();
+ // v4.2.5 : afficher le cache + bannière, plutôt que l'écran "session"
+ const cached = await readCache(isoDate);
+ if (cached) {
+ renderFromData({
+ techs: cached.techs,
+ targetDate: isoDate,
+ captureTime: cached.savedAt || null,
+ source: "cache"
+ });
+ showSessionExpiredBanner();
+ } else {
+ showSessionNeeded();
+ }
return;
}
@@ -684,12 +1249,24 @@ async function loadForDate(isoDate, opts = {}) {
// 3. Fusionner cache + frais
const merged = mergeCacheAndFresh(cached, fresh);
- // v4.1.9 : retirer immédiatement les iv du cache qui ne sont plus dans
- // le fresh (elles ont été supprimées / déplacées / annulées dans
- // EasyVista). Le user veut qu'elles disparaissent visuellement tout de
- // suite, pas qu'elles restent en "ghost".
+ // v4.2.5 : AVANT de retirer les ghosts, on lance une analyse de chaque
+ // ghost pour déterminer si c'est :
+ // - un ticket TERMINÉ par le tech (→ garder en vert ✓ simple)
+ // - un ticket CLÔTURÉ/RÉSOLU dans EasyVista (→ garder en vert ✓✓ double)
+ // - un ticket DÉPLACÉ (action ouverte au même tech autre jour) → retirer
+ // - un ticket ANNULÉ / autre → retirer
+ // L'analyse est asynchrone (re-fetch de chaque fiche) : on la lance en
+ // arrière-plan APRÈS le rendu initial pour ne pas bloquer l'UI.
+ // En attendant, les ghosts restent visibles avec un indicateur "en cours
+ // d'analyse" (petit spinner / opacité réduite).
+ const ghostsToAnalyze = [];
for (const tech of merged.techs) {
- tech.interventions = tech.interventions.filter(iv => !iv.ghost);
+ for (const iv of tech.interventions) {
+ if (iv.ghost) {
+ iv._disappearChecking = true; // marquer "en cours d'analyse"
+ ghostsToAnalyze.push({ tech, iv });
+ }
+ }
}
// 4. Afficher immédiatement (v4 : tout est déjà rempli depuis le XML !)
@@ -706,6 +1283,27 @@ async function loadForDate(isoDate, opts = {}) {
});
console.log(`[load] 1er rendu complet à ${Math.round(performance.now() - t0)} ms`);
+ // v4.2.5 : analyser les ghosts (tickets disparus du planning) pour décider
+ // s'il faut les garder en vert (terminés par tech / clôturés) ou les
+ // retirer définitivement (déplacés / annulés). Asynchrone en arrière-plan.
+ if (ghostsToAnalyze.length > 0 && !isRefreshAborted(myToken)) {
+ console.log(`[load] analyse de ${ghostsToAnalyze.length} ticket(s) disparu(s)…`);
+ analyzeDisappearedInterventions(merged.techs, ghostsToAnalyze, myToken)
+ .then(() => {
+ if (!isRefreshAborted(myToken)) {
+ renderFromData({
+ techs: merged.techs,
+ targetDate: isoDate,
+ captureTime: Date.now(),
+ source: "fresh",
+ lastRefreshKind: activeRefreshButton
+ });
+ writeCache(isoDate, { techs: merged.techs }).catch(() => {});
+ }
+ })
+ .catch(err => console.error("[disappear-analysis]", err));
+ }
+
// 5. Fetch des fiches en arrière-plan UNIQUEMENT pour obtenir :
// - le statut Clôturé/Résolu (pour le ✓ vert et le fond vert)
// - le commentaire technicien (affiché dans le tooltip)
@@ -786,12 +1384,26 @@ async function fetchPlanningForDate(isoDate) {
unixDate: unixDate
});
if (!resp.ok) {
+ // v4.2.5 : si le planning du jour est DÉJÀ rendu (cache), on affiche
+ // une bannière non bloquante en haut, le cache reste visible.
+ // Si rien n'est rendu (1er chargement, pas de cache), on affiche
+ // l'écran plein comme avant.
+ const hasCacheRendered =
+ document.getElementById("cards") &&
+ document.getElementById("cards").children.length > 0;
if (resp.error === "no_session" || resp.error === "session_expired") {
state.session = null;
- showSessionNeeded();
+ if (hasCacheRendered) {
+ showSessionExpiredBanner();
+ } else {
+ showSessionNeeded();
+ }
} else if (resp.error === "ev_unreachable") {
- // v4.2 : EasyVista inaccessible (500/503/réseau/etc.)
- showEvUnreachable();
+ if (hasCacheRendered) {
+ showEvUnreachableBanner();
+ } else {
+ showEvUnreachable();
+ }
} else {
showError("Erreur de fetch : " + (resp.error || "inconnue"));
}
@@ -806,7 +1418,14 @@ async function fetchPlanningForDate(isoDate) {
if (!resp.xml || resp.xml.length < 20) {
console.warn("[viewer] XML planning vide — session probablement invalide");
state.session = null;
- showSessionNeeded();
+ const hasCacheRendered =
+ document.getElementById("cards") &&
+ document.getElementById("cards").children.length > 0;
+ if (hasCacheRendered) {
+ showSessionExpiredBanner();
+ } else {
+ showSessionNeeded();
+ }
return null;
}
@@ -936,7 +1555,7 @@ function actionNodeToIntervention(node) {
// ─── v4 : pré-remplissage immédiat depuis les attributs XML ─────────────────
// On renseigne bulleContact/bulleLieu/categoryLine DÈS la création de l'objet.
// Plus besoin d'attendre xhr2 ou la fiche pour avoir l'affichage de base.
- // Seuls restent à fetcher (en arrière-plan, sur fiche) : status + commentaireTech.
+ // Seuls restent à fetcher (en arrière-plan, sur fiche) : status.
// Et sur hover (lazy, seulement si l'user survole) : bulleDescription complet.
const isIntervention = effectiveType === "AL-Intervention";
const bulleContact = isIntervention && attr1 ? attr1 : null;
@@ -969,7 +1588,6 @@ function actionNodeToIntervention(node) {
bulleDescription: null, // reste null, rempli lazy au premier hover (xhr2)
infobulle: null, // reste null, rempli lazy aussi
status: null, // toujours rempli par fetch fiche (en arrière-plan)
- commentaireTech: null, // toujours rempli par fetch fiche (en arrière-plan)
// v4 : ficheTarget/Checksum déjà présents dans formLink (extraits à la demande)
ficheTarget: null,
ficheChecksum: null,
@@ -1038,7 +1656,7 @@ function mergeCacheAndFresh(cached, fresh) {
// - Le fresh APPORTE (depuis le XML calendar_block) : actionId, type,
// startTime/endTime, formLink, ref (textContent), bulleContact (attr1),
// bulleLieu (attr2), categoryLine (attr3), deadline.
- // - Le cache APPORTE : status (clôturé/résolu), commentaireTech,
+ // - Le cache APPORTE : status (clôturé/résolu),
// bulleDescription (lazy-load xhr2 au hover) + infobulle, ficheFetched,
// xhr2Fetched.
// - Règle générale : fresh wins sur les champs live, cache wins sur les
@@ -1126,6 +1744,342 @@ function mergeCacheAndFresh(cached, fresh) {
return { techs: resultTechs };
}
+// ============================================================================
+// v4.2.5 : analyse des tickets disparus du planning
+// ============================================================================
+//
+// Pour chaque ticket qui était dans le cache mais n'est plus dans le XML
+// fresh, on doit décider s'il faut :
+// 1. Le GARDER en vert double ✓✓ → clôturé / résolu dans EasyVista
+// 2. Le GARDER en vert simple ✓ → terminé par le tech (commentaire LOGIN:)
+// 3. Le RETIRER → déplacé sur un autre jour / annulé / autre
+//
+// Logique (validée avec l'utilisateur) :
+// a) Re-fetch la fiche
+// b) Si statut global = CLOS ou RÉSOLU → garder, vert ✓✓
+// c) Sinon parcourir les actions OUVERTES de la fiche :
+// - Si action ouverte au nom du tech sur JOUR DIFFÉRENT → retirer (déplacée)
+// - Sinon passer à l'étape d
+// d) Parcourir les actions FERMÉES au nom du tech :
+// - Si une action fermée contient un commentaire tech (pattern `LOGIN:
+// commentaire` où LOGIN = alphanumérique 3-12 chars minuscule) → garder, vert ✓
+// - Sinon → retirer
+//
+// Distinction action ouverte/fermée :
+// Observation sur les HTML fournis : dans le JSON timeline de la fiche,
+// l'action "AL-Intervention" apparaît SEULEMENT si elle a été complétée
+// (fermée). Si elle est toujours ouverte, elle n'est pas dans le timeline.
+// Les autres types d'actions ("Ajout d'informations", "Envoi de mail", etc.)
+// apparaissent dès leur création.
+
+// Regex pour détecter un commentaire tech dans le texte d'une action.
+// Pattern : début de ligne OU
suivi d'un login court (3-12 caractères
+// alphanumériques MINUSCULES) + ":" + espace + texte.
+// Exemples qui matchent : "vyjuva: Casque remplacé", "awr: ok".
+// Exemples qui NE matchent PAS :
+// - "Service : X" (majuscule + pas un login)
+// - "Nom2, Prénom2" (contient une virgule, pas un login)
+// - "AWR 16/04/26" (pas de deux-points)
+// - "Date : vendredi 17.04" (majuscule au début, c'est un champ)
+const RX_LOGIN_COMMENTAIRE = /(?:^|\n|
)\s*([a-z0-9_]{3,12})\s*:\s+(\S[^\n<]{2,})/im;
+
+/**
+ * Extrait toutes les actions d'une fiche en parsant les blocs "rows" du HTML.
+ * Chaque action a 14 values :
+ * [2] = Intervenant (ex: "Nom, Prénom" ou "EZV_WS_REST_USER")
+ * [4] = Type d'action (ex: "AL-Intervention", "Ajout d'informations")
+ * [8] = Date de création (JJ/MM/AAAA HH:MM:SS)
+ * [9] = Date de fin
+ * [11] = Description HTML (contient le texte de l'action + commentaire tech)
+ *
+ * Retourne : [ { intervenant, type, dateCreation, dateFin, description }, ... ]
+ */
+function parseAllActionsFromFicheHtml(html) {
+ if (!html) return [];
+ // Décoder : dans le HTML, les JSON imbriqués ont \u0022 pour " et \/ pour /
+ const decoded = html
+ .replace(/\\u0022/g, '"')
+ .replace(/\\\//g, '/');
+
+ const actions = [];
+ // Chercher chaque bloc "rows":[...]
+ const rowsRegex = /"rows":\[/g;
+ let m;
+ while ((m = rowsRegex.exec(decoded)) !== null) {
+ const start = m.index + m[0].length;
+ // Trouver la fin du array [...] correspondant (balance des crochets)
+ let j = start;
+ let depth = 1;
+ while (j < decoded.length && depth > 0) {
+ const c = decoded[j];
+ if (c === '[') depth++;
+ else if (c === ']') depth--;
+ j++;
+ }
+ const block = decoded.substring(start, j - 1);
+ const values = extractValuesFromRowBlock(block);
+ if (values.length < 12) continue;
+ // Une "vraie" action a 14 valeurs. On se contente de 12 minimum
+ // pour avoir au moins la description.
+ actions.push({
+ intervenant: decodeUnicodeEscapes(values[2] || ""),
+ type: decodeUnicodeEscapes(values[4] || ""),
+ dateCreation: values[8] || "",
+ dateFin: values[9] || "",
+ description: values[11] || ""
+ });
+ }
+ return actions;
+}
+
+/**
+ * Extrait les valeurs "value":"..." d'un bloc JSON row, gère les guillemets
+ * échappés (\").
+ */
+function extractValuesFromRowBlock(block) {
+ const values = [];
+ let i = 0;
+ while (i < block.length) {
+ const mIdx = block.indexOf('"value":"', i);
+ if (mIdx < 0) break;
+ const start = mIdx + '"value":"'.length;
+ let j = start;
+ while (j < block.length) {
+ if (block[j] === '\\') { j += 2; continue; }
+ if (block[j] === '"') break;
+ j++;
+ }
+ values.push(block.substring(start, j));
+ i = j + 1;
+ }
+ return values;
+}
+
+/**
+ * Décode les échappements Unicode \u00XX présents dans les valeurs extraites.
+ */
+function decodeUnicodeEscapes(s) {
+ if (!s) return s;
+ return s.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
+}
+
+/**
+ * Détermine si une action est "fermée" ou "ouverte".
+ * - Pour AL-Intervention : on cherche sa présence dans le JSON timeline de
+ * la fiche (via la valeur [13] qui contient un JSON avec "NAME".) Si cette
+ * action existe dans le JSON, elle est considérée fermée.
+ * - Pour les autres types : on considère fermée si dateFin est remplie et
+ * différente de dateCreation (approximation raisonnable observée sur les
+ * HTML fournis).
+ * - Actions système (Intervenant = "EZV_WS_REST_USER" ou vide) : ignorées
+ * dans le matching "action au nom du tech".
+ *
+ * Pour notre logique, ce qui compte vraiment :
+ * - Actions "AL-Intervention" fermées = présentes dans le bloc JSON
+ * "timeline" de la fiche (pas dans les "rows" HTML, qui les listent toutes)
+ *
+ * Plus simplement, je détecte la présence de AL-Intervention dans le HTML
+ * comme indicateur : si `"NAME":"AL-Intervention"` figure dans le JSON
+ * timeline, alors l'AL-Intervention est fermée.
+ */
+function hasClosedAlInterventionInHtml(html) {
+ if (!html) return false;
+ // Chercher dans le HTML brut (non décodé) le pattern de timeline
+ // `\u0022NAME\u0022:\u0022AL-Intervention\u0022`
+ return /\\u0022NAME\\u0022:\\u0022AL-Intervention\\u0022/.test(html);
+}
+
+/**
+ * Vérifie si le texte d'une action contient un commentaire tech au format
+ * `LOGIN: commentaire`. Nettoie d'abord le HTML de la description.
+ */
+function hasTechCommentInDescription(description) {
+ if (!description) return false;
+ // Décoder unicode puis remplacer les
par \n pour faciliter le regex
+ const txt = decodeUnicodeEscapes(description)
+ .replace(/
/gi, '\n')
+ .replace(/<\/?p[^>]*>/gi, '\n')
+ .replace(/<[^>]+>/g, '')
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&');
+ return RX_LOGIN_COMMENTAIRE.test(txt);
+}
+
+/**
+ * Normalise un nom "Nom, Prénom" (insensible à la casse, accents ignorés)
+ * pour comparaison.
+ */
+function normalizeName(s) {
+ if (!s) return "";
+ return s
+ .toLowerCase()
+ .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+/**
+ * Détermine si une action est au nom du technicien donné.
+ * Compare l'intervenant de l'action avec le nom du tech (insensible casse/accents).
+ * Ignore les actions système (EZV_WS_REST_USER, vide).
+ */
+function actionBelongsToTech(action, techName) {
+ const interv = normalizeName(action.intervenant);
+ if (!interv || interv === "ezv_ws_rest_user") return false;
+ const tech = normalizeName(techName);
+ if (!tech) return false;
+ // Le nom du tech dans notre config est souvent "Prénom Nom" alors que
+ // l'EasyVista affiche "Nom, Prénom". On accepte les deux ordres.
+ // Simple test : au moins un mot du nom tech (longueur > 2) est dans l'intervenant.
+ const techParts = tech.split(/[\s,]+/).filter(p => p.length >= 3);
+ if (techParts.length === 0) return false;
+ // Exiger que TOUS les mots significatifs du nom tech soient dans l'intervenant
+ return techParts.every(p => interv.includes(p));
+}
+
+/**
+ * Analyse les tickets disparus du planning et décide pour chacun s'il faut
+ * le garder en vert (terminé tech ou clôturé) ou le retirer.
+ *
+ * Modifie directement les tech.interventions en place (retire les ghosts à
+ * retirer, met à jour les propriétés des ghosts à garder).
+ */
+async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken) {
+ // Traiter en parallèle pour rester rapide (max 3 fiches en parallèle)
+ const concurrency = 3;
+ const queue = [...ghostsToAnalyze];
+ const workers = [];
+ for (let w = 0; w < concurrency; w++) {
+ workers.push((async () => {
+ while (queue.length > 0) {
+ if (isRefreshAborted(myToken)) return;
+ const { tech, iv } = queue.shift();
+ try {
+ await analyzeOneDisappearedIv(tech, iv);
+ } catch (err) {
+ console.warn("[disappear] analyse échouée pour", iv.actionId, err);
+ // En cas d'erreur, on garde l'iv visible mais sans marquage spécial
+ iv._disappearChecking = false;
+ iv.ghost = false; // on la laisse visible plutôt que perdre de l'info
+ iv._disappearStatus = "error";
+ }
+ }
+ })());
+ }
+ await Promise.all(workers);
+
+ // Filtrer les iv qui doivent être retirées définitivement
+ for (const tech of techs) {
+ tech.interventions = tech.interventions.filter(iv => !iv._disappearRemove);
+ }
+}
+
+/**
+ * Analyse une seule intervention disparue.
+ * Met à jour iv._disappearStatus ("closed" | "terminated" | "moved" | "cancelled")
+ * et iv._disappearRemove (true si à retirer).
+ */
+async function analyzeOneDisappearedIv(tech, iv) {
+ // Étape 1 : re-fetch la fiche
+ const resp = await sendMessage({
+ type: "fetchFiche",
+ formLink: iv.formLink
+ });
+ if (!resp || !resp.ok) {
+ // En cas d'erreur fetch : on garde visible (pas de décision)
+ iv._disappearChecking = false;
+ iv._disappearStatus = "error";
+ iv.ghost = false;
+ return;
+ }
+ const html = resp.html;
+
+ // Étape 2 : statut global de la fiche
+ const ficheData = parseFicheHtml(html);
+ const status = ficheData.status || iv.status || null;
+ iv.status = status; // garder à jour
+
+ if (isClosedStatus(status) || isResolvedStatus(status)) {
+ // CAS 1 : clôturé / résolu → garder, vert ✓✓ (double check)
+ iv._disappearChecking = false;
+ iv._disappearStatus = "closed";
+ iv._disappearRemove = false;
+ iv.ghost = false;
+ return;
+ }
+
+ // Étape 3 : parser toutes les actions de la fiche
+ const actions = parseAllActionsFromFicheHtml(html);
+
+ // Identifier les actions AL-Intervention au nom du tech.
+ //
+ // Pour savoir si une AL-Intervention spécifique est fermée ou ouverte,
+ // on utilise l'indicateur global `hasClosedAlInterventionInHtml` :
+ // - SI la fiche contient "AL-Intervention" dans le JSON timeline
+ // → l'action AL-Intervention est fermée (terminée par le tech)
+ // - SINON → elle est encore ouverte
+ const alActionsForTech = actions.filter(a =>
+ a.type === "AL-Intervention" && actionBelongsToTech(a, tech.name || tech.label || "")
+ );
+ const hasClosedAl = hasClosedAlInterventionInHtml(html);
+
+ // CAS 2 : action AL-Intervention encore ouverte au nom du tech
+ if (alActionsForTech.length > 0 && !hasClosedAl) {
+ // Vérifier sur quel jour elle est planifiée maintenant. Si on ne peut
+ // pas déterminer, on retire par prudence (elle a été bougée, sinon
+ // elle serait encore dans le fresh).
+ // On regarde si une action ouverte référence explicitement notre jour.
+ // Simple heuristique : on regarde les dates dans les descriptions.
+ iv._disappearChecking = false;
+ iv._disappearStatus = "moved";
+ iv._disappearRemove = true; // retirer (déplacée)
+ return;
+ }
+
+ // CAS 3 : action AL-Intervention FERMÉE au nom du tech → chercher un
+ // commentaire tech dans les descriptions des actions du tech.
+ if (alActionsForTech.length > 0 && hasClosedAl) {
+ const anyHasComment = alActionsForTech.some(a =>
+ hasTechCommentInDescription(a.description)
+ );
+ if (anyHasComment) {
+ // Terminée par le tech → garder, vert ✓ simple
+ iv._disappearChecking = false;
+ iv._disappearStatus = "terminated";
+ iv._disappearRemove = false;
+ iv.ghost = false;
+ return;
+ }
+ // Pas de commentaire détecté → retirer (annulée)
+ iv._disappearChecking = false;
+ iv._disappearStatus = "cancelled";
+ iv._disappearRemove = true;
+ return;
+ }
+
+ // CAS 4 : aucune action AL-Intervention au nom du tech dans la fiche →
+ // vérifier si une action quelconque au nom du tech existe avec commentaire.
+ // Si oui, on considère que le tech a travaillé dessus.
+ const anyActionForTech = actions.filter(a =>
+ actionBelongsToTech(a, tech.name || tech.label || "")
+ );
+ const anyHasComment = anyActionForTech.some(a =>
+ hasTechCommentInDescription(a.description)
+ );
+ if (anyHasComment) {
+ iv._disappearChecking = false;
+ iv._disappearStatus = "terminated";
+ iv._disappearRemove = false;
+ iv.ghost = false;
+ return;
+ }
+
+ // CAS 5 (défaut) : aucune trace claire du tech → retirer
+ iv._disappearChecking = false;
+ iv._disappearStatus = "cancelled";
+ iv._disappearRemove = true;
+}
+
// ============================================================================
// Fetch des fiches individuelles (pour obtenir le statut et les détails)
// ============================================================================
@@ -1319,9 +2273,9 @@ async function fetchAndUpdateIntervention(iv, myToken) {
const fiche = parseFicheHtml(ficheResp.html);
iv.status = fiche.status;
- // Rétrocompat : champ plus utilisé, on le laisse à null pour ne pas casser
- // d'anciens caches avec un champ undefined.
- iv.commentaireTech = null;
+ // v4.2.5 : on retire définitivement le champ commentaireTech (obsolète
+ // depuis qu'on récupère l'action complète via l'API timeline).
+ delete iv.commentaireTech;
// Fallback : si l'XML n'avait pas la ref (rare, mais possible pour des
// actions hors-standard), on prend celle de la fiche.
if (fiche.rfc && !iv.ref) {
@@ -2404,6 +3358,10 @@ function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl,
}
function getStatusClass(iv) {
+ // v4.2.5 : priorité aux statuts de disparition analysés
+ if (iv._disappearStatus === "closed") return "status-closed";
+ if (iv._disappearStatus === "terminated") return "status-terminated";
+ if (iv._disappearStatus === "error") return null;
if (isClosedStatus(iv.status)) return "status-closed";
if (isResolvedStatus(iv.status)) return "status-resolved";
return null;
@@ -2474,9 +3432,9 @@ function moveTimelineTooltip(e) {
if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offsetY;
if (x < 4) x = 4;
if (y < 4) y = 4;
- tip.style.left = x + "px";
- tip.style.top = y + "px";
- currentTooltipPos = { x, y };
+ // v4.2.4 : utiliser setTooltipViewportPosition pour bénéficier de la
+ // détection automatique fixed/abs (et donc de la stabilité au scroll).
+ setTooltipViewportPosition(x, y);
}
// v4.2.3 : trouve l'iv correspondant au segment timeline et ouvre sa fiche
@@ -2506,9 +3464,9 @@ function findIvByActionId(actionId) {
return null;
}
-// v4.2.3 : ouvre la GRANDE popup (comme au hover sur une ligne) mais ancrée
-// juste en dessous du segment timeline cliqué, et épinglée pour qu'elle
-// reste ouverte et autorise la sélection de texte.
+// v4.2.3/4 : ouvre la GRANDE popup au clic sur un segment timeline, ancrée
+// juste en dessous du segment. Pas épinglée : se ferme sur clic ailleurs,
+// Échap, OU quand la souris quitte la popup elle-même (mouseleave).
function openPersistentTimelinePopup(el) {
const ivIdx = el.dataset.ivIdx;
if (ivIdx === undefined) return;
@@ -2520,43 +3478,43 @@ function openPersistentTimelinePopup(el) {
const iv = findIvByActionId(actionId);
if (!iv) return;
- // Fermer toute popup en cours (petite timeline ou grande bulle)
const tip = tooltipEl();
if (!tip) return;
- // Reset de l'état pour qu'un hideTooltip forcé ne soit pas bloqué
- bulleState.pinned = false;
- hideTooltip({ force: true });
- // Reset position mémorisée pour éviter re-application au scroll sur
- // l'ancienne position
- currentTooltipPos = null;
- // Construit et affiche la grande bulle, ancrée sous la timeline
+ // Nettoyer tout état précédent (ancrage, épinglage, timers)
+ bulleState.pinned = false;
+ bulleState.hoveredInBulle = false;
+ bulleState.hoveredInRow = false;
+ if (bulleState.hideTimer) { clearTimeout(bulleState.hideTimer); bulleState.hideTimer = null; }
+ tip.classList.remove("pinned");
+
+ // Construire la grande bulle
tip.innerHTML = buildTooltipHTML(iv);
tip.classList.remove("hidden");
tip.classList.add("visible");
+ // mode "anchored" : le hover ne doit pas la remplacer par une autre popup
tip.dataset.mode = "anchored";
state.currentTooltipIv = iv;
- // Position : juste en dessous de l'élément timeline, aligné à gauche
- const r = el.getBoundingClientRect();
+ // Position : juste sous le segment timeline. D'abord on reset les coords
+ // pour que getBoundingClientRect() reflète la vraie taille du nouveau
+ // contenu.
+ tip.style.left = "-9999px";
+ tip.style.top = "0px";
+ // Forcer un reflow pour que tipRect soit à jour avec le nouveau contenu
const tipRect = tip.getBoundingClientRect();
+ const r = el.getBoundingClientRect();
let x = r.left;
let y = r.bottom + 8;
if (x + tipRect.width > window.innerWidth - 8) x = window.innerWidth - tipRect.width - 8;
if (x < 4) x = 4;
if (y + tipRect.height > window.innerHeight - 8) {
- // Pas assez de place en bas : on met au-dessus de la timeline
y = r.top - tipRect.height - 8;
}
if (y < 4) y = 4;
- tip.style.left = x + "px";
- tip.style.top = y + "px";
- currentTooltipPos = { x, y };
- // Épingler pour que la popup reste ouverte (elle ne se fermera que sur
- // Échap, clic sur ✕ ou double-Ctrl). Permet aussi la sélection de texte.
- bulleState.pinned = true;
- tip.classList.add("pinned");
+ // Positionner proprement (avec détection auto fixed vs abs)
+ setTooltipViewportPosition(x, y);
}
function showTimelinePopover(e, el) {
@@ -2587,9 +3545,16 @@ function showTimelinePopover(e, el) {
`;
}
const tip = tooltipEl();
- // v4.2.3 : si une grande bulle est déjà épinglée via clic timeline, on ne
+ // v4.2.3 : si une grande bulle est déjà ancrée (clic timeline), on ne
// la remplace pas par la petite popup hover.
- if (bulleState.pinned && tip.dataset.mode === "anchored") return;
+ if (tip.dataset.mode === "anchored") return;
+ // v4.2.4 : annuler tout hideTimer en cours pour éviter que la popup
+ // précédente, en train d'être masquée, masque AUSSI celle-ci juste après.
+ // Problème typique quand on passe rapidement d'un segment à un autre.
+ if (bulleState.hideTimer) {
+ clearTimeout(bulleState.hideTimer);
+ bulleState.hideTimer = null;
+ }
tip.innerHTML = html;
tip.classList.remove("hidden", "pinned");
tip.classList.add("visible");
@@ -2608,6 +3573,9 @@ function buildInterventionRow(iv, cardEl) {
row.dataset.actionId = iv.actionId;
if (iv.isPompier) row.classList.add("is-pompier-line");
if (iv.ghost) row.classList.add("is-ghost");
+ // v4.2.5 : indicateur "en cours d'analyse" (ticket disparu, on re-fetch
+ // la fiche pour décider de le garder en vert ou le retirer).
+ if (iv._disappearChecking) row.classList.add("_checking");
const colorKey = deriveColorKey(iv);
row.classList.add("color-" + colorKey);
@@ -2669,7 +3637,14 @@ function buildInterventionRow(iv, cardEl) {
if (statusClass && iv.type !== "AL-Reservation") {
const statusEl = document.createElement("div");
statusEl.className = "iv-status-check";
- statusEl.textContent = "✓";
+ // v4.2.5 : ✓✓ double pour clôturé/résolu (statut officiel EasyVista)
+ // ✓ simple pour "terminé par tech" (commentaire LOGIN: détecté)
+ if (statusClass === "status-closed" || statusClass === "status-resolved") {
+ statusEl.textContent = "✓✓";
+ statusEl.classList.add("double");
+ } else {
+ statusEl.textContent = "✓";
+ }
row.appendChild(statusEl);
}
if (iv.ref && iv.type !== "AL-Reservation") {
@@ -2802,13 +3777,26 @@ async function openInterventionInNewTab(iv, opts = {}) {
session = resp && resp.session;
}
if (!session) {
- alert("Pas de session EasyVista active. Ouvre d'abord un onglet EasyVista.");
+ // v4.2.5 : popup modale propre au lieu d'alert natif
+ showAlertModal({
+ title: "Impossible d'ouvrir la fiche",
+ message: "Votre session EasyVista a expiré. Reconnectez-vous à EasyVista puis réessayez.",
+ buttons: [
+ { label: "Ouvrir EasyVista", variant: "primary", action: () => openEasyVista() },
+ { label: "Annuler", variant: "secondary", action: () => {} }
+ ]
+ });
return;
}
if (!iv.requestId) {
- alert("Impossible d'ouvrir : identifiant de fiche (request_id) manquant.\n" +
- "Essaie d'actualiser le planning (bouton Rafraîchir).");
+ showAlertModal({
+ title: "Impossible d'ouvrir la fiche",
+ message: "L'identifiant de la fiche est manquant. Essayez d'actualiser le planning (bouton Actualiser).",
+ buttons: [
+ { label: "OK", variant: "secondary", action: () => {} }
+ ]
+ });
return;
}
@@ -2833,7 +3821,35 @@ async function openInterventionInNewTab(iv, opts = {}) {
});
if (!ficheResp.ok) {
if (attempts >= maxAttempts) {
- alert("Impossible d'ouvrir la fiche : " + (ficheResp.error || "erreur"));
+ // v4.2.5 : popup modale selon le type d'erreur
+ if (ficheResp.error === "no_session" || ficheResp.error === "session_expired") {
+ showAlertModal({
+ title: "Session EasyVista expirée",
+ message: "Votre session a expiré pendant l'ouverture de la fiche. Reconnectez-vous à EasyVista puis réessayez.",
+ buttons: [
+ { label: "Ouvrir EasyVista", variant: "primary", action: () => openEasyVista() },
+ { label: "Annuler", variant: "secondary", action: () => {} }
+ ]
+ });
+ } else if (ficheResp.error === "ev_unreachable") {
+ showAlertModal({
+ title: "EasyVista inaccessible",
+ message: "EasyVista est inaccessible pour le moment. Réessayez dans quelques instants.",
+ buttons: [
+ { label: "Réessayer", variant: "primary", action: () => openInterventionInNewTab(iv, opts) },
+ { label: "Ouvrir EasyVista", variant: "secondary", action: () => openEasyVista() },
+ { label: "Annuler", variant: "secondary", action: () => {} }
+ ]
+ });
+ } else {
+ showAlertModal({
+ title: "Impossible d'ouvrir la fiche",
+ message: "Une erreur est survenue : " + (ficheResp.error || "inconnue"),
+ buttons: [
+ { label: "OK", variant: "secondary", action: () => {} }
+ ]
+ });
+ }
return;
}
continue; // retry
@@ -2845,12 +3861,19 @@ async function openInterventionInNewTab(iv, opts = {}) {
allMatches.forEach((m, idx) => console.log(` [${idx}] checksum = ${m[1]}`));
if (allMatches.length === 0) {
- console.warn(`[click] tentative ${attempts}: pattern target=${iv.requestId} introuvable`);
+ // v4.2.5 : le warning précédent était alarmiste pour rien.
+ // Tentative 1 peut légitimement échouer (cache stale côté EV).
+ // On log en info, on retry, et en dernier recours on ouvre quand
+ // même la fiche (avec un target de fallback) plutôt que de bloquer.
+ console.info(`[click] tentative ${attempts}/${maxAttempts}: pattern target=${iv.requestId}&checksum=... introuvable dans HTML de la fiche (taille ${ficheResp.html.length})`);
if (attempts >= maxAttempts) {
- alert("Impossible de trouver le checksum pour cette fiche (après retry).");
- return;
+ // Fallback : tenter avec le requestId seul, sans checksum précis.
+ // Ça ouvre une URL EasyVista valide qui redirige vers la fiche.
+ console.info(`[click] fallback sans checksum précis pour ${iv.requestId}`);
+ target = iv.requestId;
+ checksum = null;
+ break;
}
- // Attendre un peu avant retry
await new Promise(r => setTimeout(r, 300));
continue;
}
@@ -2863,7 +3886,15 @@ async function openInterventionInNewTab(iv, opts = {}) {
iv.ficheChecksum = checksum;
} catch (err) {
if (attempts >= maxAttempts) {
- alert("Erreur lors du fetch de la fiche : " + err.message);
+ // v4.2.5 : popup modale au lieu d'alert
+ showAlertModal({
+ title: "Erreur lors de l'ouverture de la fiche",
+ message: "Une erreur s'est produite : " + (err && err.message ? err.message : String(err)),
+ buttons: [
+ { label: "Réessayer", variant: "primary", action: () => openInterventionInNewTab(iv, opts) },
+ { label: "Annuler", variant: "secondary", action: () => {} }
+ ]
+ });
return;
}
}
@@ -2885,14 +3916,21 @@ async function openInterventionInNewTab(iv, opts = {}) {
// Construire l'URL qui fonctionne (format identique à l'URL manuelle qui
// marche dans le navigateur quand on ouvre une fiche depuis l'UI EasyVista).
const internalurltime = Math.floor(Date.now() / 1000);
- const url =
- `${session.origin}/index.php` +
- `?PHPSESSID=${encodeURIComponent(session.phpsessid)}` +
- `&internalurltime=${internalurltime}` +
- `&eventName=formEvent` +
- `&target=${encodeURIComponent(target)}` +
- `&checksum=${encodeURIComponent(checksum)}` +
- `&sender=${sender}`;
+ // v4.2.5 : si on n'a pas pu extraire le checksum précis (fallback après
+ // retry), on omet le paramètre checksum. EasyVista acceptera l'URL et
+ // redirigera vers la fiche correspondant au target.
+ const urlParts = [
+ `${session.origin}/index.php`,
+ `?PHPSESSID=${encodeURIComponent(session.phpsessid)}`,
+ `&internalurltime=${internalurltime}`,
+ `&eventName=formEvent`,
+ `&target=${encodeURIComponent(target)}`,
+ ];
+ if (checksum) {
+ urlParts.push(`&checksum=${encodeURIComponent(checksum)}`);
+ }
+ urlParts.push(`&sender=${sender}`);
+ const url = urlParts.join("");
console.log("[click] ouverture fiche iv=", iv.actionId, "ref=", iv.ref, "target=", target, "bg=", !!opts.background);
// Si background (Ctrl+Clic ou clic molette) : onglet ouvert mais pas actif,
@@ -3413,7 +4451,7 @@ function updateInterventionRow(iv) {
// Classes de statut sur la ligne
const sc = getStatusClass(iv);
- row.classList.remove("status-closed", "status-resolved");
+ row.classList.remove("status-closed", "status-resolved", "status-terminated");
if (sc) row.classList.add(sc);
// Classe de couleur sur la ligne (la pastille hérite via CSS)
@@ -3433,18 +4471,24 @@ function updateInterventionRow(iv) {
}
}
- // Check ✓ : ajouter/retirer selon statut
+ // Check ✓ : ajouter/retirer/mettre à jour selon statut
let checkEl = row.querySelector(".iv-status-check");
- if (sc && !checkEl) {
- checkEl = document.createElement("div");
- checkEl.className = "iv-status-check";
- checkEl.textContent = "✓";
- // Insérer après la ref (avant le bouton copier s'il existe)
- const copy = row.querySelector(".intervention-copy");
- if (copy) row.insertBefore(checkEl, copy);
- else if (refEl && refEl.nextSibling) row.insertBefore(checkEl, refEl.nextSibling);
- else row.appendChild(checkEl);
- } else if (!sc && checkEl) {
+ if (sc) {
+ // v4.2.5 : ✓✓ pour clos/résolu, ✓ pour terminé tech
+ const isDouble = (sc === "status-closed" || sc === "status-resolved");
+ const desiredText = isDouble ? "✓✓" : "✓";
+ if (!checkEl) {
+ checkEl = document.createElement("div");
+ checkEl.className = "iv-status-check";
+ // Insérer après la ref (avant le bouton copier s'il existe)
+ const copy = row.querySelector(".intervention-copy");
+ if (copy) row.insertBefore(checkEl, copy);
+ else if (refEl && refEl.nextSibling) row.insertBefore(checkEl, refEl.nextSibling);
+ else row.appendChild(checkEl);
+ }
+ checkEl.textContent = desiredText;
+ checkEl.classList.toggle("double", isDouble);
+ } else if (checkEl) {
checkEl.remove();
}
@@ -3503,7 +4547,7 @@ function updateInterventionRow(iv) {
`.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]`
);
if (slot) {
- slot.classList.remove("status-closed", "status-resolved", ...ALL_COLOR_CLASSES);
+ slot.classList.remove("status-closed", "status-resolved", "status-terminated", ...ALL_COLOR_CLASSES);
slot.classList.add("color-" + colorKey);
if (sc) slot.classList.add(sc);
// Maj du dataset pour le popover (titre + ref)
@@ -3606,8 +4650,13 @@ function hideTooltip(opts = {}) {
const el = tooltipEl();
el.classList.remove("visible", "pinned");
el.classList.add("hidden");
+ // v4.2.4 : reset du mode d'ancrage et de la détection de position
+ if (el.dataset) {
+ delete el.dataset.mode;
+ }
state.currentTooltipIv = null;
currentTooltipPos = null;
+ tooltipPositionMode = null; // re-détecter à la prochaine ouverture
}, 120);
}
@@ -3633,14 +4682,99 @@ function moveTooltip(e) {
// fonction est conservée pour compat mais ne fait plus rien.
}
-// v4.1.12 : positionnement fixe de la bulle, ancrée par rapport à la ligne
-// (rowEl). Par défaut à droite de la ligne, avec fallback à gauche si pas
-// assez de place, et ajustement vertical pour rester dans la fenêtre.
-// v4.1.17 : position actuelle de la bulle dans le viewport. On la mémorise
-// pour pouvoir la ré-appliquer à chaque scroll (au cas où un ancêtre
-// casse position:fixed sans qu'on s'en rende compte).
+// ============================================================================
+// v4.2.4 : Positionnement du tooltip — refonte complète
+//
+// Stratégie :
+// 1. On positionne TOUJOURS avec style.left/top en coordonnées VIEWPORT
+// (comme un élément position:fixed).
+// 2. Au 1er positionnement, on mesure si `position: fixed` marche vraiment
+// sur ce tooltip (grâce à getBoundingClientRect). Si un ancêtre le
+// casse (transform / filter / backdrop-filter / contain), le tooltip
+// tombe en position:absolute calculée depuis le containing block.
+// 3. Si `position: fixed` est cassée, on active un listener scroll qui
+// recalcule la position pour qu'elle reste STABLE à l'écran (on traite
+// alors style.left/top comme des coordonnées document et on ajoute
+// window.scrollX/Y pour compenser).
+// ============================================================================
+
+// Position stockée : targetLeft / targetTop = coordonnées VIEWPORT désirées
+// (où la popup doit apparaître à l'écran, peu importe le scroll).
let currentTooltipPos = null;
+// Mode de positionnement, détecté empiriquement :
+// null : pas encore détecté
+// "fixed" : position:fixed marche → on laisse le navigateur gérer au scroll
+// "abs" : position:fixed cassée → on compense manuellement au scroll
+let tooltipPositionMode = null;
+
+function setTooltipViewportPosition(viewportX, viewportY) {
+ const el = tooltipEl();
+ if (!el) return;
+ currentTooltipPos = { x: viewportX, y: viewportY };
+
+ // Appliquer la position en supposant que position:fixed marche
+ el.style.left = viewportX + "px";
+ el.style.top = viewportY + "px";
+
+ // Détection empirique au 1er positionnement : on compare la position
+ // réelle du tooltip (getBoundingClientRect) à la position demandée.
+ // Si ça correspond (à 1px près), position:fixed fonctionne. Sinon
+ // c'est qu'un ancêtre a cassé le containing block.
+ if (tooltipPositionMode === null) {
+ const r = el.getBoundingClientRect();
+ const deltaX = Math.abs(r.left - viewportX);
+ const deltaY = Math.abs(r.top - viewportY);
+ if (deltaX <= 1 && deltaY <= 1) {
+ tooltipPositionMode = "fixed";
+ } else {
+ tooltipPositionMode = "abs";
+ console.info(
+ "[tooltip] position:fixed cassée par un ancêtre, passage en mode compensé au scroll. " +
+ `delta=(${deltaX.toFixed(1)}, ${deltaY.toFixed(1)})`
+ );
+ }
+ }
+
+ // Si mode "abs" : le top/left qu'on vient de poser est en réalité interprété
+ // par rapport au containing block (pas le viewport). On doit compenser
+ // immédiatement pour placer la popup au bon endroit visuellement.
+ if (tooltipPositionMode === "abs") {
+ const r = el.getBoundingClientRect();
+ const offsetX = viewportX - r.left; // écart à corriger
+ const offsetY = viewportY - r.top;
+ // Nouvelle valeur absolute qui produit la position viewport voulue
+ const absLeft = parseFloat(el.style.left) + offsetX;
+ const absTop = parseFloat(el.style.top) + offsetY;
+ el.style.left = absLeft + "px";
+ el.style.top = absTop + "px";
+ // Mémoriser pour compenser au scroll
+ el._absBasisLeft = absLeft;
+ el._absBasisTop = absTop;
+ el._absBasisScrollX = window.scrollX || window.pageXOffset || 0;
+ el._absBasisScrollY = window.scrollY || window.pageYOffset || 0;
+ }
+}
+
+// Listener global scroll : si on est en mode "abs", on compense pour que la
+// popup reste visuellement au même endroit pendant le scroll.
+function reapplyTooltipPosition() {
+ if (!currentTooltipPos) return;
+ const el = tooltipEl();
+ if (!el || !el.classList.contains("visible")) return;
+ if (tooltipPositionMode !== "abs") return; // fixed marche, rien à faire
+
+ // Compenser le scroll : la popup doit rester à currentTooltipPos dans le
+ // viewport. Pour ça, on ajoute l'écart entre le scroll actuel et le
+ // scroll au moment de l'ancrage.
+ const scrollX = window.scrollX || window.pageXOffset || 0;
+ const scrollY = window.scrollY || window.pageYOffset || 0;
+ const dx = scrollX - (el._absBasisScrollX || 0);
+ const dy = scrollY - (el._absBasisScrollY || 0);
+ el.style.left = ((el._absBasisLeft || 0) + dx) + "px";
+ el.style.top = ((el._absBasisTop || 0) + dy) + "px";
+}
+
function positionTooltipAnchored(rowEl) {
const el = tooltipEl();
if (!rowEl || !el) return;
@@ -3662,19 +4796,7 @@ function positionTooltipAnchored(rowEl) {
}
if (y < 4) y = 4;
- currentTooltipPos = { x, y };
- el.style.left = x + "px";
- el.style.top = y + "px";
-}
-
-// v4.1.17 : ré-applique la position de la bulle au scroll. Safety net au
-// cas où un ancêtre casse position:fixed. Marche peu importe la cause.
-function reapplyTooltipPosition() {
- if (!currentTooltipPos) return;
- const el = tooltipEl();
- if (!el || !el.classList.contains("visible")) return;
- el.style.left = currentTooltipPos.x + "px";
- el.style.top = currentTooltipPos.y + "px";
+ setTooltipViewportPosition(x, y);
}
// v4.1.10 : pin/unpin la bulle. Quand pin, on ajoute la classe CSS "pinned"
@@ -3828,8 +4950,10 @@ function unpinTooltip() {
if (!bulleState.hoveredInBulle && !bulleState.hoveredInRow) {
el.classList.remove("visible");
el.classList.add("hidden");
+ if (el.dataset) delete el.dataset.mode;
state.currentTooltipIv = null;
currentTooltipPos = null;
+ tooltipPositionMode = null;
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
@@ -4189,8 +5313,22 @@ function hideEvUnreachable() {
function showSessionExpiredBanner() {
const b = document.getElementById("session-expired-banner");
if (b) b.classList.remove("hidden");
+ // Masquer la bannière EV si présente (on ne montre qu'une bannière à la fois)
+ hideEvUnreachableBanner();
}
function hideSessionExpiredBanner() {
const b = document.getElementById("session-expired-banner");
if (b) b.classList.add("hidden");
}
+
+// v4.2.5 : bannière non bloquante "EasyVista inaccessible"
+function showEvUnreachableBanner() {
+ const b = document.getElementById("ev-unreachable-banner");
+ if (b) b.classList.remove("hidden");
+ // On masque la bannière session expirée (1 seule bannière à la fois)
+ hideSessionExpiredBanner();
+}
+function hideEvUnreachableBanner() {
+ const b = document.getElementById("ev-unreachable-banner");
+ if (b) b.classList.add("hidden");
+}