1import { Controller } from "@hotwired/stimulus"; 2 3export default class extends Controller { 4 static values = { 5 url: String, 6 resourceType: { type: String, default: "contacts" }, 7 }; 8 9 connect() { 10 this.filters = []; 11 } 12 13 // âââ Drawer "Mes filtres" enrichi (count + notif + actions) âââ 14 async openDrawer() { 15 const drawer = document.getElementById("saved-filters-drawer"); 16 const backdrop = document.getElementById("saved-filters-drawer-backdrop"); 17 if (!drawer || !backdrop) return; 18 19 drawer.classList.remove("translate-x-full"); 20 backdrop.classList.remove("hidden"); 21 await this.loadDrawerFilters(); 22 } 23 24 closeDrawer() { 25 const drawer = document.getElementById("saved-filters-drawer"); 26 const backdrop = document.getElementById("saved-filters-drawer-backdrop"); 27 if (!drawer || !backdrop) return; 28 drawer.classList.add("translate-x-full"); 29 backdrop.classList.add("hidden"); 30 } 31 32 async loadDrawerFilters() { 33 const list = document.getElementById("saved-filters-drawer-list"); 34 if (!list) return; 35 36 list.innerHTML = `<div class="flex items-center justify-center py-16"><span class="loading loading-spinner loading-md text-primary"></span></div>`; 37 38 try { 39 const response = await fetch( 40 `${this.urlValue}?resource_type=${this.resourceTypeValue}&with_meta=true`, 41 { headers: { Accept: "application/json" } }, 42 ); 43 this.filters = await response.json(); 44 45 if (this.filters.length === 0) { 46 list.innerHTML = ` 47 <div class="text-center py-12 px-4"> 48 <div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-base-200 mb-3"> 49 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 text-base-content/40"><path stroke-linecap="round" stroke-linejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"/></svg> 50 </div> 51 <p class="text-sm font-medium text-base-content/70">Aucun filtre sauvegardé</p> 52 <p class="text-xs text-base-content/50 mt-1">Appliquez des filtres puis cliquez « Sauvegarder » pour en créer un.</p> 53 </div>`; 54 return; 55 } 56 57 list.innerHTML = this.filters.map((f) => this._renderFilterCard(f)).join(""); 58 } catch { 59 list.innerHTML = `<p class="text-sm text-error p-3 text-center">Erreur de chargement</p>`; 60 } 61 } 62 63 _renderFilterCard(f) { 64 const updated = f.updated_at ? this._timeAgo(f.updated_at) : ""; 65 const lastChecked = f.last_checked_at ? this._timeAgo(f.last_checked_at) : null; 66 const notifActive = f.notify_on_change === true; 67 const isMine = f.mine === true; 68 69 const badges = []; 70 if (f.shared) badges.push(`<span class="badge badge-ghost badge-xs">partagé</span>`); 71 if (notifActive) badges.push(`<span class="badge badge-xs gap-1" style="background: #fe7555; color: white; border: none;"> 72 <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-2.5 h-2.5"><path d="M5.85 17.1c-.97.62-2.13-.5-1.5-1.5l.7-1.1c.27-.43.43-.93.43-1.45V9a7 7 0 1 1 14 0v4.05c0 .52.16 1.02.42 1.46l.7 1.09c.63 1-.52 2.12-1.5 1.5l-1.59-1V9a5 5 0 0 0-10 0v7.1l-1.66 1zM9 19h6a3 3 0 0 1-6 0z"/></svg> 73 notif 74 </span>`); 75 76 const actions = isMine ? ` 77 <div class="flex items-center gap-1 mt-3 pt-3 border-t border-base-200/60"> 78 <button type="button" class="btn btn-ghost btn-xs gap-1 flex-1" data-action="click->saved-filters#renameFilter" data-filter-id="${f.id}" title="Renommer"> 79 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5 h-3.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z"/></svg> 80 Renommer 81 </button> 82 <button type="button" class="btn btn-ghost btn-xs gap-1 flex-1 ${notifActive ? "text-primary" : ""}" data-action="click->
82saved-filters#toggleNotify" data-filter-id="${f.id}" title="${notifActive ? "Désactiver les notifications" : "Activer les notifications"}"> 83 <svg xmlns="http://www.w3.org/2000/svg" fill="${notifActive ? "currentColor" : "none"}" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5 h-3.5"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"/></svg> 84 ${notifActive ? "Notif active" : "Notifier"} 85 </button> 86 <button type="button" class="btn btn-ghost btn-xs btn-square text-error" data-action="click->saved-filters#deleteFilter" data-filter-id="${f.id}" title="Supprimer"> 87 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5 h-3.5"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg> 88 </button> 89 </div>` : ""; 90 91 return ` 92 <div class="saved-filter-card rounded-xl border border-base-200 bg-base-100 p-3 hover:border-primary/40 transition-colors" data-filter-id="${f.id}"> 93 <div class="flex items-start gap-2"> 94 <button type="button" class="flex-1 text-left min-w-0" data-action="click->saved-filters#applyFilter" data-filter-params='${this._escapeAttr(JSON.stringify(f.filter_params))}'> 95 <div class="flex items-center gap-1.5 flex-wrap mb-1"> 96 <span class="font-semibold text-sm truncate" data-filter-name>${this._escapeHtml(f.name)}</span> 97 ${badges.join("")} 98 </div> 99 <div class="flex items-baseline gap-2"> 100 <span class="text-xl font-bold text-primary tracking-tight">${this._formatCount(f.count)}</span> 101 <span class="text-[10px] text-base-content/55 uppercase tracking-wider">${f.count === 1 ? "résultat" : "résultats"}</span> 102 </div> 103 <p class="text-[11px] text-base-content/55 mt-0.5"> 104 Mis à jour ${updated}${lastChecked ? ` · Vérifié ${lastChecked}` : ""} 105 </p> 106 </button> 107 </div> 108 ${actions} 109 </div>`; 110 } 111 112 // Ãdition inline dans la card - pas de window.prompt (UX moche) 113 renameFilter(event) { 114 event.stopPropagation(); 115 const id = event.currentTarget.dataset.filterId; 116 const card = event.currentTarget.closest(".saved-filter-card"); 117 if (!card || card.dataset.editing === "true") return; 118 const filter = this.filters.find((f) => String(f.id) === String(id)); 119 if (!filter) return; 120 121 card.dataset.editing = "true"; 122 card.innerHTML = ` 123 <div class="space-y-2"> 124 <label class="block text-[11px] font-medium text-base-content/55 uppercase tracking-wider">Renommer le filtre</label> 125 <div class="flex items-center gap-1.5"> 126 <input type="text" 127 data-rename-input 128 value="${this._escapeAttr(filter.name)}" 129 maxlength="60" 130 class="input input-sm input-bordered flex-1 text-sm focus:input-primary" 131 data-action="keydown->saved-filters#renameKeydown" /> 132 <button type="button" 133 class="btn btn-primary btn-sm btn-square" 134 data-action="click->saved-filters#saveRename" 135 data-filter-id="${id}" 136 title="Enregistrer"> 137 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> 138 </button> 139 <button type="button" 140 class="btn btn-ghost btn-sm btn-square" 141 data-action="click->
141saved-filters#cancelRename" 142 title="Annuler"> 143 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg> 144 </button> 145 </div> 146 </div> 147 `; 148 149 const input = card.querySelector("[data-rename-input]"); 150 input?.focus(); 151 input?.select(); 152 } 153 154 renameKeydown(event) { 155 if (event.key === "Enter") { 156 event.preventDefault(); 157 const card = event.currentTarget.closest(".saved-filter-card"); 158 const id = card?.dataset.filterId; 159 if (id) this._performRename(card, id, event.currentTarget.value); 160 } else if (event.key === "Escape") { 161 event.preventDefault(); 162 this.loadDrawerFilters(); 163 } 164 } 165 166 saveRename(event) { 167 event.stopPropagation(); 168 const card = event.currentTarget.closest(".saved-filter-card"); 169 const id = event.currentTarget.dataset.filterId; 170 const input = card?.querySelector("[data-rename-input]"); 171 if (!input) return; 172 this._performRename(card, id, input.value); 173 } 174 175 cancelRename() { 176 this.loadDrawerFilters(); 177 } 178 179 async _performRename(card, id, value) { 180 const newName = (value || "").trim(); 181 const filter = this.filters.find((f) => String(f.id) === String(id)); 182 if (!newName) { 183 const input = card?.querySelector("[data-rename-input]"); 184 input?.classList.add("input-error"); 185 setTimeout(() => input?.classList.remove("input-error"), 1500); 186 return; 187 } 188 if (filter && newName === filter.name) { 189 this.loadDrawerFilters(); 190 return; 191 } 192 193 const csrf = document.querySelector('meta[name="csrf-token"]')?.content; 194 try { 195 const response = await fetch(`${this.urlValue}/${id}`, { 196 method: "PATCH", 197 headers: { 198 "Content-Type": "application/json", 199 "X-CSRF-Token": csrf, 200 }, 201 body: JSON.stringify({ name: newName }), 202 }); 203 if (response.ok) { 204 this.showFlash(`Filtre renommé`, "success"); 205 await this.loadDrawerFilters(); 206 } else { 207 const data = await response.json().catch(() => ({})); 208 this.showFlash(data.error || "Erreur lors du renommage", "error"); 209 const input = card?.querySelector("[data-rename-input]"); 210 input?.classList.add("input-error"); 211 } 212 } catch { 213 this.showFlash("Erreur réseau", "error"); 214 } 215 } 216 217 async toggleNotify(event) { 218 event.stopPropagation(); 219 const id = event.currentTarget.dataset.filterId; 220 const filter = this.filters.find((f) => String(f.id) === String(id)); 221 if (!filter) return; 222 223 const newValue = !filter.notify_on_change; 224 const csrf = document.querySelector('meta[name="csrf-token"]')?.content; 225 try { 226 const response = await fetch(`${this.urlValue}/${id}`, { 227 method: "PATCH", 228 headers: { 229 "Content-Type": "application/json", 230 "X-CSRF-Token": csrf, 231 }, 232 body: JSON.stringify({ notify_on_change: newValue }), 233 }); 234 if (response.ok) { 235 this.showFlash(newValue ? "Notifications activées" : "Notifications désactivées", "success"); 236 await this.loadDrawerFilters(); 237 } else { 238 this.showFlash("Erreur", "error"); 239 } 240 } catch { 241 this.showFlash("Erreur réseau", "error"); 242 } 243 } 244 245 // Formate un timestamp ISO en "il y a Xj/Xh/Xmin" - utile pour le drawer. 246 _timeAgo(iso) { 247 const d = new Date(iso); 248 const diff = (Date.now() - d.getTime()) / 1000; // secondes 249 if (diff < 60) return "à l'instant"; 250 if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`; 251 if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`; 252 if (diff < 2592000) return `il y a ${Math.floor(diff / 86400)} j`; 253 return d.toLocaleDateString("fr-FR"); 254 } 255 256 _formatCount(n) { 257 if (n == null) return "-"; 258 if (n >= 1000) return new Intl.NumberFormat("fr-FR").format(n); 259 return String(n); 260 } 261 262 _escapeHtml(s) { 263 return String(s).replace(/[&<>"']/g, (c) => ({ 264 "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" 265 }[c])); 266 } 267 268 _escapeAttr(s) { 269 return String(s).replace(/'/g, "'"); 270 } 271 272 async toggleDropdown() { 273 const dropdown = document.getElementById("saved-filters-dropdown"); 274 if (!dropdown) return; 275 276 if (!dropdown.classList.contains("hidden")) { 277 dropdown.classList.add("hidden"); 278 return; 279 } 280 281 // Charger les filtres 282 await this.loadFilters(); 283 dropdown.classList.remove("hidden"); 284 285 // Fermer au clic extérieur 286 const close = (e) => { 287 if (!dropdown.contains(e.target) && !e.target.closest("[data-action*='saved-filters']")) { 288 dropdown.classList.add("hidden"); 289 document.removeEventListener("click", close); 290 } 291 }; 292 setTimeout(() => document.addEventListener("click", close), 0); 293 } 294 295 async loadFilters() { 296 const list = document.getElementById("saved-filters-list"); 297 if (!list) return; 298 299 list.innerHTML = '<div class="p-3 text-center"><span class="loading loading-spinner loading-xs"></span></div>'; 300 301 try { 302 const response = await fetch( 303 `${this.urlValue}?resource_type=${this.resourceTypeValue}`, 304 { headers: { Accept: "application/json" } }, 305 ); 306 this.filters = await response.json(); 307 308 if (this.filters.length === 0) { 309 list.innerHTML = 310 '<p class="text-xs text-base-content/40 p-3 text-center">Aucun filtre sauvegardé</p>'; 311 return; 312 } 313 314 list.innerHTML = this.filters 315 .map( 316 (f) => ` 317 <div class="flex items-center gap-2 px-3 py-2 hover:bg-base-200 rounded-lg cursor-pointer group" data-filter-id="${f.id}"> 318 <button type="button" class="flex-1 text-left text-sm truncate" data-action="click->
318saved-filters#applyFilter" data-filter-params='${JSON.stringify(f.filter_params)}'> 319 ${f.name} 320 ${f.shared ? '<span class="badge badge-ghost badge-xs ml-1">partagé</span>' : ""} 321 </button> 322 ${f.mine ? `<button type="button" class="btn btn-ghost btn-xs btn-circle opacity-0 group-hover:opacity-100 text-error" data-action="click->saved-filters#deleteFilter" data-filter-id="${f.id}"> 323 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg> 324 </button>` : ""} 325 </div>`, 326 ) 327 .join(""); 328 } catch { 329 list.innerHTML = 330 '<p class="text-xs text-error p-3">Erreur de chargement</p>'; 331 } 332 } 333 334 applyFilter(event) { 335 const filterName = event.currentTarget.textContent.trim().replace(/partagé$/, "").trim(); 336 const params = JSON.parse(event.currentTarget.dataset.filterParams); 337 const url = new URL(window.location.href); 338 339 // Supprimer tous les params actuels 340 Array.from(url.searchParams.keys()).forEach((key) => 341 url.searchParams.delete(key), 342 ); 343 344 // Appliquer les params du filtre 345 Object.entries(params).forEach(([key, value]) => { 346 if (Array.isArray(value)) { 347 value.forEach((v) => url.searchParams.append(key, v)); 348 } else if (value) { 349 url.searchParams.set(key, value); 350 } 351 }); 352 353 url.searchParams.set("page", 1); 354 document.getElementById("saved-filters-dropdown")?.classList.add("hidden"); 355 356 // Reset les panneaux filtres rapides et avancés 357 const filtersPanel = document.querySelector("[data-contacts-search-target='filtersPanel']"); 358 const builderPanel = document.querySelector("[data-contacts-search-target='builderPanel']"); 359 const filtersBtn = document.querySelector("[data-contacts-search-target='filtersBtn']"); 360 const builderBtn = document.querySelector("[data-contacts-search-target='builderBtn']"); 361 filtersPanel?.classList.add("hidden"); 362 builderPanel?.classList.add("hidden"); 363 filtersBtn?.classList.remove("btn-primary"); 364 filtersBtn?.classList.add("btn-outline"); 365 builderBtn?.classList.remove("btn-secondary"); 366 builderBtn?.classList.add("btn-outline"); 367 368 // Reset les inputs du formulaire 369 document.querySelectorAll("[data-contacts-search-target='input']").forEach((input) => { 370 if (input.type === "checkbox") { 371 input.checked = false; 372 } else { 373 input.value = ""; 374 if (input._flatpickr) input._flatpickr.clear(); 375 } 376 }); 377 378 // Afficher le badge du filtre actif (avec détails des params) 379 this.showActiveFilterBadge(filterName, params); 380 381 // Navigate - cherche le turbo-frame du listing par convention `<resource>_table` 382 // (ex: subscriptions_table, donation_promises_table, refunds_tableâ¦). 383 // Avant : on ne cherchait que contacts_table / donations_table â sur les autres 384 // pages la fonction tombait sur window.location.href = url qui ne reload pas 385 // car history.pushState venait de poser la même URL. Bug invisible : "rien ne 386 // se passe au clic". 387 history.pushState({}, "", url.toString()); 388 const frameId = `${this.resourceTypeValue}_table`; 389 const frame = document.getElementById(frameId); 390 if (frame) { 391 frame.src = url.toString(); 392 } else { 393 // Fallback robuste : Turbo.visit recharge même si l'URL est identique 394 // (contrairement à window.location.href). 395 if (window.Turbo?.visit) { 396 window.Turbo.visit(url.toString()); 397 } else { 398 window.location.reload(); 399 } 400 } 401 } 402 403 showActiveFilterBadge(name, params = {}) { 404 // Supprimer l'ancien badge si existant 405 document.getElementById("active-filter-badge")?.remove(); 406 407 // Construire les chips des critères appliqués (mêmes mappings que le modal "Sauvegarder") 408 const chips = []; 409 Object.entries(params).forEach(([key, value]) => { 410 if (key === "page" || key === "sort") return; 411 if (value == null || value === "") return; 412 413 // Label du paramètre - `tag_ids[]` ou `tag_ids` â "Groupe" 414 const labelKey = key.replace(/\[\]$/, "") + "[]"; 415 const paramLabel = this.paramLabels[key] || this.paramLabels[labelKey] || key.replace(/_/g, " "); 416 417 // Valeur affichable 418 let displayValue; 419 if (Array.isArray(value)) { 420 displayValue = `${value.length} sélection${value.length > 1 ? "s" : ""}`; 421 } else if (key === "query") { 422 displayValue = "Actif"; 423 } else { 424 displayValue = this.valueLabels[value] || value; 425 } 426 427 chips.push(`
428 <span class="inline-flex items-center gap-1 px-2 py-0.5 bg-white rounded-md text-[11px] border border-primary/20"> 429 <span class="text-base-content/55">${this._escapeHtml(paramLabel)}</span> 430 <span class="text-base-content font-medium">${this._escapeHtml(displayValue)}</span> 431 </span> 432 `); 433 }); 434 435 const badge = document.createElement("div"); 436 badge.id = "active-filter-badge"; 437 badge.className = "bg-primary/10 text-primary rounded-xl p-2.5 mb-1.5"; 438 badge.innerHTML = ` 439 <div class="flex items-center gap-2"> 440 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 flex-shrink-0"><path stroke-linecap="round" stroke-linejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"/></svg> 441 <span class="font-semibold text-sm flex-1 min-w-0 truncate">${this._escapeHtml(name)}</span> 442 <button type="button" class="btn btn-ghost btn-xs btn-circle" data-action="click->saved-filters#clearActiveFilter" title="Désactiver le filtre"> 443 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3 h-3"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg> 444 </button> 445 </div> 446 ${chips.length > 0 ? `<div class="flex flex-wrap gap-1 mt-2">${chips.join("")}</div>` : ""} 447 `; 448 449 // Insérer après la barre de recherche 450 const searchCard = document.querySelector("[data-controller='contacts-search'] > .card"); 451 if (searchCard) searchCard.after(badge); 452 } 453 454 clearActiveFilter() { 455 document.getElementById("active-filter-badge")?.remove(); 456 457 // Reset URL 458 const url = new URL(window.location.href); 459 Array.from(url.searchParams.keys()).forEach((key) => 460 url.searchParams.delete(key), 461 ); 462 url.searchParams.set("page", 1); 463 history.pushState({}, "", url.toString()); 464 465 // Même convention que applyFilter : frame id = `<resource>_table` 466 const frame = document.getElementById(`${this.resourceTypeValue}_table`); 467 if (frame) { 468 frame.src = url.toString(); 469 } else if (window.Turbo?.visit) { 470 window.Turbo.visit(url.toString()); 471 } 472 } 473 474 paramLabels = { 475 q: "Recherche", 476 contact_type: "Type de contact", 477 email_filter: "Email", 478 phone_filter: "Téléphone", 479 city: "Ville", 480 min_donations: "Dons minimum", 481 min_amount: "Montant min", 482 max_amount: "Montant max", 483 last_donation_after: "Dernier don après", 484 last_donation_before: "Dernier don avant", 485 donation_history: "Historique dons", 486 subscription_status: "Abonnement", 487 inactivity: "Activité", 488 query: "Filtre avancé", 489 "tag_ids[]": "Groupe", 490 campaign_id: "Campagne", 491 category_id: "Catégorie", 492 mode: "Mode de paiement", 493 donation_type: "Type de don", 494 date_from: "Date du", 495 date_to: "Date au", 496 anonymous: "Anonyme", 497 cerfa: "Cerfa", 498 }; 499 500 valueLabels = { 501 individual: "Particulier", 502 company: "Entreprise", 503 mixed: "Particulier & Entreprise", 504 present: "Renseigné", 505 blank: "Non renseigné", 506 has_donated: "A déjà donné", 507 never_donated: "Jamais donné", 508 active: "Actif", 509 inactive: "Inactif", 510 "6m": "Inactif +6 mois", 511 "1y": "Inactif +1 an", 512 "2y": "Inactif +2 ans", 513 new: "Nouveau", 514 one_time: "Ponctuel", 515 recurring: "Récurrent", 516 with: "Avec cerfa", 517 without: "Sans cerfa", 518 true: "Oui", 519 false: "Non", 520 }; 521 522 openSaveModal() { 523 const modal = document.getElementById("save-filter-modal"); 524 if (!modal) return; 525 526 const currentParams = new URLSearchParams(window.location.search); 527 this._pendingFilterParams = {}; 528 const labels = []; 529 530 currentParams.forEach((value, key) => { 531 if (key === "page" || key === "sort") return; 532 if (key.endsWith("[]")) { 533 if (!this._pendingFilterParams[key]) 534 this._pendingFilterParams[key] = []; 535 this._pendingFilterParams[key].push(value); 536 } else { 537 this._pendingFilterParams[key] = value; 538 } 539 const label = this.paramLabels[key] || this.paramLabels[key.replace("[]", "") + "[]"] || key; 540 let displayValue = this.valueLabels[value] || value; 541 // Ne pas afficher le JSON brut des filtres avancés 542 if (key === "query") displayValue = "Actif"; 543 labels.push(`<strong>${label}</strong> : ${displayValue}`); 544 }); 545 546 const noFilters = document.getElementById("save-filter-no-filters"); 547 const formContent = document.getElementById("save-filter-form-content"); 548 const summary = document.getElementById("save-filter-summary"); 549 const nameInput = document.getElementById("save-filter-name"); 550
551 if (Object.keys(this._pendingFilterParams).length === 0) { 552 noFilters?.classList.remove("hidden"); 553 formContent?.classList.add("hidden"); 554 } else { 555 noFilters?.classList.add("hidden"); 556 formContent?.classList.remove("hidden"); 557 if (summary) 558 summary.innerHTML = labels.map((l) => `<div class="py-0.5">${l}</div>`).join(""); 559 if (nameInput) nameInput.value = ""; 560 } 561 562 modal.showModal(); 563 setTimeout(() => nameInput?.focus(), 100); 564 } 565 566 async confirmSave() { 567 const nameInput = document.getElementById("save-filter-name"); 568 const name = nameInput?.value?.trim(); 569 if (!name) { 570 nameInput?.classList.add("input-error"); 571 nameInput?.focus(); 572 setTimeout(() => nameInput?.classList.remove("input-error"), 2000); 573 return; 574 } 575 576 const notifyInput = document.getElementById("save-filter-notify"); 577 const notifyOnChange = notifyInput?.checked === true; 578 579 const csrf = document.querySelector('meta[name="csrf-token"]')?.content; 580 try { 581 const response = await fetch(this.urlValue, { 582 method: "POST", 583 headers: { 584 "Content-Type": "application/json", 585 "X-CSRF-Token": csrf, 586 }, 587 body: JSON.stringify({ 588 name, 589 resource_type: this.resourceTypeValue, 590 filter_params: this._pendingFilterParams, 591 notify_on_change: notifyOnChange, 592 }), 593 }); 594 595 const modal = document.getElementById("save-filter-modal"); 596 if (response.ok) { 597 modal?.close(); 598 this.showFlash(`Filtre "${name}" sauvegardé`, "success"); 599 } else { 600 nameInput?.classList.add("input-error"); 601 } 602 } catch { 603 // Silently fail 604 } 605 } 606 607 async deleteFilter(event) { 608 event.stopPropagation(); 609 const btn = event.currentTarget;
610 const id = btn.dataset.filterId; 611 // Resolve card via classe dédiée (le bouton lui-même porte aussi data-filter-id 612 // donc closest("[data-filter-id]") matche le bouton, pas la card). 613 const card = btn.closest(".saved-filter-card"); 614 const name = card?.querySelector("[data-filter-name]")?.textContent?.trim(); 615 616 const csrf = document.querySelector('meta[name="csrf-token"]')?.content; 617 try { 618 const response = await fetch(`${this.urlValue}/${id}`, { 619 method: "DELETE", 620 headers: { "X-CSRF-Token": csrf }, 621 }); 622 if (response.ok) { 623 // Retire la card immédiatement de l'UI pour un feedback instantané 624 // (avant le re-fetch async qui rebuild la liste complète). 625 card?.remove(); 626 627 this.showFlash(`Filtre "${name || ""}" supprimé`, "success"); 628 629 // Si ce filtre était actif, le désactiver 630 const badge = document.getElementById("active-filter-badge"); 631 if (badge) { 632 this.clearActiveFilter(); 633 } 634 635 // Rebuild la liste du drawer (et fallback sur le legacy dropdown si présent) 636 if (document.getElementById("saved-filters-drawer-list")) { 637 await this.loadDrawerFilters(); 638 } else { 639 await this.loadFilters(); 640 } 641 } 642 } catch { 643 // Silently fail 644 } 645 } 646 647 showFlash(message, type = "success") { 648 const container = document.getElementById("flash-container"); 649 if (!container) return; 650 651 const colors = { 652 success: "bg-success text-success-content", 653 error: "bg-error text-error-content", 654 }; 655 const icons = { 656 success: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />', 657 error: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M12 3a9 9 0 110 18 9 9 0 010-18z" />', 658 }; 659 660 const el = document.createElement("div"); 661 el.className = `fixed bottom-4 right-4 left-4 sm:left-auto z-[60] sm:min-w-72 sm:max-w-sm`; 662 el.innerHTML = ` 663 <div role="alert" class="alert shadow-lg border-0 ${colors[type]} py-2 px-4 rounded-xl text-sm flex items-center gap-2 animate-flash-in"> 664 <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">${icons[type]}</svg> 665 <span class="flex-1">${message}</span> 666 <button type="button" class="btn btn-ghost btn-xs btn-square" onclick="this.closest('.fixed').remove()"> 667 <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 668 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> 669 </svg> 670 </button> 671 </div> 672 `; 673 674 document.body.appendChild(el); 675 setTimeout(() => { 676 el.querySelector(".alert")?.classList.add("animate-flash-out"); 677 setTimeout(() => el.remove(), 500); 678 }, 4000); 679 } 680}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.