|
|
|
@@ -47,8 +47,11 @@ 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 du fetch en parallèle (fiches + timelines).
|
|
|
|
|
// Avant v3.1 : 12. Monté à 30 pour afficher les refs plus vite sur les jours
|
|
|
|
|
// chargés (~34 interventions → 2 vagues au lieu de 3). Si le serveur sature,
|
|
|
|
|
// redescendre à 20.
|
|
|
|
|
const FETCH_CONCURRENCY = 30;
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// Mapping de catégorie → titre court + couleur
|
|
|
|
@@ -152,6 +155,24 @@ 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.
|
|
|
|
|
let currentRefreshToken = 0;
|
|
|
|
|
let abortedToken = -1;
|
|
|
|
|
|
|
|
|
|
function startNewRefresh() {
|
|
|
|
|
currentRefreshToken++;
|
|
|
|
|
return currentRefreshToken;
|
|
|
|
|
}
|
|
|
|
|
function abortCurrentRefresh() {
|
|
|
|
|
abortedToken = currentRefreshToken;
|
|
|
|
|
}
|
|
|
|
|
function isRefreshAborted() {
|
|
|
|
|
return abortedToken === currentRefreshToken;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// Boot
|
|
|
|
|
// ============================================================================
|
|
|
|
@@ -226,6 +247,10 @@ function toggleTheme() {
|
|
|
|
|
function bindTopbar() {
|
|
|
|
|
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
|
|
|
|
|
document.getElementById("refresh-btn").addEventListener("click", () => refreshPlanning());
|
|
|
|
|
document.getElementById("abort-btn").addEventListener("click", () => {
|
|
|
|
|
abortCurrentRefresh();
|
|
|
|
|
showAbortButton(false);
|
|
|
|
|
});
|
|
|
|
|
document.getElementById("clear-cache-btn").addEventListener("click", onClearCache);
|
|
|
|
|
|
|
|
|
|
document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1));
|
|
|
|
@@ -422,8 +447,16 @@ async function refreshPlanning(opts = {}) {
|
|
|
|
|
await refreshSessionAndLoad();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Bouton Rafraîchir manuel : on force le refetch des fiches
|
|
|
|
|
await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true });
|
|
|
|
|
// Rafraîchissement manuel (clic bouton) : on démarre un nouveau jeton et
|
|
|
|
|
// on fait apparaître le bouton "Arrêter". Les refresh auto (12h/15h) et
|
|
|
|
|
// les navigations de date n'ont pas ce bouton (ils ne passent pas ici).
|
|
|
|
|
startNewRefresh();
|
|
|
|
|
showAbortButton(true);
|
|
|
|
|
try {
|
|
|
|
|
await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true });
|
|
|
|
|
} finally {
|
|
|
|
|
showAbortButton(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
@@ -793,10 +826,14 @@ async function refreshStatuses(techs, isoDate) {
|
|
|
|
|
|
|
|
|
|
setRefreshing(true);
|
|
|
|
|
try {
|
|
|
|
|
// Fetcher avec concurrence = FETCH_CONCURRENCY (12)
|
|
|
|
|
// Fetcher avec concurrence = FETCH_CONCURRENCY (30)
|
|
|
|
|
// 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]);
|
|
|
|
|
}
|
|
|
|
@@ -806,6 +843,13 @@ async function refreshStatuses(techs, isoDate) {
|
|
|
|
|
for (let w = 0; w < FETCH_CONCURRENCY; 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 +876,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;
|
|
|
|
@@ -872,6 +931,12 @@ async function fetchAndUpdateIntervention(iv) {
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
@@ -1209,6 +1274,16 @@ 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");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderFromData(data) {
|
|
|
|
|
state.currentData = data;
|
|
|
|
|
document.getElementById("loading").classList.add("hidden");
|
|
|
|
@@ -2323,18 +2398,42 @@ 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"
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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 +2444,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 +2474,23 @@ 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
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|