diff --git a/background.js b/background.js index 1fa9aa3..14acfc9 100644 --- a/background.js +++ b/background.js @@ -192,7 +192,7 @@ async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum) { `&checksum=${encodeURIComponent(formChecksum)}` + `&type=todo§ionId=1&navigator=&nbRecord=0` + `&PHPSESSID=${encodeURIComponent(phpsessid)}`; - const r = await fetch(url, { credentials: "include" }); + const r = await evFetch(url, origin); if (!r.ok) { const err = new Error("HTTP " + r.status); err.kind = classifyHttpStatus(r.status); @@ -206,9 +206,90 @@ async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum) { // Détection "session invalide" // ============================================================================ +/** + * v5.0.9 : détecte plusieurs patterns de session invalide : + * 1. Page de login classique EasyVista (customer_login, my.policy) + * 2. Script de redirection court : + * (protection CSRF ou session expirée) + * 3. URL de logout : index.php?...&logout=1 + * 4. Redirection vers le portail SSO : portail.etat-de-vaud.ch/sso/ + * 5. Réponse JSON avec "isLogged": false + */ function looksLikeLoginPage(text) { - // La page de login EasyVista contient cette chaîne - return /customer_login|my\.policy/i.test((text || "").substring(0, 3000)); + const t = (text || "").substring(0, 3000); + if (!t) return false; + // Pattern 1 : page de login EV classique + if (/customer_login|my\.policy/i.test(t)) return true; + // Pattern 2 : script de redirection (< 500 chars = probablement juste ça) + if (t.length < 500 && /]*>\s*window\.location\.href\s*=/i.test(t)) return true; + // Pattern 3 : URL de logout + if (/[?&]logout=1/i.test(t)) return true; + // Pattern 4 : redirection vers portail SSO + if (/portail\.etat-de-vaud\.ch\/sso\//i.test(t)) return true; + // Pattern 5 : JSON isLogged:false + if (/"isLogged"\s*:\s*false/i.test(t)) return true; + return false; +} + +// ============================================================================ +// v5.0.9 : surveillance du timeout de session EasyVista +// ============================================================================ + +/** + * GET /timeout_ajax.php?__AJAX_TIMEOUT_FCT__=session_time + * + * Retourne le nombre de millisecondes restantes avant expiration de la + * session EasyVista (0 à 1 800 000 = 30 min max). + * + * Attention : cette requête EST authentifiée et prolonge probablement la + * session (comme toute requête PHP authentifiée). À utiliser avec parcimonie. + */ +async function fetchSessionTimeRemaining(origin, phpsessid) { + const url = `${origin}/timeout_ajax.php` + + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + + `&__AJAX_TIMEOUT_FCT__=session_time`; + console.log("[bg] fetchSessionTimeRemaining →", url.substring(0, 120)); + const r = await evFetch(url, origin); + if (!r.ok) { + throw new Error("HTTP " + r.status); + } + const body = (await r.text()).trim(); + // Vérifier que c'est bien un nombre (sinon = session morte probable) + if (!/^\d+$/.test(body)) { + console.warn("[bg] réponse session_time anormale :", body.substring(0, 200)); + // Si c'est une page de login/redirect → session expirée + if (looksLikeLoginPage(body)) { + throw new Error("session_expired"); + } + throw new Error("invalid_response"); + } + const ms = parseInt(body, 10); + console.log(`[bg] session_time = ${ms} ms = ${Math.round(ms/60000)} min`); + return ms; +} + +/** + * GET /timeout_ajax.php?__AJAX_TIMEOUT_FCT__=keep_connection + * + * Prolonge la session à 30 min. Retourne 1800000. + */ +async function extendSessionKeepAlive(origin, phpsessid) { + const url = `${origin}/timeout_ajax.php` + + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + + `&__AJAX_TIMEOUT_FCT__=keep_connection`; + console.log("[bg] extendSessionKeepAlive →", url.substring(0, 120)); + const r = await evFetch(url, origin); + if (!r.ok) { + throw new Error("HTTP " + r.status); + } + const body = (await r.text()).trim(); + if (!/^\d+$/.test(body)) { + if (looksLikeLoginPage(body)) throw new Error("session_expired"); + throw new Error("invalid_response"); + } + const ms = parseInt(body, 10); + console.log(`[bg] keep_connection → session prolongée à ${ms} ms`); + return ms; } // ============================================================================ @@ -452,6 +533,240 @@ async function submitDouchette(origin, phpsessid, opts) { } } +// ============================================================================ +// v5.0.0 : Suppression d'une absence ou d'une réservation +// ============================================================================ + +/** + * Supprime un item du planning (absence ou réservation) côté EasyVista. + * + * v5.0.1 : l'endpoint exact n'est pas totalement certain selon les versions + * EasyVista. On essaye plusieurs `function_name` jusqu'à trouver celui qui + * marche. Un "status 200" ne garantit pas que ça a été supprimé (l'API peut + * répondre 200 même sur un nom de fonction inconnu), mais ça + le reload + * post-suppression donne un bon signal : si le ticket est toujours là après + * reload, on réessaye avec le nom suivant. + * + * Pour l'absence, dans le HTML le bouton "Supprimer" appelle : + * onclick="g_arr_player[N].delete_absence();" + * qui fait probablement un GET /planning_updator_xhr.php?function_name=... + * mais le nom exact varie (peut être "delete_absence", "Planning_delete_absence", + * "fc_delete_absence", etc.) + * + * @param {string} origin + * @param {string} phpsessid + * @param {string} actionId - ID de l'action à supprimer + * @param {string} kind - "absence" ou "reservation" + */ +async function deletePlanningItem(origin, phpsessid, actionId, kind) { + if (!actionId) throw new Error("actionId manquant"); + + // v5.0.1 : plusieurs function_name à tester dans l'ordre (du plus probable + // au moins probable). Le premier qui renvoie 200 ET non-login est considéré OK. + const fnNames = kind === "reservation" + ? [ + "Planning_delete_reservation", + "delete_reservation", + "fc_delete_reservation", + "delete_act_reservation", + "delete_planning_reservation", + "remove_reservation", + // v5.0.2 : réservations sont parfois traitées comme absences côté API + "Planning_delete_absence", + "delete_absence", + "fc_delete_absence" + ] + : [ + // v5.0.2 : élargir la liste, on a essayé 3 sans succès. Les variantes + // plausibles vues dans les API EasyVista : + "Planning_delete_absence", // le plus "officiel" + "delete_absence", // le nom JS dans le onclick + "fc_delete_absence", // pattern fc_* + "delete_act_absence", // parfois "act_" dans les noms + "Planning_delete_holiday", // en anglais + "delete_holiday", + "fc_delete_holiday", + "delete_planning_absence", // variation complète + "remove_absence" + ]; + + let lastErr = null; + let lastBody = null; + for (const fn of fnNames) { + const url = `${origin}/planning_updator_xhr.php` + + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + + `&function_name=${encodeURIComponent(fn)}` + + `&action_id=${encodeURIComponent(actionId)}`; + + console.log(`[bg] deletePlanningItem → tente function_name=${fn}`, url.substring(0, 180)); + + try { + const r = await fetch(url, { method: "GET", credentials: "include" }); + const body = await r.text(); + console.log(`[bg] status=${r.status} | body (200 premiers chars) : ${(body || "").substring(0, 200)}`); + + if (r.status === 401 || r.status === 403) { + throw new Error("session_expired"); + } + if (!r.ok) { + lastErr = new Error("HTTP " + r.status); + continue; // tente le prochain + } + if (looksLikeLoginPage(body)) { + throw new Error("session_expired"); + } + + // v5.0.1 : heuristique pour détecter si la suppression a marché. + // EasyVista renvoie typiquement : + // - une chaine vide ou "ok" ou "1" si succès + // - un message d'erreur / html d'erreur si function_name inconnu + // On considère que tout ce qui n'est pas un message d'erreur évident + // est un succès. Si plusieurs fn renvoient 200, on prend le premier. + const trimmed = (body || "").trim().toLowerCase(); + const looksLikeError = trimmed.includes("error") + || trimmed.includes("erreur") + || trimmed.includes("unknown function") + || trimmed.includes("fonction inconnue") + || trimmed.includes(" ({ id, name: "? (" + id + ")", alreadyInTeam: true })), + groupId + }; + } + + // Parser le HTML. Différents patterns possibles. + const results = []; + const currentIdsSet = new Set(supportIds.split(",").filter(Boolean)); + + // v5.0.1 : log le début du HTML pour diagnostic si parsing échoue + console.log("[bg] popup HTML (début) =", popupHtml.substring(0, 500)); + + // Pattern 1 : checkboxes + texte voisin + const rxCheckbox = /]*type=["']checkbox["'][^>]*value=["'](\d{4,7})["'][^>]*>([\s\S]{0,400}?)(?= r.id === id)) { + results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) }); + } + } + console.log("[bg] parsing pattern 1 (checkbox) :", results.length, "résultats"); + + // Pattern 2 : fallback + if (results.length === 0) { + const rxOption = /]*value=["'](\d{4,7})["'][^>]*>([^<]+)<\/option>/gi; + let mO; + while ((mO = rxOption.exec(popupHtml)) !== null) { + const id = mO[1]; + const name = (mO[2] || "").trim(); + if (!results.some(r => r.id === id)) { + results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) }); + } + } + console.log("[bg] parsing pattern 2 (option) :", results.length, "résultats"); + } + + // Pattern 3 : fallback brut tags HTML contenant ID à proximité d'un nom + if (results.length === 0) { + // Chercher chaque ID 4-7 chiffres et regarder les 200 caractères qui suivent + const rxAnyId = /\b(\d{5,7})\b([\s\S]{0,200})/g; + let mA; + while ((mA = rxAnyId.exec(popupHtml)) !== null) { + const id = mA[1]; + // Ignorer les IDs qui ressemblent à des timestamps / hash + if (id.length > 6 && parseInt(id, 10) > 1000000000) continue; + const context = mA[2]; + const nameMatch = context.match(/([A-ZÀ-ÿ][A-Za-zÀ-ÿ\-']+,\s*[A-ZÀ-ÿ][A-Za-zÀ-ÿ\-' \.]{2,30})/); + if (nameMatch && !results.some(r => r.id === id)) { + results.push({ id, name: nameMatch[1].trim(), alreadyInTeam: currentIdsSet.has(id) }); + } + } + console.log("[bg] parsing pattern 3 (brut) :", results.length, "résultats"); + } + + // Ajouter les IDs actuels manquants (sans nom) + for (const id of currentIdsSet) { + if (!results.some(r => r.id === id)) { + results.push({ id, name: "? (" + id + ")", alreadyInTeam: true }); + } + } + + console.log("[bg] " + results.length + " personnes retournées"); + return { ids: results, groupId: groupId }; +} + // ============================================================================ // Messages du viewer // ============================================================================ @@ -614,6 +929,94 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return; } + if (msg.type === "deletePlanningItem") { + // v5.0.0 : supprime une absence ou réservation côté EasyVista. + // Endpoint : /planning_updator_xhr.php?function_name=...&action_id=... + // Exemples de function_name : + // - Planning_delete_absence + // - Planning_delete_reservation + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const result = await deletePlanningItem( + session.origin, session.phpsessid, msg.actionId, msg.kind + ); + sendResponse({ ok: true, result }); + } catch (err) { + sendResponse({ ok: false, error: err.message || String(err) }); + } + return; + } + + if (msg.type === "detectTeam") { + // v5.0.0 : détecte la liste des IDs de techniciens depuis le HTML + // v5.0.1 : retourne aussi les noms via la popup group_supports + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const result = await detectTeamFromEV(session.origin, session.phpsessid); + // result = { ids: [{id,name,alreadyInTeam}, ...], groupId } + sendResponse({ ok: true, members: result.ids, groupId: result.groupId }); + } catch (err) { + sendResponse({ ok: false, error: err.message || String(err) }); + } + return; + } + + if (msg.type === "getSessionRemaining") { + // v5.0.9 : récupère le temps restant avant expiration de la session EV + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const remainingMs = await fetchSessionTimeRemaining(session.origin, session.phpsessid); + sendResponse({ ok: true, remainingMs, phpsessid: session.phpsessid }); + } catch (err) { + sendResponse({ ok: false, error: err.message || String(err) }); + } + return; + } + + if (msg.type === "extendSession") { + // v5.0.9 : prolonge la session EV à 30 min via keep_connection + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const remainingMs = await extendSessionKeepAlive(session.origin, session.phpsessid); + sendResponse({ ok: true, remainingMs }); + } catch (err) { + sendResponse({ ok: false, error: err.message || String(err) }); + } + return; + } + + if (msg.type === "openEasyVistaLogin") { + // v5.0.9 : ouvre EasyVista dans un nouvel onglet pour provoquer + // le SSO Windows automatique (reconnexion transparente). + const origin = msg.origin || "https://itsma.etat-de-vaud.ch"; + try { + const tab = await chrome.tabs.create({ + url: `${origin}/index.php?eventName=HelpDesk_PlanningItem`, + active: true + }); + sendResponse({ ok: true, tabId: tab.id }); + } catch (err) { + sendResponse({ ok: false, error: err.message || String(err) }); + } + return; + } + if (msg.type === "cleanupOldCaches") { const removed = await cleanupOldCaches(msg.daysToKeep || 7); sendResponse({ ok: true, removed }); diff --git a/manifest.json b/manifest.json index a3269e8..e321dc8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,19 @@ { "manifest_version": 3, "name": "Planification", - "version": "5.0.0", - "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.3.0 : (1) conflits horaires entre interventions d'un même tech affichés en rouge + ⚠. (2) Réservations disparues retirées directement (pas de re-fetch inutile). (3) Popups épinglés détachés : plusieurs peuvent coexister, ancrés au contenu (scrollent avec la page), auto-positionnés sans se marcher dessus (toast si pas de place), Échap pour tout fermer, Ctrl×2 pour fermer si un seul épinglé. Inclut v4.2.9.", + "version": "5.0.9", + "description": "Vue claire et rapide du planning des techniciens EasyVista. Regroupe interventions et réservations par tech, affiche horaires, contact, lieu, catégorie et statut en un coup d'œil.", + "browser_specific_settings": { + "gecko": { + "id": "planification@vd.ch", + "strict_min_version": "140.0", + "data_collection_permissions": { + "required": [ + "none" + ] + } + } + }, "permissions": [ "activeTab", "scripting", @@ -18,7 +29,9 @@ "default_title": "Ouvrir la Planification" }, "background": { - "service_worker": "background.js" + "scripts": [ + "background.js" + ] }, "icons": { "16": "icons/icon16.png", diff --git a/viewer.css b/viewer.css index 4330a6e..d7db0bb 100644 --- a/viewer.css +++ b/viewer.css @@ -1816,16 +1816,87 @@ body.modal-open { box-shadow: 0 8px 24px rgba(0,0,0,0.18); /* Pas de contain: layout (hérité) car ça limite le rendu ; on laisse */ animation: pinned-popup-in 0.15s ease-out; + /* Le padding-top est augmenté pour accueillir la barre de drag. */ + padding-top: 28px !important; } @keyframes pinned-popup-in { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } } +/* v4.3.3 : animation de sortie (symétrique à l'apparition) quand on + désépingle. Appliquée par la classe .unpinning. */ +.tooltip.pinned-popup.unpinning, +.tooltip.soft-unpinned.unpinning { + animation: pinned-popup-out 0.18s ease-in forwards !important; +} +@keyframes pinned-popup-out { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.94); } +} + +/* v4.3.3 corr : quand une popup est désépinglée "mou", elle perd son look + "épinglé" et redevient un tooltip normal visuellement, tout en gardant + sa position absolute (pour ne pas sauter). */ +.tooltip.soft-unpinned { + position: absolute !important; + z-index: 5 !important; + opacity: 1 !important; + pointer-events: auto !important; + /* Pas de bordure bleue, pas de padding-top (plus de dragbar), juste les + styles de base du tooltip (hérités de .tooltip). */ + border: 1px solid var(--border-strong) !important; + box-shadow: var(--shadow-hover) !important; + padding-top: 12px !important; + animation: none !important; +} + +/* v4.3.3 : Barre de drag en haut de la popup épinglée, permet de la + déplacer (le contenu lui-même garde la sélection de texte possible). */ +.pinned-popup-dragbar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient( + to bottom, + var(--bg-muted, rgba(128,128,128,0.08)) 0%, + transparent 100% + ); + border-bottom: 1px solid var(--border, rgba(128,128,128,0.15)); + border-radius: 6px 6px 0 0; + cursor: grab; + user-select: none; + -webkit-user-select: none; +} +.pinned-popup-dragbar:active, +.pinned-popup.dragging .pinned-popup-dragbar { + cursor: grabbing; +} +/* Petite grippe visuelle au milieu pour signaler que c'est déplaçable */ +.pinned-popup-dragbar::before { + content: ""; + width: 32px; + height: 3px; + border-radius: 3px; + background: var(--border-strong, rgba(128,128,128,0.35)); +} +/* Pendant le drag, on fige l'animation pour éviter les tremblements */ +.pinned-popup.dragging { + animation: none !important; + transition: none !important; + cursor: grabbing !important; + box-shadow: 0 12px 32px rgba(0,0,0,0.28); +} + /* Bouton × de fermeture du popup épinglé */ .pinned-popup-close { position: absolute; - top: 4px; + top: 3px; right: 6px; width: 22px; height: 22px; @@ -1839,8 +1910,429 @@ body.modal-open { border-radius: 4px; cursor: pointer; transition: background 0.1s, color 0.1s; + z-index: 2; /* au-dessus de la dragbar */ } .pinned-popup-close:hover { background: var(--danger-soft, #fbe6e6); color: var(--danger, #b03030); } + +/* ───────────────────────────────────────────────────────────────────────── + v5.0.0 : horloge au milieu de la topbar (HH:MM, pas de secondes) + ───────────────────────────────────────────────────────────────────────── */ +.app-clock { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + font-size: 22px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text); + letter-spacing: 1px; + pointer-events: none; + user-select: none; +} +.topbar { position: sticky; /* déja défini plus haut */ } +/* topbar doit être en position: relative parent pour que .app-clock absolute + se positionne par rapport à elle */ +header.topbar { position: sticky !important; } +header.topbar::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; +} + +/* ───────────────────────────────────────────────────────────────────────── + v5.0.0 : ligne rouge "heure actuelle" sur la timeline (uniquement si on + affiche la date d'aujourd'hui). v5.0.1 : plus visible. + ───────────────────────────────────────────────────────────────────────── */ +.timeline-now-line { + position: absolute; + top: -2px; + bottom: -2px; + width: 4px; + background: #ff3030; + z-index: 5; + pointer-events: none; + box-shadow: 0 0 6px rgba(255, 48, 48, 0.8), + 0 0 2px rgba(255, 48, 48, 1); + border-radius: 2px; + margin-left: -2px; /* centre la barre sur la position exacte */ +} +.timeline-now-line::after { + content: ""; + position: absolute; + top: -4px; + left: 50%; + transform: translateX(-50%); + width: 12px; + height: 12px; + background: #ff3030; + border-radius: 50%; + box-shadow: 0 0 8px rgba(255, 48, 48, 0.9); +} + +/* ───────────────────────────────────────────────────────────────────────── + v5.0.0 : Panel admin (menu caché 5 clics sur titre) + ───────────────────────────────────────────────────────────────────────── */ +.admin-overlay { + /* hérite de .modal-overlay */ + align-items: flex-start; + padding: 30px 20px; +} +.admin-panel-card { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 8px; + width: 100%; + max-width: 1100px; + height: calc(100vh - 60px); + display: flex; + flex-direction: column; + box-shadow: 0 12px 40px rgba(0,0,0,0.3); + overflow: hidden; +} +.admin-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 20px; + border-bottom: 1px solid var(--border); + background: var(--bg); +} +.admin-title { + margin: 0; + font-size: 18px; + font-weight: 600; +} +.admin-close-btn { + background: transparent; + border: none; + font-size: 24px; + line-height: 1; + cursor: pointer; + padding: 4px 10px; + color: var(--text-muted); + border-radius: 4px; +} +.admin-close-btn:hover { + background: var(--danger-soft); + color: var(--danger); +} +.admin-body { + display: flex; + flex: 1; + min-height: 0; +} +.admin-sidebar { + width: 180px; + background: var(--bg); + border-right: 1px solid var(--border); + padding: 10px 0; + display: flex; + flex-direction: column; + gap: 2px; + flex-shrink: 0; +} +.admin-nav-btn { + text-align: left; + padding: 10px 18px; + background: transparent; + border: none; + cursor: pointer; + font-size: 14px; + color: var(--text); + border-left: 3px solid transparent; + transition: background 0.12s, border-color 0.12s; +} +.admin-nav-btn:hover { + background: var(--bg-hover); +} +.admin-nav-btn.active { + background: var(--bg-elevated); + border-left-color: var(--accent); + font-weight: 600; +} +.admin-content { + flex: 1; + padding: 20px 24px; + overflow-y: auto; +} +.admin-section-title { + margin: 0 0 8px 0; + font-size: 20px; + font-weight: 600; +} +.admin-section-desc { + margin: 0 0 16px 0; + color: var(--text-muted); + font-size: 13px; +} +.admin-team-table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; +} +.admin-team-table th, +.admin-team-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: middle; +} +.admin-team-table th { + background: var(--bg); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; + color: var(--text-muted); +} +.admin-input { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text); + font-size: 13px; + box-sizing: border-box; +} +.admin-input-id { + font-family: var(--mono); + max-width: 100px; +} +.admin-day-cb { + display: inline-flex; + align-items: center; + gap: 2px; + margin-right: 6px; + font-size: 11px; + cursor: pointer; + user-select: none; +} +.admin-day-cb input[type="checkbox"] { + margin: 0 2px 0 0; +} +.admin-del-btn { + background: transparent; + border: none; + cursor: pointer; + font-size: 16px; + color: var(--text-muted); + padding: 4px 8px; + border-radius: 4px; +} +.admin-del-btn:hover { + background: var(--danger-soft); + color: var(--danger); +} +.admin-readonly { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 12px; + font-family: var(--mono); + font-size: 12px; + overflow-x: auto; +} +.admin-diag-grid { + display: grid; + grid-template-columns: 200px 1fr; + gap: 8px 16px; + margin: 16px 0; + font-size: 13px; +} +.admin-diag-grid > div { + padding: 4px 0; +} + +/* ───────────────────────────────────────────────────────────────────────── + v5.0.0 : bouton supprimer dans le tooltip (absence / réservation) + ───────────────────────────────────────────────────────────────────────── */ +.tooltip-delete-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + background: var(--danger-soft, #fbe6e6); + border: 1px solid var(--danger, #b03030); + color: var(--danger, #b03030); + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + margin-top: 4px; +} +.tooltip-delete-btn:hover:not(:disabled) { + background: var(--danger, #b03030); + color: #fff; +} +.tooltip-delete-btn:disabled { + opacity: 0.6; + cursor: wait; +} + +/* Bouton danger dans les modals */ +.btn-danger, +.modal-btn-danger { + background: var(--danger, #b03030); + color: #fff; + border: 1px solid var(--danger, #b03030); +} +.btn-danger:hover, +.modal-btn-danger:hover { + background: #8e2020; +} + +/* v5.0.1 : ligne d'équipe exclue (pas cochée) - apparaît grisée */ +.admin-team-table tr.admin-row-excluded { + opacity: 0.45; +} +.admin-team-table tr.admin-row-excluded input[type="text"] { + background: var(--bg); +} + +/* v5.0.1 : bouton supprimer sur la carte "Absent toute la journée" */ +.absence-delete-wrap { + margin-top: 8px; + text-align: center; +} +.absence-delete-wrap .tooltip-delete-btn { + font-size: 11px; + padding: 4px 8px; +} + +/* v5.0.4 : boutons preset matin / après-midi / journée dans modal absence */ +.modal-preset-row { + gap: 8px; + flex-wrap: wrap; +} +.modal-preset-btn { + flex: 1; + min-width: 100px; + padding: 8px 10px; + font-size: 13px; + cursor: pointer; +} + +/* ========================================================================== + v5.0.9 : Compteur de session EasyVista (topbar) + ========================================================================== */ + +.app-session { + position: absolute; + top: 50%; + left: calc(50% + 60px); /* à droite de l'horloge (~60px de décalage) */ + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + border-radius: 14px; + font-size: 13px; + font-weight: 500; + font-variant-numeric: tabular-nums; + z-index: 9; + background: rgba(0, 0, 0, 0.05); + transition: background 0.3s, color 0.3s; +} +.app-session.hidden { + display: none; +} +.app-session .session-icon { + font-size: 14px; +} +.app-session .session-time { + font-weight: 600; +} +.app-session .session-extend-btn { + margin-left: 4px; + padding: 3px 8px; + font-size: 11px; + border-radius: 10px; + border: 1px solid currentColor; + background: transparent; + color: inherit; + cursor: pointer; + font-weight: 500; + transition: background 0.2s; +} +.app-session .session-extend-btn:hover { + background: rgba(255, 255, 255, 0.2); +} +.app-session .session-extend-btn:disabled { + opacity: 0.6; + cursor: default; +} + +/* État warning (2-5 min) : jaune */ +.app-session.session-warn { + background: #f5c518; + color: #2a2100; +} +.app-session.session-warn .session-extend-btn { + border-color: #2a2100; +} + +/* État critical (< 2 min) : rouge + pulse */ +.app-session.session-critical { + background: #e74c3c; + color: #fff; + animation: session-pulse 1s infinite; +} +.app-session.session-critical .session-extend-btn { + border-color: #fff; + background: rgba(255, 255, 255, 0.15); + font-weight: 600; +} +.app-session.session-critical .session-extend-btn:hover { + background: rgba(255, 255, 255, 0.3); +} +@keyframes session-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.5); } + 50% { box-shadow: 0 0 0 6px rgba(231, 76, 60, 0); } +} + +/* Bouton "Me reconnecter" dans la bannière session expirée */ +.session-expired-reconnect-btn { + margin-left: 12px; + padding: 6px 14px; + border-radius: 4px; + background: #fff; + color: #c0392b; + border: none; + font-weight: 600; + cursor: pointer; + font-size: 13px; + transition: background 0.2s; +} +.session-expired-reconnect-btn:hover { + background: #f8d7da; +} + +/* Bannière "Reconnexion en cours" */ +.banner-reconnecting { + background: #3498db; + color: #fff; + padding: 10px 20px; + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + font-weight: 500; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); +} +.banner-reconnecting.hidden { + display: none; +} +.banner-reconnecting .banner-spinner { + font-size: 16px; + animation: spin-slow 2s linear infinite; +} +@keyframes spin-slow { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/viewer.html b/viewer.html index 77691dc..968f53e 100644 --- a/viewer.html +++ b/viewer.html @@ -23,6 +23,10 @@ + +
+ +
`); + return `
${rows.join("")}
`; + } + + // v5.0.0 : cas spécial absence (congé, maladie, formation, pompier, ...) + if (iv.type === "AL-Absence") { + const label = iv.label || "Absence"; + rows.push(`
Type
${escapeHtml(label)}
`); + if (iv.startTime && iv.endTime) { + rows.push(row("Horaire", `${iv.startTime}–${iv.endTime}`)); + } + // Pour les absences récurrentes (Pillonel vendredi), pas d'actionId réel + // → pas de bouton supprimer. Pour les autres → oui. + if (iv.actionId) { + rows.push(`
`); + } return `
${rows.join("")}
`; } @@ -6488,15 +6771,59 @@ function hideEvUnreachable() { // que les mises à jour sont arrêtées. function showSessionExpiredBanner() { const b = document.getElementById("session-expired-banner"); - if (b) b.classList.remove("hidden"); - // Masquer la bannière EV si présente (on ne montre qu'une bannière à la fois) + if (b) { + b.classList.remove("hidden"); + // v5.0.9 : s'assurer que la bannière contient le bouton "Me reconnecter" + // et qu'il appelle triggerReconnect (SSO Windows transparent). + if (!b.querySelector(".session-expired-reconnect-btn")) { + // Chercher le premier .banner-content ou injecter du contenu si vide + let content = b.querySelector(".banner-content") || b; + // Si déjà du contenu natif, on ajoute juste le bouton à la fin + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "session-expired-reconnect-btn"; + btn.textContent = "🔄 Me reconnecter"; + btn.addEventListener("click", () => triggerReconnect()); + content.appendChild(btn); + } + } hideEvUnreachableBanner(); + hideReconnectingBanner(); } function hideSessionExpiredBanner() { const b = document.getElementById("session-expired-banner"); if (b) b.classList.add("hidden"); } +// v5.0.9 : bannière affichée pendant la reconnexion (remplace la bannière +// expirée après clic sur "Me reconnecter") +function showReconnectingBanner() { + let b = document.getElementById("session-reconnecting-banner"); + if (!b) { + // Créer la bannière si elle n'existe pas (dans le topbar) + b = document.createElement("div"); + b.id = "session-reconnecting-banner"; + b.className = "banner-reconnecting"; + b.innerHTML = ` + + + `; + // L'insérer juste après la topbar + const topbar = document.querySelector(".topbar") || document.querySelector("header") || document.body; + if (topbar.nextSibling) { + topbar.parentNode.insertBefore(b, topbar.nextSibling); + } else { + document.body.insertBefore(b, document.body.firstChild); + } + } + b.classList.remove("hidden"); + hideSessionExpiredBanner(); +} +function hideReconnectingBanner() { + const b = document.getElementById("session-reconnecting-banner"); + if (b) b.classList.add("hidden"); +} + // v4.2.5 : bannière non bloquante "EasyVista inaccessible" function showEvUnreachableBanner() { const b = document.getElementById("ev-unreachable-banner");