diff --git a/viewer.js b/viewer.js
index 120d7d8..9080536 100644
--- a/viewer.js
+++ b/viewer.js
@@ -123,7 +123,7 @@ function isRecupAction(iv) {
*/
function deriveShortTitle(iv) {
if (iv.type === "AL-Reservation") return "Réservation";
- if (iv.ref && /^I2\d/.test(iv.ref)) return "Incident";
+ if (iv.ref && /^I\d/.test(iv.ref)) return "Incident";
if (isRollOut(iv)) return "Roll Out";
if (isRecupAction(iv)) return "Récupération";
const cat = iv.categoryLine || "";
@@ -136,7 +136,7 @@ function deriveShortTitle(iv) {
function deriveColorKey(iv) {
if (iv.type === "AL-Reservation") return "reservation";
- if (iv.ref && /^I2\d/.test(iv.ref)) return "incident";
+ if (iv.ref && /^I\d/.test(iv.ref)) return "incident";
if (isRollOut(iv)) return "rollout";
if (isRecupAction(iv)) return "recup";
const cat = iv.categoryLine || "";
@@ -191,8 +191,14 @@ function abortCurrentRefresh() {
}
}
}
-function isRefreshAborted() {
- return abortedToken === currentRefreshToken;
+// v4.1.9 : isRefreshAborted(myToken) retourne true si :
+// - un nouveau refresh a été lancé (currentRefreshToken > myToken), OU
+// - l'utilisateur a explicitement cliqué "Arrêter" (abortedToken).
+// Sans myToken fourni (compat), on ne teste que l'abort explicite.
+function isRefreshAborted(myToken) {
+ if (abortedToken === currentRefreshToken) return true;
+ if (typeof myToken === "number" && myToken < currentRefreshToken) return true;
+ return false;
}
function cleanupAbortResolver(myToken) {
abortResolvers.delete(myToken);
@@ -207,6 +213,7 @@ document.addEventListener("DOMContentLoaded", init);
async function init() {
initTheme();
bindTopbar();
+ bindTooltipInteractions();
// Initialiser la date = aujourd'hui
state.currentDate = todayISO();
@@ -232,6 +239,7 @@ async function refreshSessionAndLoad() {
}
state.session = resp.session;
hideSessionNeeded();
+ hideSessionExpiredBanner();
await loadForDate(state.currentDate);
}
@@ -271,7 +279,23 @@ function toggleTheme() {
function bindTopbar() {
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
- document.getElementById("refresh-btn").addEventListener("click", () => refreshPlanning());
+ // v4.1.10 : 2 boutons de rafraîchissement.
+ // - refresh-btn (Total) : force le re-fetch de toutes les fiches (même celles
+ // déjà enrichies), utile pour voir les statuts évoluer.
+ // - refresh-partial-btn (Partiel) : re-fetch juste le XML planning pour
+ // détecter nouvelles/disparues interventions, mais ne refetch PAS les
+ // fiches déjà connues → rapide.
+ document.getElementById("refresh-btn").addEventListener("click", () => {
+ setActiveRefreshButton("total");
+ refreshPlanning({ total: true });
+ });
+ const partialBtn = document.getElementById("refresh-partial-btn");
+ if (partialBtn) {
+ partialBtn.addEventListener("click", () => {
+ setActiveRefreshButton("partial");
+ refreshPlanning({ partial: true });
+ });
+ }
document.getElementById("abort-btn").addEventListener("click", () => {
// Feedback visuel instantané : masquer le bouton tout de suite, sans
// attendre que loadForDate finisse sa race.
@@ -290,6 +314,12 @@ function bindTopbar() {
});
document.getElementById("open-ev-btn").addEventListener("click", openEasyVista);
+
+ // v4.1.12 : bindings bannière session expirée
+ const reconnectBtn = document.getElementById("session-banner-reconnect");
+ if (reconnectBtn) reconnectBtn.addEventListener("click", openEasyVista);
+ const closeBtn = document.getElementById("session-banner-close");
+ if (closeBtn) closeBtn.addEventListener("click", hideSessionExpiredBanner);
}
async function openEasyVista() {
@@ -408,9 +438,25 @@ async function loadForDate(isoDate, opts = {}) {
const t0 = performance.now();
console.log(`[load] début pour ${isoDate} (token=${myToken})`);
+ // v4.1.14 : choix du bouton qui tourne
+ // - Clic explicite "Actualiser" → _fromPartialBtn → "partial"
+ // - Clic explicite "Tout recharger" → doStatusRefresh → "total"
+ // - Sinon (nav date / chargement auto) :
+ // - cache présent → "partial" (c'est juste un diff XML)
+ // - cache absent → "total" (on charge tout pour la 1re fois)
+ // La détermination se fait APRÈS readCache.
+
try {
// 1. Afficher immédiatement depuis le cache si disponible
const cached = await readCache(isoDate);
+
+ if (!opts._fromPartialBtn) {
+ if (opts.doStatusRefresh) {
+ setActiveRefreshButton("total");
+ } else {
+ setActiveRefreshButton(cached ? "partial" : "total");
+ }
+ }
if (cached && !opts.forceRefetch) {
renderFromData({
techs: cached.techs,
@@ -418,27 +464,35 @@ async function loadForDate(isoDate, opts = {}) {
captureTime: cached.savedAt || null,
source: "cache"
});
-
- // Si cache présent ET pas de refresh explicite demandé, on s'arrête là.
- if (!opts.doStatusRefresh) {
- return;
- }
+ // v4.1.9 : on NE retourne PAS ici. On continue pour refetch le XML
+ // du planning afin de détecter les nouvelles iv et celles disparues
+ // (diff avec le cache). Les iv déjà présentes dans le cache gardent
+ // leur enrichissement (ficheActionText, statut) → pas de re-fetch
+ // inutile, seules les nouvelles passent par refreshStatuses.
} else {
showLoading();
}
- if (isRefreshAborted()) return;
+ if (isRefreshAborted(myToken)) return;
// 2. Fetch frais du planning (XML calendar_block — rapide, ~40 ko)
const tXml = performance.now();
const fresh = await fetchPlanningForDate(isoDate);
console.log(`[load] XML planning récupéré en ${Math.round(performance.now() - tXml)} ms`);
if (!fresh) return;
- if (isRefreshAborted()) return;
+ if (isRefreshAborted(myToken)) return;
// 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".
+ for (const tech of merged.techs) {
+ tech.interventions = tech.interventions.filter(iv => !iv.ghost);
+ }
+
// 4. Afficher immédiatement (v4 : tout est déjà rempli depuis le XML !)
// Le calendar_block contient attr1/attr2/attr3 = contact/lieu/catégorie,
// et textContent = ref. Donc ce 1er rendu est DÉJÀ complet visuellement
@@ -466,25 +520,33 @@ async function loadForDate(isoDate, opts = {}) {
)
);
- if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted()) {
+ if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted(myToken)) {
const tFiches = performance.now();
const nFiches = merged.techs.flatMap(t=>t.interventions).filter(i=>i.type==="AL-Intervention").length;
console.log(`[load] début fetch des ${nFiches} fiches (statuts)…`);
- await refreshStatuses(merged.techs, isoDate);
+ // forceAll : uniquement si refresh manuel (bouton "Rafraîchir").
+ // À la navigation normale entre dates, on ne refetch que les iv non
+ // encore enrichies (ficheFetched=false) — ça reprend là où on s'était
+ // arrêté si un refresh précédent a été interrompu par un changement de
+ // date.
+ await refreshStatuses(merged.techs, isoDate, { forceAll: !!opts.doStatusRefresh, myToken });
console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`);
}
// 6. Sauvegarder dans le cache (une seule fois, après que tout soit enrichi)
- if (!isRefreshAborted()) {
+ if (!isRefreshAborted(myToken)) {
await writeCache(isoDate, { techs: merged.techs });
}
- if (!isRefreshAborted()) {
+ if (!isRefreshAborted(myToken)) {
showRefreshDone();
console.log(`[load] TOTAL: ${Math.round(performance.now() - t0)} ms`);
} else {
- console.log(`[load] annulé par l'utilisateur à ${Math.round(performance.now() - t0)} ms`);
- showAbortToast();
+ // v4.1.9 : toast "annulé" uniquement si c'était un vrai clic "Arrêter",
+ // pas un simple changement de date (qui abort l'ancien silencieusement).
+ const wasExplicitAbort = (abortedToken === myToken);
+ console.log(`[load] annulé à ${Math.round(performance.now() - t0)} ms (explicite=${wasExplicitAbort})`);
+ if (wasExplicitAbort) showAbortToast();
}
} finally {
// Masquer le bouton "Arrêter" uniquement si c'est NOTRE chargement qui
@@ -502,9 +564,12 @@ async function refreshPlanning(opts = {}) {
await refreshSessionAndLoad();
return;
}
- // Refresh manuel : force le refetch des fiches. Le bouton "Arrêter" est
- // géré par loadForDate lui-même.
- await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true });
+ if (opts.partial) {
+ // v4.1.13 : _fromPartialBtn empêche loadForDate de reset activeRefreshButton à "total"
+ await loadForDate(state.currentDate, { doStatusRefresh: false, _fromPartialBtn: true });
+ } else {
+ await loadForDate(state.currentDate, { doStatusRefresh: true });
+ }
}
// ============================================================================
@@ -627,13 +692,14 @@ function actionNodeToIntervention(node) {
const nodeText = (node.textContent || "").trim();
// Extraire la ref en priorité du textContent (où elle est complète), sinon
- // fallback sur le label (historique, au cas où un jour le format changerait).
+ // fallback sur le label. v4.1.9 : pattern générique [SI]\d+_\d+ (plus
+ // hardcodé sur "2..." qui était pour 2020-2029).
let ref = null;
- const refFromText = nodeText.match(/\b([SI]2\d{5}_\d{5})\b/);
+ const refFromText = nodeText.match(/\b([SI]\d{5,8}_\d{4,6})\b/);
if (refFromText) {
ref = refFromText[1];
} else {
- const refFromLabel = label.match(/\b([SI]2\d{5}_\d{5})\b/);
+ const refFromLabel = label.match(/\b([SI]\d{5,8}_\d{4,6})\b/);
if (refFromLabel) ref = refFromLabel[1];
}
@@ -861,7 +927,10 @@ function mergeCacheAndFresh(cached, fresh) {
// Fetch des fiches individuelles (pour obtenir le statut et les détails)
// ============================================================================
-async function refreshStatuses(techs, isoDate) {
+async function refreshStatuses(techs, isoDate, opts = {}) {
+ const forceAll = !!opts.forceAll;
+ const myToken = opts.myToken;
+
// Construire la liste des interventions à fetcher, dans l'ordre de priorité :
// 1. Interventions du (des) pompier(s) en premier
// 2. Puis les autres techs par ordre alphabétique du nom de famille
@@ -879,6 +948,12 @@ async function refreshStatuses(techs, isoDate) {
// refetcher à chaque refresh.
const statusClosed = isClosedStatus(iv.status) || isResolvedStatus(iv.status);
if (statusClosed && iv.ficheFetched) continue;
+ // v4.1.7 : pause/reprise par date. Sans forceAll (= chargement normal
+ // au retour sur une date), on skip les iv déjà enrichies (ficheFetched)
+ // pour ne pas refetcher inutilement. Un clic sur "Rafraîchir" active
+ // forceAll, ce qui refetche les non-closes même si déjà enrichies (pour
+ // voir passer les statuts "En cours" → "Exécution" → "Clôturé").
+ if (!forceAll && iv.ficheFetched) continue;
toFetch.push(iv);
}
}
@@ -886,6 +961,16 @@ async function refreshStatuses(techs, isoDate) {
if (toFetch.length === 0) return;
setRefreshing(true);
+
+ // v4.1.7 : barre de progression visible uniquement si on est en train de
+ // rafraîchir la date actuellement affichée. Si l'user change de date
+ // pendant le refresh, isRefreshAborted() deviendra true et on sortira.
+ const showBar = (state.currentDate === isoDate);
+ if (showBar) {
+ updateProgressBar(0, toFetch.length);
+ showProgressBar();
+ }
+
try {
// v4.1 : SÉQUENTIEL (1 fiche à la fois) au lieu de 5 workers en parallèle.
// Raisons :
@@ -902,10 +987,15 @@ async function refreshStatuses(techs, isoDate) {
let sinceLastCacheWrite = 0;
for (let i = 0; i < toFetch.length; i++) {
- if (isRefreshAborted()) break;
- await fetchAndUpdateIntervention(toFetch[i]);
+ if (isRefreshAborted(myToken)) break;
+ await fetchAndUpdateIntervention(toFetch[i], myToken);
sinceLastCacheWrite++;
+ // Progression — uniquement si la barre concerne la date visible
+ if (showBar && state.currentDate === isoDate) {
+ updateProgressBar(i + 1, toFetch.length);
+ }
+
// Sauvegarde périodique du cache pendant le fetch
if (sinceLastCacheWrite >= CACHE_WRITE_EVERY) {
try {
@@ -919,7 +1009,7 @@ async function refreshStatuses(techs, isoDate) {
// Si annulé : on laisse les résultats partiels dans le DOM et on sauve
// quand même ce qu'on a déjà récupéré (cache incrémental).
- if (isRefreshAborted()) {
+ if (isRefreshAborted(myToken)) {
try { await writeCache(isoDate, { techs }); } catch {}
return;
}
@@ -947,14 +1037,15 @@ async function refreshStatuses(techs, isoDate) {
});
} finally {
setRefreshing(false);
+ if (showBar) hideProgressBar();
}
}
-async function fetchAndUpdateIntervention(iv) {
+async function fetchAndUpdateIntervention(iv, myToken) {
try {
- // Bail-out coopératif : si l'utilisateur a cliqué sur "Arrêter",
- // on ne fetch pas cette intervention.
- if (isRefreshAborted()) {
+ // Bail-out coopératif : si l'utilisateur a cliqué sur "Arrêter" ou a
+ // changé de date, on ne fetch pas cette intervention.
+ if (isRefreshAborted(myToken)) {
iv.ficheFetched = true;
iv.ficheFetchError = "aborted";
return;
@@ -968,9 +1059,13 @@ async function fetchAndUpdateIntervention(iv) {
// valide pour l'ouverture au clic.
// ─── Étape 1 : xhr2 (rapide, ~400 o) ────────────────────────────────
- if (!iv.xhr2Fetched && !isRefreshAborted()) {
+ if (!iv.xhr2Fetched && !isRefreshAborted(myToken)) {
try {
const xhr2Resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId });
+ // v4.1.9 : si on a été aborté pendant l'attente, ne PAS appliquer
+ // le résultat au DOM (on ne doit plus toucher à une ligne qui
+ // appartient à la date précédente).
+ if (isRefreshAborted(myToken)) return;
if (xhr2Resp && xhr2Resp.ok) {
const parsed = parseXhr2Body(xhr2Resp.body);
if (parsed) {
@@ -990,37 +1085,38 @@ async function fetchAndUpdateIntervention(iv) {
}
}
- if (isRefreshAborted()) return;
+ if (isRefreshAborted(myToken)) return;
// ─── Étape 2 : fetch fiche (statut + commentaire + checksum) ──────────
// Retry léger : une erreur réseau ponctuelle (timeout, 5xx) ne doit pas
// perdre la ligne. 1 seul retry après 400ms. Session expirée n'est PAS
// retryée (ça ne passera pas mieux la 2e fois).
let ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
- if (!ficheResp.ok && ficheResp.error !== "session_expired" && !isRefreshAborted()) {
+ if (isRefreshAborted(myToken)) return;
+ if (!ficheResp.ok && ficheResp.error !== "session_expired" && !isRefreshAborted(myToken)) {
await new Promise(r => setTimeout(r, 400));
- if (!isRefreshAborted()) {
+ if (!isRefreshAborted(myToken)) {
ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
}
}
+ if (isRefreshAborted(myToken)) return;
if (!ficheResp.ok) {
iv.ficheFetched = true;
iv.ficheFetchError = ficheResp.error || "fetch_failed";
if (ficheResp.error === "session_expired") {
state.session = null;
+ // v4.1.12 : afficher immédiatement la bannière de session expirée
+ // pour que l'utilisateur voie pourquoi le fetch s'arrête.
+ showSessionExpiredBanner();
}
return;
}
- const techFullName = iv.techId ? TEAM[iv.techId] : null;
- const fiche = parseFicheHtml(ficheResp.html, techFullName);
+ const fiche = parseFicheHtml(ficheResp.html);
iv.status = fiche.status;
- // v4.1.5 : on stocke tous les textes d'action assignés à ce tech
- // (normalement 1, parfois plusieurs). Ils sont affichés dans le tooltip.
- iv.ficheActionTexts = fiche.actionTexts || [];
- // Rétrocompat : garder commentaireTech à null (champ plus utilisé, on garde
- // le nom pour éviter de casser les caches existants avec un champ undefined).
+ // Rétrocompat : champ plus utilisé, on le laisse à null pour ne pas casser
+ // d'anciens caches avec un champ undefined.
iv.commentaireTech = null;
// Fallback : si l'XML n'avait pas la ref (rare, mais possible pour des
// actions hors-standard), on prend celle de la fiche.
@@ -1028,6 +1124,39 @@ async function fetchAndUpdateIntervention(iv) {
iv.ref = fiche.rfc;
}
+ // ─── Étape 3 : API timeline → texte complet de l'action ─────────────
+ // Le HTML brut de la fiche ne contient PAS les valeurs d'action (elles
+ // sont injectées côté client par Angular via un appel REST). On appelle
+ // donc le même endpoint REST qu'Angular pour récupérer la description
+ // complète, match par ACTION_ID === iv.actionId (fiable, numérique).
+ //
+ // Ce texte REMPLACE le texte xhr2 tronqué dans le tooltip.
+ // Si l'appel échoue ou ne trouve rien, on garde le fallback xhr2 dans
+ // iv.bulleDescription (déjà stocké à l'étape 1).
+ if (fiche.formId && fiche.formChecksum && fiche.formSenderGuid &&
+ iv.actionId && !isRefreshAborted(myToken)) {
+ try {
+ const tlResp = await sendMessage({
+ type: "fetchTimelineApi",
+ guid: fiche.formSenderGuid,
+ formId: fiche.formId,
+ formChecksum: fiche.formChecksum
+ });
+ if (isRefreshAborted(myToken)) return;
+ if (tlResp && tlResp.ok) {
+ const fullText = parseTimelineJsonForAction(tlResp.body, iv.actionId);
+ if (fullText) {
+ iv.ficheActionText = fullText;
+ }
+ } else if (tlResp && tlResp.error === "session_expired") {
+ state.session = null;
+ showSessionExpiredBanner();
+ }
+ } catch (err) {
+ console.warn("[timeline] erreur iv", iv.actionId, err);
+ }
+ }
+
// ─── Extraire le checksum pour ouvrir la fiche ─────────────────────
// STRICTEMENT IDENTIQUE à v4 originale (qui fonctionne pour l'ouverture) :
// - On n'extrait QUE si ficheChecksum n'est pas déjà là (une fois trouvé
@@ -1063,7 +1192,11 @@ async function fetchAndUpdateIntervention(iv) {
// Rendu incrémental : mettre à jour la ligne dans le DOM immédiatement
// (statut clos → fond vert + ✓, commentaire tech dans le tooltip).
- updateInterventionRow(iv);
+ // v4.1.9 : ne touche au DOM que si on est toujours sur la même date
+ // qui a été demandée initialement (sinon on corromprait la nouvelle vue).
+ if (!isRefreshAborted(myToken)) {
+ updateInterventionRow(iv);
+ }
} catch (err) {
iv.ficheFetched = true;
iv.ficheFetchError = String(err);
@@ -1146,15 +1279,29 @@ function isCancelledStatus(s) {
// supprimées car ces infos viennent maintenant du XML attr1/attr2/attr3 ou du
// lazy-load xhr2 au hover.
-function parseFicheHtml(html, techFullName) {
+/**
+ * Parse le HTML brut d'une fiche EasyVista (rendu serveur, ~460 Ko, NON hydraté
+ * par Angular donc ne contient PAS les valeurs d'actions — celles-ci sont
+ * chargées séparément via l'API timeline).
+ *
+ * Rôle : extraire les champs nécessaires :
+ * - status : STATUS_FR (affichage ✓ et fond vert si clos)
+ * - rfc : RFC_NUMBER (fallback si pas dans XML)
+ * - formId : id numérique du form (SD_REQUEST pour S... ou incident)
+ * - formChecksum : checksum du form (pour appel API timeline)
+ * - formSenderGuid : v4.1.9 — GUID du form (différent pour incident I...
+ * vs demande S...). Extrait dynamiquement depuis les
+ * liens target=FORM_ID&checksum=...&sender={GUID} du
+ * HTML lui-même. Pour les demandes S → C99ECD05..., pour
+ * les incidents I → 07ED9C68... (ou autre selon config).
+ */
+function parseFicheHtml(html) {
const out = {
status: null,
rfc: null,
- // v4.1.5 : liste des textes d'action dont l'Intervenant == techFullName.
- // Chaque élément = texte brut nettoyé (Date/Heure/Lieu/Contact/... ou
- // commentaires FRD). Normalement 1 seul, mais parfois plusieurs quand la
- // fiche a eu plusieurs actions pour le même tech.
- actionTexts: []
+ formId: null,
+ formChecksum: null,
+ formSenderGuid: null
};
// STATUS_FR (valeur parfois encodée en \u00XX)
@@ -1165,88 +1312,133 @@ function parseFicheHtml(html, techFullName) {
m = html.match(/"dbFieldName"\s*:\s*"RFC_NUMBER"[^}]*?"value"\s*:\s*"([^"]{5,30})"/);
if (m) out.rfc = m[1];
- // ─── Extraction des blocs AM_ACTION-DESCRIPTION ─────────────────────────
- // Dans la nouvelle version d'EasyVista, le texte d'action est dans
- //
CONTENU
...
- // sous la section field-label-id="AM_ACTION-DESCRIPTION".
- //
- // Il peut y avoir PLUSIEURS sections (une par action liée à la demande).
- // Chaque section a son propre Intervenant (field-label="Intervenant").
- //
- // On récupère UNIQUEMENT les textes dont l'intervenant == techFullName.
+ // formData.form.{id,checksum} : indispensable pour l'API timeline.
+ // On matche dans les deux ordres possibles.
+ m = html.match(/var formData\s*=\s*\{"form":\{[^}]*?"checksum":"([a-f0-9]{40})"[^}]*?"id":"(\d+)"/);
+ if (m) {
+ out.formChecksum = m[1];
+ out.formId = m[2];
+ } else {
+ m = html.match(/var formData\s*=\s*\{"form":\{[^}]*?"id":"(\d+)"[^}]*?"checksum":"([a-f0-9]{40})"/);
+ if (m) {
+ out.formId = m[1];
+ out.formChecksum = m[2];
+ }
+ }
- // ─── Extraction des blocs AM_ACTION-DESCRIPTION ─────────────────────────
- // Dans la version actuelle d'EasyVista, chaque action d'une fiche est
- // structurée comme suit (dans l'ordre vertical du HTML) :
- //
- // field-label="Intervenant" ... value="Nom, Prénom" ← le tech
- // field-label-id="AM_ACTION-DESCRIPTION" ...
- // ...
TEXTE DE L'ACTION
← contenu
- //
- // Il peut y avoir PLUSIEURS actions dans la même fiche. Certaines sont
- // automatiques (workflow) : leur "intervenant" n'est pas un nom humain
- // mais un label d'étape ("1. Analyse...", "2.1 Récupération..."). Ces
- // actions ont aussi un htmlEditor-base mais VIDE. On les ignore.
- //
- // Stratégie :
- // 1. Trouver tous les blocs htmlEditor-base NON vides + leur position
- // 2. Trouver toutes les valeurs de champ Intervenant (le value= du
- // ng-model="colData.value") + leur position
- // 3. Pour chaque bloc non vide, trouver l'intervenant le plus proche
- // EN AMONT (l'intervenant précède toujours son texte)
- // 4. Filtrer par techFullName
-
- // Trouver les intervenants : on cherche le pattern "field-label=\"Intervenant\""
- // puis le premier value="..." précédé de ng-model="colData.value".
- // Ce 2e pattern saute les autres attributs Angular présents entre les deux.
- const intervenants = []; // [{ pos, name }]
- const intervLabelRx = /field-label="Intervenant"\s+field-label-id="AM_EMPLOYEE-LAST_NAME"/g;
- let il;
- while ((il = intervLabelRx.exec(html)) !== null) {
- const searchFrom = il.index;
- // Chercher le value= associé dans les 20 000 chars suivants
- const window = html.substring(searchFrom, searchFrom + 20000);
- const vm = window.match(/ng-model="colData\.value"[\s\S]{0,500}?value="([^"]*)"/);
- if (vm) {
- const name = decodeHtmlEntities(vm[1]).trim();
- intervenants.push({ pos: searchFrom, name });
+ // v4.1.9 : déduire le GUID du form. On cherche dans le HTML un lien qui
+ // référence notre formId (target=FORM_ID...) avec un sender. C'est le GUID
+ // du form principal utilisé pour l'API timeline :
+ // - demande S... → {C99ECD05-3D48-4C62-ABF0-66292053AED6}
+ // - incident I... → {07ED9C68-6172-48EA-8A58-90912B0A283E}
+ // v4.1.10 (fix) : regex robuste qui accepte &, &, et parcourt jusqu'à
+ // 300 chars entre target=ID et sender= (au lieu de stopper au 1er "/'/espace
+ // ce qui peut échouer sur certains HTML).
+ if (out.formId) {
+ const rx = new RegExp(
+ `target=${out.formId}(?:&(?:amp;)?\\w+=[^&"'\\s<>]*){0,10}?&(?:amp;)?sender=(%7B[A-F0-9\\-]{36}%7D)`,
+ "i"
+ );
+ const sm = html.match(rx);
+ if (sm) {
+ out.formSenderGuid = sm[1]; // garder encodé (déjà prêt pour URL)
} else {
- intervenants.push({ pos: searchFrom, name: null });
+ // Fallback : chercher le GUID le plus fréquent associé à notre formId
+ // dans tout le HTML (tolérant à n'importe quelle séquence entre les 2).
+ const rxLoose = new RegExp(
+ `target=${out.formId}[\\s\\S]{0,300}?sender=(%7B[A-F0-9\\-]{36}%7D)`,
+ "gi"
+ );
+ const counts = new Map();
+ let lm;
+ while ((lm = rxLoose.exec(html)) !== null) {
+ counts.set(lm[1], (counts.get(lm[1]) || 0) + 1);
+ }
+ // Prendre le plus fréquent
+ let best = null;
+ let bestCount = 0;
+ for (const [guid, c] of counts) {
+ if (c > bestCount) { best = guid; bestCount = c; }
+ }
+ if (best) out.formSenderGuid = best;
}
- }
- // Trouver les blocs htmlEditor-base et leur contenu nettoyé
- const editorRx = /]*class="htmlEditor-base[^"]*"[^>]*>([\s\S]*?)<\/div>/g;
- const blocs = []; // [{ pos, cleaned }]
- let em;
- while ((em = editorRx.exec(html)) !== null) {
- const cleaned = cleanHtmlBlock(em[1]);
- // Ignorer les blocs vides ou trop courts (< 10 chars après nettoyage)
- if (cleaned && cleaned.length >= 10) {
- blocs.push({ pos: em.index, cleaned });
- }
- }
-
- // Pour chaque bloc non vide, trouver l'intervenant humain le plus proche en
- // amont. Si techFullName fourni, garder uniquement les blocs dont
- // l'intervenant correspond.
- for (const bloc of blocs) {
- let closestInterv = null;
- for (const iv of intervenants) {
- if (iv.pos < bloc.pos && iv.name) {
- closestInterv = iv;
- } else if (iv.pos >= bloc.pos) {
- break;
+ // v4.1.10 (fix définitif) : si toujours pas trouvé, fallback par défaut
+ // sur le GUID des demandes S... (le plus courant). Pour les rares
+ // incidents I... où le HTML brut n'aurait aucun lien target=FORM_ID, le
+ // timeline ne sera pas chargé mais le reste fonctionne.
+ if (!out.formSenderGuid && out.rfc) {
+ if (/^S/i.test(out.rfc)) {
+ out.formSenderGuid = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D";
+ } else if (/^I/i.test(out.rfc)) {
+ out.formSenderGuid = "%7B07ED9C68-6172-48EA-8A58-90912B0A283E%7D";
}
}
- if (!closestInterv) continue;
- if (techFullName && !namesMatch(closestInterv.name, techFullName)) continue;
- out.actionTexts.push(bloc.cleaned);
}
return out;
}
+/**
+ * Parse le JSON renvoyé par /api/v1/internal/forms/{GUID}/timeline et en
+ * extrait le texte de description complet pour UNE action donnée.
+ *
+ * Structure du JSON :
+ * { data: { data: {
+ * columns: [...13 cols],
+ * values: [ ← 1 entrée par action dans la fiche
+ * { rows: [
+ * {value:"..."}, // [0..10] statut, groupe, dates, etc.
+ * {value:"Date : ... Heure : ... Lieu : ..."}, // [11] DESCRIPTION ⭐
+ * {value:""},
+ * {value:"{\"ACTION_ID\":\"57700033\",...}"} // [13] JSON stringifié
+ * ] }
+ * ] }}}
+ *
+ * On cherche l'action dont rows[13].ACTION_ID === actionId ; si trouvée, on
+ * retourne rows[11] nettoyé (br→\n, entités décodées) ; sinon null.
+ */
+function parseTimelineJsonForAction(jsonText, actionId) {
+ if (!jsonText || !actionId) return null;
+ let data;
+ try {
+ data = JSON.parse(jsonText);
+ } catch (e) {
+ console.warn("[timeline] JSON parse failed:", e);
+ return null;
+ }
+
+ const values = data?.data?.data?.values;
+ if (!Array.isArray(values)) return null;
+
+ const targetId = String(actionId);
+
+ for (const entry of values) {
+ const rows = entry?.rows;
+ if (!Array.isArray(rows) || rows.length < 14) continue;
+
+ // rows[13] = JSON stringifié qui contient ACTION_ID
+ const extraRaw = rows[13]?.value;
+ if (!extraRaw || typeof extraRaw !== "string") continue;
+
+ let extra;
+ try {
+ extra = JSON.parse(extraRaw);
+ } catch {
+ continue;
+ }
+
+ if (String(extra.ACTION_ID) !== targetId) continue;
+
+ // Trouvé : extraire la description (rows[11]) et la nettoyer.
+ const rawDesc = rows[11]?.value || extra["AM_ACTION.DESCRIPTION"] || "";
+ const cleaned = cleanHtmlBlock(rawDesc);
+ return cleaned || null;
+ }
+
+ return null;
+}
+
/**
* Nettoie un bloc HTML pour obtenir du texte brut lisible.
* -
(avec ou sans attributs) → \n @@ -1266,7 +1458,8 @@ function cleanHtmlBlock(html) { .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") - .replace(/&/g, "&"); + .replace(/&/g, "&") + .replace(/\u200b/g, ""); // zero-width space // Tags HTML restants s = s.replace(/<[^>]+>/g, ""); // Espaces compactés, lignes trimmed, lignes vides retirées @@ -1274,35 +1467,6 @@ function cleanHtmlBlock(html) { return s; } -/** - * Décode les entités HTML courantes dans une chaîne courte (ex: un nom). - */ -function decodeHtmlEntities(s) { - if (!s) return s; - return s.replace(/ /g, " ") - .replace(/>/g, ">") - .replace(/</g, "<") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(/&/g, "&"); -} - -/** - * Compare deux noms de personne en étant tolérant (casse, accents, espaces, - * espace autour de la virgule). - */ -function namesMatch(a, b) { - if (!a || !b) return false; - const norm = s => String(s) - .normalize("NFD").replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/\s+/g, " ") - .replace(/\s*,\s*/g, ",") - .trim(); - return norm(a) === norm(b); -} - function decodeJsonString(s) { return s .replace(/\\r/g, "") @@ -1410,20 +1574,32 @@ let refreshCounter = 0; // Timer pour effacer le ✓ vert après 5 s let refreshDoneTimer = null; +// v4.1.13 : quel bouton doit tourner pendant le refresh en cours. +// Valeurs : "total" (par défaut / chargement auto), "partial", ou "xml_only". +let activeRefreshButton = "total"; + +function setActiveRefreshButton(kind) { + activeRefreshButton = kind || "total"; +} + function setRefreshing(on) { - const icon = document.getElementById("refresh-icon"); + const iconTotal = document.getElementById("refresh-icon"); + const iconPartial = document.getElementById("refresh-partial-icon"); + // Quel icône doit tourner ? Seulement celui correspondant au bouton + // qui a lancé le refresh (ou "total" par défaut). + const targetIcon = (activeRefreshButton === "partial") ? iconPartial : iconTotal; if (on) { refreshCounter++; - if (icon) icon.classList.add("spinning"); + if (targetIcon) targetIcon.classList.add("spinning"); clearCheckMark(); // Afficher "Rafraîchissement en cours…" si on n'a pas déjà les données - // (on ne veut pas écraser l'heure du cache si on est juste en train - // de re-fetch en arrière-plan) updateCaptureInfoText(); } else { refreshCounter = Math.max(0, refreshCounter - 1); - if (refreshCounter === 0 && icon) { - icon.classList.remove("spinning"); + if (refreshCounter === 0) { + // Arrêt : stopper les deux icônes au cas où + if (iconTotal) iconTotal.classList.remove("spinning"); + if (iconPartial) iconPartial.classList.remove("spinning"); } updateCaptureInfoText(); } @@ -1466,6 +1642,35 @@ function clearCheckMark() { } } +// ─── Barre de progression (v4.1.7) ───────────────────────────────────── +// État global : on affiche la progression du fetch en cours, uniquement si +// c'est le fetch de la page actuellement visible. Si l'utilisateur change +// de date, la barre suit la nouvelle date (son propre état). +function showProgressBar() { + const bar = document.getElementById("progress-bar"); + if (bar) bar.classList.remove("hidden"); +} + +function hideProgressBar() { + const bar = document.getElementById("progress-bar"); + if (bar) bar.classList.add("hidden"); + updateProgressBar(0, 0); +} + +function updateProgressBar(done, total) { + const fill = document.getElementById("progress-bar-fill"); + const label = document.getElementById("progress-bar-label"); + if (!fill || !label) return; + if (total <= 0) { + fill.style.width = "0%"; + label.textContent = ""; + return; + } + const pct = Math.min(100, Math.round((done / total) * 100)); + fill.style.width = pct + "%"; + label.textContent = `Rafraîchissement… ${done} / ${total}`; +} + // Affiche/masque le bouton "Arrêter". N'est montré que pendant un refresh // manuel (clic utilisateur), pas pendant les chargements normaux ni les // refresh auto 12h/15h. @@ -1923,7 +2128,7 @@ function buildInterventionRow(iv, cardEl) { if (iv.formLink && !iv.ghost) { row.classList.add("clickable"); - row.title = "Cliquer pour ouvrir la fiche (Ctrl+clic ou clic molette = arrière-plan)"; + // v4.1.8 : plus de title au survol (info déjà dans le tooltip en bas) // Clic normal : ouvre l'onglet et change de page // Ctrl/Cmd+Clic : ouvre en arrière-plan (reste sur le planning) @@ -2030,16 +2235,15 @@ function buildInterventionRow(iv, cardEl) { } row.appendChild(rightCol); - // Tooltip + // Tooltip (fixe, ne suit pas la souris — v4.1.12) row.addEventListener("mouseenter", (e) => { - showTooltip(e, iv); + showTooltip(e, iv, row); highlightIntervention(cardEl, ivIdx, true); }); row.addEventListener("mouseleave", () => { hideTooltip(); highlightIntervention(cardEl, ivIdx, false); }); - row.addEventListener("mousemove", moveTooltip); return row; } @@ -2062,7 +2266,10 @@ function buildInterventionRow(iv, cardEl) { categoryEl.textContent = deriveShortTitle(iv); bottomEl.appendChild(categoryEl); - const signature = extractPlanifSignature(iv.bulleDescription); + // v4.1.8 : extraire la signature depuis le texte COMPLET (fiche) en + // priorité, sinon depuis le xhr2 tronqué. Le xhr2 tronqué peut couper la + // signature, la fiche a toujours le texte complet. + const signature = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription); if (signature) { const sigEl = document.createElement("span"); sigEl.className = "iv-signature"; @@ -2073,16 +2280,15 @@ function buildInterventionRow(iv, cardEl) { rightCol.appendChild(bottomEl); row.appendChild(rightCol); - // Tooltip (au survol) + // Tooltip (fixe, ne suit pas la souris — v4.1.12) row.addEventListener("mouseenter", (e) => { - showTooltip(e, iv); + showTooltip(e, iv, row); highlightIntervention(cardEl, ivIdx, true); }); row.addEventListener("mouseleave", () => { hideTooltip(); highlightIntervention(cardEl, ivIdx, false); }); - row.addEventListener("mousemove", moveTooltip); return row; } @@ -2315,25 +2521,53 @@ function extractContacts(raw) { } /** - * Split UN seul bloc "Nom Prénom +41..." en { name, phone }. + * Split UN seul bloc "Nom Prénom +41... [autres tels] [commentaires]" en + * { name, phone }. + * + * Stratégie robuste (v4.1.8) : + * - On cherche TOUS les numéros de téléphone (long ou court). + * - Le nom = ce qui précède le PREMIER numéro. + * - Le champ phone concatène les numéros trouvés (séparés par " / "). + * - Ce qui suit les numéros (commentaires "S'annoncer à la réception...", + * "téléphone à l'utilisateur") est JETÉ : ça ne fait pas partie du contact. + * + * Pattern numéro (inchangé, connu pour marcher) : + * Long : +41 / +33 / 0X suivi de 8+ caractères de [chiffres espaces . -] + * Court: 5 chiffres isolés (entre espaces, parenthèses, ou début/fin) */ function splitOneContact(raw) { if (!raw) return { name: null, phone: null }; - const rxLong = /(\+41\s?\d[\d\s.\-]{8,}|\+33\s?\d[\d\s.\-]{8,}|0\d[\d\s.\-]{8,})/; - const rxShort = /(?:^|\s|\()(\d{5})(?:\s|\)|$)/; - let phone = null; - let name = raw; - let mLong = raw.match(rxLong); - if (mLong) { - phone = formatPhone(mLong[1]); - name = raw.replace(mLong[1], "").trim(); - } else { - let mShort = raw.match(rxShort); - if (mShort) { - phone = formatPhone(mShort[1]); - name = raw.replace(mShort[0], " ").trim(); + const rxLong = /(\+41\s?\d[\d\s.\-]{8,}|\+33\s?\d[\d\s.\-]{8,}|0\d[\d\s.\-]{8,})/g; + const rxShort = /(?:^|[\s(])(\d{5})(?=[\s)]|$)/g; + + // Trouver toutes les positions de match pour LONG et SHORT + const matches = []; + let mm; + while ((mm = rxLong.exec(raw)) !== null) { + matches.push({ start: mm.index, end: mm.index + mm[1].length, tel: mm[1] }); + } + while ((mm = rxShort.exec(raw)) !== null) { + // Ne pas prendre un short qui chevauche un long déjà trouvé + const shortTel = mm[1]; + const shortStart = mm.index + mm[0].indexOf(shortTel); + const shortEnd = shortStart + shortTel.length; + const overlaps = matches.some(x => shortStart < x.end && shortEnd > x.start); + if (!overlaps) { + matches.push({ start: shortStart, end: shortEnd, tel: shortTel }); } } + matches.sort((a, b) => a.start - b.start); + + let name = raw; + let phone = null; + if (matches.length > 0) { + // Nom = ce qui précède le 1er numéro + name = raw.substring(0, matches[0].start).trim(); + // Tels formatés, joints par " / " + const tels = matches.map(x => formatPhone(x.tel)).filter(Boolean); + phone = tels.length > 0 ? tels.join(" / ") : null; + } + name = cleanContactName(name); return { name, phone }; } @@ -2343,7 +2577,9 @@ function splitOneContact(raw) { * - retire tout ce qui est dans des parenthèses (...) * - retire les éventuels "Nom utilisateur :" ou libellés * - retire les virgules en trop en fin - * - Conserve juste "Nom, Prénom" + * - v4.1.8 : tronque les commentaires parasites après le nom + * (ex: "Dupont, Jean S'annoncer à la réception" → "Dupont, Jean") + * - Conserve juste "Nom, Prénom" (ou "Nom Prénom" si pas de virgule) */ function cleanContactName(raw) { if (!raw) return null; @@ -2362,6 +2598,34 @@ function cleanContactName(raw) { s = s.replace(/\s{2,}/g, " ").trim(); // Ponctuation en bord s = s.replace(/^[\s,;:.\-]+|[\s,;:.\-]+$/g, "").trim(); + if (!s) return null; + + // v4.1.8 : tronquer les commentaires parasites qui suivent le nom. + // On considère qu'un nom de personne ne dépasse pas 4 mots (Nom, Prénom, + // et éventuellement particule "Da Silva" ou second prénom). + // Si après les 4 premiers mots on a encore des mots, ce sont des parasites + // (ex: "Barbosa Oliveira, Bruno S'annoncer à la réception" → on coupe + // après "Bruno"). Le signal de fin de nom : un mot commençant par une + // minuscule (les noms/prénoms commencent par une majuscule). + // On garde quand même le 1er mot (parfois un mot comme "de", "von", + // "van" est lowercase). + const words = s.split(/\s+/); + const keep = []; + for (let i = 0; i < words.length; i++) { + const w = words[i]; + if (i === 0) { keep.push(w); continue; } + // Particules courantes : de / da / du / van / von / le / la + if (/^(de|da|du|van|von|le|la|del|di|der)$/i.test(w)) { keep.push(w); continue; } + // Si on a déjà au moins 2 mots et que ce mot commence par une minuscule, + // c'est un commentaire qui commence → on arrête. + if (keep.length >= 2 && /^[a-zéèêàâîôûç]/.test(w)) break; + // Limite dure : 4 mots maximum (Barbosa Oliveira, Bruno Dupont OK) + if (keep.length >= 4) break; + keep.push(w); + } + s = keep.join(" "); + // Ponctuation de nouveau en fin au cas où + s = s.replace(/[\s,;:.\-]+$/, "").trim(); return s || null; } @@ -2621,6 +2885,25 @@ function updateInterventionRow(iv) { const catEl = row.querySelector(".iv-category"); if (catEl) catEl.textContent = deriveShortTitle(iv); + // v4.1.8 : signature planificateur (XXX JJ.MM). Si le texte fiche (complet) + // est arrivé, il peut maintenant fournir une signature que le xhr2 tronqué + // n'avait pas. On met à jour le span .iv-signature en conséquence. + const bottomEl = row.querySelector(".iv-bottom-line"); + if (bottomEl) { + let sigEl = bottomEl.querySelector(".iv-signature"); + const sig = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription); + if (sig) { + if (!sigEl) { + sigEl = document.createElement("span"); + sigEl.className = "iv-signature"; + bottomEl.appendChild(sigEl); + } + sigEl.textContent = sig; + } else if (sigEl) { + sigEl.remove(); + } + } + // v4.1.2 : régénérer les blocs lieu/contact depuis les valeurs actuelles. // Priorité à iv.infobulle (xhr2 lazy, vraies infos) puis attr1/attr2 (planif). const rightCol = row.querySelector(".iv-right"); @@ -2654,12 +2937,40 @@ function updateInterventionRow(iv) { const tooltipEl = () => document.getElementById("tooltip"); -function showTooltip(e, iv) { +// v4.1.10 : état persistant de la bulle +// - pinned : une fois épinglée (double Ctrl), la bulle reste à sa position, +// ne suit plus la souris, et ne se ferme ni au mouseleave ni au +// mouseleave suivant. On peut sélectionner le texte dedans. +// Clic hors bulle (ailleurs que sur une autre intervention) ou +// nouveau double-Ctrl → désépingle. +// - hoveredInBulle : si la souris entre DANS la bulle elle-même, la bulle +// reste visible même si elle n'est pas épinglée. Elle ne +// disparaît que quand la souris sort à la fois de la carte ET +// de la bulle. +let bulleState = { + pinned: false, + hoveredInBulle: false, + hoveredInRow: false, + hideTimer: null +}; + +function showTooltip(e, iv, rowEl) { const el = tooltipEl(); el.innerHTML = buildTooltipHTML(iv); - el.classList.remove("hidden"); + el.classList.remove("hidden", "pinned"); el.classList.add("visible"); - moveTooltip(e); + // Reset pin en changeant d'iv + if (bulleState.pinned && state.currentTooltipIv !== iv) { + bulleState.pinned = false; + } + if (bulleState.hideTimer) { + clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = null; + } + bulleState.hoveredInRow = true; + // v4.1.12 : positionner la bulle UNE SEULE FOIS au mouseenter, près de la + // carte (row) et pas du curseur. Elle ne bouge plus pendant le survol. + positionTooltipAnchored(rowEl || (e && e.currentTarget)); // v4 : lazy-load du texte complet de l'action au premier hover. // Sans await : on affiche le tooltip IMMÉDIATEMENT avec ce qu'on a (lieu, @@ -2683,23 +2994,218 @@ function showTooltip(e, iv) { // d'écraser un tooltip différent si un autre hover s'est produit entretemps) state.currentTooltipIv = iv; } -function hideTooltip() { - const el = tooltipEl(); - el.classList.remove("visible"); - el.classList.add("hidden"); - state.currentTooltipIv = null; + +function hideTooltip(opts = {}) { + // Si la bulle est épinglée, on ignore (sauf force: true = unpin explicite) + if (bulleState.pinned && !opts.force) return; + bulleState.hoveredInRow = false; + // Petit délai : laisse le temps à la souris d'ENTRER dans la bulle elle-même + // (si l'user veut sélectionner du texte). On annule la fermeture si + // hoveredInBulle passe à true entre-temps. + if (bulleState.hideTimer) clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = setTimeout(() => { + if (bulleState.hoveredInBulle || bulleState.hoveredInRow) return; + if (bulleState.pinned) return; + const el = tooltipEl(); + el.classList.remove("visible", "pinned"); + el.classList.add("hidden"); + state.currentTooltipIv = null; + }, 120); } + function moveTooltip(e) { + // v4.1.12 : la bulle est FIXE (positionnée une fois au mouseenter). Cette + // fonction est conservée pour compat mais ne fait plus rien. +} + +// 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. +function positionTooltipAnchored(rowEl) { const el = tooltipEl(); - if (el.classList.contains("hidden")) return; + if (!rowEl || !el) return; const pad = 14; - const rect = el.getBoundingClientRect(); - let x = e.clientX + pad; - let y = e.clientY + pad; - if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - pad; - if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - pad; - el.style.left = Math.max(4, x) + "px"; - el.style.top = Math.max(4, y) + "px"; + const rowRect = rowEl.getBoundingClientRect(); + // Dimensions de la bulle : rendre visible puis mesurer + const tipRect = el.getBoundingClientRect(); + + // Position X : à droite de la ligne par défaut + let x = rowRect.right + pad; + if (x + tipRect.width > window.innerWidth - 8) { + // Pas assez de place à droite → à gauche + x = rowRect.left - tipRect.width - pad; + } + if (x < 4) x = 4; + + // Position Y : aligné en haut de la ligne + let y = rowRect.top; + if (y + tipRect.height > window.innerHeight - 8) { + y = window.innerHeight - tipRect.height - 8; + } + if (y < 4) y = 4; + + el.style.left = x + "px"; + el.style.top = y + "px"; +} + +// v4.1.10 : pin/unpin la bulle. Quand pin, on ajoute la classe CSS "pinned" +// qui change le curseur (text) et autorise la sélection. +function pinTooltip() { + if (!state.currentTooltipIv) return; + bulleState.pinned = true; + const el = tooltipEl(); + el.classList.add("pinned"); +} + +// v4.1.14 : recharger UNIQUEMENT cette intervention. Reset les flags de +// fetch pour forcer la récupération xhr2 + fiche + timeline, puis appeler +// fetchAndUpdateIntervention qui met à jour la carte ET (si la bulle est +// toujours ouverte sur cette iv) son contenu. +// Pendant ce temps, seul le bouton ↻ de la bulle tourne — pas les boutons +// Actualiser / Tout recharger de la topbar. +let singleReloadCounter = 0; +async function reloadSingleIntervention(iv, btnEl) { + if (!iv || iv.type === "AL-Reservation") return; + // Empêcher double-clic en cours + if (iv._reloading) return; + iv._reloading = true; + + // Reset flags pour forcer un refetch complet + iv.xhr2Fetched = false; + iv.ficheFetched = false; + iv.ficheActionText = null; + iv.ficheFetchError = null; + + // Marquer le bouton ↻ comme en cours + if (btnEl) btnEl.classList.add("spinning"); + singleReloadCounter++; + + try { + // Utiliser le token courant pour que l'abort au changement de date + // stoppe aussi ce reload + await fetchAndUpdateIntervention(iv, currentRefreshToken); + // Si la bulle est toujours ouverte sur cette iv, régénérer son HTML + const tip = tooltipEl(); + if (tip.classList.contains("visible") && state.currentTooltipIv === iv) { + tip.innerHTML = buildTooltipHTML(iv); + } + // Sauvegarder le cache pour cette date + const cached = await readCache(state.currentDate); + if (cached && cached.techs) { + // Remettre l'iv à jour dans le cache + for (const tech of cached.techs) { + for (let i = 0; i < (tech.interventions || []).length; i++) { + if (tech.interventions[i].actionId === iv.actionId) { + tech.interventions[i] = iv; + } + } + } + await writeCache(state.currentDate, { techs: cached.techs }); + } + } catch (err) { + console.warn("[reloadSingle] erreur iv", iv.actionId, err); + } finally { + iv._reloading = false; + singleReloadCounter = Math.max(0, singleReloadCounter - 1); + if (btnEl) btnEl.classList.remove("spinning"); + } +} +function unpinTooltip() { + bulleState.pinned = false; + const el = tooltipEl(); + el.classList.remove("pinned"); + // v4.1.13 : test immédiat si la souris est toujours dans la bulle ou sur + // la ligne. Si ni l'un ni l'autre, on ferme tout de suite (sans timer). + if (!bulleState.hoveredInBulle && !bulleState.hoveredInRow) { + el.classList.remove("visible"); + el.classList.add("hidden"); + state.currentTooltipIv = null; + if (bulleState.hideTimer) { + clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = null; + } + } + // Sinon : la bulle reste visible, et c'est le mouseleave qui la fermera + // normalement quand la souris sortira. +} + +// v4.1.10 : interactions bulle (double-Ctrl pour pin/unpin, hover dans la +// bulle pour persistance, clic hors pour unpin). +function bindTooltipInteractions() { + const el = tooltipEl(); + if (!el) return; + + // Hover sur la bulle elle-même : empêche la fermeture + el.addEventListener("mouseenter", () => { + bulleState.hoveredInBulle = true; + if (bulleState.hideTimer) { + clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = null; + } + }); + el.addEventListener("mouseleave", () => { + bulleState.hoveredInBulle = false; + if (!bulleState.hoveredInRow && !bulleState.pinned) { + hideTooltip(); + } + }); + + // Double-Ctrl : pin/unpin + // On détecte 2 keydown Control dans une fenêtre de 400 ms. + let lastCtrlTs = 0; + document.addEventListener("keydown", (e) => { + if (e.key !== "Control") return; + // Ignorer si la touche est répétée (hold) + if (e.repeat) return; + const now = performance.now(); + if (now - lastCtrlTs < 400) { + // Double-Ctrl détecté + lastCtrlTs = 0; + if (bulleState.pinned) { + unpinTooltip(); + } else if (state.currentTooltipIv) { + pinTooltip(); + } + } else { + lastCtrlTs = now; + } + }); + + // v4.1.13 : clic sur le bouton 📌 ou ↻ (bouton d'action de la bulle) + el.addEventListener("click", (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + e.stopPropagation(); + e.preventDefault(); + const action = btn.dataset.action; + if (action === "pin") { + if (bulleState.pinned) { + unpinTooltip(); + } else if (state.currentTooltipIv) { + pinTooltip(); + } + } else if (action === "reload") { + // v4.1.14 : recharger uniquement l'intervention actuellement affichée + if (state.currentTooltipIv) { + reloadSingleIntervention(state.currentTooltipIv, btn); + } + } + }); + + // Clic hors bulle : unpin si épinglé. + // Attention : ne pas déclencher sur clic DANS la bulle (elle contient du + // texte sélectionnable), ni sur clic sur une intervention (qui ouvre la + // fiche — le user n'attend pas que la bulle reste épinglée dans ce cas + // mais le comportement "ouvrir la fiche" reste prioritaire). + document.addEventListener("mousedown", (e) => { + if (!bulleState.pinned) return; + // Clic dans la bulle → on laisse (sélection de texte) + if (el.contains(e.target)) return; + // Dans tous les autres cas (y compris clic sur une autre intervention), + // on désépingle. Si c'était un clic sur intervention, le handler + // d'ouverture de la fiche s'exécutera ensuite normalement. + unpinTooltip(); + }); } function buildTooltipHTML(iv) { @@ -2730,14 +3236,17 @@ function buildTooltipHTML(iv) { rows.push(row("Horaire", `${iv.startTime}–${iv.endTime}`)); } - // ─── Texte complet de l'action, formaté avec retours à la ligne ────────── - // Le texte brut est comme : "Date : 20.04 Heure : MatinLieu : Ville1/Rue1 1 bisContact : ..." - // On ajoute des retours à la ligne AVANT chaque étiquette connue. - if (iv.bulleDescription) { - const formatted = formatActionTextMultiline(iv.bulleDescription); - rows.push(`Action ${escapeHtml(formatted).replace(/\n/g, "
")} `);
+ // ─── Texte d'action : fiche (complet) en priorité, sinon xhr2 (tronqué) ──
+ // v4.1.8 : un seul bloc "Action" qui s'enrichit automatiquement. Au début,
+ // le xhr2 tronqué s'affiche ; dès que le fetch timeline est revenu,
+ // iv.ficheActionText remplace le texte dans le même bloc.
+ const actionText = iv.ficheActionText ||
+ (iv.bulleDescription ? formatActionTextMultiline(iv.bulleDescription) : null);
+ if (actionText) {
+ const htmlAction = escapeHtml(actionText).replace(/\n/g, "
"); + rows.push(`Action ${htmlAction} `);
} else {
- // Si pas de description, afficher les infos structurées qu'on a
+ // Si pas de description (même pas de xhr2), afficher les infos structurées qu'on a
const hasAction = !!(i.date || i.heure || i.lieu || i.contact || i.service ||
i.probleme || i.aFaire || i.materiel);
if (i.date || i.heure) {
@@ -2763,17 +3272,6 @@ function buildTooltipHTML(iv) {
// Deadline (si connue et différente)
if (iv.deadline && !i.date) rows.push(row("Deadline", iv.deadline));
- // v4.1.5 : textes d'action de la fiche EasyVista assignés à ce tech
- // (potentiellement plusieurs si le tech a plusieurs actions sur ce ticket).
- if (iv.ficheActionTexts && iv.ficheActionTexts.length > 0) {
- rows.push(`
`); - for (const txt of iv.ficheActionTexts) { - // Afficher chaque texte en respectant les sauts de ligne - const html = escapeHtml(txt).replace(/\n/g, "
"); - rows.push(`Action ${html} `);
- }
- }
-
if (iv.ref) {
rows.push(`
`); rows.push(row("Référence", iv.ref)); @@ -2788,9 +3286,20 @@ function buildTooltipHTML(iv) { } if (rows.length === 0) { - return `
(avec ou sans attributs) → \n @@ -1266,7 +1458,8 @@ function cleanHtmlBlock(html) { .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") - .replace(/&/g, "&"); + .replace(/&/g, "&") + .replace(/\u200b/g, ""); // zero-width space // Tags HTML restants s = s.replace(/<[^>]+>/g, ""); // Espaces compactés, lignes trimmed, lignes vides retirées @@ -1274,35 +1467,6 @@ function cleanHtmlBlock(html) { return s; } -/** - * Décode les entités HTML courantes dans une chaîne courte (ex: un nom). - */ -function decodeHtmlEntities(s) { - if (!s) return s; - return s.replace(/ /g, " ") - .replace(/>/g, ">") - .replace(/</g, "<") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(/&/g, "&"); -} - -/** - * Compare deux noms de personne en étant tolérant (casse, accents, espaces, - * espace autour de la virgule). - */ -function namesMatch(a, b) { - if (!a || !b) return false; - const norm = s => String(s) - .normalize("NFD").replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/\s+/g, " ") - .replace(/\s*,\s*/g, ",") - .trim(); - return norm(a) === norm(b); -} - function decodeJsonString(s) { return s .replace(/\\r/g, "") @@ -1410,20 +1574,32 @@ let refreshCounter = 0; // Timer pour effacer le ✓ vert après 5 s let refreshDoneTimer = null; +// v4.1.13 : quel bouton doit tourner pendant le refresh en cours. +// Valeurs : "total" (par défaut / chargement auto), "partial", ou "xml_only". +let activeRefreshButton = "total"; + +function setActiveRefreshButton(kind) { + activeRefreshButton = kind || "total"; +} + function setRefreshing(on) { - const icon = document.getElementById("refresh-icon"); + const iconTotal = document.getElementById("refresh-icon"); + const iconPartial = document.getElementById("refresh-partial-icon"); + // Quel icône doit tourner ? Seulement celui correspondant au bouton + // qui a lancé le refresh (ou "total" par défaut). + const targetIcon = (activeRefreshButton === "partial") ? iconPartial : iconTotal; if (on) { refreshCounter++; - if (icon) icon.classList.add("spinning"); + if (targetIcon) targetIcon.classList.add("spinning"); clearCheckMark(); // Afficher "Rafraîchissement en cours…" si on n'a pas déjà les données - // (on ne veut pas écraser l'heure du cache si on est juste en train - // de re-fetch en arrière-plan) updateCaptureInfoText(); } else { refreshCounter = Math.max(0, refreshCounter - 1); - if (refreshCounter === 0 && icon) { - icon.classList.remove("spinning"); + if (refreshCounter === 0) { + // Arrêt : stopper les deux icônes au cas où + if (iconTotal) iconTotal.classList.remove("spinning"); + if (iconPartial) iconPartial.classList.remove("spinning"); } updateCaptureInfoText(); } @@ -1466,6 +1642,35 @@ function clearCheckMark() { } } +// ─── Barre de progression (v4.1.7) ───────────────────────────────────── +// État global : on affiche la progression du fetch en cours, uniquement si +// c'est le fetch de la page actuellement visible. Si l'utilisateur change +// de date, la barre suit la nouvelle date (son propre état). +function showProgressBar() { + const bar = document.getElementById("progress-bar"); + if (bar) bar.classList.remove("hidden"); +} + +function hideProgressBar() { + const bar = document.getElementById("progress-bar"); + if (bar) bar.classList.add("hidden"); + updateProgressBar(0, 0); +} + +function updateProgressBar(done, total) { + const fill = document.getElementById("progress-bar-fill"); + const label = document.getElementById("progress-bar-label"); + if (!fill || !label) return; + if (total <= 0) { + fill.style.width = "0%"; + label.textContent = ""; + return; + } + const pct = Math.min(100, Math.round((done / total) * 100)); + fill.style.width = pct + "%"; + label.textContent = `Rafraîchissement… ${done} / ${total}`; +} + // Affiche/masque le bouton "Arrêter". N'est montré que pendant un refresh // manuel (clic utilisateur), pas pendant les chargements normaux ni les // refresh auto 12h/15h. @@ -1923,7 +2128,7 @@ function buildInterventionRow(iv, cardEl) { if (iv.formLink && !iv.ghost) { row.classList.add("clickable"); - row.title = "Cliquer pour ouvrir la fiche (Ctrl+clic ou clic molette = arrière-plan)"; + // v4.1.8 : plus de title au survol (info déjà dans le tooltip en bas) // Clic normal : ouvre l'onglet et change de page // Ctrl/Cmd+Clic : ouvre en arrière-plan (reste sur le planning) @@ -2030,16 +2235,15 @@ function buildInterventionRow(iv, cardEl) { } row.appendChild(rightCol); - // Tooltip + // Tooltip (fixe, ne suit pas la souris — v4.1.12) row.addEventListener("mouseenter", (e) => { - showTooltip(e, iv); + showTooltip(e, iv, row); highlightIntervention(cardEl, ivIdx, true); }); row.addEventListener("mouseleave", () => { hideTooltip(); highlightIntervention(cardEl, ivIdx, false); }); - row.addEventListener("mousemove", moveTooltip); return row; } @@ -2062,7 +2266,10 @@ function buildInterventionRow(iv, cardEl) { categoryEl.textContent = deriveShortTitle(iv); bottomEl.appendChild(categoryEl); - const signature = extractPlanifSignature(iv.bulleDescription); + // v4.1.8 : extraire la signature depuis le texte COMPLET (fiche) en + // priorité, sinon depuis le xhr2 tronqué. Le xhr2 tronqué peut couper la + // signature, la fiche a toujours le texte complet. + const signature = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription); if (signature) { const sigEl = document.createElement("span"); sigEl.className = "iv-signature"; @@ -2073,16 +2280,15 @@ function buildInterventionRow(iv, cardEl) { rightCol.appendChild(bottomEl); row.appendChild(rightCol); - // Tooltip (au survol) + // Tooltip (fixe, ne suit pas la souris — v4.1.12) row.addEventListener("mouseenter", (e) => { - showTooltip(e, iv); + showTooltip(e, iv, row); highlightIntervention(cardEl, ivIdx, true); }); row.addEventListener("mouseleave", () => { hideTooltip(); highlightIntervention(cardEl, ivIdx, false); }); - row.addEventListener("mousemove", moveTooltip); return row; } @@ -2315,25 +2521,53 @@ function extractContacts(raw) { } /** - * Split UN seul bloc "Nom Prénom +41..." en { name, phone }. + * Split UN seul bloc "Nom Prénom +41... [autres tels] [commentaires]" en + * { name, phone }. + * + * Stratégie robuste (v4.1.8) : + * - On cherche TOUS les numéros de téléphone (long ou court). + * - Le nom = ce qui précède le PREMIER numéro. + * - Le champ phone concatène les numéros trouvés (séparés par " / "). + * - Ce qui suit les numéros (commentaires "S'annoncer à la réception...", + * "téléphone à l'utilisateur") est JETÉ : ça ne fait pas partie du contact. + * + * Pattern numéro (inchangé, connu pour marcher) : + * Long : +41 / +33 / 0X suivi de 8+ caractères de [chiffres espaces . -] + * Court: 5 chiffres isolés (entre espaces, parenthèses, ou début/fin) */ function splitOneContact(raw) { if (!raw) return { name: null, phone: null }; - const rxLong = /(\+41\s?\d[\d\s.\-]{8,}|\+33\s?\d[\d\s.\-]{8,}|0\d[\d\s.\-]{8,})/; - const rxShort = /(?:^|\s|\()(\d{5})(?:\s|\)|$)/; - let phone = null; - let name = raw; - let mLong = raw.match(rxLong); - if (mLong) { - phone = formatPhone(mLong[1]); - name = raw.replace(mLong[1], "").trim(); - } else { - let mShort = raw.match(rxShort); - if (mShort) { - phone = formatPhone(mShort[1]); - name = raw.replace(mShort[0], " ").trim(); + const rxLong = /(\+41\s?\d[\d\s.\-]{8,}|\+33\s?\d[\d\s.\-]{8,}|0\d[\d\s.\-]{8,})/g; + const rxShort = /(?:^|[\s(])(\d{5})(?=[\s)]|$)/g; + + // Trouver toutes les positions de match pour LONG et SHORT + const matches = []; + let mm; + while ((mm = rxLong.exec(raw)) !== null) { + matches.push({ start: mm.index, end: mm.index + mm[1].length, tel: mm[1] }); + } + while ((mm = rxShort.exec(raw)) !== null) { + // Ne pas prendre un short qui chevauche un long déjà trouvé + const shortTel = mm[1]; + const shortStart = mm.index + mm[0].indexOf(shortTel); + const shortEnd = shortStart + shortTel.length; + const overlaps = matches.some(x => shortStart < x.end && shortEnd > x.start); + if (!overlaps) { + matches.push({ start: shortStart, end: shortEnd, tel: shortTel }); } } + matches.sort((a, b) => a.start - b.start); + + let name = raw; + let phone = null; + if (matches.length > 0) { + // Nom = ce qui précède le 1er numéro + name = raw.substring(0, matches[0].start).trim(); + // Tels formatés, joints par " / " + const tels = matches.map(x => formatPhone(x.tel)).filter(Boolean); + phone = tels.length > 0 ? tels.join(" / ") : null; + } + name = cleanContactName(name); return { name, phone }; } @@ -2343,7 +2577,9 @@ function splitOneContact(raw) { * - retire tout ce qui est dans des parenthèses (...) * - retire les éventuels "Nom utilisateur :" ou libellés * - retire les virgules en trop en fin - * - Conserve juste "Nom, Prénom" + * - v4.1.8 : tronque les commentaires parasites après le nom + * (ex: "Dupont, Jean S'annoncer à la réception" → "Dupont, Jean") + * - Conserve juste "Nom, Prénom" (ou "Nom Prénom" si pas de virgule) */ function cleanContactName(raw) { if (!raw) return null; @@ -2362,6 +2598,34 @@ function cleanContactName(raw) { s = s.replace(/\s{2,}/g, " ").trim(); // Ponctuation en bord s = s.replace(/^[\s,;:.\-]+|[\s,;:.\-]+$/g, "").trim(); + if (!s) return null; + + // v4.1.8 : tronquer les commentaires parasites qui suivent le nom. + // On considère qu'un nom de personne ne dépasse pas 4 mots (Nom, Prénom, + // et éventuellement particule "Da Silva" ou second prénom). + // Si après les 4 premiers mots on a encore des mots, ce sont des parasites + // (ex: "Barbosa Oliveira, Bruno S'annoncer à la réception" → on coupe + // après "Bruno"). Le signal de fin de nom : un mot commençant par une + // minuscule (les noms/prénoms commencent par une majuscule). + // On garde quand même le 1er mot (parfois un mot comme "de", "von", + // "van" est lowercase). + const words = s.split(/\s+/); + const keep = []; + for (let i = 0; i < words.length; i++) { + const w = words[i]; + if (i === 0) { keep.push(w); continue; } + // Particules courantes : de / da / du / van / von / le / la + if (/^(de|da|du|van|von|le|la|del|di|der)$/i.test(w)) { keep.push(w); continue; } + // Si on a déjà au moins 2 mots et que ce mot commence par une minuscule, + // c'est un commentaire qui commence → on arrête. + if (keep.length >= 2 && /^[a-zéèêàâîôûç]/.test(w)) break; + // Limite dure : 4 mots maximum (Barbosa Oliveira, Bruno Dupont OK) + if (keep.length >= 4) break; + keep.push(w); + } + s = keep.join(" "); + // Ponctuation de nouveau en fin au cas où + s = s.replace(/[\s,;:.\-]+$/, "").trim(); return s || null; } @@ -2621,6 +2885,25 @@ function updateInterventionRow(iv) { const catEl = row.querySelector(".iv-category"); if (catEl) catEl.textContent = deriveShortTitle(iv); + // v4.1.8 : signature planificateur (XXX JJ.MM). Si le texte fiche (complet) + // est arrivé, il peut maintenant fournir une signature que le xhr2 tronqué + // n'avait pas. On met à jour le span .iv-signature en conséquence. + const bottomEl = row.querySelector(".iv-bottom-line"); + if (bottomEl) { + let sigEl = bottomEl.querySelector(".iv-signature"); + const sig = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription); + if (sig) { + if (!sigEl) { + sigEl = document.createElement("span"); + sigEl.className = "iv-signature"; + bottomEl.appendChild(sigEl); + } + sigEl.textContent = sig; + } else if (sigEl) { + sigEl.remove(); + } + } + // v4.1.2 : régénérer les blocs lieu/contact depuis les valeurs actuelles. // Priorité à iv.infobulle (xhr2 lazy, vraies infos) puis attr1/attr2 (planif). const rightCol = row.querySelector(".iv-right"); @@ -2654,12 +2937,40 @@ function updateInterventionRow(iv) { const tooltipEl = () => document.getElementById("tooltip"); -function showTooltip(e, iv) { +// v4.1.10 : état persistant de la bulle +// - pinned : une fois épinglée (double Ctrl), la bulle reste à sa position, +// ne suit plus la souris, et ne se ferme ni au mouseleave ni au +// mouseleave suivant. On peut sélectionner le texte dedans. +// Clic hors bulle (ailleurs que sur une autre intervention) ou +// nouveau double-Ctrl → désépingle. +// - hoveredInBulle : si la souris entre DANS la bulle elle-même, la bulle +// reste visible même si elle n'est pas épinglée. Elle ne +// disparaît que quand la souris sort à la fois de la carte ET +// de la bulle. +let bulleState = { + pinned: false, + hoveredInBulle: false, + hoveredInRow: false, + hideTimer: null +}; + +function showTooltip(e, iv, rowEl) { const el = tooltipEl(); el.innerHTML = buildTooltipHTML(iv); - el.classList.remove("hidden"); + el.classList.remove("hidden", "pinned"); el.classList.add("visible"); - moveTooltip(e); + // Reset pin en changeant d'iv + if (bulleState.pinned && state.currentTooltipIv !== iv) { + bulleState.pinned = false; + } + if (bulleState.hideTimer) { + clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = null; + } + bulleState.hoveredInRow = true; + // v4.1.12 : positionner la bulle UNE SEULE FOIS au mouseenter, près de la + // carte (row) et pas du curseur. Elle ne bouge plus pendant le survol. + positionTooltipAnchored(rowEl || (e && e.currentTarget)); // v4 : lazy-load du texte complet de l'action au premier hover. // Sans await : on affiche le tooltip IMMÉDIATEMENT avec ce qu'on a (lieu, @@ -2683,23 +2994,218 @@ function showTooltip(e, iv) { // d'écraser un tooltip différent si un autre hover s'est produit entretemps) state.currentTooltipIv = iv; } -function hideTooltip() { - const el = tooltipEl(); - el.classList.remove("visible"); - el.classList.add("hidden"); - state.currentTooltipIv = null; + +function hideTooltip(opts = {}) { + // Si la bulle est épinglée, on ignore (sauf force: true = unpin explicite) + if (bulleState.pinned && !opts.force) return; + bulleState.hoveredInRow = false; + // Petit délai : laisse le temps à la souris d'ENTRER dans la bulle elle-même + // (si l'user veut sélectionner du texte). On annule la fermeture si + // hoveredInBulle passe à true entre-temps. + if (bulleState.hideTimer) clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = setTimeout(() => { + if (bulleState.hoveredInBulle || bulleState.hoveredInRow) return; + if (bulleState.pinned) return; + const el = tooltipEl(); + el.classList.remove("visible", "pinned"); + el.classList.add("hidden"); + state.currentTooltipIv = null; + }, 120); } + function moveTooltip(e) { + // v4.1.12 : la bulle est FIXE (positionnée une fois au mouseenter). Cette + // fonction est conservée pour compat mais ne fait plus rien. +} + +// 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. +function positionTooltipAnchored(rowEl) { const el = tooltipEl(); - if (el.classList.contains("hidden")) return; + if (!rowEl || !el) return; const pad = 14; - const rect = el.getBoundingClientRect(); - let x = e.clientX + pad; - let y = e.clientY + pad; - if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - pad; - if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - pad; - el.style.left = Math.max(4, x) + "px"; - el.style.top = Math.max(4, y) + "px"; + const rowRect = rowEl.getBoundingClientRect(); + // Dimensions de la bulle : rendre visible puis mesurer + const tipRect = el.getBoundingClientRect(); + + // Position X : à droite de la ligne par défaut + let x = rowRect.right + pad; + if (x + tipRect.width > window.innerWidth - 8) { + // Pas assez de place à droite → à gauche + x = rowRect.left - tipRect.width - pad; + } + if (x < 4) x = 4; + + // Position Y : aligné en haut de la ligne + let y = rowRect.top; + if (y + tipRect.height > window.innerHeight - 8) { + y = window.innerHeight - tipRect.height - 8; + } + if (y < 4) y = 4; + + el.style.left = x + "px"; + el.style.top = y + "px"; +} + +// v4.1.10 : pin/unpin la bulle. Quand pin, on ajoute la classe CSS "pinned" +// qui change le curseur (text) et autorise la sélection. +function pinTooltip() { + if (!state.currentTooltipIv) return; + bulleState.pinned = true; + const el = tooltipEl(); + el.classList.add("pinned"); +} + +// v4.1.14 : recharger UNIQUEMENT cette intervention. Reset les flags de +// fetch pour forcer la récupération xhr2 + fiche + timeline, puis appeler +// fetchAndUpdateIntervention qui met à jour la carte ET (si la bulle est +// toujours ouverte sur cette iv) son contenu. +// Pendant ce temps, seul le bouton ↻ de la bulle tourne — pas les boutons +// Actualiser / Tout recharger de la topbar. +let singleReloadCounter = 0; +async function reloadSingleIntervention(iv, btnEl) { + if (!iv || iv.type === "AL-Reservation") return; + // Empêcher double-clic en cours + if (iv._reloading) return; + iv._reloading = true; + + // Reset flags pour forcer un refetch complet + iv.xhr2Fetched = false; + iv.ficheFetched = false; + iv.ficheActionText = null; + iv.ficheFetchError = null; + + // Marquer le bouton ↻ comme en cours + if (btnEl) btnEl.classList.add("spinning"); + singleReloadCounter++; + + try { + // Utiliser le token courant pour que l'abort au changement de date + // stoppe aussi ce reload + await fetchAndUpdateIntervention(iv, currentRefreshToken); + // Si la bulle est toujours ouverte sur cette iv, régénérer son HTML + const tip = tooltipEl(); + if (tip.classList.contains("visible") && state.currentTooltipIv === iv) { + tip.innerHTML = buildTooltipHTML(iv); + } + // Sauvegarder le cache pour cette date + const cached = await readCache(state.currentDate); + if (cached && cached.techs) { + // Remettre l'iv à jour dans le cache + for (const tech of cached.techs) { + for (let i = 0; i < (tech.interventions || []).length; i++) { + if (tech.interventions[i].actionId === iv.actionId) { + tech.interventions[i] = iv; + } + } + } + await writeCache(state.currentDate, { techs: cached.techs }); + } + } catch (err) { + console.warn("[reloadSingle] erreur iv", iv.actionId, err); + } finally { + iv._reloading = false; + singleReloadCounter = Math.max(0, singleReloadCounter - 1); + if (btnEl) btnEl.classList.remove("spinning"); + } +} +function unpinTooltip() { + bulleState.pinned = false; + const el = tooltipEl(); + el.classList.remove("pinned"); + // v4.1.13 : test immédiat si la souris est toujours dans la bulle ou sur + // la ligne. Si ni l'un ni l'autre, on ferme tout de suite (sans timer). + if (!bulleState.hoveredInBulle && !bulleState.hoveredInRow) { + el.classList.remove("visible"); + el.classList.add("hidden"); + state.currentTooltipIv = null; + if (bulleState.hideTimer) { + clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = null; + } + } + // Sinon : la bulle reste visible, et c'est le mouseleave qui la fermera + // normalement quand la souris sortira. +} + +// v4.1.10 : interactions bulle (double-Ctrl pour pin/unpin, hover dans la +// bulle pour persistance, clic hors pour unpin). +function bindTooltipInteractions() { + const el = tooltipEl(); + if (!el) return; + + // Hover sur la bulle elle-même : empêche la fermeture + el.addEventListener("mouseenter", () => { + bulleState.hoveredInBulle = true; + if (bulleState.hideTimer) { + clearTimeout(bulleState.hideTimer); + bulleState.hideTimer = null; + } + }); + el.addEventListener("mouseleave", () => { + bulleState.hoveredInBulle = false; + if (!bulleState.hoveredInRow && !bulleState.pinned) { + hideTooltip(); + } + }); + + // Double-Ctrl : pin/unpin + // On détecte 2 keydown Control dans une fenêtre de 400 ms. + let lastCtrlTs = 0; + document.addEventListener("keydown", (e) => { + if (e.key !== "Control") return; + // Ignorer si la touche est répétée (hold) + if (e.repeat) return; + const now = performance.now(); + if (now - lastCtrlTs < 400) { + // Double-Ctrl détecté + lastCtrlTs = 0; + if (bulleState.pinned) { + unpinTooltip(); + } else if (state.currentTooltipIv) { + pinTooltip(); + } + } else { + lastCtrlTs = now; + } + }); + + // v4.1.13 : clic sur le bouton 📌 ou ↻ (bouton d'action de la bulle) + el.addEventListener("click", (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + e.stopPropagation(); + e.preventDefault(); + const action = btn.dataset.action; + if (action === "pin") { + if (bulleState.pinned) { + unpinTooltip(); + } else if (state.currentTooltipIv) { + pinTooltip(); + } + } else if (action === "reload") { + // v4.1.14 : recharger uniquement l'intervention actuellement affichée + if (state.currentTooltipIv) { + reloadSingleIntervention(state.currentTooltipIv, btn); + } + } + }); + + // Clic hors bulle : unpin si épinglé. + // Attention : ne pas déclencher sur clic DANS la bulle (elle contient du + // texte sélectionnable), ni sur clic sur une intervention (qui ouvre la + // fiche — le user n'attend pas que la bulle reste épinglée dans ce cas + // mais le comportement "ouvrir la fiche" reste prioritaire). + document.addEventListener("mousedown", (e) => { + if (!bulleState.pinned) return; + // Clic dans la bulle → on laisse (sélection de texte) + if (el.contains(e.target)) return; + // Dans tous les autres cas (y compris clic sur une autre intervention), + // on désépingle. Si c'était un clic sur intervention, le handler + // d'ouverture de la fiche s'exécutera ensuite normalement. + unpinTooltip(); + }); } function buildTooltipHTML(iv) { @@ -2730,14 +3236,17 @@ function buildTooltipHTML(iv) { rows.push(row("Horaire", `${iv.startTime}–${iv.endTime}`)); } - // ─── Texte complet de l'action, formaté avec retours à la ligne ────────── - // Le texte brut est comme : "Date : 20.04 Heure : MatinLieu : Ville1/Rue1 1 bisContact : ..." - // On ajoute des retours à la ligne AVANT chaque étiquette connue. - if (iv.bulleDescription) { - const formatted = formatActionTextMultiline(iv.bulleDescription); - rows.push(`
")}
"); + rows.push(`
`); - for (const txt of iv.ficheActionTexts) { - // Afficher chaque texte en respectant les sauts de ligne - const html = escapeHtml(txt).replace(/\n/g, "
"); - rows.push(`
`); rows.push(row("Référence", iv.ref)); @@ -2788,9 +3286,20 @@ function buildTooltipHTML(iv) { } if (rows.length === 0) { - return `
- Info
- Aucun détail disponible
+
+
+
+ 📌
+- Info
- Aucun détail disponible
- ${rows.join("")}
+
+
+
+ 📌
+- ${rows.join("")}