v5.0.1 — Refonte topbar : horloge HH:MM + compteur session EV + admin caché (5 clics titre)

This commit is contained in:
Quentin Rouiller
2026-04-20 13:21:16 +02:00
parent c59abbed23
commit 6d3058028f
5 changed files with 1176 additions and 7 deletions
+628 -5
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,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(`<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>`;
}