Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 984f326b39 | |||
| 6d3058028f |
+274
@@ -423,6 +423,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
|
// Messages du viewer
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -585,6 +819,46 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
return;
|
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") {
|
if (msg.type === "cleanupOldCaches") {
|
||||||
const removed = await cleanupOldCaches(msg.daysToKeep || 7);
|
const removed = await cleanupOldCaches(msg.daysToKeep || 7);
|
||||||
sendResponse({ ok: true, removed });
|
sendResponse({ ok: true, removed });
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Planification",
|
"name": "Planification",
|
||||||
"version": "4.3.3",
|
"version": "5.0.3",
|
||||||
"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.",
|
"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"],
|
"permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
|
|||||||
+288
@@ -1916,3 +1916,291 @@ body.modal-open {
|
|||||||
background: var(--danger-soft, #fbe6e6);
|
background: var(--danger-soft, #fbe6e6);
|
||||||
color: var(--danger, #b03030);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -13,7 +13,7 @@
|
|||||||
<button id="user-badge" class="user-badge hidden"
|
<button id="user-badge" class="user-badge hidden"
|
||||||
type="button" aria-label="Utilisateur connecté"
|
type="button" aria-label="Utilisateur connecté"
|
||||||
title="Utilisateur connecté"></button>
|
title="Utilisateur connecté"></button>
|
||||||
<h1>Planification</h1>
|
<h1 id="app-title">Planification</h1>
|
||||||
<div class="date-nav">
|
<div class="date-nav">
|
||||||
<button id="nav-prev" class="btn btn-nav" title="Jour précédent" aria-label="Jour précédent">◀</button>
|
<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">
|
<input type="date" id="date-picker" class="date-input">
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
<span id="capture-info" class="capture-info"></span>
|
<span id="capture-info" class="capture-info"></span>
|
||||||
<span id="refresh-check" class="refresh-check hidden" title="Mise à jour terminée">✓</span>
|
<span id="refresh-check" class="refresh-check hidden" title="Mise à jour terminée">✓</span>
|
||||||
</div>
|
</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">
|
<div class="topbar-right">
|
||||||
<!-- v4.2.6 : bouton créer une absence pour un ou plusieurs techs -->
|
<!-- 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">
|
<button id="absence-btn" class="btn btn-action" title="Créer une absence pour un ou plusieurs techniciens">
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ async function init() {
|
|||||||
bindTooltipInteractions();
|
bindTooltipInteractions();
|
||||||
initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal
|
initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal
|
||||||
initAppFooter(); // v4.2.9 : pied de page discret bas-droite
|
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
|
// Initialiser la date = aujourd'hui
|
||||||
state.currentDate = todayISO();
|
state.currentDate = todayISO();
|
||||||
@@ -714,6 +716,534 @@ function initAppFooter() {
|
|||||||
document.body.appendChild(el);
|
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
|
// v4.2.6 : Modals Absence et Douchette
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -823,6 +1353,11 @@ function showAbsenceModal() {
|
|||||||
card.className = "modal-card modal-wide";
|
card.className = "modal-card modal-wide";
|
||||||
card.setAttribute("role", "dialog");
|
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");
|
const title = document.createElement("h2");
|
||||||
title.className = "modal-title";
|
title.className = "modal-title";
|
||||||
title.textContent = "Créer une absence";
|
title.textContent = "Créer une absence";
|
||||||
@@ -889,6 +1424,17 @@ function showAbsenceModal() {
|
|||||||
endGroup.appendChild(endRow);
|
endGroup.appendChild(endRow);
|
||||||
card.appendChild(endGroup);
|
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
|
// Type d'absence
|
||||||
const typeGroup = document.createElement("div");
|
const typeGroup = document.createElement("div");
|
||||||
typeGroup.className = "modal-form-group";
|
typeGroup.className = "modal-form-group";
|
||||||
@@ -953,6 +1499,17 @@ function showAbsenceModal() {
|
|||||||
});
|
});
|
||||||
return;
|
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
|
// Désactiver le bouton pendant l'envoi
|
||||||
applyBtn.disabled = true;
|
applyBtn.disabled = true;
|
||||||
applyBtn.textContent = "Envoi…";
|
applyBtn.textContent = "Envoi…";
|
||||||
@@ -967,9 +1524,11 @@ function showAbsenceModal() {
|
|||||||
});
|
});
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
showToast("Absence créée", techIds.length + " tech" + (techIds.length > 1 ? "s" : ""));
|
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) {
|
if (state.session) {
|
||||||
await loadForDate(state.currentDate, { forceRefetch: true });
|
await loadForDate(dateAtOpen, { forceRefetch: true });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
applyBtn.disabled = false;
|
applyBtn.disabled = false;
|
||||||
@@ -1584,11 +2143,20 @@ function actionNodeToIntervention(node) {
|
|||||||
// Dans le XML, action_type = "AL-Absence" pour ce genre de créneau, mais
|
// Dans le XML, action_type = "AL-Absence" pour ce genre de créneau, mais
|
||||||
// action_label contient le vrai pattern :
|
// action_label contient le vrai pattern :
|
||||||
// action_label = "Xxxxx / Créé par : Nom, Prénom"
|
// action_label = "Xxxxx / Créé par : Nom, Prénom"
|
||||||
// Ex: "Ecrans / Créé par : Nom20, Prénom20"
|
// Ex: "Ecrans / Créé par : Nom20, Prénom20" → Réservation (matériel)
|
||||||
// "Rollout / Créé par : Nom24, Prénom24"
|
// "Rollout / Créé par : Nom24, Prénom24" → Réservation
|
||||||
// "Congés / Créé par : ..." → pas une réservation, c'est une absence
|
// "Congés / Créé par : ..." → Absence
|
||||||
// "Maladie / Créé par : ..." → idem
|
// "Maladie / Créé par : ..." → Absence
|
||||||
// "Pompier / Créé par : ..." → idem
|
// "Pompier / Créé par : ..." → Absence
|
||||||
|
// "Evènements spéciaux / Créé par : ..." → Absence
|
||||||
|
// "Réunion / Créé par : ..." → Absence
|
||||||
|
// "Déménagement / Créé par : ..." → Absence
|
||||||
|
//
|
||||||
|
// v5.0.2 : pour éviter les faux positifs, on considère qu'une entrée est
|
||||||
|
// une Réservation UNIQUEMENT si son label correspond à une ressource
|
||||||
|
// matérielle (Ecrans, PC, MAC, Téléphones, UTP, Rollout). Tout le reste
|
||||||
|
// est une absence. Ça couvre les types de HOLIDAY_TYPES non-matériels.
|
||||||
|
const RESERVATION_LABELS = /^(ecran(s)?|pc|mac|t[ée]l[ée]phones?|utp|rollout)$/i;
|
||||||
let effectiveType = actionType;
|
let effectiveType = actionType;
|
||||||
let reservationLabel = null;
|
let reservationLabel = null;
|
||||||
let reservationCreator = null;
|
let reservationCreator = null;
|
||||||
@@ -1596,14 +2164,15 @@ function actionNodeToIntervention(node) {
|
|||||||
if (reservationMatch) {
|
if (reservationMatch) {
|
||||||
const label1 = reservationMatch[1].trim();
|
const label1 = reservationMatch[1].trim();
|
||||||
const creator = reservationMatch[2].trim();
|
const creator = reservationMatch[2].trim();
|
||||||
// Les "absences" connues (Congés/Maladie/Pompier) restent des absences
|
if (RESERVATION_LABELS.test(label1)) {
|
||||||
if (/^(cong[ée]s|maladie|pompier)$/i.test(label1)) {
|
// Ressource matérielle → Réservation
|
||||||
effectiveType = "AL-Absence";
|
|
||||||
} else {
|
|
||||||
// Tout autre label (Ecrans, Rollout, ...) → Réservation
|
|
||||||
effectiveType = "AL-Reservation";
|
effectiveType = "AL-Reservation";
|
||||||
reservationLabel = label1;
|
reservationLabel = label1;
|
||||||
reservationCreator = creator;
|
reservationCreator = creator;
|
||||||
|
} else {
|
||||||
|
// Tout autre (Congés, Maladie, Pompier, Evènements spéciaux, Réunion,
|
||||||
|
// Déménagement, Formation, etc.) → Absence
|
||||||
|
effectiveType = "AL-Absence";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1786,6 +2355,14 @@ function mergeCacheAndFresh(cached, fresh) {
|
|||||||
if (!outTech) continue;
|
if (!outTech) continue;
|
||||||
for (const iv of tech.interventions || []) {
|
for (const iv of tech.interventions || []) {
|
||||||
if (!freshActionIds.has(iv.actionId)) {
|
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 };
|
const ghost = { ...iv, ghost: true };
|
||||||
outTech.interventions.push(ghost);
|
outTech.interventions.push(ghost);
|
||||||
}
|
}
|
||||||
@@ -3335,6 +3912,25 @@ function buildCard(tech, isoDate) {
|
|||||||
note.textContent = "Absent toute la journée";
|
note.textContent = "Absent toute la journée";
|
||||||
}
|
}
|
||||||
body.appendChild(note);
|
body.appendChild(note);
|
||||||
|
// v5.0.1 : bouton 🗑 pour supprimer l'absence (seulement si actionId réel,
|
||||||
|
// pas les absences récurrentes type Pillonel vendredi qui n'existent pas
|
||||||
|
// dans EasyVista).
|
||||||
|
if (ab.actionId && !ab.isPompier && !ab._recurring) {
|
||||||
|
const delWrap = document.createElement("div");
|
||||||
|
delWrap.className = "absence-delete-wrap";
|
||||||
|
const delBtn = document.createElement("button");
|
||||||
|
delBtn.type = "button";
|
||||||
|
delBtn.className = "tooltip-delete-btn";
|
||||||
|
delBtn.textContent = "🗑 Supprimer l'absence";
|
||||||
|
delBtn.dataset.actionId = ab.actionId;
|
||||||
|
delBtn.dataset.kind = "absence";
|
||||||
|
delBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
_triggerDeleteItem(ab.actionId, "absence");
|
||||||
|
});
|
||||||
|
delWrap.appendChild(delBtn);
|
||||||
|
body.appendChild(delWrap);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// v4.1.20 : cas spécifique Pillonel Olivier, absent tous les vendredis.
|
// v4.1.20 : cas spécifique Pillonel Olivier, absent tous les vendredis.
|
||||||
@@ -3414,11 +4010,14 @@ function buildCard(tech, isoDate) {
|
|||||||
// Timeline
|
// Timeline
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) {
|
// v5.0.0 : constantes timeline globales (avant : locales à buildTimeline),
|
||||||
const DAY_START = 8 * 60;
|
// pour que updateNowLine puisse les utiliser aussi.
|
||||||
const DAY_END = 18 * 60;
|
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;
|
const DAY_LEN = DAY_END - DAY_START;
|
||||||
|
|
||||||
|
function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) {
|
||||||
|
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "timeline";
|
wrap.className = "timeline";
|
||||||
if (isPompier) wrap.classList.add("timeline-pompier");
|
if (isPompier) wrap.classList.add("timeline-pompier");
|
||||||
@@ -5249,6 +5848,58 @@ function _softUnpinPopup(el) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ferme toutes les popups épinglées (appelé par Échap ou changement de date). */
|
/** 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() {
|
function closeAllPinnedPopups() {
|
||||||
for (const p of pinnedPopups.slice()) {
|
for (const p of pinnedPopups.slice()) {
|
||||||
p.el.remove();
|
p.el.remove();
|
||||||
@@ -5577,6 +6228,11 @@ function bindTooltipInteractions() {
|
|||||||
}, 1200);
|
}, 1200);
|
||||||
}).catch(() => {});
|
}).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 +6264,23 @@ function buildTooltipHTML(iv) {
|
|||||||
}
|
}
|
||||||
if (iv.reservationLabel) rows.push(row("Sujet", iv.reservationLabel));
|
if (iv.reservationLabel) rows.push(row("Sujet", iv.reservationLabel));
|
||||||
if (iv.reservationCreator) rows.push(row("Par", iv.reservationCreator));
|
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>`;
|
return `<dl>${rows.join("")}</dl>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user