PageSourceSearch

https://www.allodons.fr/assets/controllers/saved_filters_controller-8f1ff79b.js

js allodons.fr collected 2026-09-26 09:18:01 UTC 28,678 bytes, 680 lines download raw bytes

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      "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;"
265    }[c]));
266  }
267
268  _escapeAttr(s) {
269    return String(s).replace(/'/g, "&#39;");
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.