diff --git a/background.js b/background.js index 2822f88..86e6345 100644 --- a/background.js +++ b/background.js @@ -285,6 +285,144 @@ async function fetchCurrentUser(origin, phpsessid) { return { name, login, service }; } +// ============================================================================ +// v4.2.6 : Création d'absence +// ============================================================================ + +/** + * Envoie un POST vers plan_set_holidays_popup.php pour créer une absence. + * Format attendu (analysé depuis le HTML EasyVista) : + * Query params : PHPSESSID, MAIN_DIRECTORY, ROOT_DIRECTORY, current_date, + * empl_ids, begin_hour, end_hour, plagehoraire + * Body : start_date, start_time, end_date, end_time, label_guid, dialog_action + * + * @param {string} origin - "https://itsma.vd.ch" ou similaire + * @param {string} phpsessid + * @param {Object} opts - { techIds: string[], startDate: "DD/MM/YYYY", + * startTime: "HH:MM:SS", endDate, endTime, + * typeGuid, currentDate } + */ +async function submitAbsence(origin, phpsessid, opts) { + const emplIds = (opts.techIds || []).join(","); + if (!emplIds) throw new Error("Aucun technicien sélectionné"); + + const internalurltime = Math.floor(Date.now() / 1000); + const url = `${origin}/include/components/staff/planning/plan_set_holidays_popup.php` + + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + + `&internalurltime=${internalurltime}` + + `&MAIN_DIRECTORY=${encodeURIComponent("/")}` + + `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}` + + `¤t_date=${encodeURIComponent(opts.currentDate)}` + + `&empl_ids=${encodeURIComponent(emplIds)}` + + `&begin_hour=8` + + `&end_hour=18` + + `&plagehoraire=0`; + + const body = new URLSearchParams(); + body.set("start_date", opts.startDate); + body.set("start_time", opts.startTime); + body.set("end_date", opts.endDate); + body.set("end_time", opts.endTime); + body.set("label_guid", opts.typeGuid); + body.set("dialog_action", "save_holidays"); + + console.log("[bg] submitAbsence →", url.substring(0, 140)); + console.log("[bg] body:", body.toString()); + + const r = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString() + }); + + console.log("[bg] status =", r.status); + + if (!r.ok) { + throw new Error("HTTP " + r.status); + } + + const responseText = await r.text(); + if (looksLikeLoginPage(responseText)) { + throw new Error("session_expired"); + } + + // Succès : on ne sait pas le format exact de la réponse EasyVista, on + // considère qu'un HTTP 200 non-login signifie succès. + return { status: r.status }; +} + +// ============================================================================ +// v4.2.6 : Envoi sur douchette +// ============================================================================ + +/** + * Envoie la planification du jour sur la douchette des techs sélectionnés. + * + * Endpoint identifié (via l'inspection de la page EasyVista) : + * POST /include/components/staff/planning/plan_set_tech_planif_popup.php + * Query : PHPSESSID, current_date, empl_ids (CSV), begin_hour, end_hour, + * plagehoraire + * Body : dialog_action=save_planif + * + * Contrairement à l'absence, un seul POST suffit pour tous les techs (empl_ids + * est une CSV), pas besoin de boucler. + * + * @param {string} origin + * @param {string} phpsessid + * @param {Object} opts - { techIds, currentDate } + * @returns {{ okCount, errors }} + */ +async function submitDouchette(origin, phpsessid, opts) { + const techIds = opts.techIds || []; + if (techIds.length === 0) throw new Error("Aucun technicien sélectionné"); + + const emplIds = techIds.join(","); + const internalurltime = Math.floor(Date.now() / 1000); + const url = `${origin}/include/components/staff/planning/plan_set_tech_planif_popup.php` + + `?PHPSESSID=${encodeURIComponent(phpsessid)}` + + `&internalurltime=${internalurltime}` + + `&MAIN_DIRECTORY=${encodeURIComponent("/")}` + + `&ROOT_DIRECTORY=${encodeURIComponent("/ccv/data/www/itsma/htdocs/")}` + + `¤t_date=${encodeURIComponent(opts.currentDate)}` + + `&empl_ids=${encodeURIComponent(emplIds)}` + + `&begin_hour=8` + + `&end_hour=18` + + `&plagehoraire=0`; + + const body = new URLSearchParams(); + body.set("dialog_action", "save_planif"); + + console.log("[bg] submitDouchette →", url.substring(0, 160)); + console.log("[bg] body:", body.toString()); + console.log("[bg] techs:", emplIds); + + try { + const r = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString() + }); + console.log("[bg] status =", r.status); + + if (r.status === 401 || r.status === 403) { + return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) }; + } + if (!r.ok) { + return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "HTTP " + r.status })) }; + } + const responseText = await r.text(); + if (looksLikeLoginPage(responseText)) { + return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: "session_expired" })) }; + } + return { okCount: techIds.length, errors: [] }; + } catch (err) { + const msg = err && err.message ? err.message : String(err); + return { okCount: 0, errors: techIds.map(t => ({ techId: t, error: msg })) }; + } +} + // ============================================================================ // Messages du viewer // ============================================================================ @@ -412,6 +550,41 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return; } + if (msg.type === "submitAbsence") { + // v4.2.6 : crée une absence dans EasyVista via POST vers + // /include/components/staff/planning/plan_set_holidays_popup.php + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const result = await submitAbsence(session.origin, session.phpsessid, msg); + sendResponse({ ok: true, result }); + } catch (err) { + sendResponse({ ok: false, error: err.message || String(err) }); + } + return; + } + + if (msg.type === "submitDouchette") { + // v4.2.6 : envoie la planification sur la douchette de chaque tech. + // On teste plusieurs URLs possibles (l'endpoint exact n'est pas dans + // le HTML statique que nous avons analysé). + const session = await findEasyVistaSession(); + if (!session) { + sendResponse({ ok: false, error: "no_session" }); + return; + } + try { + const result = await submitDouchette(session.origin, session.phpsessid, msg); + sendResponse({ ok: true, okCount: result.okCount, errors: result.errors }); + } 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 bf0791f..bb207ea 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, - "name": "Planning Techniciens — Vue claire", - "version": "4.2.3", - "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.2.3 : titre renommé 'Planification', pastille d'initiales utilisateur à gauche (clic = popup nom complet), timeline petite popup qui suit la souris, clic timeline = grande popup persistante sous la timeline, double-clic = ouvre fiche, Ctrl+clic = fiche en arrière-plan, 2 contacts séparés par 'et' affichés sur 2 lignes, numéros courts 5 chiffres commençant par 6/7/8 avec espaces reconnus.", + "name": "Planification", + "version": "4.2.8", + "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.2.8 : liste de techniciens dans les modals Absence/Douchette entièrement visible sans scroll. Inclut v4.2.7 (URL exacte douchette).", "permissions": [ "activeTab", "scripting", @@ -15,7 +15,7 @@ "https://itsma.vd.ch/*" ], "action": { - "default_title": "Ouvrir la vue claire du planning" + "default_title": "Ouvrir la Planification" }, "background": { "service_worker": "background.js" diff --git a/viewer.css b/viewer.css index 77e0c5a..72ad2bd 100644 --- a/viewer.css +++ b/viewer.css @@ -179,27 +179,48 @@ html, body { display: flex; align-items: center; gap: 12px; - padding: 10px 16px; - background: linear-gradient(90deg, #7a1f1f, #8b2a2a); + padding: 12px 18px; + /* v4.2.5 : rouge plus vif + bord plus épais pour visibilité max */ + background: linear-gradient(90deg, #c93030, #d84848); color: #fff; - border-bottom: 1px solid #5a1515; - font-size: 13px; - box-shadow: 0 2px 6px rgba(0,0,0,0.25); + border-top: 2px solid #ff6060; + border-bottom: 2px solid #7a1515; + font-size: 14px; + font-weight: 500; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + /* petite animation d'apparition pour attirer l'œil */ + animation: session-banner-in 0.22s ease-out; +} +@keyframes session-banner-in { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} +/* v4.2.5 : variante ORANGE pour "EV inaccessible" (distinct de session expirée) */ +.session-banner.ev-banner { + background: linear-gradient(90deg, #c77920, #e09a3a); + border-top: 2px solid #ffbb60; + border-bottom: 2px solid #7a4a15; +} +.session-banner.ev-banner .btn-primary { + color: #8a4a10; } .session-banner.hidden { display: none; } .session-banner-icon { - font-size: 18px; + font-size: 20px; flex-shrink: 0; } .session-banner-text { flex: 1; line-height: 1.4; } +.session-banner-text strong { + font-weight: 600; +} .session-banner .btn-primary { background: #fff; - color: #7a1f1f; + color: #9a2020; border: 0; font-weight: 600; } @@ -207,8 +228,26 @@ html, body { background: #f0f0f0; } .session-banner .btn-sm { - padding: 4px 12px; + padding: 5px 12px; font-size: 12px; + /* v4.2.5 : btn-sm non-primary dans la bannière = contour blanc */ + background: transparent; + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.5); + font-weight: 500; +} +.session-banner .btn-sm:hover { + background: rgba(255, 255, 255, 0.12); +} +.session-banner .btn-primary.btn-sm { + /* reset : le primary override le style du btn-sm */ + background: #fff; + color: #9a2020; + border: 0; + font-weight: 600; +} +.session-banner.ev-banner .btn-primary.btn-sm { + color: #8a4a10; } .session-banner .btn-icon { background: transparent; @@ -217,6 +256,7 @@ html, body { font-size: 20px; line-height: 1; padding: 4px 8px; + cursor: pointer; } .session-banner .btn-icon:hover { background: rgba(255,255,255,0.15); @@ -826,6 +866,49 @@ html, body { width: 5px; } +/* v4.2.5 : statut "terminée par le tech" (commentaire LOGIN: détecté). + Vert PLUS CLAIR que status-closed (distinction visuelle du ✓ simple + vs ✓✓ double). */ +.intervention-v2.status-terminated { + background: var(--c-recup-soft, rgba(63, 185, 80, 0.12)); + box-shadow: inset 4px 0 0 var(--c-recup, #3fb950); +} +.intervention-v2.status-terminated:hover { + background: var(--c-recup-soft, rgba(63, 185, 80, 0.12)); + filter: brightness(0.96); +} +.intervention-v2.status-terminated .intervention-dot { + background: var(--c-recup, #3fb950); + width: 5px; +} +.intervention-v2.status-terminated .iv-status-check { + color: var(--c-recup, #3fb950); +} +.timeline-slot.status-terminated { background: var(--c-recup, #3fb950); } + +/* v4.2.5 : carte "en cours d'analyse" (ghost juste disparu, on re-fetch la + fiche pour décider du sort). Opacité réduite + petit spinner discret. */ +.intervention-v2._checking { + opacity: 0.6; + position: relative; +} +.intervention-v2._checking::after { + content: ""; + position: absolute; + right: 10px; + top: 50%; + width: 12px; + height: 12px; + margin-top: -6px; + border: 2px solid var(--border, #ccc); + border-top-color: var(--text-muted, #666); + border-radius: 50%; + animation: iv-check-spin 0.9s linear infinite; +} +@keyframes iv-check-spin { + to { transform: rotate(360deg); } +} + .intervention-v2.is-ghost { opacity: 0.5; text-decoration: line-through; @@ -866,6 +949,14 @@ html, body { } .intervention-v2.status-resolved .iv-status-check { color: var(--c-resolved); } +/* v4.2.5 : ✓✓ double check (clôturé/résolu) — un peu plus petit pour tenir + les 2 caractères. Espacement négatif pour les rapprocher. */ +.iv-status-check.double { + font-size: 14px; + letter-spacing: -3px; + padding-right: 3px; /* compenser le letter-spacing côté droit */ +} + .intervention-copy { grid-area: copy; align-self: start; @@ -1067,6 +1158,13 @@ html, body { ========================================================================== */ .tooltip { position: fixed !important; + /* v4.2.4 : forcer un stacking context propre et l'isolation pour que le + tooltip ne soit pas affecté par un éventuel filter/transform/contain + sur un ancêtre (qui casserait position:fixed). `contain: layout` et + `will-change: transform` garantissent aussi que le navigateur traite + ce tooltip indépendamment. */ + isolation: isolate; + contain: layout; z-index: 100; max-width: 620px; max-height: calc(100vh - 40px); @@ -1082,9 +1180,6 @@ html, body { pointer-events: none; opacity: 0; transition: opacity 0.1s; - /* v4.2 : sélection de texte autorisée en permanence. Avant (v4.1.10) on - bloquait par défaut et n'activait qu'en mode épinglé, mais c'était - contre-productif — on veut pouvoir copier un numéro sans pin d'abord. */ user-select: text; -webkit-user-select: text; } @@ -1401,6 +1496,15 @@ html, body { .btn-modal-cancel:hover { background: var(--bg-hover, rgba(128, 128, 128, 0.08)); } +/* v4.2.5 : bouton primaire (action principale) pour modals d'alerte */ +.btn-modal-primary { + background: var(--c-accent, #3fb950); + color: #fff; + border-color: var(--c-accent, #3fb950); +} +.btn-modal-primary:hover { + filter: brightness(1.08); +} /* ───────────────────────────────────────────────────────────────────────── v4.1.20 : Message d'absence récurrente (Pillonel vendredi) @@ -1516,3 +1620,134 @@ html, body { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } + +/* ───────────────────────────────────────────────────────────────────────── + v4.2.6 : boutons d'action topbar (Absence, Douchette) + ───────────────────────────────────────────────────────────────────────── */ +.btn-action { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 13px; + font-weight: 500; + background: transparent; + color: var(--text, #e0e0e0); + border: 1px solid var(--border, rgba(128, 128, 128, 0.3)); + border-radius: 6px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} +.btn-action:hover { + background: var(--bg-hover, rgba(128, 128, 128, 0.12)); + border-color: var(--border-strong, rgba(128, 128, 128, 0.5)); +} +.btn-action:active { + transform: translateY(1px); +} +.btn-action-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} +.btn-action-emoji { + font-size: 14px; + line-height: 1; +} +.btn-action-label { + white-space: nowrap; +} + +/* ───────────────────────────────────────────────────────────────────────── + v4.2.6 : modals Absence et Douchette + ───────────────────────────────────────────────────────────────────────── */ +.modal-card.modal-wide { + width: min(520px, 92vw); +} +.modal-form-group { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 14px; +} +.modal-form-row { + display: flex; + gap: 8px; + align-items: center; +} +.modal-form-row > * { + flex: 1; +} +.modal-form-label { + font-size: 12px; + font-weight: 500; + color: var(--text-muted, #888); + text-transform: uppercase; + letter-spacing: 0.3px; +} +.modal-form-input, +.modal-form-select { + padding: 8px 10px; + font-size: 13px; + background: var(--bg, #fff); + color: var(--text, #111); + border: 1px solid var(--border, rgba(128, 128, 128, 0.3)); + border-radius: 6px; + font-family: inherit; +} +.modal-form-input:focus, +.modal-form-select:focus { + outline: none; + border-color: var(--c-accent, #3fb950); + box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.15); +} + +/* Liste checkboxes techniciens */ +.modal-tech-list { + display: flex; + flex-direction: column; + gap: 4px; + /* v4.2.8 : plus de max-height → tous les techs (max 8 + "Tout") visibles + d'un coup sans avoir à scroller dans la liste. */ + padding: 6px; + background: var(--bg-muted, rgba(128, 128, 128, 0.06)); + border: 1px solid var(--border, rgba(128, 128, 128, 0.2)); + border-radius: 6px; +} +.modal-tech-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + transition: background 0.1s; +} +.modal-tech-item:hover { + background: var(--bg-hover, rgba(128, 128, 128, 0.12)); +} +.modal-tech-item input[type="checkbox"] { + width: 15px; + height: 15px; + cursor: pointer; + accent-color: var(--c-accent, #3fb950); +} +.modal-tech-item.tech-selectall { + font-weight: 600; + border-bottom: 1px solid var(--border, rgba(128, 128, 128, 0.2)); + padding-bottom: 8px; + margin-bottom: 2px; +} +.modal-tech-item.tech-selectall:hover { + background: var(--bg-hover, rgba(128, 128, 128, 0.12)); +} + +/* Boutons Appliquer/Envoyer/Annuler côte à côte */ +.modal-actions.horizontal { + flex-direction: row; + gap: 8px; +} +.modal-actions.horizontal .btn { + flex: 1; +} diff --git a/viewer.html b/viewer.html index 7ee753b..bb01067 100644 --- a/viewer.html +++ b/viewer.html @@ -24,6 +24,21 @@
+ + + +
+ + +