diff --git a/background.js b/background.js index 86e6345..34c3616 100644 --- a/background.js +++ b/background.js @@ -423,6 +423,232 @@ 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_absence", // nom JS "brut" vu dans le onclick + "Planning_delete_absence", + "fc_delete_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("]*\bvalue=["']([0-9,]+)["']/i); + const mGroup = planHtml.match(/name=["']plan_group_id["'][^>]*\bvalue=["'](\d+)["']/i) + || planHtml.match(/[?&]group_id=(\d+)/); + const supportIds = mSupport ? mSupport[1] : ""; + const groupId = mGroup ? mGroup[1] : "191"; + console.log("[bg] support_ids =", supportIds, "| group_id =", groupId); + + // Étape 2 : fetch la popup de sélection des intervenants du groupe + const popupUrl = origin + "/include/components/staff/planning/plan_view_group_supports.php" + + "?PHPSESSID=" + encodeURIComponent(phpsessid) + + "&eventName=" + + "&theme=" + + "&support_ids=" + encodeURIComponent(supportIds) + + "&group_id=" + encodeURIComponent(groupId); + + console.log("[bg] detectTeamFromEV → popup group_supports", popupUrl.substring(0, 140)); + let popupHtml = ""; + try { + const r = await fetch(popupUrl, { method: "GET", credentials: "include" }); + if (!r.ok) throw new Error("HTTP " + r.status + " sur popup group"); + popupHtml = await r.text(); + if (looksLikeLoginPage(popupHtml)) throw new Error("session_expired"); + } catch (e) { + console.warn("[bg] detectTeam: fetch popup failed:", e); + // Fallback : on retourne au moins les IDs actuels avec noms vides + const idsCsv = supportIds; + const ids = idsCsv ? idsCsv.split(",").filter(Boolean) : []; + return { ids: ids.map(id => ({ id, name: "? (" + id + ")", alreadyInTeam: true })) }; + } + + // Étape 3 : parser le HTML. La structure typique EV : + // ... Ciuppa, Mathieu ... + // Ou bien : + // 76272Ciuppa, Mathieu... + // + // On tente plusieurs patterns. + + const results = []; + const currentIdsSet = new Set((supportIds || "").split(",").filter(Boolean)); + + // Pattern 1 : checkboxes + texte voisin + // "(...)Ciuppa, Mathieu(...)" + const rxCheckbox = /]*type=["']checkbox["'][^>]*value=["'](\d{4,7})["'][^>]*>([\s\S]{0,300}?)(?= r.id === id)) { + results.push({ id, name: name || "? (" + id + ")", alreadyInTeam: currentIdsSet.has(id) }); + } + } + + // 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) }); + } + } + } + + // Pattern 3 : fallback "76272 - Nom, Prénom" brut dans le texte + if (results.length === 0) { + const rxBrut = /\b(\d{4,7})\s*[-–:]\s*([A-ZÀ-ÿ][A-Za-zÀ-ÿ\-']+,\s*[A-ZÀ-ÿ][A-Za-zÀ-ÿ\-' \.]+)/g; + let mB; + while ((mB = rxBrut.exec(popupHtml)) !== null) { + const id = mB[1]; + const name = mB[2].trim(); + if (!results.some(r => r.id === id)) { + results.push({ id, name, alreadyInTeam: currentIdsSet.has(id) }); + } + } + } + + // 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 détectées dans le groupe"); + return { ids: results, groupId: groupId }; +} + // ============================================================================ // Messages du viewer // ============================================================================ @@ -585,6 +811,46 @@ 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 === "cleanupOldCaches") { const removed = await cleanupOldCaches(msg.daysToKeep || 7); sendResponse({ ok: true, removed }); diff --git a/manifest.json b/manifest.json index a447128..bcaae40 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planification", - "version": "4.3.3", + "version": "5.0.1", "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.", "permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"], "host_permissions": [ diff --git a/viewer.css b/viewer.css index b59f349..052a420 100644 --- a/viewer.css +++ b/viewer.css @@ -1916,3 +1916,281 @@ body.modal-open { 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); +} diff --git a/viewer.html b/viewer.html index bb01067..f060897 100644 --- a/viewer.html +++ b/viewer.html @@ -13,7 +13,7 @@ -

Planification

+

Planification

@@ -23,6 +23,8 @@
+ +
`); + 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("")}
`; }