|
|
|
@@ -5,13 +5,13 @@
|
|
|
|
|
* réservations, absences, statut pompier, etc. Regroupé par tech avec
|
|
|
|
|
* timelines visuelles et popups détaillés.
|
|
|
|
|
*
|
|
|
|
|
* Copyright (c) 2026 FroSteel
|
|
|
|
|
* Copyright (c) 2026 Quentin
|
|
|
|
|
* Licensed under the MIT License — see LICENSE file in the project root.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
* @repository https://gitea.netaplaid.ch/FroSteel/Planification
|
|
|
|
|
*
|
|
|
|
|
* Développé par FroSteel (FRS) — DGNSI, Canton de Vaud. Licence MIT.
|
|
|
|
|
* Développé par Quentin (FRS) — DGNSI, Canton de Vaud. Licence MIT.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
@@ -543,15 +543,23 @@ let _myActionsRows = null;
|
|
|
|
|
let _myActionsLoading = false;
|
|
|
|
|
let _myActionsDiag = ""; // diagnostic du dernier fetch (affiché dans l'onglet)
|
|
|
|
|
|
|
|
|
|
// v2026.5.46 — impersonation (RÉSERVÉE à FroSteel) : permet de tester
|
|
|
|
|
// v2026.5.46 — impersonation (RÉSERVÉE à Quentin) : permet de tester
|
|
|
|
|
// la vue coordinateur en "se faisant passer pour" une autre personne. Chargé
|
|
|
|
|
// depuis admin_config (cfg.impersonate = { enabled, name, initials, memberId }).
|
|
|
|
|
let _impersonate = null;
|
|
|
|
|
// Vrai uniquement si l'utilisateur connecté EST FroSteel (garde-fou
|
|
|
|
|
// Vrai uniquement si l'utilisateur connecté EST Quentin (garde-fou
|
|
|
|
|
// pour n'afficher l'onglet Test qu'à lui).
|
|
|
|
|
function _isQuentin() {
|
|
|
|
|
// Empreintes FNV-1a (32 bits) du nom et du prenom de l'auteur : jamais en clair dans le code.
|
|
|
|
|
const _EMPREINTES_AUTEUR = [0x716d54e7, 0xa6e31b47];
|
|
|
|
|
function _empreinteMot(mot) {
|
|
|
|
|
let h = 0x811c9dc5;
|
|
|
|
|
for (const c of mot) { h ^= c.codePointAt(0); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
|
|
|
return h;
|
|
|
|
|
}
|
|
|
|
|
function _estAuteur() {
|
|
|
|
|
const n = (state && state.currentUser && state.currentUser.name) || "";
|
|
|
|
|
return /exemple/i.test(n) && /FroSteel/i.test(n);
|
|
|
|
|
const mots = new Set(n.toLowerCase().split(/[^a-zà-ÿ]+/).filter(Boolean).map(_empreinteMot));
|
|
|
|
|
return _EMPREINTES_AUTEUR.every((e) => mots.has(e));
|
|
|
|
|
}
|
|
|
|
|
// Identité effective utilisée pour retrouver "l'action liée à la personne"
|
|
|
|
|
// (Partie A « Mes actions »). Si impersonation active ET FRS → identité simulée,
|
|
|
|
@@ -563,7 +571,7 @@ function _effectiveIdentity() {
|
|
|
|
|
memberId: (state && state.currentUser && state.currentUser.memberId) || "",
|
|
|
|
|
impersonated: false
|
|
|
|
|
};
|
|
|
|
|
if (_isQuentin() && _impersonate && _impersonate.enabled &&
|
|
|
|
|
if (_estAuteur() && _impersonate && _impersonate.enabled &&
|
|
|
|
|
(_impersonate.name || _impersonate.initials || _impersonate.memberId)) {
|
|
|
|
|
return {
|
|
|
|
|
name: _impersonate.name || "",
|
|
|
|
@@ -1176,7 +1184,7 @@ const _CURRENT_USER_RETRY_DELAY_MS = 60 * 1000;
|
|
|
|
|
/**
|
|
|
|
|
* Récupère le nom de l'utilisateur EasyVista connecté et l'affiche dans le badge.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
async function fetchAndShowCurrentUser() {
|
|
|
|
|
const attemptId = _currentUserRetryCount + 1;
|
|
|
|
@@ -1278,7 +1286,7 @@ async function fetchAndShowCurrentUser() {
|
|
|
|
|
*
|
|
|
|
|
* @param {string} reason - contexte pour les logs (ex: "after_load_success")
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function _maybeRetryFetchUser(reason) {
|
|
|
|
|
if (state.currentUser && state.currentUser.name) {
|
|
|
|
@@ -1432,7 +1440,7 @@ function toggleUserNamePopup() {
|
|
|
|
|
// gris atténué, séparé par une fine ligne supérieure.
|
|
|
|
|
const authorLine = document.createElement("div");
|
|
|
|
|
authorLine.className = "user-name-popup-author";
|
|
|
|
|
authorLine.textContent = "Développé par FroSteel";
|
|
|
|
|
authorLine.textContent = "Développé par Quentin";
|
|
|
|
|
authorLine.title = "Auteur de l'extension";
|
|
|
|
|
popup.appendChild(authorLine);
|
|
|
|
|
|
|
|
|
@@ -2143,7 +2151,7 @@ function initModalScrollLock() {
|
|
|
|
|
/**
|
|
|
|
|
* Pied de page discret bas-droite avec FRS et numéro de version.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function initAppFooter() {
|
|
|
|
|
if (document.querySelector(".app-footer")) return;
|
|
|
|
@@ -2214,7 +2222,7 @@ function _toggleView() {
|
|
|
|
|
* logs verbeux étape par étape ont été retirés, ils polluaient la console
|
|
|
|
|
* à chaque toggle.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function _applyViewMode() {
|
|
|
|
|
const mode = _getCurrentView();
|
|
|
|
@@ -2291,7 +2299,7 @@ function _applyViewMode() {
|
|
|
|
|
* v2026.5.36 : crée ou retrouve la sidebar, et y déplace les éléments listés.
|
|
|
|
|
* Les éléments sont déplacés dans l'ordre du tableau.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function _moveElementsToSidebar(ids) {
|
|
|
|
|
// Créer (ou retrouver) le wrapper qui contiendra sidebar + main côte à côte
|
|
|
|
@@ -2369,7 +2377,7 @@ function _moveElementsToSidebar(ids) {
|
|
|
|
|
* v2026.5.36 : restaure chaque élément à son parent d'origine (mémorisé dans
|
|
|
|
|
* data-orig-parent). Utilisé quand on revient en vue classique.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function _restoreElementsToTopbar(ids) {
|
|
|
|
|
// v2026.5.37 : d'abord remettre les flèches dans .date-nav (avant de
|
|
|
|
@@ -2476,7 +2484,7 @@ function _restoreToOriginalParent(el, label) {
|
|
|
|
|
/**
|
|
|
|
|
* Horloge HH:MM au milieu de la topbar, mise à jour chaque minute (depuis v5.0.0).
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function initAppClock() {
|
|
|
|
|
const el = document.getElementById("app-clock");
|
|
|
|
@@ -3110,10 +3118,10 @@ async function showAdminPanel(initialSection) {
|
|
|
|
|
{ id: "diagnostics",label: "Diagnostics", render: renderAdminSectionDiagnostics },
|
|
|
|
|
{ id: "about", label: "À propos", render: renderAdminSectionAbout }
|
|
|
|
|
];
|
|
|
|
|
// v2026.5.46 — onglet "Test (FRS)" RÉSERVÉ à FroSteel : permet de
|
|
|
|
|
// v2026.5.46 — onglet "Test (FRS)" RÉSERVÉ à Quentin : permet de
|
|
|
|
|
// se faire passer pour une autre personne (coordinateur) pour tester « Mes
|
|
|
|
|
// actions » sans être réellement coordinateur. Caché pour tout autre utilisateur.
|
|
|
|
|
if (_isQuentin()) {
|
|
|
|
|
if (_estAuteur()) {
|
|
|
|
|
sections.push({ id: "qrotest", label: "🧪 Test (FRS)", render: renderAdminSectionImpersonate });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -4888,7 +4896,7 @@ function renderAdminSectionImpersonate(container, cfg, saveFn) {
|
|
|
|
|
|
|
|
|
|
const intro = document.createElement("p");
|
|
|
|
|
intro.style.cssText = "font-size:13px; color:var(--text-muted); margin:0 0 6px;";
|
|
|
|
|
intro.textContent = "Réservé à FroSteel. Permet de tester la vue « Mes actions » en te faisant passer pour une autre personne (ex. un coordinateur). N'affecte que l'affichage local de l'extension — aucune action côté EasyVista.";
|
|
|
|
|
intro.textContent = "Réservé à Quentin. Permet de tester la vue « Mes actions » en te faisant passer pour une autre personne (ex. un coordinateur). N'affecte que l'affichage local de l'extension — aucune action côté EasyVista.";
|
|
|
|
|
container.appendChild(intro);
|
|
|
|
|
|
|
|
|
|
const who = document.createElement("p");
|
|
|
|
@@ -5456,7 +5464,7 @@ function renderAdminSectionAbout(container, cfg, saveFn) {
|
|
|
|
|
</div>
|
|
|
|
|
<div class="admin-about-row">
|
|
|
|
|
<div class="admin-about-label">Auteur</div>
|
|
|
|
|
<div class="admin-about-value"><strong>FroSteel</strong></div>
|
|
|
|
|
<div class="admin-about-value"><strong>Quentin</strong></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="admin-about-row">
|
|
|
|
|
<div class="admin-about-label">Affiliation</div>
|
|
|
|
@@ -6616,7 +6624,13 @@ async function submitAbsence(opts) {
|
|
|
|
|
// ville, rue, catégorie) au créneau, propagées par le merge et affichées dans
|
|
|
|
|
// la bulle. Les réservations créées directement dans EV restent inchangées.
|
|
|
|
|
const RESERVATION_TYPE_GUID = "{E7432422-55CB-4DB9-8A26-619D036E2155}"; // Evènements spéciaux
|
|
|
|
|
const RESERVATION_CATEGORIES = ["Installation", "Remplacement", "Récupération", "Incident"];
|
|
|
|
|
// v2026.5.50 — « Roll Out » et « Déménagement » : catégories propres à la
|
|
|
|
|
// Planification (méta du carnet, partagée par le dossier) ; côté EasyVista, la
|
|
|
|
|
// réservation reste « Evènements spéciaux ».
|
|
|
|
|
const RESERVATION_CATEGORIES = ["Installation", "Remplacement", "Récupération", "Incident", "Roll Out", "Déménagement"];
|
|
|
|
|
// Celles que seule la Planification repère (texte de l'action) : la fiche EV,
|
|
|
|
|
// qui ne connaît que le catalogue, ne doit pas les remplacer.
|
|
|
|
|
const CATEGORIES_PROPRES_PLANIFICATION = ["Roll Out", "Déménagement"];
|
|
|
|
|
// v2026.5.46 — GUID du type "Pompier" (utilisé par le popup pompier de la
|
|
|
|
|
// modal Réservation). Doit rester aligné avec HOLIDAY_TYPES.
|
|
|
|
|
const POMPIER_TYPE_GUID = "{60D70502-063D-45AD-9415-25C1C556105F}";
|
|
|
|
@@ -6630,7 +6644,8 @@ function _resTypeColorKey(cat) {
|
|
|
|
|
case "récupération":
|
|
|
|
|
case "recuperation": return "recup";
|
|
|
|
|
case "incident": return "incident";
|
|
|
|
|
default: return null;
|
|
|
|
|
case "roll out": return "rollout";
|
|
|
|
|
default: return null; // dont « Déménagement » : couleur par défaut des réservations
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -6645,7 +6660,11 @@ function _lookupInterventionInfo(num) {
|
|
|
|
|
if (String(iv.ref || "").trim() === n || String(iv.actionId || "").trim() === n) {
|
|
|
|
|
const cat = (iv.categoryLine || "").toLowerCase();
|
|
|
|
|
let categorie = "";
|
|
|
|
|
if (/install/.test(cat)) categorie = "Installation";
|
|
|
|
|
// Une réf I… (incident) ne reçoit pas les catégories propres à la Planification.
|
|
|
|
|
const refIncident = /^I\d/.test(iv.ref || "");
|
|
|
|
|
if (!refIncident && isRollOut(iv)) categorie = "Roll Out";
|
|
|
|
|
else if (!refIncident && /d[ée]m[ée]nag/.test(cat)) categorie = "Déménagement";
|
|
|
|
|
else if (/install/.test(cat)) categorie = "Installation";
|
|
|
|
|
else if (/remplac/.test(cat)) categorie = "Remplacement";
|
|
|
|
|
else if (/r[ée]cup/.test(cat)) categorie = "Récupération";
|
|
|
|
|
else if (/incident/.test(cat)) categorie = "Incident";
|
|
|
|
@@ -6764,7 +6783,13 @@ async function _fetchReservationInfo(num) {
|
|
|
|
|
const titM = html.match(/"dbFieldName"\s*:\s*"TITLE_FR"[^}]*?"value"\s*:\s*"([^"]{2,300})"/);
|
|
|
|
|
if (titM) titreFiche = (decodeJsonString ? decodeJsonString(titM[1]) : titM[1]).trim();
|
|
|
|
|
const titreCourt = deriveShortTitle({ ref: n, categoryLine: titreFiche });
|
|
|
|
|
const categorie = RESERVATION_CATEGORIES.includes(titreCourt) ? titreCourt : "";
|
|
|
|
|
let categorie = RESERVATION_CATEGORIES.includes(titreCourt) ? titreCourt : "";
|
|
|
|
|
// Catégories propres à la Planification, repérées dans le titre de la fiche
|
|
|
|
|
// (deriveShortTitle ne les connaît pas sans le texte de l'action).
|
|
|
|
|
if (!categorie && titreCourt !== "Incident") {
|
|
|
|
|
if (/roll\s*-?\s*out/i.test(titreFiche)) categorie = "Roll Out";
|
|
|
|
|
else if (/d[ée]m[ée]nag/i.test(titreFiche)) categorie = "Déménagement";
|
|
|
|
|
}
|
|
|
|
|
let ville = "", rue = "";
|
|
|
|
|
const locM = html.match(/"dbFieldName"\s*:\s*"[^"]*(?:LOCATION|LOCALISATION|ADDRESS|ADRESSE)[^"]*"[^}]*?"value"\s*:\s*"([^"]{3,160})"/i)
|
|
|
|
|
|| html.match(/Localisation[^>]{0,40}>\s*([^<]{3,160})/i);
|
|
|
|
@@ -6820,11 +6845,18 @@ function _wireResNumberLookup(numField, cibles) {
|
|
|
|
|
if (num === lastNum) return;
|
|
|
|
|
lastNum = num;
|
|
|
|
|
LOG.info("reservation", `n° saisi : ${num} → recherche infos (planning + EV)`);
|
|
|
|
|
apply(_lookupInterventionInfo(num)); // instantané (cache planning)
|
|
|
|
|
const infoPlanning = _lookupInterventionInfo(num);
|
|
|
|
|
apply(infoPlanning); // instantané (cache planning)
|
|
|
|
|
// Roll Out / Déménagement trouvé par le planning pour CE n° : la fiche EV ne
|
|
|
|
|
// le voit pas et ne doit pas le remplacer par une catégorie du catalogue.
|
|
|
|
|
const categoriePropre = infoPlanning && CATEGORIES_PROPRES_PLANIFICATION.includes(infoPlanning.categorie)
|
|
|
|
|
? infoPlanning.categorie : "";
|
|
|
|
|
const seq = ++seqRef; // fetch EV réel en arrière-plan
|
|
|
|
|
if (loadingBar) loadingBar.classList.remove("hidden");
|
|
|
|
|
_fetchReservationInfo(num).then(info => {
|
|
|
|
|
if (seq === seqRef) { apply(info); if (loadingBar) loadingBar.classList.add("hidden"); }
|
|
|
|
|
if (seq !== seqRef) return;
|
|
|
|
|
apply(info && categoriePropre ? { ...info, categorie: categoriePropre } : info);
|
|
|
|
|
if (loadingBar) loadingBar.classList.add("hidden");
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
numField.addEventListener("change", trigger);
|
|
|
|
@@ -7056,6 +7088,12 @@ function showReservationEditModal(iv) {
|
|
|
|
|
const catSelect = document.createElement("select"); catSelect.className = "modal-form-select";
|
|
|
|
|
const emptyOpt = document.createElement("option"); emptyOpt.value = ""; emptyOpt.textContent = "—"; catSelect.appendChild(emptyOpt);
|
|
|
|
|
RESERVATION_CATEGORIES.forEach(c => { const o = document.createElement("option"); o.value = c; o.textContent = c; if (c === iv.resCategorie) o.selected = true; catSelect.appendChild(o); });
|
|
|
|
|
// Catégorie inconnue de cette version (posée par une version plus récente,
|
|
|
|
|
// reçue du dossier) : gardée telle quelle, sinon l'enregistrer l'effacerait
|
|
|
|
|
// sur tous les postes (l'édition la plus récente gagne à la fusion).
|
|
|
|
|
if (iv.resCategorie && !RESERVATION_CATEGORIES.includes(iv.resCategorie)) {
|
|
|
|
|
const o = document.createElement("option"); o.value = iv.resCategorie; o.textContent = iv.resCategorie; o.selected = true; catSelect.appendChild(o);
|
|
|
|
|
}
|
|
|
|
|
catGroup.appendChild(catSelect); grid.appendChild(catGroup);
|
|
|
|
|
const villeField = _mkField("Ville / village", iv.resVille, "");
|
|
|
|
|
const rueField = _mkField("Rue / chemin / route", iv.resRue, "");
|
|
|
|
@@ -7660,7 +7698,7 @@ function sendMessage(msg) {
|
|
|
|
|
// Filets supplémentaires à l'ingestion : correspondance par réf, et signature
|
|
|
|
|
// composite (tech|type|dates|libellé) pour les résa/absences sans requestId.
|
|
|
|
|
//
|
|
|
|
|
// Auteur : FroSteel (FRS).
|
|
|
|
|
// Auteur : Quentin (FRS).
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
const TOMBSTONE_TTL_MS = 7 * 24 * 3600 * 1000; // rétention tombstone (convergence postes)
|
|
|
|
@@ -7904,7 +7942,7 @@ function ivFreshness(iv) {
|
|
|
|
|
* 3. existence : max des événements des deux côtés, puis règle vie/mort —
|
|
|
|
|
* une VIE strictement postérieure à la mort lève les tombstones
|
|
|
|
|
* (ré-apparition EV autoritaire), sinon la mort est conservée/propagée.
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function ivMerge(a, b) {
|
|
|
|
|
if (!a) return b;
|
|
|
|
@@ -8197,7 +8235,7 @@ function _ivAbsenceSig(iv, techId) {
|
|
|
|
|
*
|
|
|
|
|
* @returns {{day: object, toAnalyze: object[]}} le store mis à jour + la
|
|
|
|
|
* liste des enregistrements à passer à l'analyse de disparition.
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function storeApplyFresh(day, freshTechs, isoDate) {
|
|
|
|
|
const now = Date.now();
|
|
|
|
@@ -8854,7 +8892,7 @@ function _legacyDayToV2(isoDate, v1) {
|
|
|
|
|
* Convertit tout l'ancien dossier (racine v1) vers le store v2 : liste les
|
|
|
|
|
* jours, lit chaque fichier v1, convertit, fusionne au store local (dayMerge)
|
|
|
|
|
* et sauvegarde — le push différé propage ensuite vers le sous-dossier v2.
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
async function importLegacyFolderCache(rootPath, onProgress) {
|
|
|
|
|
if (state.networkContext !== "internal") return { ok: false, error: "réseau externe — dossier partagé injoignable" };
|
|
|
|
@@ -8958,7 +8996,7 @@ function scheduleFolderPush(isoDate) {
|
|
|
|
|
* v2026.5.46 — le rendu après tirage du dossier passe par storeProject
|
|
|
|
|
* (mêmes filtres que tout rendu : tombstones, dock, déplacées récentes) → fini
|
|
|
|
|
* le flash d'une iv parquée/déplacée qui réapparaissait via la sync.
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
// v2026.5.49 — blocage ferme lié à la connexion : les opérations sensibles
|
|
|
|
|
// (lecture/écriture du dossier PARTAGÉ, déplacement, suppression) exigent une
|
|
|
|
@@ -9080,7 +9118,7 @@ async function reconcileCacheForDate(isoDate) {
|
|
|
|
|
* 6. phase B : fiches complètes + analyse des disparues en SÉQUENTIEL ;
|
|
|
|
|
* 7. sauvegarde finale + convergence dossier en arrière-plan.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
async function loadForDate(isoDate, opts = {}) {
|
|
|
|
|
LOG.info("load", `début pour ${isoDate}`, { opts, hasSession: !!state.session });
|
|
|
|
@@ -9793,10 +9831,10 @@ const RX_LOGIN_COMMENTAIRE = /(?:^|\n|<br\s*\/?>)\s*([a-z0-9_]{3,12})\s*:\s+(\S[
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Extrait toutes les actions d'une fiche en parsant les blocs "rows" du HTML.
|
|
|
|
|
* Une action a 14 values. Indices stables :
|
|
|
|
|
* Une action a 13 values (14 jusqu'au 28.09.2026). Indices stables :
|
|
|
|
|
* [2] = Intervenant (ex: "Nom, Prénom" ou "EZV_WS_REST_USER")
|
|
|
|
|
* [4] = Type d'action (ex: "AL-Intervention", "Ajout d'informations")
|
|
|
|
|
* [13] = JSON stringifié contenant ACTION_ID, AM_DONE_BY_ID, …
|
|
|
|
|
* dernière cellule = JSON stringifié contenant ACTION_ID, AM_DONE_BY_ID, …
|
|
|
|
|
*
|
|
|
|
|
* v2026.5.45 — la position des dates et de la description varie selon le
|
|
|
|
|
* layout EV (anciennement [8]/[9]/[11], aujourd'hui [6]/[7]/[9] sur les
|
|
|
|
@@ -9842,26 +9880,30 @@ function parseAllActionsFromFicheHtml(html) {
|
|
|
|
|
// Une "vraie" action a 14 valeurs. On se contente de 12 minimum
|
|
|
|
|
// pour avoir au moins la description.
|
|
|
|
|
//
|
|
|
|
|
// v2026.5.44 on extrait aussi actionId / amDoneById depuis values[13]
|
|
|
|
|
// qui est un JSON stringifié contenant {NAME, ACTION_ID, AM_DONE_BY_ID, …}.
|
|
|
|
|
// v2026.5.44 on extrait aussi actionId / amDoneById depuis la cellule JSON
|
|
|
|
|
// stringifié qui contient {NAME, ACTION_ID, AM_DONE_BY_ID, …}.
|
|
|
|
|
// Permet de filtrer la "bonne action" par ACTION_ID au lieu de matcher
|
|
|
|
|
// par nom de tech (le matching par nom échouait par exemple sur les
|
|
|
|
|
// actions où l'intervenant est noté "EZV_WS_REST_USER" ou un format
|
|
|
|
|
// qui ne correspond pas exactement au tech.name de la config).
|
|
|
|
|
let actionId = null;
|
|
|
|
|
let amDoneById = null;
|
|
|
|
|
if (values.length >= 14 && values[13]) {
|
|
|
|
|
// v2026.5.50 — la cellule JSON est repérée par son contenu (la dernière qui
|
|
|
|
|
// mentionne ACTION_ID) : EV a retiré la cellule vide qui la précédait
|
|
|
|
|
// (29.09.2026), elle est passée de values[13] à values[12].
|
|
|
|
|
const valeurJson = [...values].reverse().find(v => v && v.includes("ACTION_ID"));
|
|
|
|
|
if (valeurJson) {
|
|
|
|
|
// v2026.5.46 — BUG : dans le HTML, les guillemets du JSON imbriqué sont
|
|
|
|
|
// encodés " (pas des " littéraux). Le regex cherchait des " littéraux
|
|
|
|
|
// → ACTION_ID et surtout AM_DONE_BY_ID étaient TOUJOURS null → le matching
|
|
|
|
|
// d'action par id (le plus fiable, indépendant du nom) ne marchait JAMAIS.
|
|
|
|
|
// On DÉCODE values[13] d'abord pour que le regex matche.
|
|
|
|
|
const raw13 = decodeUnicodeEscapes(values[13]);
|
|
|
|
|
// On DÉCODE la cellule d'abord pour que le regex matche.
|
|
|
|
|
const jsonDecode = decodeUnicodeEscapes(valeurJson);
|
|
|
|
|
// Parsing tolérant via regex (on accepte avec ou sans guillemets autour
|
|
|
|
|
// des valeurs numériques) — robuste aux formats JSON avec ou sans
|
|
|
|
|
// échappements résiduels.
|
|
|
|
|
const mAid = raw13.match(/"ACTION_ID"\s*:\s*"?(\d+)"?/);
|
|
|
|
|
const mDid = raw13.match(/"AM_DONE_BY_ID"\s*:\s*"?(\d*)"?/);
|
|
|
|
|
const mAid = jsonDecode.match(/"ACTION_ID"\s*:\s*"?(\d+)"?/);
|
|
|
|
|
const mDid = jsonDecode.match(/"AM_DONE_BY_ID"\s*:\s*"?(\d*)"?/);
|
|
|
|
|
if (mAid) actionId = mAid[1];
|
|
|
|
|
if (mDid && mDid[1]) amDoneById = mDid[1];
|
|
|
|
|
}
|
|
|
|
@@ -10148,7 +10190,7 @@ function actionBelongsToTech(action, techName, techId) {
|
|
|
|
|
* 3-4) actions de la fiche : du tech ? même jour ? commentaire « login: » ?
|
|
|
|
|
* 4bis) action de PLANIFICATION via xhr2 (la fiche ne la contient pas).
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
async function analyzeMissingIv(iv) {
|
|
|
|
|
const techName = iv.techName || "";
|
|
|
|
@@ -10725,6 +10767,9 @@ async function fetchAndUpdateIntervention(iv, myToken, opts = {}) {
|
|
|
|
|
if (!ficheResp.ok) {
|
|
|
|
|
iv.ficheFetched = true;
|
|
|
|
|
iv.ficheFetchError = ficheResp.error || "fetch_failed";
|
|
|
|
|
if (ficheResp.error !== "session_expired") {
|
|
|
|
|
LOG.warn("seq", `iv ${iv.actionId} (ref=${iv.ref}) fiche non chargée : ${iv.ficheFetchError}`);
|
|
|
|
|
}
|
|
|
|
|
if (ficheResp.error === "session_expired") {
|
|
|
|
|
state.session = null;
|
|
|
|
|
// session expirée → + bannière + abort de tout le refresh.
|
|
|
|
@@ -10830,6 +10875,8 @@ async function fetchAndUpdateIntervention(iv, myToken, opts = {}) {
|
|
|
|
|
} else if (tlResp && tlResp.error === "session_expired") {
|
|
|
|
|
state.session = null;
|
|
|
|
|
showSessionExpiredBanner();
|
|
|
|
|
} else {
|
|
|
|
|
LOG.warn("timeline", `iv ${iv.actionId} : timeline non chargée (${(tlResp && tlResp.error) || "échec"})`);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
LOG.warn("timeline", "erreur iv", iv.actionId, err);
|
|
|
|
@@ -11161,12 +11208,16 @@ function parseFicheHtml(html) {
|
|
|
|
|
* { rows: [
|
|
|
|
|
* {value:"..."}, // [0..10] statut, groupe, dates, etc.
|
|
|
|
|
* {value:"Date : ... Heure : ... Lieu : ..."}, // [11] DESCRIPTION
|
|
|
|
|
* {value:""},
|
|
|
|
|
* {value:"{\"ACTION_ID\":\"57700033\",...}"} // [13] JSON stringifié
|
|
|
|
|
* {value:"{\"ACTION_ID\":\"57700033\",...}"} // [12] JSON stringifié
|
|
|
|
|
* ] }
|
|
|
|
|
* ] }}}
|
|
|
|
|
*
|
|
|
|
|
* On cherche l'action dont rows[13].ACTION_ID === actionId ; si trouvée, on
|
|
|
|
|
* v2026.5.50 — jusqu'au 28.09.2026, EV intercalait une cellule vide en [12] et
|
|
|
|
|
* le JSON était en [13] (14 cellules) ; depuis, 13 cellules. La cellule JSON est
|
|
|
|
|
* repérée par son contenu (la dernière qui mentionne "ACTION_ID"), valable pour
|
|
|
|
|
* les deux formats.
|
|
|
|
|
*
|
|
|
|
|
* On cherche l'action dont le JSON porte ACTION_ID === actionId ; si trouvée, on
|
|
|
|
|
* retourne rows[11] nettoyé (br→\n, entités décodées) ; sinon null.
|
|
|
|
|
*/
|
|
|
|
|
function parseTimelineJsonForAction(jsonText, actionId) {
|
|
|
|
@@ -11182,15 +11233,23 @@ function parseTimelineJsonForAction(jsonText, actionId) {
|
|
|
|
|
const values = data?.data?.data?.values;
|
|
|
|
|
if (!Array.isArray(values)) return null;
|
|
|
|
|
|
|
|
|
|
// Colonne de la description repérée par son nom (« Commentaire ou
|
|
|
|
|
// Description ») : une cellule ajoutée ou retirée avant elle ne la décale plus.
|
|
|
|
|
const colonnes = data?.data?.data?.columns;
|
|
|
|
|
const idxColonneDescription = _indexColonneTimeline(colonnes, /description/i);
|
|
|
|
|
const targetId = String(actionId);
|
|
|
|
|
let actionsLisibles = 0;
|
|
|
|
|
|
|
|
|
|
for (const entry of values) {
|
|
|
|
|
const rows = entry?.rows;
|
|
|
|
|
if (!Array.isArray(rows) || rows.length < 14) continue;
|
|
|
|
|
if (!Array.isArray(rows)) continue;
|
|
|
|
|
|
|
|
|
|
// rows[13] = JSON stringifié qui contient ACTION_ID
|
|
|
|
|
const extraRaw = rows[13]?.value;
|
|
|
|
|
if (!extraRaw || typeof extraRaw !== "string") continue;
|
|
|
|
|
// JSON stringifié qui contient ACTION_ID : la dernière cellule qui le mentionne.
|
|
|
|
|
const celluleJson = [...rows].reverse().find(
|
|
|
|
|
c => typeof c?.value === "string" && c.value.includes('"ACTION_ID"'));
|
|
|
|
|
const extraRaw = celluleJson?.value;
|
|
|
|
|
if (!extraRaw) continue;
|
|
|
|
|
actionsLisibles++;
|
|
|
|
|
|
|
|
|
|
let extra;
|
|
|
|
|
try {
|
|
|
|
@@ -11205,15 +11264,54 @@ function parseTimelineJsonForAction(jsonText, actionId) {
|
|
|
|
|
|
|
|
|
|
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"] || "";
|
|
|
|
|
// Trouvé. Description : la colonne nommée, sinon la position historique 11,
|
|
|
|
|
// jamais la cellule JSON elle-même ; en dernier recours, le champ du JSON.
|
|
|
|
|
const celluleDesc = rows[idxColonneDescription >= 0 ? idxColonneDescription : 11];
|
|
|
|
|
const texteCellule = celluleDesc && celluleDesc !== celluleJson &&
|
|
|
|
|
typeof celluleDesc.value === "string" ? celluleDesc.value : "";
|
|
|
|
|
const rawDesc = texteCellule || extra["AM_ACTION.DESCRIPTION"] || "";
|
|
|
|
|
const cleaned = cleanHtmlBlock(rawDesc);
|
|
|
|
|
return cleaned || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Des actions, mais aucune lisible : EV a changé le format de la timeline.
|
|
|
|
|
if (values.length > 0 && actionsLisibles === 0) {
|
|
|
|
|
_signalerFormatEvInconnu("timeline", {
|
|
|
|
|
nbActions: values.length,
|
|
|
|
|
nbCellules: values.map(e => (Array.isArray(e?.rows) ? e.rows.length : null)),
|
|
|
|
|
colonnes: Array.isArray(colonnes) ? colonnes.map(_nomColonneTimeline) : "absentes"
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const _CHAMPS_NOM_COLONNE_TIMELINE = ["dbFieldName", "name", "label", "title"];
|
|
|
|
|
|
|
|
|
|
/** Nom lisible d'une colonne de la timeline EV (le premier champ de nom renseigné). */
|
|
|
|
|
function _nomColonneTimeline(colonne) {
|
|
|
|
|
const champ = _CHAMPS_NOM_COLONNE_TIMELINE.find(k => typeof colonne?.[k] === "string" && colonne[k]);
|
|
|
|
|
return champ ? colonne[champ] : "?";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Index de la première colonne de la timeline EV dont un nom répond au motif ; -1 sinon. */
|
|
|
|
|
function _indexColonneTimeline(colonnes, motif) {
|
|
|
|
|
if (!Array.isArray(colonnes)) return -1;
|
|
|
|
|
return colonnes.findIndex(c => _CHAMPS_NOM_COLONNE_TIMELINE.some(
|
|
|
|
|
k => typeof c?.[k] === "string" && motif.test(c[k])));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Signale UNE fois par session et par source une réponse EV dont le format n'est
|
|
|
|
|
* plus reconnu (mise à jour d'EasyVista) : structure seulement (nombres de
|
|
|
|
|
* cellules, noms de colonnes), jamais les valeurs.
|
|
|
|
|
*/
|
|
|
|
|
const _formatsEvInconnusSignales = new Set();
|
|
|
|
|
function _signalerFormatEvInconnu(source, structure) {
|
|
|
|
|
if (_formatsEvInconnusSignales.has(source)) return;
|
|
|
|
|
_formatsEvInconnusSignales.add(source);
|
|
|
|
|
LOG.warn(source, "format EasyVista non reconnu : texte des actions indisponible", structure);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Nettoie un bloc HTML pour obtenir du texte brut lisible.
|
|
|
|
@@ -12209,7 +12307,7 @@ function _recurringAbsenceLabel(isoDate) {
|
|
|
|
|
// v2026.5.46 — menu contextuel (clic droit) sur le nom d'un technicien :
|
|
|
|
|
// ouvre directement Réservation / Absence / Pompier / Sync douchette avec CE
|
|
|
|
|
// tech pré-sélectionné (la liste/sélecteur de tech est masquée dans la modal).
|
|
|
|
|
// Les boutons « Ajouter » du haut restent inchangés. Auteur : FroSteel.
|
|
|
|
|
// Les boutons « Ajouter » du haut restent inchangés. Auteur : Quentin.
|
|
|
|
|
let _techCtxMenuEl = null;
|
|
|
|
|
function _closeTechContextMenu() {
|
|
|
|
|
if (_techCtxMenuEl) { _techCtxMenuEl.remove(); _techCtxMenuEl = null; }
|
|
|
|
@@ -12317,7 +12415,7 @@ function _showQuickAddMenu(ev, tech, startHHMM, endHHMM, disabledReason) {
|
|
|
|
|
/**
|
|
|
|
|
* Construit la card HTML d'un technicien (header, timeline, interventions, absences).
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function buildCard(tech, isoDate) {
|
|
|
|
|
const card = document.createElement("section");
|
|
|
|
@@ -13008,7 +13106,7 @@ async function _initDayBoundsFromConfig() {
|
|
|
|
|
|
|
|
|
|
// v2026.5.46 — abrège une adresse pour les cartes étroites du mode
|
|
|
|
|
// exact : retire le TYPE de voie et sa particule (« Rue de l'Exemple 5 »
|
|
|
|
|
// devient « Université 5 », « Av. de l'Exemple 14 » devient « Gare 14 »).
|
|
|
|
|
// devient « Exemple 5 », « Av. de l'Exemple 14 » devient « Exemple 14 »).
|
|
|
|
|
// Si tout disparaît (« Grand-Rue » seule), on garde l'adresse d'origine.
|
|
|
|
|
function _shortAddress(adresse) {
|
|
|
|
|
const a = String(adresse || "").trim();
|
|
|
|
@@ -14057,7 +14155,7 @@ function getStatusClass(iv) {
|
|
|
|
|
/**
|
|
|
|
|
* Lie les segments timeline à un popover persistant au clic (depuis v4.2.3).
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function bindTimelinePopover(el) {
|
|
|
|
|
// v2026.5.40 : comportement UNIFIÉ vue classique + vue horizontale.
|
|
|
|
@@ -14191,7 +14289,7 @@ function findIvByActionId(actionId) {
|
|
|
|
|
/**
|
|
|
|
|
* Ouvre une grande popup détaillée pour un segment timeline.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function openPersistentTimelinePopup(el) {
|
|
|
|
|
if (!el) {
|
|
|
|
@@ -17547,10 +17645,10 @@ function cleanContactName(raw) {
|
|
|
|
|
// v2026.5.49 — retirer les « ? » (et le caractère de remplacement Unicode
|
|
|
|
|
// U+FFFD, souvent rendu « ? ») collés au nom : c'est un encodage cassé côté
|
|
|
|
|
// EasyVista, jamais un vrai caractère d'un nom de personne. Ex : "?Exemple"
|
|
|
|
|
// → "Exemple", "Exemple?, FroSteel" → "FroSteel".
|
|
|
|
|
// → "Exemple", "Exemple?, Quentin" → "Quentin".
|
|
|
|
|
s = s.replace(/[?\uFFFD]/g, " ");
|
|
|
|
|
// ... et recoller l'espace laisse devant une virgule/point-virgule
|
|
|
|
|
// ("Exemple?, FroSteel" -> "Exemple , FroSteel" -> "FroSteel").
|
|
|
|
|
// ("Exemple?, Quentin" -> "Exemple , Quentin" -> "Quentin").
|
|
|
|
|
s = s.replace(/\s+([,;:])/g, "$1");
|
|
|
|
|
// Espaces multiples → un seul
|
|
|
|
|
s = s.replace(/\s{2,}/g, " ").trim();
|
|
|
|
@@ -18076,7 +18174,7 @@ function _cancelPendingShowTooltip() {
|
|
|
|
|
/**
|
|
|
|
|
* Affiche un tooltip au survol d'une intervention/réservation.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function showTooltip(e, iv, rowEl, posOpts) {
|
|
|
|
|
if (_techCtxMenuEl) return; // v2026.5.46 — menu contextuel ouvert : pas de popup par-dessus
|
|
|
|
@@ -18375,7 +18473,7 @@ function reapplyTooltipPosition() {
|
|
|
|
|
* @param {HTMLElement} sourceEl - l'élément déclencheur (row, card, segment)
|
|
|
|
|
* @param {object} opts - options { anchorBelow: true pour préférer dessous }
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function positionTooltipAnchored(sourceEl, opts) {
|
|
|
|
|
opts = opts || {};
|
|
|
|
@@ -18555,7 +18653,7 @@ function _rectsOverlap(a, b) {
|
|
|
|
|
* empêche un simple `style.zIndex = "9"` de prendre effet. Il FAUT utiliser
|
|
|
|
|
* `setProperty(..., 'important')` pour battre le `!important` du CSS.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function _bringPinnedPopupToFront(popup) {
|
|
|
|
|
if (!popup) return;
|
|
|
|
@@ -18617,7 +18715,7 @@ function _applyRememberedPopupSize(popup, iv, date) {
|
|
|
|
|
* ligne source `rowEl`. Essaie dans l'ordre : droite, gauche, dessous, dessus.
|
|
|
|
|
* Retourne {x, y} en coordonnées document, ou null si aucune position libre.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function _findFreePopupPosition(rowEl, w, h) {
|
|
|
|
|
const pad = 14;
|
|
|
|
@@ -18731,7 +18829,7 @@ function _findFreePopupPosition(rowEl, w, h) {
|
|
|
|
|
* v4.3.0 : épingle la bulle courante en la clonant dans un popup détaché
|
|
|
|
|
* ancré au contenu. Le tooltip live redevient disponible.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function pinTooltip() {
|
|
|
|
|
if (!state.currentTooltipIv) return;
|
|
|
|
@@ -19225,7 +19323,7 @@ async function _refreshPinnedPopupIv(popup, iv) {
|
|
|
|
|
*
|
|
|
|
|
* @param {HTMLElement} el - le popup à désépingler
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function _softUnpinPopup(el) {
|
|
|
|
|
if (!el) {
|
|
|
|
@@ -19848,7 +19946,7 @@ function _getPopupSafeArea() {
|
|
|
|
|
* dans la safe area. Appelé à l'épinglage, pendant le drag, et quand le dock
|
|
|
|
|
* apparaît/disparaît.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function _clampPopupInSafeArea(popup) {
|
|
|
|
|
if (!popup) return;
|
|
|
|
@@ -20731,7 +20829,7 @@ function bindTooltipInteractions() {
|
|
|
|
|
/**
|
|
|
|
|
* Construit le HTML du tooltip détaillé pour une intervention/réservation.
|
|
|
|
|
*
|
|
|
|
|
* @author FroSteel
|
|
|
|
|
* @author Quentin
|
|
|
|
|
*/
|
|
|
|
|
function buildTooltipHTML(iv) {
|
|
|
|
|
if (!iv) return '<dl><dt>Info</dt><dd>—</dd></dl>';
|
|
|
|
|