diff --git a/background.js b/background.js
index fa61c7d..11d5929 100644
--- a/background.js
+++ b/background.js
@@ -7,8 +7,8 @@
// - fetchPlanning : fetch le XML du planning pour une date (1 requête = tout)
// - fetchXhr2 : fetch un texte d'action détaillé (utilisé en lazy-load au survol)
// - fetchFiche : fetch une fiche individuelle (HTML) pour statut + commentaire tech
-// 3. Programmer les alarmes de refresh auto (12h, 15h)
-// 4. Nettoyer les vieux caches (>7 jours)
+// 3. Nettoyer les vieux caches (>7 jours)
+// (v4.2 : l'auto-refresh 12h/15h a été retiré)
//
// v4 : suppression de fetchTimeline (pu utilisé). Le calendar_block contient
// directement ref/contact/lieu/catégorie dans ses attributs attr1/attr2/attr3,
@@ -87,12 +87,30 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) {
console.log("[bg] fetchPlanningXml →", url.substring(0, 140));
const r = await fetch(url, { credentials: "include" });
console.log("[bg] status =", r.status);
- if (!r.ok) throw new Error("HTTP " + r.status);
+ if (!r.ok) {
+ // v4.2 : classifier l'erreur HTTP pour que le viewer affiche le bon
+ // écran (session expirée vs EV inaccessible).
+ const err = new Error("HTTP " + r.status);
+ err.kind = classifyHttpStatus(r.status);
+ err.status = r.status;
+ throw err;
+ }
const xml = await r.text();
console.log("[bg] taille XML =", xml.length);
return xml;
}
+/**
+ * v4.2 : classifie un statut HTTP comme "session_expired" ou "ev_unreachable".
+ * - 401, 403, 404 → session_expired (EV renvoie souvent 404 au lieu de rediriger
+ * vers la page de login quand PHPSESSID n'est plus valide)
+ * - 5xx, autres → ev_unreachable (service down, surcharge, etc.)
+ */
+function classifyHttpStatus(status) {
+ if (status === 401 || status === 403 || status === 404) return "session_expired";
+ return "ev_unreachable";
+}
+
/**
* Fetch planning_xhr_2.php?id=ACTIONID pour UNE intervention.
* Retourne ~400 octets au format custom :
@@ -101,7 +119,12 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) {
async function fetchXhr2(origin, phpsessid, actionId) {
const url = `${origin}/planning_xhr_2.php?PHPSESSID=${encodeURIComponent(phpsessid)}&id=${encodeURIComponent(actionId)}`;
const r = await fetch(url, { credentials: "include" });
- if (!r.ok) throw new Error("HTTP " + r.status);
+ if (!r.ok) {
+ const err = new Error("HTTP " + r.status);
+ err.kind = classifyHttpStatus(r.status);
+ err.status = r.status;
+ throw err;
+ }
return await r.text();
}
@@ -109,7 +132,12 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`;
console.log("[bg] fetchFicheHtml →", url.substring(0, 120));
const r = await fetch(url, { credentials: "include" });
- if (!r.ok) throw new Error("HTTP " + r.status);
+ if (!r.ok) {
+ const err = new Error("HTTP " + r.status);
+ err.kind = classifyHttpStatus(r.status);
+ err.status = r.status;
+ throw err;
+ }
const html = await r.text();
console.log("[bg] fiche status =", r.status, "| taille =", html.length);
return html;
@@ -136,7 +164,12 @@ async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum) {
`&type=todo§ionId=1&navigator=&nbRecord=0` +
`&PHPSESSID=${encodeURIComponent(phpsessid)}`;
const r = await fetch(url, { credentials: "include" });
- if (!r.ok) throw new Error("HTTP " + r.status);
+ if (!r.ok) {
+ const err = new Error("HTTP " + r.status);
+ err.kind = classifyHttpStatus(r.status);
+ err.status = r.status;
+ throw err;
+ }
return await r.text();
}
@@ -149,6 +182,93 @@ function looksLikeLoginPage(text) {
return /customer_login|my\.policy/i.test((text || "").substring(0, 3000));
}
+// ============================================================================
+// v4.2 : récupération de l'utilisateur connecté
+// ============================================================================
+
+/**
+ * Essaie de récupérer le nom de l'utilisateur EasyVista connecté en fetchant
+ * la page d'accueil avec la session active. EasyVista n'exposant pas
+ * d'endpoint public simple, on cherche des patterns typiques dans le HTML :
+ * -
...Nom, Prénom...
+ * - éléments avec data-user-name, data-user-login
+ * - balises cachées ou variables JS EV.User.name
+ * - champ "Bienvenue Nom Prénom"
+ * Retourne { name: "Nom Prénom" | null, login: "..." | null } ou null si
+ * tout a échoué.
+ */
+async function fetchCurrentUser(origin, phpsessid) {
+ const url = `${origin}/index.php?PHPSESSID=${encodeURIComponent(phpsessid)}`;
+ const resp = await fetch(url, {
+ method: "GET",
+ credentials: "include",
+ headers: { "Accept": "text/html,*/*" }
+ });
+ // v4.2 : cette fonction est lancée en tâche de fond au démarrage. Si la
+ // session est expirée ou EV inaccessible, on retourne juste null — le
+ // planning lui-même déclenchera l'écran d'erreur approprié.
+ if (!resp.ok) return null;
+ const html = await resp.text();
+ if (looksLikeLoginPage(html)) return null;
+
+ // Essais de patterns (du plus spécifique au plus générique)
+ const patterns = [
+ // Attribut data-user-name (si EasyVista l'expose)
+ /data-user-name\s*=\s*["']([^"']+)["']/i,
+ /data-username\s*=\s*["']([^"']+)["']/i,
+ /data-user-fullname\s*=\s*["']([^"']+)["']/i,
+ // Variable JS typique EasyVista
+ /EV\.User\.name\s*=\s*["']([^"']+)["']/,
+ /EV\.User\.fullname\s*=\s*["']([^"']+)["']/,
+ /userFullName\s*[:=]\s*["']([^"']+)["']/,
+ /currentUser(?:Name)?\s*[:=]\s*["']([^"']+)["']/,
+ // Balises cachées ou spans avec classe "user"
+ /<(?:span|div)[^>]*class=["'][^"']*(?:user[_-]?(?:name|full|display))[^"']*["'][^>]*>([^<]{2,80})<\/(?:span|div)>/i,
+ // "Bienvenue" / "Welcome"
+ /(?:Bienvenue|Welcome)[,\s]+(?:M\.?\s+|Mme\s+)?([A-ZÀÁÂÄÇÉÈÊËÍÎÏÓÔÖÚÛÜÑ][\wÀ-ÿ'.\-]+(?:\s*,?\s+[A-ZÀÁÂÄÇÉÈÊËÍÎÏÓÔÖÚÛÜÑ][\wÀ-ÿ'.\-]+){0,3})/,
+ // Title de la page (souvent "EasyVista - Nom Prénom")
+ /([^<]*)<\/title>/i
+ ];
+
+ let name = null;
+ for (const rx of patterns) {
+ const m = html.match(rx);
+ if (m && m[1]) {
+ const candidate = m[1].trim()
+ .replace(/\s+/g, " ")
+ // Enlever des éléments du type "EasyVista" / "Planning" / etc.
+ .replace(/^(?:EasyVista|EV|Accueil|Home|Planning|ITSMA)[\s\-|•]+/i, "")
+ .replace(/[\s\-|•]+(?:EasyVista|EV|ITSMA)$/i, "")
+ .trim();
+ if (candidate && candidate.length >= 3 && candidate.length <= 80
+ && /[A-Za-zÀ-ÿ]/.test(candidate)
+ && !/\b(login|connexion|sign\s*in|easyvista|ITSMA)\b/i.test(candidate)) {
+ name = candidate;
+ break;
+ }
+ }
+ }
+
+ // Chercher aussi le login (ID court) — utile comme fallback secondaire
+ let login = null;
+ const loginPatterns = [
+ /data-user-login\s*=\s*["']([^"']+)["']/i,
+ /data-login\s*=\s*["']([^"']+)["']/i,
+ /EV\.User\.login\s*=\s*["']([^"']+)["']/,
+ /userLogin\s*[:=]\s*["']([^"']+)["']/
+ ];
+ for (const rx of loginPatterns) {
+ const m = html.match(rx);
+ if (m && m[1]) {
+ login = m[1].trim();
+ break;
+ }
+ }
+
+ if (!name && !login) return null;
+ return { name, login };
+}
+
// ============================================================================
// Messages du viewer
// ============================================================================
@@ -168,13 +288,21 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ ok: false, error: "no_session" });
return;
}
- // Fetch XML calendar_block du planning (rapide ~40 ko)
- const xml = await fetchPlanningXml(session.origin, session.phpsessid, msg.unixDate);
- if (looksLikeLoginPage(xml)) {
- sendResponse({ ok: false, error: "session_expired" });
- return;
+ try {
+ // Fetch XML calendar_block du planning (rapide ~40 ko)
+ const xml = await fetchPlanningXml(session.origin, session.phpsessid, msg.unixDate);
+ if (looksLikeLoginPage(xml)) {
+ sendResponse({ ok: false, error: "session_expired" });
+ return;
+ }
+ sendResponse({ ok: true, xml, session });
+ } catch (err) {
+ // v4.2 : classification de l'erreur pour afficher le bon écran
+ const errorCode = err.kind || (
+ /network|fetch|typeerror/i.test(err.message) ? "ev_unreachable" : "ev_unreachable"
+ );
+ sendResponse({ ok: false, error: errorCode, httpStatus: err.status, detail: err.message });
}
- sendResponse({ ok: true, xml, session });
return;
}
@@ -188,7 +316,12 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
const body = await fetchXhr2(session.origin, session.phpsessid, msg.actionId);
sendResponse({ ok: true, body });
} catch (err) {
- sendResponse({ ok: false, error: String(err) });
+ sendResponse({
+ ok: false,
+ error: err.kind || "fetch_failed",
+ httpStatus: err.status,
+ detail: err.message || String(err)
+ });
}
return;
}
@@ -199,12 +332,21 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ ok: false, error: "no_session" });
return;
}
- const html = await fetchFicheHtml(session.origin, session.phpsessid, msg.formLink);
- if (looksLikeLoginPage(html)) {
- sendResponse({ ok: false, error: "session_expired" });
- return;
+ try {
+ const html = await fetchFicheHtml(session.origin, session.phpsessid, msg.formLink);
+ if (looksLikeLoginPage(html)) {
+ sendResponse({ ok: false, error: "session_expired" });
+ return;
+ }
+ sendResponse({ ok: true, html, session });
+ } catch (err) {
+ sendResponse({
+ ok: false,
+ error: err.kind || "fetch_failed",
+ httpStatus: err.status,
+ detail: err.message || String(err)
+ });
}
- sendResponse({ ok: true, html, session });
return;
}
@@ -225,14 +367,32 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
sendResponse({ ok: true, body });
} catch (err) {
- sendResponse({ ok: false, error: String(err) });
+ sendResponse({
+ ok: false,
+ error: err.kind || "fetch_failed",
+ httpStatus: err.status,
+ detail: err.message || String(err)
+ });
}
return;
}
- if (msg.type === "scheduleAutoRefresh") {
- scheduleAutoRefreshAlarms();
- sendResponse({ ok: true });
+ if (msg.type === "fetchCurrentUser") {
+ // v4.2 : essaie d'identifier l'utilisateur EasyVista connecté en
+ // fetchant la page d'accueil et en cherchant dans le HTML un champ
+ // contenant son nom. Si on trouve rien, on renvoie { ok: true,
+ // user: null } pour que l'UI sache qu'on n'a pas pu.
+ const session = await findEasyVistaSession();
+ if (!session) {
+ sendResponse({ ok: false, error: "no_session" });
+ return;
+ }
+ try {
+ const user = await fetchCurrentUser(session.origin, session.phpsessid);
+ sendResponse({ ok: true, user });
+ } catch (err) {
+ sendResponse({ ok: false, error: String(err) });
+ }
return;
}
@@ -254,45 +414,21 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
});
// ============================================================================
-// Alarmes : refresh auto 12h / 15h
+// v4.2 : les alarmes d'auto-refresh 12h/15h ont été supprimées. Seul le
+// nettoyage quotidien des caches > 7 jours reste.
+// On supprime aussi activement les anciennes alarmes créées par les
+// versions précédentes pour éviter qu'elles restent programmées.
// ============================================================================
-function scheduleAutoRefreshAlarms() {
- // Calculer le prochain 12h et 15h à partir de maintenant
- const now = new Date();
-
- function nextAt(hour, minute) {
- const d = new Date();
- d.setHours(hour, minute, 0, 0);
- if (d <= now) d.setDate(d.getDate() + 1);
- return d.getTime();
+async function clearLegacyRefreshAlarms() {
+ try {
+ await chrome.alarms.clear("refresh_12h");
+ await chrome.alarms.clear("refresh_15h");
+ } catch (e) {
+ console.warn("clearLegacyRefreshAlarms:", e);
}
-
- chrome.alarms.create("refresh_12h", {
- when: nextAt(12, 0),
- periodInMinutes: 24 * 60 // tous les jours
- });
- chrome.alarms.create("refresh_15h", {
- when: nextAt(15, 0),
- periodInMinutes: 24 * 60
- });
}
-chrome.alarms.onAlarm.addListener(async (alarm) => {
- if (alarm.name === "refresh_12h" || alarm.name === "refresh_15h") {
- // Envoyer un message à tous les viewers ouverts pour qu'ils se rafraîchissent
- const viewerUrl = chrome.runtime.getURL("viewer.html");
- const tabs = await chrome.tabs.query({ url: viewerUrl + "*" });
- for (const tab of tabs) {
- try {
- await chrome.tabs.sendMessage(tab.id, { type: "autoRefresh" });
- } catch {
- // Onglet fermé ou pas réactif, on ignore
- }
- }
- }
-});
-
// ============================================================================
// Nettoyage caches > 7 jours
// ============================================================================
@@ -317,13 +453,13 @@ async function cleanupOldCaches(daysToKeep) {
return toRemove.length;
}
-// Au démarrage, programmer les alarmes et nettoyer
+// Au démarrage, nettoyer les anciennes alarmes et les anciens caches
chrome.runtime.onInstalled.addListener(() => {
- scheduleAutoRefreshAlarms();
+ clearLegacyRefreshAlarms();
cleanupOldCaches(7).catch(err => console.warn("cleanup:", err));
});
chrome.runtime.onStartup.addListener(() => {
- scheduleAutoRefreshAlarms();
+ clearLegacyRefreshAlarms();
cleanupOldCaches(7).catch(err => console.warn("cleanup:", err));
});
diff --git a/manifest.json b/manifest.json
index 7b97333..e0821c7 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "Planning Techniciens — Vue claire",
- "version": "4.1.14",
- "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.1.14 : bouton ↻ dans la bulle pour recharger une seule intervention (sans que les boutons topbar tournent), Actualiser tourne quand on arrive sur une date avec cache, signature planif vraiment à droite, progress bar lisible avec halo text-shadow (plus de fond noir).",
+ "version": "4.2.1",
+ "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.2.1 : messages d'erreur clairs (session expirée vs EasyVista inaccessible) avec bouton Ouvrir EasyVista et Réessayer, vouvoiement uniformisé. Inclut v4.2.0 : contact + personne de contact sur site avec anomalie rouge, parser téléphone élargi (41XXX sans +), sélection texte dans la bulle sans épingler, utilisateur EV connecté en haut, suppression auto-refresh 12h/15h.",
"permissions": [
"activeTab",
"scripting",
diff --git a/viewer.css b/viewer.css
index 41ede86..6e88445 100644
--- a/viewer.css
+++ b/viewer.css
@@ -231,7 +231,12 @@ html, body {
top: 56px;
z-index: 9;
height: 22px;
- background: var(--bg-subtle, rgba(128, 128, 128, 0.08));
+ /* v4.1.17 : backdrop-blur sur toute la barre → ce qui défile derrière
+ est légèrement flouté sur TOUTE la largeur. Pas d'opacité sombre
+ ajoutée, transparence préservée. */
+ background: rgba(128, 128, 128, 0.08);
+ backdrop-filter: blur(3px);
+ -webkit-backdrop-filter: blur(3px);
border-bottom: 1px solid var(--border, rgba(128, 128, 128, 0.2));
overflow: hidden;
}
@@ -258,10 +263,8 @@ html, body {
color: #fff;
pointer-events: none;
letter-spacing: 0.3px;
- /* v4.1.14 : text-shadow multi-directionnel qui crée un halo sombre autour
- du texte. Lisible peu importe ce qui défile derrière (noms, icônes,
- fond gris ou barre verte). Aucun fond opaque → la transparence de la
- barre est totalement préservée. */
+ /* v4.1.14/17 : text-shadow multi-directionnel (halo sombre autour du
+ texte). Le backdrop-blur est sur toute la barre, plus besoin de pill. */
text-shadow:
0 0 2px rgba(0, 0, 0, 0.95),
0 0 3px rgba(0, 0, 0, 0.85),
@@ -269,6 +272,7 @@ html, body {
0 -1px 2px rgba(0, 0, 0, 0.75),
1px 0 2px rgba(0, 0, 0, 0.75),
-1px 0 2px rgba(0, 0, 0, 0.75);
+ z-index: 2;
}
/* Navigation de date */
@@ -759,9 +763,12 @@ html, body {
display: grid;
grid-template-columns: 4px 58px 1fr auto;
grid-template-rows: auto auto;
+ /* v4.1.17 : la ligne du bas (right) s'étend maintenant sur les 2 colonnes
+ droite (right + status) pour que la signature aille vraiment jusqu'au
+ bord droit. Le ✓ status est positionné en absolute par-dessus. */
grid-template-areas:
- "dot time ref copy"
- "dot time right status";
+ "dot time ref copy"
+ "dot time right right";
gap: 2px 10px;
align-items: start;
padding: 10px 12px 12px 8px;
@@ -845,12 +852,17 @@ html, body {
}
.iv-status-check {
- grid-area: status;
- align-self: center;
+ /* v4.1.17 : absolute en bas à droite (la grid-area "status" a été
+ fusionnée avec "right" pour étendre la signature jusqu'au bord). */
+ position: absolute;
+ right: 10px;
+ bottom: 10px;
font-size: 16px;
font-weight: 700;
color: var(--c-closed);
- padding-right: 6px;
+ pointer-events: none;
+ /* Au-dessus de la signature, mais discret */
+ z-index: 1;
}
.intervention-v2.status-resolved .iv-status-check { color: var(--c-resolved); }
@@ -984,11 +996,13 @@ html, body {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- /* v4.1.14 : flex: 1 pour prendre tout l'espace disponible entre category
- et signature — pousse la signature au bord droit. min-width: 0 permet
- l'ellipsis sur les longues catégories. */
- flex: 1 1 auto;
+ /* v4.1.15 : taille naturelle (pas de flex:1 qui étirait le texte et
+ rendait la signature juste à côté). Sans flex, la catégorie reste à
+ son contenu + justify-content:space-between pousse la signature à
+ l'extrême droite du parent. */
min-width: 0;
+ flex: 0 1 auto;
+ max-width: calc(100% - 70px);
}
.iv-signature {
color: var(--text-faint);
@@ -996,9 +1010,16 @@ html, body {
font-family: var(--mono);
flex-shrink: 0;
letter-spacing: 0.02em;
- /* v4.1.14 : collée au bord droit, pas de padding-right parasite */
text-align: right;
+ /* v4.1.15/17 : margin-left: auto pour collage garanti à droite */
margin-left: auto;
+ white-space: nowrap;
+}
+/* v4.1.17 : si statut clos/résolu, le ✓ est à droite en absolute → décaler
+ la signature pour ne pas se chevaucher */
+.intervention-v2.status-closed .iv-signature,
+.intervention-v2.status-resolved .iv-signature {
+ padding-right: 22px;
}
/* Réservation (créneau bloqué par un coordinateur) */
@@ -1045,7 +1066,7 @@ html, body {
Tooltip
========================================================================== */
.tooltip {
- position: fixed;
+ position: fixed !important;
z-index: 100;
max-width: 620px;
max-height: calc(100vh - 40px);
@@ -1061,20 +1082,22 @@ html, body {
pointer-events: none;
opacity: 0;
transition: opacity 0.1s;
- /* v4.1.10 : empêcher la sélection par défaut (évite sélection accidentelle
- pendant qu'on bouge la souris). Ré-activé quand .pinned. */
- user-select: none;
+ /* v4.2 : sélection de texte autorisée en permanence. Avant (v4.1.10) on
+ bloquait par défaut et n'activait qu'en mode épinglé, mais c'était
+ contre-productif — on veut pouvoir copier un numéro sans pin d'abord. */
+ user-select: text;
+ -webkit-user-select: text;
}
.tooltip.visible {
opacity: 1;
/* v4.1.10 : permet à la souris d'entrer dans la bulle pour la garder
visible (persistance au hover) et, en mode pinned, pour sélectionner. */
pointer-events: auto;
+ /* v4.2 : curseur texte par défaut (pour signaler que c'est sélectionnable) */
+ cursor: text;
}
.tooltip.pinned {
- /* v4.1.10 : bulle épinglée → curseur texte + sélection active */
- user-select: text;
- cursor: text;
+ /* Bulle épinglée : bordure verte pour indiquer le mode */
border-color: var(--c-accent, #3fb950);
box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.15), var(--shadow-hover);
}
@@ -1130,6 +1153,41 @@ html, body {
background: rgba(63, 185, 80, 0.15);
}
+/* v4.1.15 : référence dans la bulle avec bouton copier inline */
+.tt-ref-cell {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+.tt-ref-val {
+ font-family: var(--mono, monospace);
+}
+.tt-copy-btn {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--text-muted);
+ width: 26px;
+ height: 22px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ transition: background 0.12s, color 0.12s, border-color 0.12s;
+}
+.tt-copy-btn:hover {
+ background: var(--bg-hover);
+ color: var(--text);
+ border-color: var(--border-strong);
+}
+.tt-copy-btn.copied {
+ background: rgba(63, 185, 80, 0.2);
+ border-color: #3fb950;
+ color: #3fb950;
+}
+
.tooltip dl {
margin: 0;
display: grid;
@@ -1245,3 +1303,153 @@ html, body {
font-weight: 700;
letter-spacing: 0.02em;
}
+
+/* ─────────────────────────────────────────────────────────────────────────
+ v4.1.20 : Modal central de confirmation (vider cache)
+ ───────────────────────────────────────────────────────────────────────── */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ /* Flou + assombrissement léger de l'arrière-plan */
+ background: rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ animation: modal-fade-in 0.15s ease-out;
+}
+@keyframes modal-fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.modal-card {
+ background: var(--bg, #ffffff);
+ color: var(--text, #111);
+ border: 1px solid var(--border, rgba(128, 128, 128, 0.25));
+ border-radius: 12px;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25),
+ 0 2px 8px rgba(0, 0, 0, 0.15);
+ padding: 24px 24px 20px;
+ width: min(440px, 92vw);
+ max-height: 90vh;
+ overflow-y: auto;
+ animation: modal-card-in 0.18s cubic-bezier(0.16, 1, 0.3, 1);
+}
+@keyframes modal-card-in {
+ from { opacity: 0; transform: translateY(8px) scale(0.98); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+.modal-title {
+ margin: 0 0 12px 0;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text, #111);
+}
+.modal-message {
+ margin: 0 0 20px 0;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--text-muted, #555);
+}
+.modal-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.modal-actions .btn {
+ width: 100%;
+ padding: 10px 14px;
+ font-size: 13px;
+ font-weight: 600;
+ text-align: center;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background 0.12s, transform 0.06s;
+ border: 1px solid transparent;
+}
+.modal-actions .btn:active { transform: translateY(1px); }
+
+/* Vider le cache du jour : danger modéré (orange) */
+.btn-modal-danger {
+ background: rgba(234, 128, 38, 0.12);
+ color: #c85a00;
+ border-color: rgba(234, 128, 38, 0.3);
+}
+.btn-modal-danger:hover {
+ background: rgba(234, 128, 38, 0.22);
+}
+/* Vider tout le cache : danger fort (rouge) */
+.btn-modal-danger-strong {
+ background: rgba(220, 60, 60, 0.12);
+ color: #c03030;
+ border-color: rgba(220, 60, 60, 0.3);
+}
+.btn-modal-danger-strong:hover {
+ background: rgba(220, 60, 60, 0.22);
+}
+/* Annuler : neutre */
+.btn-modal-cancel {
+ background: transparent;
+ color: var(--text-muted, #666);
+ border-color: var(--border, rgba(128, 128, 128, 0.3));
+ margin-top: 4px;
+}
+.btn-modal-cancel:hover {
+ background: var(--bg-hover, rgba(128, 128, 128, 0.08));
+}
+
+/* ─────────────────────────────────────────────────────────────────────────
+ v4.1.20 : Message d'absence récurrente (Pillonel vendredi)
+ ───────────────────────────────────────────────────────────────────────── */
+.tech-absence-recurring {
+ padding: 14px 12px;
+ text-align: center;
+ font-size: 13px;
+ font-style: italic;
+ color: var(--text-faint, #888);
+ background: rgba(128, 128, 128, 0.04);
+ border-top: 1px solid var(--border, rgba(128, 128, 128, 0.15));
+ border-bottom: 1px solid var(--border, rgba(128, 128, 128, 0.15));
+}
+
+/* v4.2 : contact en rouge quand anomalie détectée (Contact + Personne de
+ contact présents tous les deux dans l'action = situation suspecte).
+ On signale visuellement pour que l'user aille vérifier dans la fiche. */
+.iv-contact-line.iv-contact-anomalie {
+ color: #dc3030;
+}
+.iv-contact-line.iv-contact-anomalie .iv-contact,
+.iv-contact-line.iv-contact-anomalie .iv-phone {
+ color: #dc3030;
+}
+
+/* v4.2 : badge utilisateur EasyVista connecté (en haut à droite de la topbar) */
+.current-user {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-muted, #666);
+ background: rgba(128, 128, 128, 0.08);
+ border: 1px solid var(--border, rgba(128, 128, 128, 0.2));
+ border-radius: 999px;
+ margin-right: 8px;
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.current-user::before {
+ content: "👤";
+ font-size: 11px;
+ opacity: 0.7;
+}
+.current-user.hidden {
+ display: none;
+}
diff --git a/viewer.html b/viewer.html
index e29ae22..92c4d02 100644
--- a/viewer.html
+++ b/viewer.html
@@ -19,6 +19,7 @@
✓
+