|
|
|
@@ -47,8 +47,16 @@ const LS_THEME = "planning_theme";
|
|
|
|
|
const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD
|
|
|
|
|
const CACHE_DAYS = 7;
|
|
|
|
|
|
|
|
|
|
// Concurrence du fetch en parallèle (fiches + timelines)
|
|
|
|
|
const FETCH_CONCURRENCY = 12;
|
|
|
|
|
// Concurrence des fetches en parallèle.
|
|
|
|
|
// En v3.1.1 : xhr2 (bulles) et fetches fiches tournent SIMULTANÉMENT pour
|
|
|
|
|
// que les refs arrivent plus vite. Chacun a sa propre concurrency, et le
|
|
|
|
|
// total reste raisonnable pour le serveur EasyVista.
|
|
|
|
|
// - xhr2 : petits (~400 o) et rapides → 10 workers suffisent
|
|
|
|
|
// - fiches : gros (~250 Ko) → 15 workers pour vraiment accélérer
|
|
|
|
|
// Total max simultané : 25 requêtes, ce qui reste confortable.
|
|
|
|
|
// Si le serveur renvoie des erreurs ou XML vides → baisser les deux.
|
|
|
|
|
const FETCH_CONCURRENCY_BULLES = 10;
|
|
|
|
|
const FETCH_CONCURRENCY_FICHES = 15;
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// Mapping de catégorie → titre court + couleur
|
|
|
|
@@ -152,6 +160,46 @@ let state = {
|
|
|
|
|
loading: false
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─── Annulation coopérative d'un refresh manuel (v3.1) ──────────────────────
|
|
|
|
|
// Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent
|
|
|
|
|
// isRefreshAborted() avant chaque fetch : si le jeton a changé ou si
|
|
|
|
|
// l'utilisateur a cliqué sur "Arrêter", ils s'arrêtent proprement.
|
|
|
|
|
//
|
|
|
|
|
// v3.2 : on ajoute une "abortPromise" par refresh. loadForDate race cette
|
|
|
|
|
// promesse avec son Promise.all, donc dès qu'on clique Arrêter, loadForDate
|
|
|
|
|
// sort immédiatement (masque le bouton, fait un toast), même si les fetches
|
|
|
|
|
// en cours continuent silencieusement. Le changement de token les rend
|
|
|
|
|
// inoffensifs (ils ne peuvent plus écrire le cache ni updater le DOM).
|
|
|
|
|
let currentRefreshToken = 0;
|
|
|
|
|
let abortedToken = -1;
|
|
|
|
|
let abortResolvers = new Map(); // token → resolve fn of the abort promise
|
|
|
|
|
|
|
|
|
|
function startNewRefresh() {
|
|
|
|
|
currentRefreshToken++;
|
|
|
|
|
return currentRefreshToken;
|
|
|
|
|
}
|
|
|
|
|
function makeAbortPromise(myToken) {
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
abortResolvers.set(myToken, resolve);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
function abortCurrentRefresh() {
|
|
|
|
|
abortedToken = currentRefreshToken;
|
|
|
|
|
// Réveiller tous les loadForDate en attente (normalement un seul)
|
|
|
|
|
for (const [token, resolve] of abortResolvers) {
|
|
|
|
|
if (token <= currentRefreshToken) {
|
|
|
|
|
resolve("aborted");
|
|
|
|
|
abortResolvers.delete(token);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function isRefreshAborted() {
|
|
|
|
|
return abortedToken === currentRefreshToken;
|
|
|
|
|
}
|
|
|
|
|
function cleanupAbortResolver(myToken) {
|
|
|
|
|
abortResolvers.delete(myToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// Boot
|
|
|
|
|
// ============================================================================
|
|
|
|
@@ -226,6 +274,13 @@ function toggleTheme() {
|
|
|
|
|
function bindTopbar() {
|
|
|
|
|
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
|
|
|
|
|
document.getElementById("refresh-btn").addEventListener("click", () => refreshPlanning());
|
|
|
|
|
document.getElementById("abort-btn").addEventListener("click", () => {
|
|
|
|
|
// Feedback visuel instantané : masquer le bouton tout de suite, sans
|
|
|
|
|
// attendre que loadForDate finisse sa race.
|
|
|
|
|
showAbortButton(false);
|
|
|
|
|
abortCurrentRefresh();
|
|
|
|
|
showAbortToast();
|
|
|
|
|
});
|
|
|
|
|
document.getElementById("clear-cache-btn").addEventListener("click", onClearCache);
|
|
|
|
|
|
|
|
|
|
document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1));
|
|
|
|
@@ -346,75 +401,134 @@ async function loadForDate(isoDate, opts = {}) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 1. Afficher immédiatement depuis le cache si disponible
|
|
|
|
|
const cached = await readCache(isoDate);
|
|
|
|
|
if (cached && !opts.forceRefetch) {
|
|
|
|
|
renderFromData({
|
|
|
|
|
techs: cached.techs,
|
|
|
|
|
targetDate: isoDate,
|
|
|
|
|
captureTime: cached.savedAt || null,
|
|
|
|
|
source: "cache"
|
|
|
|
|
});
|
|
|
|
|
// (v3.1.1) Tout chargement = un nouveau jeton d'annulation. Le bouton
|
|
|
|
|
// "Arrêter" apparaît pour TOUT refresh (clic manuel, navigation date,
|
|
|
|
|
// ouverture vue claire), pas juste refreshPlanning(). Le bouton disparaît
|
|
|
|
|
// quand le chargement est vraiment fini (finally).
|
|
|
|
|
const myToken = startNewRefresh();
|
|
|
|
|
showAbortButton(true);
|
|
|
|
|
const t0 = performance.now();
|
|
|
|
|
console.log(`[load] début pour ${isoDate} (token=${myToken})`);
|
|
|
|
|
|
|
|
|
|
// Si cache présent ET pas de refresh explicite demandé, on s'arrête là.
|
|
|
|
|
// Pas de fetch XML, pas de fetch xhr2, pas de fetch fiches.
|
|
|
|
|
// Le cache d'un jour précédent reste affiché jusqu'au prochain refresh manuel.
|
|
|
|
|
if (!opts.doStatusRefresh) {
|
|
|
|
|
return;
|
|
|
|
|
try {
|
|
|
|
|
// 1. Afficher immédiatement depuis le cache si disponible
|
|
|
|
|
const cached = await readCache(isoDate);
|
|
|
|
|
if (cached && !opts.forceRefetch) {
|
|
|
|
|
renderFromData({
|
|
|
|
|
techs: cached.techs,
|
|
|
|
|
targetDate: isoDate,
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
showLoading();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
showLoading();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Fetch frais du planning (XML calendar_block — rapide, ~40 ko)
|
|
|
|
|
const fresh = await fetchPlanningForDate(isoDate);
|
|
|
|
|
if (!fresh) return;
|
|
|
|
|
if (isRefreshAborted()) return;
|
|
|
|
|
|
|
|
|
|
// 3. Fusionner cache + frais
|
|
|
|
|
const merged = mergeCacheAndFresh(cached, fresh);
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
// 4. Afficher immédiatement avec ce qu'on a
|
|
|
|
|
renderFromData({
|
|
|
|
|
techs: merged.techs,
|
|
|
|
|
targetDate: isoDate,
|
|
|
|
|
captureTime: Date.now(),
|
|
|
|
|
source: "fresh"
|
|
|
|
|
});
|
|
|
|
|
// 3. Fusionner cache + frais
|
|
|
|
|
const merged = mergeCacheAndFresh(cached, fresh);
|
|
|
|
|
|
|
|
|
|
// 5. PHASE BULLES (xhr_2) : fetch planning_xhr_2.php pour chaque intervention
|
|
|
|
|
const bulleNeeded = [];
|
|
|
|
|
for (const tech of merged.techs) {
|
|
|
|
|
for (const iv of tech.interventions) {
|
|
|
|
|
if (iv.type !== "AL-Intervention") continue;
|
|
|
|
|
if (iv.infobulle && iv.bulleContact) continue;
|
|
|
|
|
bulleNeeded.push(iv);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (bulleNeeded.length > 0) {
|
|
|
|
|
console.log(`[load] fetch xhr2 pour ${bulleNeeded.length} interventions…`);
|
|
|
|
|
await fetchBullesForInterventions(bulleNeeded);
|
|
|
|
|
// 4. Afficher immédiatement avec ce qu'on a
|
|
|
|
|
renderFromData({
|
|
|
|
|
techs: merged.techs,
|
|
|
|
|
targetDate: isoDate,
|
|
|
|
|
captureTime: Date.now(),
|
|
|
|
|
source: "fresh+bulles"
|
|
|
|
|
source: "fresh"
|
|
|
|
|
});
|
|
|
|
|
console.log(`[load] 1er rendu (sans refs) à ${Math.round(performance.now() - t0)} ms`);
|
|
|
|
|
|
|
|
|
|
// 5. SÉQUENTIEL : xhr2 (lieu/contact de la bulle) EN PREMIER,
|
|
|
|
|
// puis fetches fiches (ref/statut/texte complet) APRÈS.
|
|
|
|
|
// Raison : le texte complet de la timeline (branche fiches) peut
|
|
|
|
|
// écraser bulleDescription/bulleContact/bulleLieu. Il ne faut pas
|
|
|
|
|
// que ça arrive AVANT que la bulle de base soit posée, sinon on se
|
|
|
|
|
// retrouve avec une popup vide ou incomplète pendant des secondes.
|
|
|
|
|
const bulleNeeded = [];
|
|
|
|
|
for (const tech of merged.techs) {
|
|
|
|
|
for (const iv of tech.interventions) {
|
|
|
|
|
if (iv.type !== "AL-Intervention") continue;
|
|
|
|
|
if (iv.infobulle && iv.bulleContact) continue;
|
|
|
|
|
bulleNeeded.push(iv);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// On refetche les fiches si :
|
|
|
|
|
// - au moins une intervention n'a jamais été fetchée (pas de ficheTarget), OU
|
|
|
|
|
// - au moins une intervention n'a pas encore l'actionDescription complète de la fiche
|
|
|
|
|
// (cas du cache chargé depuis une version antérieure à v3.2)
|
|
|
|
|
const needFetch = merged.techs.some(tech =>
|
|
|
|
|
tech.interventions.some(iv =>
|
|
|
|
|
iv.type === "AL-Intervention" &&
|
|
|
|
|
(!iv.ficheTarget || !iv.actionDescriptionFetched)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Étape 5a : xhr2 d'abord (bulle de base : lieu/contact/texte court)
|
|
|
|
|
if (bulleNeeded.length > 0 && !isRefreshAborted()) {
|
|
|
|
|
const tBulles = performance.now();
|
|
|
|
|
console.log(`[load] fetch xhr2 pour ${bulleNeeded.length} interventions…`);
|
|
|
|
|
await fetchBullesForInterventions(bulleNeeded);
|
|
|
|
|
console.log(`[load] xhr2 finis en ${Math.round(performance.now() - tBulles)} ms`);
|
|
|
|
|
if (!isRefreshAborted()) {
|
|
|
|
|
renderFromData({
|
|
|
|
|
techs: merged.techs,
|
|
|
|
|
targetDate: isoDate,
|
|
|
|
|
captureTime: Date.now(),
|
|
|
|
|
source: "fresh+bulles"
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Étape 5b : fiches APRÈS les xhr2 (ref/statut + texte complet de timeline)
|
|
|
|
|
if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted()) {
|
|
|
|
|
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…`);
|
|
|
|
|
await refreshStatuses(merged.techs, isoDate);
|
|
|
|
|
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)
|
|
|
|
|
// Uniquement si on est allé au bout (pas d'annulation).
|
|
|
|
|
if (!isRefreshAborted()) {
|
|
|
|
|
await writeCache(isoDate, { techs: merged.techs });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isRefreshAborted()) {
|
|
|
|
|
showRefreshDone();
|
|
|
|
|
console.log(`[load] TOTAL: ${Math.round(performance.now() - t0)} ms`);
|
|
|
|
|
|
|
|
|
|
// Retry silencieux en arrière-plan pour les interventions dont le texte
|
|
|
|
|
// d'action n'a pas pu être récupéré (timeline partielle au 1er coup).
|
|
|
|
|
// Lancé SANS await : l'user peut continuer à utiliser l'extension.
|
|
|
|
|
// La fonction respecte le token : si l'user change de jour, elle s'arrête.
|
|
|
|
|
runBackgroundTimelineRetry(merged.techs, isoDate, myToken).catch(() => {});
|
|
|
|
|
} else {
|
|
|
|
|
console.log(`[load] annulé par l'utilisateur à ${Math.round(performance.now() - t0)} ms`);
|
|
|
|
|
showAbortToast();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
// Masquer le bouton "Arrêter" uniquement si c'est NOTRE chargement qui
|
|
|
|
|
// se termine (pas un chargement postérieur que l'utilisateur aurait lancé
|
|
|
|
|
// entre-temps en naviguant ailleurs).
|
|
|
|
|
if (currentRefreshToken === myToken) {
|
|
|
|
|
showAbortButton(false);
|
|
|
|
|
}
|
|
|
|
|
cleanupAbortResolver(myToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 6. Sauvegarder dans le cache
|
|
|
|
|
await writeCache(isoDate, { techs: merged.techs });
|
|
|
|
|
|
|
|
|
|
// 7. Fetch fiches en arrière-plan (pour statut + target/checksum clic)
|
|
|
|
|
const needFetch = merged.techs.some(tech =>
|
|
|
|
|
tech.interventions.some(iv =>
|
|
|
|
|
iv.type === "AL-Intervention" && !iv.ficheTarget
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
if (opts.doStatusRefresh || needFetch) {
|
|
|
|
|
await refreshStatuses(merged.techs, isoDate);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showRefreshDone();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshPlanning(opts = {}) {
|
|
|
|
@@ -422,7 +536,8 @@ async function refreshPlanning(opts = {}) {
|
|
|
|
|
await refreshSessionAndLoad();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Bouton Rafraîchir manuel : on force le refetch des fiches
|
|
|
|
|
// 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 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -449,6 +564,18 @@ async function fetchPlanningForDate(isoDate) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Safeguard (v3.1) : le serveur EasyVista répond parfois 200 avec un
|
|
|
|
|
// corps vide — typiquement quand la session vient d'être invalidée, ou
|
|
|
|
|
// quand il soupçonne du scraping (trop de requêtes parallèles). Dans
|
|
|
|
|
// les deux cas, on traite ça comme une session expirée : inutile de
|
|
|
|
|
// parser (ça ferait "Document is empty") ni de retry en boucle.
|
|
|
|
|
if (!resp.xml || resp.xml.length < 20) {
|
|
|
|
|
console.warn("[viewer] XML planning vide — session probablement invalide");
|
|
|
|
|
state.session = null;
|
|
|
|
|
showSessionNeeded();
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parser le HTML complet du planning (contient TOUT : ref, catégorie,
|
|
|
|
|
// contact, lieu, description, formLinks, request_id + checksum)
|
|
|
|
|
const techs = parsePlanningXml(resp.xml, isoDate);
|
|
|
|
@@ -625,6 +752,7 @@ async function fetchBullesForInterventions(interventions) {
|
|
|
|
|
|
|
|
|
|
async function worker() {
|
|
|
|
|
while (idx < interventions.length) {
|
|
|
|
|
if (isRefreshAborted()) return;
|
|
|
|
|
const i = idx++;
|
|
|
|
|
const iv = interventions[i];
|
|
|
|
|
try {
|
|
|
|
@@ -652,7 +780,7 @@ async function fetchBullesForInterventions(interventions) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const workers = [];
|
|
|
|
|
const nWorkers = Math.min(FETCH_CONCURRENCY, interventions.length);
|
|
|
|
|
const nWorkers = Math.min(FETCH_CONCURRENCY_BULLES, interventions.length);
|
|
|
|
|
for (let w = 0; w < nWorkers; w++) workers.push(worker());
|
|
|
|
|
await Promise.all(workers);
|
|
|
|
|
console.log(`[xhr2] ${ok} OK, ${fail} échecs sur ${interventions.length}`);
|
|
|
|
@@ -782,9 +910,11 @@ async function refreshStatuses(techs, isoDate) {
|
|
|
|
|
if (!iv.formLink) continue;
|
|
|
|
|
// On skip si :
|
|
|
|
|
// - Déjà clos / résolu ET ficheTarget déjà connu (statut + requestId OK)
|
|
|
|
|
// - Sinon on garde (pour avoir statut frais OU ficheTarget pour clic)
|
|
|
|
|
// ET actionDescription déjà remplacée depuis la fiche
|
|
|
|
|
// - Sinon on garde (pour avoir statut frais OU ficheTarget pour clic
|
|
|
|
|
// OU le texte complet de l'action)
|
|
|
|
|
const statusClosed = isClosedStatus(iv.status) || isResolvedStatus(iv.status);
|
|
|
|
|
if (statusClosed && iv.ficheTarget) continue;
|
|
|
|
|
if (statusClosed && iv.ficheTarget && iv.actionDescriptionFetched) continue;
|
|
|
|
|
toFetch.push(iv);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -793,19 +923,31 @@ async function refreshStatuses(techs, isoDate) {
|
|
|
|
|
|
|
|
|
|
setRefreshing(true);
|
|
|
|
|
try {
|
|
|
|
|
// Fetcher avec concurrence = FETCH_CONCURRENCY (12)
|
|
|
|
|
// Fetcher avec concurrence = FETCH_CONCURRENCY_FICHES (15)
|
|
|
|
|
// Chaque worker vérifie isRefreshAborted() AVANT de prendre la prochaine
|
|
|
|
|
// intervention : si l'utilisateur a cliqué "Arrêter", les workers
|
|
|
|
|
// s'arrêtent proprement dans ~100ms.
|
|
|
|
|
let idx = 0;
|
|
|
|
|
async function worker() {
|
|
|
|
|
while (idx < toFetch.length) {
|
|
|
|
|
if (isRefreshAborted()) return;
|
|
|
|
|
const i = idx++;
|
|
|
|
|
await fetchAndUpdateIntervention(toFetch[i]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const workers = [];
|
|
|
|
|
for (let w = 0; w < FETCH_CONCURRENCY; w++) workers.push(worker());
|
|
|
|
|
const nWorkers = Math.min(FETCH_CONCURRENCY_FICHES, toFetch.length);
|
|
|
|
|
for (let w = 0; w < nWorkers; w++) workers.push(worker());
|
|
|
|
|
await Promise.all(workers);
|
|
|
|
|
|
|
|
|
|
// Si annulé : on laisse les refs déjà arrivées s'afficher (le rendu
|
|
|
|
|
// incrémental les a mises dans le DOM), on skip juste le re-render
|
|
|
|
|
// final et le nettoyage ghosts/cache.
|
|
|
|
|
if (isRefreshAborted()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Résoudre le sort des ghosts
|
|
|
|
|
for (const tech of techs) {
|
|
|
|
|
tech.interventions = tech.interventions.filter(iv => {
|
|
|
|
@@ -832,14 +974,29 @@ async function refreshStatuses(techs, isoDate) {
|
|
|
|
|
|
|
|
|
|
async function fetchAndUpdateIntervention(iv) {
|
|
|
|
|
try {
|
|
|
|
|
// Bail-out coopératif : si l'utilisateur a cliqué sur "Arrêter",
|
|
|
|
|
// on ne fetch pas cette intervention.
|
|
|
|
|
if (isRefreshAborted()) {
|
|
|
|
|
iv.ficheFetched = true;
|
|
|
|
|
iv.ficheFetchError = "aborted";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch de la fiche (HTML) pour récupérer statut + commentaire tech +
|
|
|
|
|
// extraire target/checksum qui servent à :
|
|
|
|
|
// - l'API timeline (texte validé de l'action, si xhr2 n'avait pas été assez)
|
|
|
|
|
// - construire une URL d'ouverture qui marche (clic sur intervention)
|
|
|
|
|
const ficheResp = await sendMessage({
|
|
|
|
|
type: "fetchFiche",
|
|
|
|
|
formLink: iv.formLink
|
|
|
|
|
});
|
|
|
|
|
//
|
|
|
|
|
// 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()) {
|
|
|
|
|
await new Promise(r => setTimeout(r, 400));
|
|
|
|
|
if (!isRefreshAborted()) {
|
|
|
|
|
ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!ficheResp.ok) {
|
|
|
|
|
iv.ficheFetched = true;
|
|
|
|
@@ -858,20 +1015,82 @@ async function fetchAndUpdateIntervention(iv) {
|
|
|
|
|
}
|
|
|
|
|
iv.commentaireTech = fiche.commentaireTech;
|
|
|
|
|
|
|
|
|
|
// ─── Remplacement du texte d'action + contact/lieu depuis la fiche ─────────
|
|
|
|
|
// Le texte de la bulle (planning_xhr_2.php) est parfois tronqué/incomplet.
|
|
|
|
|
// La fiche contient le texte complet dans AM_ACTION.DESCRIPTION.
|
|
|
|
|
// SÉCURITÉ : on ne remplace QUE si l'Intervenant de la fiche correspond au
|
|
|
|
|
// tech de la ligne du planning (car une même fiche peut avoir plusieurs
|
|
|
|
|
// actions assignées à différents techs, et on fetche la MÊME fiche pour tous).
|
|
|
|
|
const expectedTechName = iv.techId ? TEAM[iv.techId] : null;
|
|
|
|
|
const matchOk = fiche.intervenant && expectedTechName &&
|
|
|
|
|
namesMatch(fiche.intervenant, expectedTechName);
|
|
|
|
|
if (fiche.actionDescription && matchOk) {
|
|
|
|
|
// Remplace le texte d'action (affiché dans la popup)
|
|
|
|
|
iv.bulleDescription = fiche.actionDescription;
|
|
|
|
|
iv.actionDescriptionFetched = true; // flag : déjà remplacé depuis la fiche
|
|
|
|
|
// Reparse contact/lieu depuis le nouveau texte : la carte affiche
|
|
|
|
|
// bulleContact/bulleLieu, donc il faut les mettre à jour aussi.
|
|
|
|
|
const infob = parseActionText(fiche.actionDescription);
|
|
|
|
|
if (infob) {
|
|
|
|
|
iv.infobulle = infob;
|
|
|
|
|
if (infob.contact) iv.bulleContact = infob.contact;
|
|
|
|
|
if (infob.lieu) iv.bulleLieu = infob.lieu;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Si ça ne matche pas : on garde bulleDescription/Contact/Lieu tels quels (sécurité)
|
|
|
|
|
|
|
|
|
|
// Extraire le checksum CORRECT pour ouvrir la fiche :
|
|
|
|
|
// - Le target de la FICHE = iv.requestId (vient du XML)
|
|
|
|
|
// - Il faut trouver le checksum qui est accolé à ce target dans le HTML
|
|
|
|
|
// (pattern : target=REQUEST_ID&checksum=XXX...)
|
|
|
|
|
// La regex principale cherche "target=REQUEST_ID&checksum=XXX" mais peut
|
|
|
|
|
// échouer si ce pattern n'apparaît pas dans le HTML (selon les sections
|
|
|
|
|
// hydratées par Angular). On a plusieurs fallbacks robustes.
|
|
|
|
|
if (iv.requestId) {
|
|
|
|
|
const rx = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`);
|
|
|
|
|
const ckm = ficheResp.html.match(rx);
|
|
|
|
|
if (ckm) {
|
|
|
|
|
let checksumFound = false;
|
|
|
|
|
// Tentative 1 : target=ID&checksum=... (pattern le plus courant dans les liens)
|
|
|
|
|
const rx1 = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`);
|
|
|
|
|
const m1 = ficheResp.html.match(rx1);
|
|
|
|
|
if (m1) {
|
|
|
|
|
iv.ficheTarget = iv.requestId;
|
|
|
|
|
iv.ficheChecksum = ckm[1];
|
|
|
|
|
iv.ficheChecksum = m1[1];
|
|
|
|
|
checksumFound = true;
|
|
|
|
|
} else {
|
|
|
|
|
// Tentative 2 : dans le JSON formData : "id":"REQUEST_ID"..."checksum":"..."
|
|
|
|
|
// ou l'inverse : "checksum":"..."..."id":"REQUEST_ID"
|
|
|
|
|
const rx2a = new RegExp(`"id"\\s*:\\s*"${iv.requestId}"[\\s\\S]{0,200}?"checksum"\\s*:\\s*"([a-f0-9]{40})"`);
|
|
|
|
|
const m2a = ficheResp.html.match(rx2a);
|
|
|
|
|
if (m2a) {
|
|
|
|
|
iv.ficheTarget = iv.requestId;
|
|
|
|
|
iv.ficheChecksum = m2a[1];
|
|
|
|
|
checksumFound = true;
|
|
|
|
|
} else {
|
|
|
|
|
const rx2b = new RegExp(`"checksum"\\s*:\\s*"([a-f0-9]{40})"[\\s\\S]{0,200}?"id"\\s*:\\s*"${iv.requestId}"`);
|
|
|
|
|
const m2b = ficheResp.html.match(rx2b);
|
|
|
|
|
if (m2b) {
|
|
|
|
|
iv.ficheTarget = iv.requestId;
|
|
|
|
|
iv.ficheChecksum = m2b[1];
|
|
|
|
|
checksumFound = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Tentative 3 (ultime) : le checksum global du form principal.
|
|
|
|
|
if (!checksumFound) {
|
|
|
|
|
const rx3 = /"form"\s*:\s*\{[^}]*?"checksum"\s*:\s*"([a-f0-9]{40})"[\s\S]{0,2000}?"id"\s*:\s*"(\d+)"/;
|
|
|
|
|
const m3 = ficheResp.html.match(rx3);
|
|
|
|
|
if (m3 && m3[2] === String(iv.requestId)) {
|
|
|
|
|
iv.ficheTarget = iv.requestId;
|
|
|
|
|
iv.ficheChecksum = m3[1];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
iv.ficheFetched = true;
|
|
|
|
|
|
|
|
|
|
// ─── RENDU INCRÉMENTAL (v3.1) ─────────────────────────────────────────
|
|
|
|
|
// La ref (RFC_NUMBER) et le statut sont déjà connus : on met à jour la
|
|
|
|
|
// ligne correspondante DANS LE DOM immédiatement, sans attendre que les
|
|
|
|
|
// autres workers aient fini. Pas de re-rendu global.
|
|
|
|
|
updateInterventionRow(iv);
|
|
|
|
|
|
|
|
|
|
// Pour l'API timeline, on utilise le MÊME target + checksum (celui de la fiche)
|
|
|
|
|
const timelineTarget = iv.ficheTarget;
|
|
|
|
|
const timelineChecksum = iv.ficheChecksum;
|
|
|
|
@@ -879,8 +1098,15 @@ async function fetchAndUpdateIntervention(iv) {
|
|
|
|
|
// Étape timeline API : on veut le texte COMPLET de l'action.
|
|
|
|
|
// planning_xhr_2.php tronque souvent à ~300 chars, mais l'API timeline
|
|
|
|
|
// retourne le texte intégral. On la fetch à chaque fois que possible.
|
|
|
|
|
//
|
|
|
|
|
// PROBLÈME OBSERVÉ : EasyVista retourne parfois une timeline "partielle"
|
|
|
|
|
// au 1er appel (ex: 8 Ko au lieu de 44 Ko), sans le texte de l'action
|
|
|
|
|
// courante. Le serveur a besoin de "construire" le contexte après le fetch
|
|
|
|
|
// de la fiche. Dans ce cas on MARQUE l'intervention pour un retry silencieux
|
|
|
|
|
// en arrière-plan (fait plus tard par runBackgroundTimelineRetry).
|
|
|
|
|
const needsTimelineValidation = !iv.actionText;
|
|
|
|
|
if (needsTimelineValidation && timelineTarget && timelineChecksum) {
|
|
|
|
|
if (isRefreshAborted()) return;
|
|
|
|
|
const tlResp = await sendMessage({
|
|
|
|
|
type: "fetchTimeline",
|
|
|
|
|
target: timelineTarget,
|
|
|
|
@@ -889,18 +1115,13 @@ async function fetchAndUpdateIntervention(iv) {
|
|
|
|
|
if (tlResp && tlResp.ok) {
|
|
|
|
|
const actionDetails = parseTimelineJson(tlResp.body, iv.actionId);
|
|
|
|
|
if (actionDetails && actionDetails.text) {
|
|
|
|
|
iv.actionText = actionDetails.text;
|
|
|
|
|
iv.actionDone = actionDetails.doneById;
|
|
|
|
|
// Le texte de timeline est plus complet que bulleDescription :
|
|
|
|
|
// on remplace bulleDescription par actionText pour le tooltip.
|
|
|
|
|
iv.bulleDescription = actionDetails.text;
|
|
|
|
|
const infob = parseActionText(actionDetails.text);
|
|
|
|
|
if (infob) {
|
|
|
|
|
iv.infobulle = infob;
|
|
|
|
|
if (infob.contact) iv.bulleContact = infob.contact;
|
|
|
|
|
if (infob.lieu) iv.bulleLieu = infob.lieu;
|
|
|
|
|
}
|
|
|
|
|
applyActionTextToIv(iv, actionDetails);
|
|
|
|
|
} else {
|
|
|
|
|
// Timeline partielle : marquer pour retry silencieux en arrière-plan
|
|
|
|
|
iv.actionTextPending = true;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
iv.actionTextPending = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
@@ -910,6 +1131,94 @@ async function fetchAndUpdateIntervention(iv) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Applique les détails d'action (texte timeline) à une intervention :
|
|
|
|
|
* - met à jour bulleDescription (texte affiché dans la popup)
|
|
|
|
|
* - reparse contact/lieu pour mettre à jour la carte
|
|
|
|
|
* - rafraîchit la ligne dans le DOM
|
|
|
|
|
* Utilisé à la fois par le flow principal et par le retry silencieux.
|
|
|
|
|
*/
|
|
|
|
|
function applyActionTextToIv(iv, actionDetails) {
|
|
|
|
|
iv.actionText = actionDetails.text;
|
|
|
|
|
iv.actionDone = actionDetails.doneById;
|
|
|
|
|
iv.bulleDescription = actionDetails.text;
|
|
|
|
|
iv.actionDescriptionFetched = true;
|
|
|
|
|
iv.actionTextPending = false;
|
|
|
|
|
const infob = parseActionText(actionDetails.text);
|
|
|
|
|
if (infob) {
|
|
|
|
|
iv.infobulle = infob;
|
|
|
|
|
if (infob.contact) iv.bulleContact = infob.contact;
|
|
|
|
|
if (infob.lieu) iv.bulleLieu = infob.lieu;
|
|
|
|
|
}
|
|
|
|
|
// Rafraîchir la ligne dans le DOM (lieu/contact mis à jour en live)
|
|
|
|
|
updateInterventionRow(iv);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Retry silencieux en arrière-plan : liste les interventions dont le texte
|
|
|
|
|
* d'action n'a pas pu être récupéré (timeline partielle au 1er coup), et
|
|
|
|
|
* refait un fetch timeline pour chacune, avec un petit délai entre les appels
|
|
|
|
|
* pour ne pas surcharger le serveur.
|
|
|
|
|
*
|
|
|
|
|
* Cette fonction est lancée sans await — elle tourne en tâche de fond pendant
|
|
|
|
|
* que l'utilisateur navigue. Elle respecte le jeton de refresh : si l'user
|
|
|
|
|
* change de jour, le jeton change et le retry s'arrête silencieusement.
|
|
|
|
|
*
|
|
|
|
|
* Aucun spinner ni indication visuelle : l'user ne voit rien, sauf que les
|
|
|
|
|
* popups se mettent à jour quand le texte arrive.
|
|
|
|
|
*/
|
|
|
|
|
async function runBackgroundTimelineRetry(techs, isoDate, myToken) {
|
|
|
|
|
// Collecter les interventions qui ont besoin d'un retry
|
|
|
|
|
const pending = [];
|
|
|
|
|
for (const tech of techs) {
|
|
|
|
|
for (const iv of tech.interventions) {
|
|
|
|
|
if (iv.actionTextPending && iv.ficheTarget && iv.ficheChecksum) {
|
|
|
|
|
pending.push(iv);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (pending.length === 0) return;
|
|
|
|
|
|
|
|
|
|
// Attendre un peu avant de démarrer (laisser le serveur "respirer")
|
|
|
|
|
await new Promise(r => setTimeout(r, 1500));
|
|
|
|
|
|
|
|
|
|
// Si l'user a changé de jour entre-temps, abandonner
|
|
|
|
|
if (currentRefreshToken !== myToken) return;
|
|
|
|
|
|
|
|
|
|
for (const iv of pending) {
|
|
|
|
|
// Si l'user a navigué ailleurs OU cliqué arrêter : on sort sans bruit
|
|
|
|
|
if (currentRefreshToken !== myToken) return;
|
|
|
|
|
if (isRefreshAborted()) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const tlResp = await sendMessage({
|
|
|
|
|
type: "fetchTimeline",
|
|
|
|
|
target: iv.ficheTarget,
|
|
|
|
|
checksum: iv.ficheChecksum
|
|
|
|
|
});
|
|
|
|
|
if (tlResp && tlResp.ok) {
|
|
|
|
|
const actionDetails = parseTimelineJson(tlResp.body, iv.actionId);
|
|
|
|
|
if (actionDetails && actionDetails.text) {
|
|
|
|
|
applyActionTextToIv(iv, actionDetails);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Silence : c'est du retry en arrière-plan, on ne dérange pas l'user
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Petit délai entre chaque retry pour ménager le serveur
|
|
|
|
|
await new Promise(r => setTimeout(r, 400));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sauvegarder le cache avec les nouvelles infos (si on est toujours
|
|
|
|
|
// sur la même date et même token)
|
|
|
|
|
if (currentRefreshToken === myToken && !isRefreshAborted()) {
|
|
|
|
|
try {
|
|
|
|
|
await writeCache(isoDate, { techs });
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isClosedStatus(s) {
|
|
|
|
|
return !!s && CLOSED_STATUS.some(x => s.includes(x));
|
|
|
|
|
}
|
|
|
|
@@ -929,7 +1238,9 @@ function parseFicheHtml(html) {
|
|
|
|
|
status: null,
|
|
|
|
|
rfc: null,
|
|
|
|
|
categoryLine: null,
|
|
|
|
|
commentaireTech: null
|
|
|
|
|
commentaireTech: null,
|
|
|
|
|
intervenant: null, // Nom du tech assigné à l'action (format "Nom, Prénom")
|
|
|
|
|
actionDescription: null // Texte complet "Date:... Lieu:... Contact:..." (propre, sans HTML)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// STATUS_FR (valeur parfois encodée en \u00XX)
|
|
|
|
@@ -954,9 +1265,90 @@ function parseFicheHtml(html) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Intervenant assigné (AM_EMPLOYEE.LAST_NAME dans la section "Action") ───
|
|
|
|
|
// HTML Angular rendu : <div field-label="Intervenant" field-label-id="AM_EMPLOYEE-LAST_NAME">
|
|
|
|
|
// ... <input ... value="Nom, Prénom" ng-attr-name="...">
|
|
|
|
|
// On cherche la PREMIÈRE occurrence (celle de l'action courante, pas la timeline).
|
|
|
|
|
m = html.match(/field-label="Intervenant"\s+field-label-id="AM_EMPLOYEE-LAST_NAME"[\s\S]*?value="([^"]+)"\s+ng-attr-name/);
|
|
|
|
|
if (m) {
|
|
|
|
|
out.intervenant = decodeHtmlEntities(m[1]).trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Texte complet de l'action (AM_ACTION.DESCRIPTION) ───
|
|
|
|
|
// HTML Angular : <div field-label-id="AM_ACTION-DESCRIPTION"> ... <div class="fr-element fr-view ...">TEXTE AVEC <br></div>
|
|
|
|
|
// On extrait le HTML entre les balises, puis on nettoie (br → \n, décode entités, strip tags).
|
|
|
|
|
m = html.match(/field-label-id="AM_ACTION-DESCRIPTION"[\s\S]*?<div[^>]*class="fr-element fr-view[^"]*"[^>]*>([\s\S]*?)<\/div>/);
|
|
|
|
|
if (m) {
|
|
|
|
|
const cleaned = cleanActionDescriptionHtml(m[1]);
|
|
|
|
|
if (cleaned && cleaned.length > 5) {
|
|
|
|
|
out.actionDescription = cleaned;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Nettoie le HTML du champ AM_ACTION.DESCRIPTION pour obtenir un texte propre :
|
|
|
|
|
* - <br>, <br/>, <br /> → \n
|
|
|
|
|
* - → espace, > → >, < → <, & → &, " → "
|
|
|
|
|
* - Supprime toute autre balise HTML résiduelle
|
|
|
|
|
* - Trim lignes et supprime lignes vides multiples
|
|
|
|
|
*/
|
|
|
|
|
function cleanActionDescriptionHtml(html) {
|
|
|
|
|
if (!html) return "";
|
|
|
|
|
let s = html;
|
|
|
|
|
// Normaliser les retours à la ligne
|
|
|
|
|
s = s.replace(/<br\s*\/?>/gi, "\n");
|
|
|
|
|
// Décoder les entités HTML courantes
|
|
|
|
|
s = s.replace(/ /g, " ")
|
|
|
|
|
.replace(/>/g, ">")
|
|
|
|
|
.replace(/</g, "<")
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, "'")
|
|
|
|
|
.replace(/'/g, "'")
|
|
|
|
|
.replace(/&/g, "&");
|
|
|
|
|
// Virer les balises HTML restantes (au cas où)
|
|
|
|
|
s = s.replace(/<[^>]+>/g, "");
|
|
|
|
|
// Nettoyer : trim chaque ligne, retirer lignes vides en excès
|
|
|
|
|
s = s.split("\n").map(l => l.trim()).join("\n");
|
|
|
|
|
s = s.replace(/\n{3,}/g, "\n\n").trim();
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Décode les entités HTML dans une chaîne courte (ex: un nom "O'Brien" → "O'Brien").
|
|
|
|
|
*/
|
|
|
|
|
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 (é/è/ê → e, ç → c, etc.)
|
|
|
|
|
* - espaces multiples
|
|
|
|
|
* - espace autour de la virgule
|
|
|
|
|
* Ex: "Ciuppa, Mathieu" matche "ciuppa,mathieu" ou "CIUPPA , Mathieu"
|
|
|
|
|
*/
|
|
|
|
|
function namesMatch(a, b) {
|
|
|
|
|
if (!a || !b) return false;
|
|
|
|
|
const norm = s => String(s)
|
|
|
|
|
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // retire accents
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/\s+/g, " ")
|
|
|
|
|
.replace(/\s*,\s*/g, ",")
|
|
|
|
|
.trim();
|
|
|
|
|
return norm(a) === norm(b);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function decodeJsonString(s) {
|
|
|
|
|
return s
|
|
|
|
|
.replace(/\\r/g, "")
|
|
|
|
@@ -1209,6 +1601,26 @@ function clearCheckMark() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
function showAbortButton(on) {
|
|
|
|
|
const btn = document.getElementById("abort-btn");
|
|
|
|
|
if (!btn) return;
|
|
|
|
|
if (on) btn.classList.remove("hidden");
|
|
|
|
|
else btn.classList.add("hidden");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Toast de feedback quand l'user clique Arrêter. Les fetches en cours peuvent
|
|
|
|
|
* encore prendre 1-2 secondes avant de se terminer (on ne peut pas vraiment
|
|
|
|
|
* annuler un fetch() en cours), mais du point de vue de l'interface tout
|
|
|
|
|
* est arrêté : plus de mise à jour, plus de cache, plus rien.
|
|
|
|
|
*/
|
|
|
|
|
function showAbortToast() {
|
|
|
|
|
showToast("Rafraîchissement", "arrêté");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderFromData(data) {
|
|
|
|
|
state.currentData = data;
|
|
|
|
|
document.getElementById("loading").classList.add("hidden");
|
|
|
|
@@ -1770,56 +2182,8 @@ function buildInterventionRow(iv, cardEl) {
|
|
|
|
|
const contactRaw = i.contact || iv.bulleContact || null;
|
|
|
|
|
const lieuRaw = i.lieu || iv.bulleLieu || null;
|
|
|
|
|
|
|
|
|
|
// Extraire tous les contacts (s'il y en a plusieurs séparés par "ou", etc.)
|
|
|
|
|
const contacts = extractContacts(contactRaw);
|
|
|
|
|
|
|
|
|
|
// Split le lieu : ville / adresse
|
|
|
|
|
const { ville, adresse } = splitLieu(lieuRaw);
|
|
|
|
|
|
|
|
|
|
// ── Lieu : ville (MAJUSCULES GRAS) puis adresse (italique noir) ──────────
|
|
|
|
|
if (ville || adresse) {
|
|
|
|
|
const lieuBlock = document.createElement("div");
|
|
|
|
|
lieuBlock.className = "iv-lieu-block";
|
|
|
|
|
if (ville) {
|
|
|
|
|
const villeEl = document.createElement("div");
|
|
|
|
|
villeEl.className = "iv-lieu-ville";
|
|
|
|
|
villeEl.textContent = ville.toUpperCase();
|
|
|
|
|
lieuBlock.appendChild(villeEl);
|
|
|
|
|
}
|
|
|
|
|
if (adresse) {
|
|
|
|
|
const addrEl = document.createElement("div");
|
|
|
|
|
addrEl.className = "iv-lieu-adresse";
|
|
|
|
|
addrEl.textContent = adresse;
|
|
|
|
|
lieuBlock.appendChild(addrEl);
|
|
|
|
|
}
|
|
|
|
|
rightCol.appendChild(lieuBlock);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Contact(s) + téléphone — un par ligne si plusieurs ──────────────────
|
|
|
|
|
for (const c of contacts) {
|
|
|
|
|
if (!c.name && !c.phone) continue;
|
|
|
|
|
const contactEl = document.createElement("div");
|
|
|
|
|
contactEl.className = "iv-contact-line";
|
|
|
|
|
if (c.name) {
|
|
|
|
|
const nameSpan = document.createElement("span");
|
|
|
|
|
nameSpan.className = "iv-contact";
|
|
|
|
|
nameSpan.textContent = c.name;
|
|
|
|
|
contactEl.appendChild(nameSpan);
|
|
|
|
|
}
|
|
|
|
|
if (c.phone) {
|
|
|
|
|
if (c.name) {
|
|
|
|
|
const sep = document.createElement("span");
|
|
|
|
|
sep.className = "iv-sep";
|
|
|
|
|
sep.textContent = " | ";
|
|
|
|
|
contactEl.appendChild(sep);
|
|
|
|
|
}
|
|
|
|
|
const phoneSpan = document.createElement("span");
|
|
|
|
|
phoneSpan.className = "iv-phone";
|
|
|
|
|
phoneSpan.textContent = c.phone;
|
|
|
|
|
contactEl.appendChild(phoneSpan);
|
|
|
|
|
}
|
|
|
|
|
rightCol.appendChild(contactEl);
|
|
|
|
|
}
|
|
|
|
|
// Rendu initial de lieu + contacts dans rightCol
|
|
|
|
|
renderLieuContactBlocks(rightCol, lieuRaw, contactRaw);
|
|
|
|
|
|
|
|
|
|
// ── Bas : Catégorie (à gauche) + Signature planificateur (à droite) ──────
|
|
|
|
|
const bottomEl = document.createElement("div");
|
|
|
|
@@ -2323,18 +2687,109 @@ async function copyRef(ref, btn) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Met à jour dans le DOM la ligne correspondant à une intervention (après fetch)
|
|
|
|
|
function updateInterventionInDom(iv) {
|
|
|
|
|
const row = document.querySelector(`.intervention[data-action-id="${iv.actionId}"]`);
|
|
|
|
|
// ─── Rendu incrémental (v3.1) ───────────────────────────────────────────────
|
|
|
|
|
// Met à jour UNE ligne d'intervention dans le DOM (après qu'un fetch fiche
|
|
|
|
|
// ait enrichi l'objet iv avec sa ref, son statut, etc.). Appelée par
|
|
|
|
|
// fetchAndUpdateIntervention pour afficher la ref dès qu'elle arrive, sans
|
|
|
|
|
// attendre que tous les workers aient fini ni re-rendre toute la vue.
|
|
|
|
|
//
|
|
|
|
|
// Doit rester en phase avec la structure DOM construite par
|
|
|
|
|
// buildInterventionRow (classes iv-ref-header, iv-status-check,
|
|
|
|
|
// intervention-copy, intervention-dot, timeline-slot...).
|
|
|
|
|
const ALL_COLOR_CLASSES = [
|
|
|
|
|
"color-livraison", "color-installation", "color-recup",
|
|
|
|
|
"color-remplacement", "color-incident", "color-rollout",
|
|
|
|
|
"color-reservation", "color-autre"
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* (Re)génère les blocs Lieu et Contact(s) dans le conteneur .iv-right.
|
|
|
|
|
* Supprime d'abord les anciens blocs (.iv-lieu-block + .iv-contact-line),
|
|
|
|
|
* puis insère les nouveaux AVANT le bloc .iv-bottom-line (si présent) pour
|
|
|
|
|
* conserver l'ordre d'affichage. Utilisé à la création ET lors de la
|
|
|
|
|
* mise à jour après fetch de la fiche.
|
|
|
|
|
*/
|
|
|
|
|
function renderLieuContactBlocks(rightCol, lieuRaw, contactRaw) {
|
|
|
|
|
// Supprime les anciens blocs lieu/contact
|
|
|
|
|
rightCol.querySelectorAll(".iv-lieu-block, .iv-contact-line").forEach(el => el.remove());
|
|
|
|
|
|
|
|
|
|
const contacts = extractContacts(contactRaw);
|
|
|
|
|
const { ville, adresse } = splitLieu(lieuRaw);
|
|
|
|
|
|
|
|
|
|
// Point d'insertion : avant .iv-bottom-line (catégorie + signature), sinon à la fin
|
|
|
|
|
const anchor = rightCol.querySelector(".iv-bottom-line");
|
|
|
|
|
const insert = (el) => {
|
|
|
|
|
if (anchor) rightCol.insertBefore(el, anchor);
|
|
|
|
|
else rightCol.appendChild(el);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Lieu : ville (MAJUSCULES GRAS) puis adresse (italique noir) ──────────
|
|
|
|
|
if (ville || adresse) {
|
|
|
|
|
const lieuBlock = document.createElement("div");
|
|
|
|
|
lieuBlock.className = "iv-lieu-block";
|
|
|
|
|
if (ville) {
|
|
|
|
|
const villeEl = document.createElement("div");
|
|
|
|
|
villeEl.className = "iv-lieu-ville";
|
|
|
|
|
villeEl.textContent = ville.toUpperCase();
|
|
|
|
|
lieuBlock.appendChild(villeEl);
|
|
|
|
|
}
|
|
|
|
|
if (adresse) {
|
|
|
|
|
const addrEl = document.createElement("div");
|
|
|
|
|
addrEl.className = "iv-lieu-adresse";
|
|
|
|
|
addrEl.textContent = adresse;
|
|
|
|
|
lieuBlock.appendChild(addrEl);
|
|
|
|
|
}
|
|
|
|
|
insert(lieuBlock);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Contact(s) + téléphone — un par ligne si plusieurs ──────────────────
|
|
|
|
|
for (const c of contacts) {
|
|
|
|
|
if (!c.name && !c.phone) continue;
|
|
|
|
|
const contactEl = document.createElement("div");
|
|
|
|
|
contactEl.className = "iv-contact-line";
|
|
|
|
|
if (c.name) {
|
|
|
|
|
const nameSpan = document.createElement("span");
|
|
|
|
|
nameSpan.className = "iv-contact";
|
|
|
|
|
nameSpan.textContent = c.name;
|
|
|
|
|
contactEl.appendChild(nameSpan);
|
|
|
|
|
}
|
|
|
|
|
if (c.phone) {
|
|
|
|
|
if (c.name) {
|
|
|
|
|
const sep = document.createElement("span");
|
|
|
|
|
sep.className = "iv-sep";
|
|
|
|
|
sep.textContent = " | ";
|
|
|
|
|
contactEl.appendChild(sep);
|
|
|
|
|
}
|
|
|
|
|
const phoneSpan = document.createElement("span");
|
|
|
|
|
phoneSpan.className = "iv-phone";
|
|
|
|
|
phoneSpan.textContent = c.phone;
|
|
|
|
|
contactEl.appendChild(phoneSpan);
|
|
|
|
|
}
|
|
|
|
|
insert(contactEl);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateInterventionRow(iv) {
|
|
|
|
|
// Réservations : pas concerné (pas de fetch fiche pour elles)
|
|
|
|
|
if (iv.type === "AL-Reservation") return;
|
|
|
|
|
|
|
|
|
|
const row = document.querySelector(
|
|
|
|
|
`.intervention-v2[data-action-id="${iv.actionId}"]`
|
|
|
|
|
);
|
|
|
|
|
if (!row) return;
|
|
|
|
|
|
|
|
|
|
// Statut visuel
|
|
|
|
|
row.classList.remove("status-closed", "status-resolved");
|
|
|
|
|
// Classes de statut sur la ligne
|
|
|
|
|
const sc = getStatusClass(iv);
|
|
|
|
|
row.classList.remove("status-closed", "status-resolved");
|
|
|
|
|
if (sc) row.classList.add(sc);
|
|
|
|
|
|
|
|
|
|
// Ref (S260xxx) : mise à jour si on l'a trouvée dans la fiche
|
|
|
|
|
const refEl = row.querySelector(".intervention-refhdr");
|
|
|
|
|
// Classe de couleur sur la ligne (la pastille hérite via CSS)
|
|
|
|
|
const colorKey = deriveColorKey(iv);
|
|
|
|
|
row.classList.remove(...ALL_COLOR_CLASSES);
|
|
|
|
|
row.classList.add("color-" + colorKey);
|
|
|
|
|
|
|
|
|
|
// Ref (le titre gros en haut de la ligne)
|
|
|
|
|
const refEl = row.querySelector(".iv-ref-header");
|
|
|
|
|
if (refEl) {
|
|
|
|
|
if (iv.ref) {
|
|
|
|
|
refEl.textContent = iv.ref;
|
|
|
|
@@ -2345,28 +2800,22 @@ function updateInterventionInDom(iv) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Titre (catégorie)
|
|
|
|
|
const title = row.querySelector(".intervention-title");
|
|
|
|
|
if (title) title.textContent = deriveShortTitle(iv);
|
|
|
|
|
|
|
|
|
|
// Meta
|
|
|
|
|
const meta = row.querySelector(".intervention-meta");
|
|
|
|
|
if (meta) meta.textContent = shortMeta(iv);
|
|
|
|
|
|
|
|
|
|
// Check ✓ : ajouter ou retirer
|
|
|
|
|
let statusEl = row.querySelector(".intervention-status");
|
|
|
|
|
if (sc && !statusEl) {
|
|
|
|
|
statusEl = document.createElement("div");
|
|
|
|
|
statusEl.className = "intervention-status";
|
|
|
|
|
statusEl.textContent = "✓";
|
|
|
|
|
// Check ✓ : ajouter/retirer selon statut
|
|
|
|
|
let checkEl = row.querySelector(".iv-status-check");
|
|
|
|
|
if (sc && !checkEl) {
|
|
|
|
|
checkEl = document.createElement("div");
|
|
|
|
|
checkEl.className = "iv-status-check";
|
|
|
|
|
checkEl.textContent = "✓";
|
|
|
|
|
// Insérer après la ref (avant le bouton copier s'il existe)
|
|
|
|
|
const copy = row.querySelector(".intervention-copy");
|
|
|
|
|
if (copy) row.insertBefore(statusEl, copy);
|
|
|
|
|
else row.appendChild(statusEl);
|
|
|
|
|
} else if (!sc && statusEl) {
|
|
|
|
|
statusEl.remove();
|
|
|
|
|
if (copy) row.insertBefore(checkEl, copy);
|
|
|
|
|
else if (refEl && refEl.nextSibling) row.insertBefore(checkEl, refEl.nextSibling);
|
|
|
|
|
else row.appendChild(checkEl);
|
|
|
|
|
} else if (!sc && checkEl) {
|
|
|
|
|
checkEl.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bouton copier : ajouter si on a maintenant une ref
|
|
|
|
|
// Bouton 📋 copier : ajouter si on a maintenant une ref et qu'il n'existe pas
|
|
|
|
|
let copyBtn = row.querySelector(".intervention-copy");
|
|
|
|
|
if (iv.ref && !copyBtn) {
|
|
|
|
|
copyBtn = document.createElement("button");
|
|
|
|
@@ -2381,20 +2830,33 @@ function updateInterventionInDom(iv) {
|
|
|
|
|
row.appendChild(copyBtn);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mettre à jour la classe couleur (pour la pastille)
|
|
|
|
|
const colorKey = deriveColorKey(iv);
|
|
|
|
|
row.classList.remove("color-livraison", "color-recup", "color-remplacement", "color-autre");
|
|
|
|
|
row.classList.add("color-" + colorKey);
|
|
|
|
|
// Catégorie affichée en bas (dépend de la ref pour Incident, etc.)
|
|
|
|
|
const catEl = row.querySelector(".iv-category");
|
|
|
|
|
if (catEl) catEl.textContent = deriveShortTitle(iv);
|
|
|
|
|
|
|
|
|
|
// Mettre à jour le bloc timeline correspondant
|
|
|
|
|
// Lieu + Contact(s) : régénérés depuis les valeurs actuelles de iv
|
|
|
|
|
// (elles peuvent avoir été mises à jour par le fetch de la fiche).
|
|
|
|
|
const rightCol = row.querySelector(".iv-right");
|
|
|
|
|
if (rightCol) {
|
|
|
|
|
const info = iv.infobulle || {};
|
|
|
|
|
const contactRaw = info.contact || iv.bulleContact || null;
|
|
|
|
|
const lieuRaw = info.lieu || iv.bulleLieu || null;
|
|
|
|
|
renderLieuContactBlocks(rightCol, lieuRaw, contactRaw);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Segment timeline correspondant : même couleur + même classe statut
|
|
|
|
|
const card = row.closest(".card");
|
|
|
|
|
if (card) {
|
|
|
|
|
const slot = card.querySelector(`.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]`);
|
|
|
|
|
if (card && row.dataset.ivIdx !== undefined) {
|
|
|
|
|
const slot = card.querySelector(
|
|
|
|
|
`.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]`
|
|
|
|
|
);
|
|
|
|
|
if (slot) {
|
|
|
|
|
slot.classList.remove("status-closed", "status-resolved",
|
|
|
|
|
"color-livraison", "color-recup", "color-remplacement", "color-autre");
|
|
|
|
|
slot.classList.remove("status-closed", "status-resolved", ...ALL_COLOR_CLASSES);
|
|
|
|
|
slot.classList.add("color-" + colorKey);
|
|
|
|
|
if (sc) slot.classList.add(sc);
|
|
|
|
|
// Maj du dataset pour le popover (titre + ref)
|
|
|
|
|
slot.dataset.title = deriveShortTitle(iv);
|
|
|
|
|
if (iv.ref) slot.dataset.ref = iv.ref;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|