Compare commits

...

6 Commits

Author SHA1 Message Date
Quentin Rouiller 77c68dbe83 v5.0.7 — Correctifs 2026-04-21 12:50:36 +02:00
Quentin Rouiller d4fc8ff250 v5.0.6 — Correctifs 2026-04-21 12:46:58 +02:00
Quentin Rouiller 3996e3fb4f v5.0.5 — Correctifs admin/UX 2026-04-21 12:42:50 +02:00
Quentin Rouiller 86f52029f5 v5.0.4 — Améliorations admin/UX 2026-04-21 12:40:08 +02:00
Quentin Rouiller 984f326b39 v5.0.3 — Ajustements admin et stabilité 2026-04-20 14:03:34 +02:00
Quentin Rouiller 6d3058028f v5.0.1 — Refonte topbar : horloge HH:MM + compteur session EV + admin caché (5 clics titre) 2026-04-20 13:21:16 +02:00
5 changed files with 1311 additions and 26 deletions
+280
View File
@@ -140,6 +140,12 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
}
const html = await r.text();
console.log("[bg] fiche status =", r.status, "| taille =", html.length);
// v5.0.7 : si la réponse est anormalement petite (< 500 octets), c'est
// probablement une redirection ou une réponse d'erreur courte — on logge
// le contenu pour diagnostiquer.
if (html.length < 500) {
console.warn("[bg] ⚠ fiche très courte, contenu =", JSON.stringify(html));
}
return html;
}
@@ -423,6 +429,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("<html");
if (!looksLikeError) {
console.log(`[bg] → suppression OK avec function_name=${fn}`);
return { status: r.status, functionName: fn, body: body.substring(0, 200) };
}
console.log(`[bg] → réponse ressemble à une erreur, on tente le prochain nom`);
lastBody = body;
} catch (err) {
if (err.message === "session_expired") throw err;
console.warn(`[bg] erreur avec ${fn}:`, err);
lastErr = err;
}
}
// Aucun n'a fonctionné
throw new Error("Aucun endpoint de suppression n'a fonctionné. "
+ (lastBody ? "Dernière réponse : " + lastBody.substring(0, 100) : "")
+ (lastErr ? " | " + lastErr.message : ""));
}
// ============================================================================
// v5.0.0 : Détection de la liste des techniciens depuis la page planning EV
// ============================================================================
/**
* v5.0.1 : Détection de la liste complète des membres du groupe EasyVista.
*
* Stratégie :
* 1) On part des valeurs connues (group_id=191 et support_ids par défaut).
* Pas besoin de fetcher la page planning HTML (qui souvent ne contient
* pas ces valeurs accessibles en fetch direct, car EasyVista utilise
* des redirections JS).
* 2) Fetch direct /include/components/staff/planning/plan_view_group_supports.php
* qui retourne le HTML d'une popup listant tous les membres du groupe.
* 3) Parser ce HTML pour extraire les paires (id, nom).
*
* Retourne { ids: [{id, name, alreadyInTeam}], groupId }.
*/
async function detectTeamFromEV(origin, phpsessid) {
// v5.0.1 : valeurs par défaut (correspondent au groupe actuel).
// À terme elles devraient venir de la config admin.
const DEFAULT_GROUP_ID = "191";
const DEFAULT_SUPPORT_IDS = "76272,83725,66635,92235,90070,40944,72485,86874";
const groupId = DEFAULT_GROUP_ID;
const supportIds = DEFAULT_SUPPORT_IDS;
console.log("[bg] detectTeamFromEV : group_id =", groupId, "| support_ids =", supportIds);
// 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");
console.log("[bg] URL =", popupUrl.substring(0, 240));
let popupHtml = "";
try {
const r = await fetch(popupUrl, { method: "GET", credentials: "include" });
console.log("[bg] popup status =", r.status);
if (!r.ok) throw new Error("HTTP " + r.status + " sur popup group");
popupHtml = await r.text();
console.log("[bg] popup taille HTML =", popupHtml.length);
if (looksLikeLoginPage(popupHtml)) throw new Error("session_expired");
} catch (e) {
console.warn("[bg] detectTeam: fetch popup failed:", e);
// Fallback : au moins on retourne les IDs connus avec noms vides
const ids = DEFAULT_SUPPORT_IDS.split(",").filter(Boolean);
return {
ids: ids.map(id => ({ 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 = /<input[^>]*type=["']checkbox["'][^>]*value=["'](\d{4,7})["'][^>]*>([\s\S]{0,400}?)(?=<input|<\/tr|<\/table|$)/gi;
let mC;
while ((mC = rxCheckbox.exec(popupHtml)) !== null) {
const id = mC[1];
const context = mC[2];
const nameMatch = context.match(/([A-ZÀ-ÿ][A-Za-zÀ-ÿ\-']+,\s*[A-ZÀ-ÿ][A-Za-zÀ-ÿ\-' \.]+)/);
const name = nameMatch ? nameMatch[1].trim() : null;
if (!results.some(r => 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 <option value="76272">Nom...</option>
if (results.length === 0) {
const rxOption = /<option[^>]*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
// ============================================================================
@@ -585,6 +825,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 });
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Planification",
"version": "4.3.3",
"version": "5.0.7",
"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": [
+301
View File
@@ -1916,3 +1916,304 @@ 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);
}
/* 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;
}
+3 -1
View File
@@ -13,7 +13,7 @@
<button id="user-badge" class="user-badge hidden"
type="button" aria-label="Utilisateur connecté"
title="Utilisateur connecté"></button>
<h1>Planification</h1>
<h1 id="app-title">Planification</h1>
<div class="date-nav">
<button id="nav-prev" class="btn btn-nav" title="Jour précédent" aria-label="Jour précédent"></button>
<input type="date" id="date-picker" class="date-input">
@@ -23,6 +23,8 @@
<span id="capture-info" class="capture-info"></span>
<span id="refresh-check" class="refresh-check hidden" title="Mise à jour terminée"></span>
</div>
<!-- v5.0.0 : horloge au milieu, format HH:MM, mise à jour toutes les min -->
<div id="app-clock" class="app-clock" title="Heure actuelle"></div>
<div class="topbar-right">
<!-- v4.2.6 : bouton créer une absence pour un ou plusieurs techs -->
<button id="absence-btn" class="btn btn-action" title="Créer une absence pour un ou plusieurs techniciens">
+726 -24
View File
@@ -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 = "<tr><th>Inclure</th><th>ID</th><th>Nom affiché</th><th>Absences récurrentes</th><th></th></tr>";
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 = `
<div><strong>Version</strong></div><div>${escapeHtml(version)}</div>
<div><strong>Date courante</strong></div><div>${escapeHtml(state.currentDate || "?")}</div>
<div><strong>Aujourd'hui</strong></div><div>${escapeHtml(todayISO())}</div>
<div><strong>Session EasyVista</strong></div><div>${state.session ? "✓ connecté (" + (state.session.origin || "?") + ")" : "✗ non détecté"}</div>
<div><strong>Popups épinglées</strong></div><div>${pinnedPopups.length}</div>
`;
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,49 @@ function showAbsenceModal() {
endGroup.appendChild(endRow);
card.appendChild(endGroup);
// v5.0.4 : presets rapides pour les horaires (matin / après-midi / journée)
const presetGroup = document.createElement("div");
presetGroup.className = "modal-form-group";
const presetLabel = document.createElement("label");
presetLabel.className = "modal-form-label";
presetLabel.textContent = "Presets rapides";
presetGroup.appendChild(presetLabel);
const presetRow = document.createElement("div");
presetRow.className = "modal-form-row modal-preset-row";
const presets = [
{ label: "Matin", start: "08:00", end: "12:00" },
{ label: "Après-midi", start: "13:00", end: "18:00" },
{ label: "Toute la journée", start: "08:00", end: "18:00" }
];
for (const p of presets) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-secondary modal-preset-btn";
btn.textContent = p.label;
btn.addEventListener("click", () => {
startTime.value = p.start;
endTime.value = p.end;
// Synchroniser visuellement la mise à jour et déclencher
// endDateTouched si besoin (la date reste inchangée)
startTime.dispatchEvent(new Event("input", { bubbles: true }));
endTime.dispatchEvent(new Event("input", { bubbles: true }));
});
presetRow.appendChild(btn);
}
presetGroup.appendChild(presetRow);
card.appendChild(presetGroup);
// 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 +1531,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 +1556,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;
@@ -1364,11 +1955,19 @@ async function loadForDate(isoDate, opts = {}) {
)
);
// v5.0.6 : logs détaillés pour diagnostiquer pourquoi le fetch ne se
// lance pas.
const totalIv = merged.techs.reduce((s, t) => s + (t.interventions || []).length, 0);
const totalInterIv = merged.techs.reduce((s, t) =>
s + (t.interventions || []).filter(i => i.type === "AL-Intervention").length, 0);
const notFetched = merged.techs.reduce((s, t) =>
s + (t.interventions || []).filter(i => i.type === "AL-Intervention" && !i.ficheFetched).length, 0);
console.log(`[load] merged : ${merged.techs.length} techs, ${totalIv} iv totales, ${totalInterIv} interventions réelles, ${notFetched} sans fiche`);
console.log(`[load] needFetch = ${needFetch} | doStatusRefresh = ${!!opts.doStatusRefresh} | forceRefetch = ${!!opts.forceRefetch} | aborted = ${isRefreshAborted(myToken)}`);
// v4.3.2 : avant de lancer le fetch des fiches (lourd, ~460 KB chacune),
// on fait d'abord un batch xhr2 (léger, ~2-5 KB chacun) pour récupérer
// les vraies infos contact/lieu de toutes les interventions en parallèle.
// Comme ça les cartes s'enrichissent en 1-3 secondes au lieu d'attendre
// que l'utilisateur les survole une par une.
if (!isRefreshAborted(myToken)) {
await prefetchAllXhr2(merged.techs, myToken, opts.doStatusRefresh);
}
@@ -1377,13 +1976,10 @@ async function loadForDate(isoDate, opts = {}) {
const tFiches = performance.now();
const nFiches = merged.techs.flatMap(t=>t.interventions).filter(i=>i.type==="AL-Intervention").length;
console.log(`[load] début fetch des ${nFiches} fiches (statuts)…`);
// forceAll : uniquement si refresh manuel (bouton "rafraichir").
// À la navigation normale entre dates, on ne refetch que les iv non
// encore enrichies (ficheFetched=false) — ça reprend là où on s'était
// arrêté si un refresh précédent a été interrompu par un changement de
// date.
await refreshStatuses(merged.techs, isoDate, { forceAll: !!opts.doStatusRefresh, myToken });
console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`);
} else {
console.log(`[load] PAS DE FETCH : needFetch=${needFetch}, doStatusRefresh=${!!opts.doStatusRefresh}, aborted=${isRefreshAborted(myToken)}`);
}
// 6. Sauvegarder dans le cache (une seule fois, après que tout soit enrichi)
@@ -1580,15 +2176,18 @@ function actionNodeToIntervention(node) {
if (refFromLabel) ref = refFromLabel[1];
}
// Détection du type "Réservation" : un coordinateur a bloqué un créneau.
// Dans le XML, action_type = "AL-Absence" pour ce genre de créneau, mais
// action_label contient le vrai pattern :
// action_label = "Xxxxx / Créé par : Nom, Prénom"
// Ex: "Ecrans / Créé par : Nom20, Prénom20"
// "Rollout / Créé par : Nom24, Prénom24"
// "Congés / Créé par : ..." → pas une réservation, c'est une absence
// "Maladie / Créé par : ..." → idem
// "Pompier / Créé par : ..." → idem
// Détection du type "Réservation" vs "Absence".
//
// v5.0.3 (simplifiée) : le label suit le pattern "Nom / Créé par : X Y".
//
// - Congés / Maladie / Pompier → AL-Absence (tech réellement absent)
// - TOUT LE RESTE (Ecrans, PC, MAC, Rollout, Téléphones, UTP, Réunion,
// Déménagement, Evènements spéciaux, Formation, ...)
// → AL-Reservation (créneau bloqué, tech pas absent)
//
// Cette règle simple évite les cas "absence toute la journée" déclenchés
// par erreur pour des réservations de type événement / réunion.
const ABSENCE_LABELS = /^(cong[ée]s|maladie|pompier)$/i;
let effectiveType = actionType;
let reservationLabel = null;
let reservationCreator = null;
@@ -1596,11 +2195,11 @@ function actionNodeToIntervention(node) {
if (reservationMatch) {
const label1 = reservationMatch[1].trim();
const creator = reservationMatch[2].trim();
// Les "absences" connues (Congés/Maladie/Pompier) restent des absences
if (/^(cong[ée]s|maladie|pompier)$/i.test(label1)) {
if (ABSENCE_LABELS.test(label1)) {
// Vraie absence du tech
effectiveType = "AL-Absence";
} else {
// Tout autre label (Ecrans, Rollout, ...) → Réservation
// Réservation : créneau bloqué (matériel ou activité), tech pas absent
effectiveType = "AL-Reservation";
reservationLabel = label1;
reservationCreator = creator;
@@ -1786,6 +2385,14 @@ function mergeCacheAndFresh(cached, fresh) {
if (!outTech) continue;
for (const iv of tech.interventions || []) {
if (!freshActionIds.has(iv.actionId)) {
// v5.0.1 : les absences et réservations supprimées côté EasyVista
// sont définitivement retirées (pas ghost). La logique ghost est
// conçue pour les interventions dont on veut garder trace en attendant
// la vérification du statut (clos/annulé). Absences/réservations n'ont
// pas de notion de statut, une disparition = suppression pure.
if (iv.type === "AL-Absence" || iv.type === "AL-Reservation") {
continue; // ne pas rajouter
}
const ghost = { ...iv, ghost: true };
outTech.interventions.push(ghost);
}
@@ -3335,6 +3942,24 @@ function buildCard(tech, isoDate) {
note.textContent = "Absent toute la journée";
}
body.appendChild(note);
// v5.0.4 : tooltip au hover sur toute la carte absent (pas juste un
// bouton visible). Contient : détail période + bouton supprimer si
// c'est une absence supprimable (actionId réel, pas pompier récurrent).
if (ab.actionId && !ab.isPompier && !ab._recurring) {
// On attache le tooltip sur la CARD ENTIÈRE (card) — comme ça
// survoler n'importe où sur la zone grisée "absent" le déclenche.
const ivCopy = {
...ab,
type: "AL-Absence" // force pour buildTooltipHTML
};
card.addEventListener("mouseenter", (e) => {
showTooltip(e, ivCopy, card);
});
card.addEventListener("mouseleave", () => {
hideTooltip();
});
}
}
// v4.1.20 : cas spécifique Pillonel Olivier, absent tous les vendredis.
@@ -3414,11 +4039,14 @@ function buildCard(tech, isoDate) {
// Timeline
// ============================================================================
function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) {
const DAY_START = 8 * 60;
const DAY_END = 18 * 60;
// 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 wrap = document.createElement("div");
wrap.className = "timeline";
if (isPompier) wrap.classList.add("timeline-pompier");
@@ -5249,6 +5877,58 @@ function _softUnpinPopup(el) {
}
/** Ferme toutes les popups épinglées (appelé par Échap ou changement de date). */
/**
* v5.0.1 : helper pour déclencher la suppression d'une absence ou réservation.
* Affiche la modal de confirmation, puis appelle le background.
*/
function _triggerDeleteItem(actionId, kind, triggerBtn) {
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 () => {
if (triggerBtn) {
triggerBtn.disabled = true;
triggerBtn.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.");
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: () => {} }]
});
if (triggerBtn) {
triggerBtn.disabled = false;
triggerBtn.textContent = "🗑 Supprimer l'absence";
}
}
}
}
]
});
}
function closeAllPinnedPopups() {
for (const p of pinnedPopups.slice()) {
p.el.remove();
@@ -5577,6 +6257,11 @@ function bindTooltipInteractions() {
}, 1200);
}).catch(() => {});
}
} else if (action === "delete-item") {
// v5.0.0 : supprimer absence/réservation (depuis tooltip)
const actionId = btn.dataset.actionId;
const kind = btn.dataset.kind || "absence";
_triggerDeleteItem(actionId, kind, btn);
}
});
@@ -5608,6 +6293,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(`<dt></dt><dd><button type="button" class="tooltip-delete-btn" data-action="delete-item" data-action-id="${escapeHtml(iv.actionId || "")}" data-kind="reservation">🗑 Supprimer cette réservation</button></dd>`);
return `<dl>${rows.join("")}</dl>`;
}
// v5.0.0 : cas spécial absence (congé, maladie, formation, pompier, ...)
if (iv.type === "AL-Absence") {
const label = iv.label || "Absence";
rows.push(`<dt>Type</dt><dd><span class="status-pill other">${escapeHtml(label)}</span></dd>`);
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(`<dt></dt><dd><button type="button" class="tooltip-delete-btn" data-action="delete-item" data-action-id="${escapeHtml(iv.actionId)}" data-kind="absence">🗑 Supprimer cette absence</button></dd>`);
}
return `<dl>${rows.join("")}</dl>`;
}