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 :
+ //
76272 Ciuppa, Mathieu ...
+ // Ciuppa, 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 Nom...
+ 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 @@
✓
+
+
diff --git a/viewer.js b/viewer.js
index 94f70f9..1dabe37 100644
--- a/viewer.js
+++ b/viewer.js
@@ -211,6 +211,8 @@ async function init() {
bindTooltipInteractions();
initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal
initAppFooter(); // v4.2.9 : pied de page discret bas-droite
+ initAppClock(); // v5.0.0 : horloge HH:MM au milieu topbar
+ initAdminMenu(); // v5.0.0 : menu admin caché (5 clics sur titre)
// Initialiser la date = aujourd'hui
state.currentDate = todayISO();
@@ -714,6 +716,534 @@ function initAppFooter() {
document.body.appendChild(el);
}
+// v5.0.0 : horloge HH:MM au milieu de la topbar. Mise à jour toutes les 30s
+// (les secondes ne sont pas affichées donc pas besoin d'un tick plus rapide).
+function initAppClock() {
+ const el = document.getElementById("app-clock");
+ if (!el) return;
+ const tick = () => {
+ const d = new Date();
+ const h = String(d.getHours()).padStart(2, "0");
+ const m = String(d.getMinutes()).padStart(2, "0");
+ el.textContent = `${h}:${m}`;
+ // v5.0.0 : profite du tick pour mettre à jour la ligne rouge "now"
+ updateNowLine();
+ };
+ tick();
+ // Tick toutes les 30s : ça garantit une MAJ rapide au changement de min
+ setInterval(tick, 30 * 1000);
+}
+
+// v5.0.0 : ligne verticale rouge "heure actuelle" sur la timeline, visible
+// UNIQUEMENT quand on affiche aujourd'hui. Appelée à chaque tick de l'horloge
+// + après chaque render (cf renderFromData).
+function updateNowLine() {
+ const isToday = state.currentDate === todayISO();
+ // Retirer toutes les lignes existantes d'abord
+ document.querySelectorAll(".timeline-now-line").forEach(el => el.remove());
+ if (!isToday) return;
+ // Calculer la position en % sur la timeline (DAY_START à DAY_END)
+ const now = new Date();
+ const nowMin = now.getHours() * 60 + now.getMinutes();
+ if (nowMin < DAY_START || nowMin > DAY_END) return; // hors plage affichée
+ const pct = ((nowMin - DAY_START) / DAY_LEN) * 100;
+ // Ajouter une ligne sur chaque barre timeline visible
+ document.querySelectorAll(".timeline-bar").forEach(bar => {
+ const line = document.createElement("div");
+ line.className = "timeline-now-line";
+ line.style.left = pct + "%";
+ bar.appendChild(line);
+ });
+}
+
+// v5.0.0 : menu admin caché. 5 clics consécutifs sur le titre "Planification"
+// (avec max 2 secondes entre chaque clic) ouvrent le panneau admin.
+function initAdminMenu() {
+ const title = document.getElementById("app-title");
+ if (!title) return;
+ let clicks = 0;
+ let resetTimer = null;
+ title.addEventListener("click", () => {
+ clicks++;
+ if (resetTimer) clearTimeout(resetTimer);
+ resetTimer = setTimeout(() => { clicks = 0; }, 2000);
+ if (clicks >= 5) {
+ clicks = 0;
+ clearTimeout(resetTimer);
+ showAdminPanel();
+ }
+ });
+ // Cursor pointer pour indiquer (discrètement) qu'il est cliquable
+ title.style.cursor = "default";
+}
+
+// v5.0.0 : stockage des paramètres admin dans chrome.storage.local.
+// Clé unique : "admin_config". Contient la config éditable (équipe,
+// absences récurrentes, statuts etc.). Au 1er lancement : initialisée
+// avec les valeurs hardcodées actuelles.
+const ADMIN_CONFIG_KEY = "admin_config";
+
+function getDefaultAdminConfig() {
+ return {
+ team: { ...TEAM }, // Clone pour ne pas modifier le hardcode
+ recurringAbsences: { ...RECURRING_ABSENCES }, // idem
+ groupId: "191",
+ evOrigins: ["https://itsma.etat-de-vaud.ch", "https://itsma.vd.ch"],
+ closedStatus: [...CLOSED_STATUS],
+ resolvedStatus: [...RESOLVED_STATUS],
+ cancelledStatus: [...CANCELLED_STATUS],
+ dayStart: 8,
+ dayEnd: 18,
+ cacheDays: 7
+ };
+}
+
+async function loadAdminConfig() {
+ try {
+ const stored = await chrome.storage.local.get(ADMIN_CONFIG_KEY);
+ if (stored && stored[ADMIN_CONFIG_KEY]) {
+ // Fusion avec les defaults (pour rajouter d'éventuelles nouvelles clés)
+ return { ...getDefaultAdminConfig(), ...stored[ADMIN_CONFIG_KEY] };
+ }
+ } catch (e) {
+ console.warn("[admin] loadAdminConfig err", e);
+ }
+ return getDefaultAdminConfig();
+}
+
+async function saveAdminConfig(cfg) {
+ try {
+ await chrome.storage.local.set({ [ADMIN_CONFIG_KEY]: cfg });
+ console.log("[admin] config sauvegardée");
+ return true;
+ } catch (e) {
+ console.error("[admin] saveAdminConfig err", e);
+ return false;
+ }
+}
+
+// v5.0.0 : affiche le panel admin plein écran.
+async function showAdminPanel() {
+ // Ferme un éventuel panel existant
+ const existing = document.getElementById("admin-panel");
+ if (existing) existing.remove();
+
+ // Charge la config actuelle
+ const cfg = await loadAdminConfig();
+
+ // Overlay plein écran
+ const overlay = document.createElement("div");
+ overlay.id = "admin-panel";
+ overlay.className = "modal-overlay admin-overlay";
+
+ const card = document.createElement("div");
+ card.className = "admin-panel-card";
+
+ // En-tête
+ const header = document.createElement("div");
+ header.className = "admin-header";
+ const title = document.createElement("h2");
+ title.textContent = "⚙ Administration";
+ title.className = "admin-title";
+ const closeBtn = document.createElement("button");
+ closeBtn.type = "button";
+ closeBtn.className = "admin-close-btn";
+ closeBtn.textContent = "×";
+ closeBtn.title = "Fermer (Échap)";
+ closeBtn.addEventListener("click", () => overlay.remove());
+ header.appendChild(title);
+ header.appendChild(closeBtn);
+ card.appendChild(header);
+
+ // Navigation latérale (onglets)
+ const body = document.createElement("div");
+ body.className = "admin-body";
+
+ const sidebar = document.createElement("nav");
+ sidebar.className = "admin-sidebar";
+
+ const content = document.createElement("div");
+ content.className = "admin-content";
+
+ const sections = [
+ { id: "team", label: "Équipe", render: renderAdminSectionTeam },
+ { id: "easyvista", label: "EasyVista", render: renderAdminSectionEV },
+ { id: "appearance", label: "Apparence", render: renderAdminSectionAppearance },
+ { id: "statuses", label: "Statuts", render: renderAdminSectionStatuses },
+ { id: "diagnostics",label: "Diagnostics", render: renderAdminSectionDiagnostics }
+ ];
+
+ let currentSection = "team";
+
+ const navButtons = {};
+ for (const section of sections) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "admin-nav-btn";
+ btn.textContent = section.label;
+ btn.dataset.section = section.id;
+ if (section.id === currentSection) btn.classList.add("active");
+ btn.addEventListener("click", () => {
+ currentSection = section.id;
+ for (const k in navButtons) navButtons[k].classList.remove("active");
+ btn.classList.add("active");
+ content.innerHTML = "";
+ section.render(content, cfg, () => saveAndReload(cfg));
+ });
+ navButtons[section.id] = btn;
+ sidebar.appendChild(btn);
+ }
+
+ body.appendChild(sidebar);
+ body.appendChild(content);
+ card.appendChild(body);
+ overlay.appendChild(card);
+ document.body.appendChild(overlay);
+
+ // Rendu initial : section "Équipe"
+ sections[0].render(content, cfg, () => saveAndReload(cfg));
+
+ // Échap ferme le panel
+ const escHandler = (e) => {
+ if (e.key === "Escape") {
+ overlay.remove();
+ document.removeEventListener("keydown", escHandler);
+ }
+ };
+ document.addEventListener("keydown", escHandler);
+
+ async function saveAndReload(updatedCfg) {
+ const ok = await saveAdminConfig(updatedCfg);
+ if (ok) {
+ showToast("Config enregistrée", "Rechargez l'extension pour appliquer");
+ } else {
+ showAlertModal({
+ title: "Erreur",
+ message: "Impossible d'enregistrer la configuration.",
+ buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
+ });
+ }
+ }
+}
+
+// v5.0.0 : section "Équipe" du panel admin.
+// v5.0.1 : affiche la liste complète du groupe EasyVista (20+ personnes),
+// avec case à cocher "inclure dans la planification" pour chacune.
+function renderAdminSectionTeam(container, cfg, saveFn) {
+ const h = document.createElement("h3");
+ h.textContent = "Équipe";
+ h.className = "admin-section-title";
+ container.appendChild(h);
+
+ const desc = document.createElement("p");
+ desc.className = "admin-section-desc";
+ desc.textContent = "Sélectionnez les personnes qui doivent apparaître dans la planification. Les IDs viennent d'EasyVista (bouton Détecter) ou peuvent être saisis manuellement.";
+ container.appendChild(desc);
+
+ // État local : liste {id, name, included, days:[0..6]}
+ // Au départ on remplit depuis cfg.team actuel, puis la détection EV
+ // enrichit cette liste.
+ const rows = [];
+ for (const [id, name] of Object.entries(cfg.team || {})) {
+ rows.push({
+ id,
+ name,
+ included: true,
+ days: (cfg.recurringAbsences[id] || []).slice()
+ });
+ }
+
+ const tableWrap = document.createElement("div");
+ tableWrap.className = "admin-team-wrap";
+ container.appendChild(tableWrap);
+
+ function render() {
+ tableWrap.innerHTML = "";
+
+ // Bouton "Détecter depuis EasyVista"
+ const detectBtn = document.createElement("button");
+ detectBtn.type = "button";
+ detectBtn.className = "btn btn-secondary";
+ detectBtn.textContent = "🔍 Détecter depuis EasyVista (groupe complet)";
+ detectBtn.style.marginBottom = "12px";
+ detectBtn.addEventListener("click", async () => {
+ detectBtn.disabled = true;
+ detectBtn.textContent = "Détection en cours…";
+ try {
+ const resp = await sendMessage({ type: "detectTeam" });
+ if (resp && resp.ok && resp.members && resp.members.length) {
+ // Merge : pour chaque membre détecté, ajoute à `rows` s'il n'y est
+ // pas déjà. S'il y est déjà, met à jour le nom (si meilleur).
+ for (const m of resp.members) {
+ const existing = rows.find(r => r.id === m.id);
+ if (existing) {
+ // Améliorer le nom si le nom actuel commence par "?"
+ if (m.name && !m.name.startsWith("?") && existing.name.startsWith("?")) {
+ existing.name = m.name;
+ }
+ } else {
+ rows.push({
+ id: m.id,
+ name: m.name || "? (" + m.id + ")",
+ included: !!m.alreadyInTeam, // coché si déjà dans l'équipe
+ days: []
+ });
+ }
+ }
+ showToast("Détecté", resp.members.length + " personne(s) dans le groupe");
+ render();
+ } else {
+ showAlertModal({
+ title: "Détection impossible",
+ message: (resp && resp.error) || "Aucune personne trouvée. Vérifiez que vous êtes connecté à EasyVista.",
+ buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
+ });
+ }
+ } catch (err) {
+ console.warn("[admin] detectTeam err", err);
+ } finally {
+ detectBtn.disabled = false;
+ detectBtn.textContent = "🔍 Détecter depuis EasyVista (groupe complet)";
+ }
+ });
+ tableWrap.appendChild(detectBtn);
+
+ // Stats : nb inclus / total
+ const included = rows.filter(r => r.included).length;
+ const stats = document.createElement("div");
+ stats.className = "admin-section-desc";
+ stats.style.marginTop = "0";
+ stats.textContent = `${included} personne(s) incluse(s) sur ${rows.length} connue(s).`;
+ tableWrap.appendChild(stats);
+
+ // Table
+ const table = document.createElement("table");
+ table.className = "admin-team-table";
+ const thead = document.createElement("thead");
+ thead.innerHTML = "Inclure ID Nom affiché Absences récurrentes ";
+ table.appendChild(thead);
+ const tbody = document.createElement("tbody");
+ table.appendChild(tbody);
+
+ const days = ["Dim","Lun","Mar","Mer","Jeu","Ven","Sam"];
+ rows.forEach((r, idx) => {
+ const tr = document.createElement("tr");
+ if (!r.included) tr.classList.add("admin-row-excluded");
+
+ // Checkbox inclure
+ const tdInc = document.createElement("td");
+ const cb = document.createElement("input");
+ cb.type = "checkbox";
+ cb.checked = r.included;
+ cb.addEventListener("change", () => {
+ r.included = cb.checked;
+ tr.classList.toggle("admin-row-excluded", !r.included);
+ stats.textContent = `${rows.filter(x => x.included).length} personne(s) incluse(s) sur ${rows.length} connue(s).`;
+ });
+ tdInc.appendChild(cb);
+ tr.appendChild(tdInc);
+
+ // ID
+ const tdId = document.createElement("td");
+ const inpId = document.createElement("input");
+ inpId.type = "text";
+ inpId.value = r.id;
+ inpId.placeholder = "76272";
+ inpId.className = "admin-input admin-input-id";
+ inpId.addEventListener("input", () => { r.id = inpId.value.trim(); });
+ tdId.appendChild(inpId);
+ tr.appendChild(tdId);
+
+ // Nom
+ const tdName = document.createElement("td");
+ const inpName = document.createElement("input");
+ inpName.type = "text";
+ inpName.value = r.name;
+ inpName.placeholder = "Dupont, Jean";
+ inpName.className = "admin-input";
+ inpName.addEventListener("input", () => { r.name = inpName.value.trim(); });
+ tdName.appendChild(inpName);
+ tr.appendChild(tdName);
+
+ // Jours d'absence récurrente
+ const tdAbs = document.createElement("td");
+ for (let d = 0; d < 7; d++) {
+ const lbl = document.createElement("label");
+ lbl.className = "admin-day-cb";
+ const cbd = document.createElement("input");
+ cbd.type = "checkbox";
+ cbd.checked = r.days.includes(d);
+ cbd.addEventListener("change", () => {
+ if (cbd.checked && !r.days.includes(d)) r.days.push(d);
+ if (!cbd.checked) r.days = r.days.filter(x => x !== d);
+ });
+ lbl.appendChild(cbd);
+ lbl.appendChild(document.createTextNode(days[d]));
+ tdAbs.appendChild(lbl);
+ }
+ tr.appendChild(tdAbs);
+
+ // Bouton supprimer ligne
+ const tdDel = document.createElement("td");
+ const delBtn = document.createElement("button");
+ delBtn.type = "button";
+ delBtn.className = "admin-del-btn";
+ delBtn.textContent = "🗑";
+ delBtn.title = "Retirer cette ligne";
+ delBtn.addEventListener("click", () => {
+ rows.splice(idx, 1);
+ render();
+ });
+ tdDel.appendChild(delBtn);
+ tr.appendChild(tdDel);
+
+ tbody.appendChild(tr);
+ });
+
+ tableWrap.appendChild(table);
+
+ // Bouton Ajouter manuellement
+ const addBtn = document.createElement("button");
+ addBtn.type = "button";
+ addBtn.className = "btn btn-secondary";
+ addBtn.textContent = "+ Ajouter manuellement";
+ addBtn.style.marginTop = "10px";
+ addBtn.addEventListener("click", () => {
+ rows.push({ id: "", name: "", included: true, days: [] });
+ render();
+ });
+ tableWrap.appendChild(addBtn);
+
+ // Bouton Enregistrer
+ const saveBtn = document.createElement("button");
+ saveBtn.type = "button";
+ saveBtn.className = "btn btn-primary";
+ saveBtn.textContent = "💾 Enregistrer";
+ saveBtn.style.marginTop = "20px";
+ saveBtn.style.marginLeft = "10px";
+ saveBtn.addEventListener("click", () => {
+ // Reconstruire cfg.team et cfg.recurringAbsences à partir de rows
+ const newTeam = {};
+ const newRecAbs = {};
+ for (const r of rows) {
+ if (!r.included || !r.id) continue;
+ newTeam[r.id] = r.name || ("? (" + r.id + ")");
+ if (r.days && r.days.length > 0) newRecAbs[r.id] = r.days.slice();
+ }
+ cfg.team = newTeam;
+ cfg.recurringAbsences = newRecAbs;
+ saveFn();
+ });
+ tableWrap.appendChild(saveBtn);
+ }
+
+ render();
+}
+
+// v5.0.0 : sections suivantes (placeholders, à enrichir v5.0.1+)
+function renderAdminSectionEV(container, cfg, saveFn) {
+ const h = document.createElement("h3");
+ h.textContent = "EasyVista";
+ h.className = "admin-section-title";
+ container.appendChild(h);
+ const desc = document.createElement("p");
+ desc.className = "admin-section-desc";
+ desc.textContent = "Section à venir dans v5.0.1. Origines EasyVista + group_id.";
+ container.appendChild(desc);
+ // Infos lecture seule pour l'instant
+ const pre = document.createElement("pre");
+ pre.className = "admin-readonly";
+ pre.textContent = JSON.stringify({
+ evOrigins: cfg.evOrigins,
+ groupId: cfg.groupId
+ }, null, 2);
+ container.appendChild(pre);
+}
+
+function renderAdminSectionAppearance(container, cfg, saveFn) {
+ const h = document.createElement("h3");
+ h.textContent = "Apparence";
+ h.className = "admin-section-title";
+ container.appendChild(h);
+ const desc = document.createElement("p");
+ desc.className = "admin-section-desc";
+ desc.textContent = "Section à venir dans v5.0.x. Heures journée, durée cache, thème.";
+ container.appendChild(desc);
+ const pre = document.createElement("pre");
+ pre.className = "admin-readonly";
+ pre.textContent = JSON.stringify({
+ dayStart: cfg.dayStart,
+ dayEnd: cfg.dayEnd,
+ cacheDays: cfg.cacheDays
+ }, null, 2);
+ container.appendChild(pre);
+}
+
+function renderAdminSectionStatuses(container, cfg, saveFn) {
+ const h = document.createElement("h3");
+ h.textContent = "Statuts";
+ h.className = "admin-section-title";
+ container.appendChild(h);
+ const desc = document.createElement("p");
+ desc.className = "admin-section-desc";
+ desc.textContent = "Section à venir dans v5.0.x. Mots-clés Clôturé / Résolu / Annulé.";
+ container.appendChild(desc);
+ const pre = document.createElement("pre");
+ pre.className = "admin-readonly";
+ pre.textContent = JSON.stringify({
+ closed: cfg.closedStatus,
+ resolved: cfg.resolvedStatus,
+ cancelled: cfg.cancelledStatus
+ }, null, 2);
+ container.appendChild(pre);
+}
+
+function renderAdminSectionDiagnostics(container, cfg, saveFn) {
+ const h = document.createElement("h3");
+ h.textContent = "Diagnostics";
+ h.className = "admin-section-title";
+ container.appendChild(h);
+
+ const version = (chrome && chrome.runtime && chrome.runtime.getManifest)
+ ? chrome.runtime.getManifest().version : "?";
+
+ const info = document.createElement("div");
+ info.className = "admin-diag-grid";
+ info.innerHTML = `
+ Version
${escapeHtml(version)}
+ Date courante
${escapeHtml(state.currentDate || "?")}
+ Aujourd'hui
${escapeHtml(todayISO())}
+ Session EasyVista
${state.session ? "✓ connecté (" + (state.session.origin || "?") + ")" : "✗ non détecté"}
+ Popups épinglées
${pinnedPopups.length}
+ `;
+ container.appendChild(info);
+
+ // Bouton reset
+ const resetBtn = document.createElement("button");
+ resetBtn.type = "button";
+ resetBtn.className = "btn btn-danger";
+ resetBtn.textContent = "⚠ Réinitialiser la configuration (équipe, etc.)";
+ resetBtn.style.marginTop = "20px";
+ resetBtn.addEventListener("click", () => {
+ showAlertModal({
+ title: "Confirmer la réinitialisation",
+ message: "Remettre TOUTES les configurations aux valeurs par défaut ? (les techniciens ajoutés manuellement seront perdus)",
+ buttons: [
+ { label: "Annuler", variant: "secondary", action: () => {} },
+ {
+ label: "Réinitialiser",
+ variant: "danger",
+ action: async () => {
+ await chrome.storage.local.remove(ADMIN_CONFIG_KEY);
+ showToast("Réinitialisé", "Rechargez la page pour voir les défauts");
+ }
+ }
+ ]
+ });
+ });
+ container.appendChild(resetBtn);
+}
+
// ============================================================================
// v4.2.6 : Modals Absence et Douchette
// ============================================================================
@@ -823,6 +1353,11 @@ function showAbsenceModal() {
card.className = "modal-card modal-wide";
card.setAttribute("role", "dialog");
+ // v5.0.0 : on mémorise la date affichée au moment de l'ouverture de la
+ // modal. Le reload après création se fait sur cette date précise, pas
+ // sur state.currentDate (qui aurait pu changer entre-temps).
+ const dateAtOpen = state.currentDate || todayISO();
+
const title = document.createElement("h2");
title.className = "modal-title";
title.textContent = "Créer une absence";
@@ -889,6 +1424,17 @@ function showAbsenceModal() {
endGroup.appendChild(endRow);
card.appendChild(endGroup);
+ // v5.0.0 : la date de fin suit la date de début tant que l'user ne l'a
+ // pas explicitement modifiée. 95% des absences sont d'un seul jour, donc
+ // changer juste le start doit mettre à jour le end aussi.
+ let endDateTouched = false;
+ endDate.addEventListener("input", () => { endDateTouched = true; });
+ startDate.addEventListener("input", () => {
+ if (!endDateTouched || endDate.value < startDate.value) {
+ endDate.value = startDate.value;
+ }
+ });
+
// Type d'absence
const typeGroup = document.createElement("div");
typeGroup.className = "modal-form-group";
@@ -953,6 +1499,17 @@ function showAbsenceModal() {
});
return;
}
+ // v5.0.0 : validation fin >= début pour ne pas envoyer des absences
+ // inversées à EasyVista (il les accepte mais elles n'apparaissent jamais
+ // dans le planning, cf bug constaté).
+ if (ed < sd || (ed === sd && et <= st)) {
+ showAlertModal({
+ title: "Dates incohérentes",
+ message: "La date/heure de fin doit être après la date/heure de début.",
+ buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
+ });
+ return;
+ }
// Désactiver le bouton pendant l'envoi
applyBtn.disabled = true;
applyBtn.textContent = "Envoi…";
@@ -967,9 +1524,11 @@ function showAbsenceModal() {
});
overlay.remove();
showToast("Absence créée", techIds.length + " tech" + (techIds.length > 1 ? "s" : ""));
- // Reload le planning du jour pour voir l'absence
+ // v5.0.0 : reload le planning DE LA DATE AFFICHÉE AVANT (dateAtOpen),
+ // pas de state.currentDate qui a pu être modifié entre-temps (bug
+ // où le planning sautait à la date de début de l'absence).
if (state.session) {
- await loadForDate(state.currentDate, { forceRefetch: true });
+ await loadForDate(dateAtOpen, { forceRefetch: true });
}
} catch (err) {
applyBtn.disabled = false;
@@ -3414,10 +3973,13 @@ function buildCard(tech, isoDate) {
// Timeline
// ============================================================================
+// v5.0.0 : constantes timeline globales (avant : locales à buildTimeline),
+// pour que updateNowLine puisse les utiliser aussi.
+const DAY_START = 8 * 60; // 08:00 en minutes
+const DAY_END = 18 * 60; // 18:00 en minutes
+const DAY_LEN = DAY_END - DAY_START;
+
function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) {
- const DAY_START = 8 * 60;
- const DAY_END = 18 * 60;
- const DAY_LEN = DAY_END - DAY_START;
const wrap = document.createElement("div");
wrap.className = "timeline";
@@ -5577,6 +6139,50 @@ function bindTooltipInteractions() {
}, 1200);
}).catch(() => {});
}
+ } else if (action === "delete-item") {
+ // v5.0.0 : supprimer absence/réservation
+ const actionId = btn.dataset.actionId;
+ const kind = btn.dataset.kind || "absence";
+ if (!actionId) return;
+ const label = kind === "reservation" ? "cette réservation" : "cette absence";
+ showAlertModal({
+ title: "Confirmer la suppression",
+ message: `Voulez-vous vraiment supprimer ${label} ? Cette action est irréversible.`,
+ buttons: [
+ { label: "Annuler", variant: "secondary", action: () => {} },
+ {
+ label: "Supprimer",
+ variant: "danger",
+ action: async () => {
+ btn.disabled = true;
+ btn.textContent = "Suppression…";
+ try {
+ const resp = await sendMessage({
+ type: "deletePlanningItem",
+ actionId: actionId,
+ kind: kind
+ });
+ if (!resp || !resp.ok) {
+ throw new Error(resp && resp.error ? resp.error : "erreur inconnue");
+ }
+ showToast("Supprimé", "L'élément a été retiré du planning.");
+ // Unpin tooltip + reload de la date courante
+ unpinTooltip();
+ closeAllPinnedPopups();
+ if (state.session) {
+ await loadForDate(state.currentDate, { forceRefetch: true });
+ }
+ } catch (err) {
+ showAlertModal({
+ title: "Erreur lors de la suppression",
+ message: "Impossible de supprimer : " + (err.message || err),
+ buttons: [{ label: "OK", variant: "secondary", action: () => {} }]
+ });
+ }
+ }
+ }
+ ]
+ });
}
});
@@ -5608,6 +6214,23 @@ function buildTooltipHTML(iv) {
}
if (iv.reservationLabel) rows.push(row("Sujet", iv.reservationLabel));
if (iv.reservationCreator) rows.push(row("Par", iv.reservationCreator));
+ // v5.0.0 : bouton supprimer pour les réservations (avec confirmation)
+ rows.push(`🗑 Supprimer cette réservation `);
+ 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(`🗑 Supprimer cette absence `);
+ }
return `${rows.join("")} `;
}