PageSourceSearch

https://ffcvcalendar.netlify.app/js/admin.js

js ffcvcalendar.netlify.app collected 2026-10-03 10:43:55 UTC 42,663 bytes, 993 lines download raw bytes

1// ============================================================================
2//  Espace administrateur : connexion, validation, édition des courses
3// ============================================================================
4let courses = [];
5let coursesAffichees = []; // liste actuellement visible (après filtres) → export
6let ongletActif = "propositions";
7let moiUserId = null; // identifiant de l'administrateur connecté
8
9document.addEventListener("DOMContentLoaded", async () => {
10  if (configIncomplete()) {
11    document.getElementById("bandeau-config").classList.remove("masque");
12    return;
13  }
14  peuplerReferentiels();
15  brancherEvenements();
16
17  // Session déjà active ?
18  const { data } = await sb.auth.getSession();
19  if (data.session) {
20    await verifierAdminEtAfficher();
21  } else {
22    montrerConnexion();
23  }
24});
25
26// --- Écrans ---------------------------------------------------------------
27function montrerConnexion() {
28  document.getElementById("ecran-connexion").classList.remove("masque");
29  document.getElementById("ecran-admin").classList.add("masque");
30  document.getElementById("btn-deconnexion").classList.add("masque");
31  document.getElementById("lien-doc").classList.add("masque");
32}
33function montrerAdmin() {
34  document.getElementById("ecran-connexion").classList.add("masque");
35  document.getElementById("ecran-admin").classList.remove("masque");
36  document.getElementById("btn-deconnexion").classList.remove("masque");
37  document.getElementById("lien-doc").classList.remove("masque");
38}
39
40// --- Connexion ------------------------------------------------------------
41async function connexion(e) {
42  e.preventDefault();
43  const alerte = document.getElementById("conn-alerte");
44  const btn = document.getElementById("btn-connexion");
45  alerte.innerHTML = "";
46  btn.disabled = true; btn.textContent = "Connexion…";
47
48  const email = document.getElementById("email").value.trim();
49  const password = document.getElementById("mdp").value;
50  const { error } = await sb.auth.signInWithPassword({ email, password });
51
52  btn.disabled = false; btn.textContent = "Se connecter";
53  if (error) {
54    alerte.innerHTML = `<div class="alerte alerte-err">Identifiants incorrects.</div>`;
55    return;
56  }
57  await journaliserConnexion();
58  await verifierAdminEtAfficher();
59}
60
61// --- Mot de passe oublié ---------------------------------------------------
62function afficherFormulaireOubli(oui) {
63  document.getElementById("form-connexion").classList.toggle("masque", oui);
64  document.getElementById("lien-oubli").parentElement.classList.toggle("masque", oui);
65  document.getElementById("form-oubli").classList.toggle("masque", !oui);
66  document.getElementById("conn-alerte").innerHTML = "";
67  document.getElementById("oubli-alerte").innerHTML = "";
68}
69
70async function envoyerOubli(e) {
71  e.preventDefault();
72  const alerte = document.getElementById("oubli-alerte");
73  const btn = document.getElementById("btn-oubli");
74  const email = document.getElementById("oubli-email").value.trim();
75  if (!email) return;
76
77  const webhook = (window.APP_CONFIG && window.APP_CONFIG.MAKE_WEBHOOK) || "";
78  if (!webhook) {
79    alerte.innerHTML = `<div class="alerte alerte-err">Webhook non configuré (MAKE_WEBHOOK dans js/config.js).</div>`;
80    return;
81  }
82
83  btn.disabled = true; btn.textContent = "Envoi…";
84
85  // Appel du webhook Make (form-urlencoded). Le scénario Make génère le lien de
86  // récupération via l'API Admin Supabase (service_role côté Make) et envoie
87  // l'email vers reset.html?token_hash=...&type=recovery.
88  const params = new URLSearchParams({
89    type: "recovery",
90    email,
91    redirect_to: new URL("reset.html", window.location.href).href,
92    app: "Calendrier des courses FFCV",
93  });
94  try {
95    await fetch(webhook, { method: "POST", mode: "no-cors", body: params });
96  } catch (err) {
97    /* message neutre quoi qu'il arrive */
98  }
99
100  btn.disabled = false; btn.textContent = "Envoyer le lien";
101  // Message neutre (on ne divulgue pas l'existence du compte).
102  alerte.innerHTML = `<div class="alerte alerte-ok">Si un compte existe pour cet email, un lien de réinitialisation vient d'être envoyé. Pensez à vérifier vos courriers indésirables.</div>`;
103  document.getElementById("form-oubli").reset();
104}
105
106async function verifierAdminEtAfficher() {
107  const { data, error } = await sb.rpc("is_admin");
108  if (error || !data) {
109    // Pas administrateur : si c'est un arbitre, on le route vers son espace.
110    const { data: estArbitre } = await sb.rpc("is_arbitre");
111    if (estArbitre) { window.location.replace("arbitre.html"); return; }
112    // Ni admin ni arbitre : on refuse.
113    await sb.auth.signOut();
114    montrerConnexion();
115    document.getElementById("conn-alerte").innerHTML =
116      `<div class="alerte alerte-err">Ce compte n'a pas de droits d'accès.</div>`;
117    return;
118  }
119  montrerAdmin();
120  const { data: u } = await sb.auth.getUser();
121  moiUserId = u?.user?.id || null;
122  await chargerParametres();
123  peuplerReferentiels();
124  await chargerCourses();
125}
126
127async function deconnexion() {
128  await journaliserConnexion("deconnexion"); // avant signOut (session encore active)
129  await sb.auth.signOut();
130  courses = [];
131  montrerConnexion();
132}
133
134// --- Chargement des courses -----------------------------------------------
135async function chargerCourses() {
136  const { data, error } = await sb
137    .from("courses")
138    .select("*")
139    .order("date_debut", { ascending: true });
140
141  if (error) {
142    alerteAdmin(`Erreur de chargement : ${error.message}`, true);
143    return;
144  }
145  courses = data || [];
146  remplirSaisonsAdmin();
147  remplirClubs();
148  remplirLiguesAdmin();
149  peuplerFiltresColonnes();
150  majCompteurPropositions();
151  rendreTableau();
152}
153
154// Alimente les listes déroulantes de filtres de colonne à partir des données.
155function peuplerFiltresColonnes() {
156  const uniques = (champ) =>
157    [...new Set(courses.map((c) => c[champ]).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr"));
158  remplirSelectFiltre('[data-filtre="club"]', uniques("club"));
159  remplirSelectFiltre('[data-filtre="ligue"]', uniques("ligue"));
160  remplirSelectFiltre('[data-filtre="type_evenement"]', nomsTypes());
161  remplirSelectFiltre('[data-filtre="classes"]', CLASSES);
162}
163
164// (Re)remplit un <select> de filtre en conservant la valeur sélectionnée.
165function remplirSelectFiltre(selecteur, valeurs) {
166  const sel = document.querySelector(selecteur);
167  if (!sel) return;
168  const courant = sel.value;
169  const premier = sel.options[0] ? sel.options[0].outerHTML : '<option value="">Tous</option>';
170  sel.innerHTML = premier + valeurs.map((v) => `<option value="${echapper(v)}">${echapper(v)}</option>`).join("");
171  sel.value = courant;
172}
173
174// Alimente l'autocomplétion des clubs à partir des clubs déjà enregistrés.
175function remplirClubs() {
176  const dl = document.getElementById("liste-clubs");
177  if (!dl) return;
178  const clubs = [...new Set(courses.map((c) =>
178 c.club).filter(Boolean))].sort();
179  dl.innerHTML = clubs.map((c) => `<option value="${echapper(c)}"></option>`).join("");
180}
181
182// Alimente l'autocomplétion des ligues à partir des ligues déjà enregistrées.
183function remplirLiguesAdmin() {
184  const dl = document.getElementById("liste-ligues");
185  if (!dl) return;
186  const ligues = [...new Set(courses.map((c) => c.ligue).filter(Boolean))].sort();
187  dl.innerHTML = ligues.map((l) => `<option value="${echapper(l)}"></option>`).join("");
188}
189
190function remplirSaisonsAdmin() {
191  const sel = document.getElementById("a-saison");
192  const actuel = sel.value;
193  const saisons = [...new Set(courses.map((c) => c.saison).filter(Boolean))].sort().reverse();
194  sel.innerHTML = '<option value="">Toutes</option>';
195  saisons.forEach((s) => sel.add(new Option(s, s)));
196  sel.value = actuel;
197}
198
199function majCompteurPropositions() {
200  const n = courses.filter((c) => c.statut === "PROPOSEE").length;
201  document.getElementById("cpt-prop").textContent = n;
202}
203
204// --- Rendu du tableau ------------------------------------------------------
205function rendreTableau() {
206  const q = document.getElementById("a-recherche").value.trim().toLowerCase();
207  const saison = document.getElementById("a-saison").value;
208  const statut = document.getElementById("a-statut").value;
209
210  let liste = courses.slice();
211  if (ongletActif === "propositions") {
212    liste = liste.filter((c) => c.statut === "PROPOSEE");
213  }
214  if (saison) liste = liste.filter((c) => c.saison === saison);
215  if (statut) liste = liste.filter((c) => statutEffectif(c) === statut || c.statut === statut);
216  if (q) {
217    liste = liste.filter((c) =>
218      `${c.nom || ""} ${c.lieu || ""} ${c.type_evenement || ""}`.toLowerCase().includes(q)
219    );
220  }
221  liste = appliquerFiltresColonnes(liste);
222  coursesAffichees = liste;
223
224  const corps = document.getElementById("corps-tableau");
225  const vide = document.getElementById("tableau-vide");
226  if (!liste.length) {
227    corps.innerHTML = "";
228    vide.classList.remove("masque");
229    vide.innerHTML = ongletActif === "propositions"
230      ? `<span class="emoji">✅</span>Aucune proposition en attente.`
231      : `<span class="emoji">📭</span>Aucune course ne correspond.`;
232    return;
233  }
234  vide.classList.add("masque");
235
236  corps.innerHTML = liste.map(ligne).join("");
237  // Brancher les actions
238  corps.querySelectorAll("[data-action]").forEach((btn) => {
239    btn.addEventListener("click", () => {
240      const id = btn.dataset.id;
241      const action = btn.dataset.action;
242      if (action === "editer") ouvrirEdition(id);
243      else if (action === "valider") changerStatut(id, "VALIDEE");
244      else if (action === "refuser") changerStatut(id, "REFUSEE");
245    });
246  });
247}
248
249// Badge Oui/Non pour les colonnes de suivi.
250function ouiNon(v) {
251  return v ? `<span class="badge-on">Oui</span>` : `<span class="badge-off">Non</span>`;
252}
253
254// Applique les filtres de la ligne d'en-tête (texte, listes, Oui/Non).
255function appliquerFiltresColonnes(liste) {
256  const filtres = [...document.querySelectorAll("#ligne-filtres [data-filtre]")]
257    .map((el) => ({ champ: el.dataset.filtre, val: (el.value || "").trim().toLowerCase(), type: el.tagName }))
258    .filter((f) => f.val);
259  const ouiNonFiltres = [...document.querySelectorAll("#ligne-filtres [data-oui-non]")]
260    .map((el) => ({ champ: el.dataset.ouiNon, val: el.value }))
261    .filter((f) => f.val);
262
263  return liste.filter((c) => {
264    for (const f of filtres) {
265      let cible;
266      if (f.champ === "dates") cible = formatPlage(c.date_debut, c.date_fin);
267      else if (f.champ === "classes") cible = (c.classes || []).join(" ");
268      else cible = c[f.champ] || "";
269      cible = String(cible).toLowerCase();
270      // Les <select> exigent une égalité ; les champs texte, une inclusion.
271      if (f.type === "SELECT") {
272        if (f.champ === "classes") { if (!(c.classes || []).map((x) => x.toLowerCase()).includes(f.val)) return false; }
273        else if (cible !== f.val) return false;
274      } else if (!cible.includes(f.val)) {
275        return false;
276      }
277    }
278    for (const f of ouiNonFiltres) {
279      const present = !!c[f.champ];
280      if ((f.val === "oui") !== present) return false;
281    }
282    return true;
283  });
284}
285
286function ligne(c) {
287  const classes = (c.classes || []).slice(0, 4).join(", ") + ((c.classes || []).length > 4 ? "…" : "");
288  let actions = `<button class="btn btn-secondary btn-sm" data-action="editer" data-id="${c.id}">Modifier</button>`;
289  if (c.statut === "PROPOSEE") {
290    actions =
291      `<button class="btn btn-ok btn-sm" data-action="valider" data-id="${c.id}">Valider</button>` +
292      `<button class="btn btn-danger btn-sm" data-action="refuser" data-id="${c.id}">Refuser</button>` +
293      actions;
294  }
295  const propo = c.proposeur_nom
296    ? `<br /><small style="color:var(--texte-doux)">par ${echapper(c.proposeur_nom)}</small>`
297    : "";
298  return `
299    <tr>
300      <td><button class="btn btn-secondary btn-sm" data-action="editer" data-id="${c.id}">Modifier</button></td>
301      <td>${echapper(formatPlage(c.date_debut, c.date_fin))}</td>
302      <td>${echapper(c.nom || "—")}${propo}</td>
303      <td>${echapper(c.club || "—")}</td>
304      <td>${echapper(c.ligue || "—")}</td>
305      <td>${echapper(c.lieu || "—")}</td>
306      <td>${echapper(c.type_evenement || "—")}</td>
307      <td>${echapper(classes || "—")}</td>
308      <td>${echapper(c.directeur_course || "—")}</td>
309      <td>${echapper(c.directeur_jury || "—")}</td>
310      <td>${ouiNon(!!c.ers_url)}</td>
311      <td>${ouiNon(!!c.inscriptions_url)}</td>
312      <td>${ouiNon(!!c.resultat_url)}</td>
313      <td>${ouiNon(!!c.lm_directeur_course)}</td>
314      <td>${ouiNon(!!c.lm_president_jury)}</td>
315      <td>${ouiNon(!!c.ndf_directeur_course)}</td>
316      <td>${ouiNon(!!c.ndf_president_jury)}</td>
317      <td>${pastilleHTML(c)}</td>
318      <td><div class="actions-cell">${actions}</div></td>
319    </tr>`;
320}
321
322// --- Validation rapide -----------------------------------------------------
323async function changerStatut(id, statut) {
324  const { error } = await sb.from("courses").update({ statut }).eq("id", id);
325  if (error) { alerteAdmin(`Échec : ${error.message}`, true); return; }
326  const c = courses.find((x) => x.id === id);
327  if (c) c.statut = statut;
328  majCompteurPropositions();
329  rendreTableau();
330  alerteAdmin(statut === "VALIDEE" ? "Proposition validée : course publiée (À venir)." : "Proposition refusée.");
331}
332
333// --- Édition / création ----------------------------------------------------
334function ouvrirEdition(id) {
335  const form = document.getElementById("form-edition");
336  form.reset();
337  document.getElementById("edit-alerte").innerHTML = "";
338  const c = id ? courses.find((x) => x.id === id) : null;
339
340  document.getElementById("titre-edition").textContent = c ? "Modifier la course" : "Nouvelle course";
341  document.getElementById("btn-supprimer").style.display = c ? "" : "none";
342
343  form.id.value = c ? c.id : "";
344  form.nom.value = c?.nom || "";
345  form.club.value = c?.club || "";
346  form.ligue.value = c?.ligue || "";
347  form.lieu.value = c?.lieu || "";
348  form.type_evenement.value = c?.type_evenement || "";
349  form.date_debut.value = c?.date_debut || "";
350  form.date_fin.value = c?.date_fin || "";
351  form.saison.value = c?.saison || "";
352  // Sécurité : si le statut stocké n'est pas dans la liste sélectionnable, l'ajouter.
353  const codeStatut = c?.statut || "VALIDEE";
354  if (![...form.statut.options].some((o) => o.value === codeStatut)) {
355    form.statut.add(new Option(statutInfo(codeStatut).libelle, codeStatut));
356  }
357  form.statut.value = codeStatut;
358  form.directeur_course.value = c?.directeur_course || "";
359  form.directeur_jury.value = c?.directeur_jury || "";
360  form.inscriptions_url.value = c?.inscriptions_url || "";
361  form.ers_url.value = c?.ers_url || "";
362  form.resultat_url.value = c?.resultat_url || "";
363  form.lm_directeur_course.checked = !!c?.lm_directeur_course;
364  form.lm_president_jury.checked = !!c?.lm_president_jury;
365  form.ndf_directeur_course.checked = !!c?.ndf_directeur_course;
366  form.ndf_president_jury.checked = !!c?.ndf_president_jury;
367
368  const setClasses = new Set(c?.classes || []);
369  document.querySelectorAll('#edit-classes input[type="checkbox"]').forEach((cb) => {
370    cb.checked = setClasses.has(cb.value);
371  });
372
373  const bloc = document.getElementById("bloc-proposeur");
374  if (c && (c.proposeur_nom || c.proposeur_email || c.proposeur_message)) {
375    bloc.style.display = "";
376    document.getElementById("txt-proposeur").innerHTML =
377      `${echapper(c.proposeur_nom || "")}${c.proposeur_email ? " · " + echapper(c.proposeur_email) : ""}` +
378      `${c.proposeur_message ? "<br />« " + echapper(c.proposeur_message) + " »" : ""}`;
379  } else {
380    bloc.style.display = "none";
381  }
382
383  document.getElementById("modale-edition").classList.add("ouverte");
384}
385
386async function enregistrer(e) {
387  e.preventDefault();
388  const form = e.target;
389  const alerte = document.getElementById("edit-alerte");
390  const btn = document.getElementById("btn-enregistrer");
391  alerte.innerHTML = "";
392
393  const classes = [...document.querySelectorAll('#edit-classes input:checked')].map((c) => c.value);
394  const payload = {
395    nom: form.nom.value.trim() || null,
396    club: form.club.value.trim() || null,
397    ligue: form.ligue.value.trim() || null,
398    lieu: form.lieu.value.trim() || null,
399    type_evenement: form.type_evenement.value.trim() || null,
400    date_debut: form.date_debut.value || null,
401    date_fin: form.date_fin.value || form.date_debut.value || null,
402    saison: form.saison.value.trim() || null,
403    statut: form.statut.value,
404    classes,
405    directeur_course: form.directeur_course.value.trim() || null,
406    directeur_jury: form.directeur_jury.value.trim() || null,
407    lm_directeur_course: form.lm_directeur_course.checked,
408    lm_president_jury: form.lm_president_jury.checked,
409    ndf_directeur_course: form.ndf_directeur_course.checked,
410    ndf_president_jury: form.ndf_president_jury.checked,
411    resultat_url: form.resultat_url.value.trim() || null,
412    inscriptions_url: form.inscriptions_url.value.trim() || null,
413    ers_url: form.ers_url.value.trim() || null,
414  };
415
416  btn.disabled = true; btn.textContent = "Enregistrement…";
417  const id = form.id.value;
418  const { error } = id
419    ? await sb.from("courses").update(payload).eq("id", id)
420    : await sb.from("courses").insert(payload);
421  btn.disabled = false; btn.textContent = "Enregistrer";
422
423  if (error) {
424    alerte.innerHTML = `<div class="alerte alerte-err">Échec : ${echapper(error.message)}</div>`;
425    return;
426  }
427  document.getElementById("modale-edition").classList.remove("ouverte");
428  alerteAdmin(id ? "Course modifiée." : "Course créée.");
429  await chargerCourses();
430}
431
432async function supprimer() {
433  const id = document.getElementById("form-edition").id.value;
434  if (!id) return;
435  const c = courses.find((x) => x.id === id);
436  if (!confirm(`Supprimer définitivement « ${c?.nom || "cette course"} » (${c?.lieu || ""}) ?`)) return;
437
438  const { error } = await sb.from("courses").delete().eq("id", id);
439  if (error) { alerteAdmin(`Échec : ${error.message}`, true); return; }
440  document.getElementById("modale-edition").classList.remove("ouverte");
441  alerteAdmin("Course supprimée.");
442  await chargerCourses();
443}
444
445// --- Référentiels & évènements --------------------------------------------
446// Idempotent : peut être rappelé après chaque changement de paramètres.
447function peuplerReferentiels() {
448  const box = document.getElementById("edit-classes");
449  box.innerHTML = CLASSES.map(
450    (cl) => `<label class="case-classe"><input type="checkbox" value="${echapper(cl)}" /> ${echapper(cl)}</label>`
451  ).join("");
452
453  // Statuts sélectionnables dans l'éditeur.
454  const selStatut = document.getElementById("edit-statut");
455  selStatut.innerHTML = "";
456  STATUTS_LISTE.filter((s) => s.selectionnable).forEach((s) =>
457    selStatut.add(new Option(s.libelle, s.code))
458  );
459
460  // Filtre par statut (tous les statuts).
461  const filtreStatut = document.getElementById("a-statut");
462  const cur = filtreStatut.value;
463  filtreStatut.innerHTML = '<option value="">Tous</option>';
464  STATUTS_LISTE.forEach((s) => filtreStatut.add(new Option(s.libelle, s.code)));
465  filtreStatut.value = cur;
466
467  // Autocomplétion des types d'événement.
468  const dl = document.getElementById("liste-types");
469  dl.innerHTML = nomsTypes().map((t) => `<option value="${echapper(t)}"></option>`).join("");
470}
471
472function brancherEvenements() {
473  document.getElementById("form-connexion").addEventListener("submit", connexion);
474  document.getElementById("btn-deconnexion").addEventListener("click", deconnexion);
475
476  // Mot de passe oublié
477  document.getElementById("lien-oubli").addEventListener("click", (e) => { e.preventDefault(); afficherFormulaireOubli(true); });
478  document.getElementById("lien-retour-connexion").addEventListener("click", (e) => { e.preventDefault(); afficherFormulaireOubli(false); });
479  document.getElementById("form-oubli").addEventListener("submit", envoyerOubli);
480
481  document.querySelectorAll(".onglet").forEach((o) =>
482    o.addEventListener("click", () => basculerOnglet(o.dataset.onglet))
483  );
484
485  // Paramètres : ajout d'un type / d'un statut + actions par ligne (délégation)
486  document.getElementById("ajout-type").addEventListener("click", ajouterLigneType);
487  document.getElementById("ajout-statut").addEventListener("click", ajouterLigneStatut);
488  document.getElementById("liste-param-types").addEventListener("click", actionParamType);
489  document.getElementById("liste-param-statuts").addEventListener("click", actionParamStatut);
490
491  // Journal de connexions
492  document.getElementById("btn-rafraichir-connexions").addEventListener("click", () => {
493    chargerConnexions();
494    alerteConnexions("Journal actualisé.");
495  });
496  document.getElementById("conn-filtre-user").addEventListener("change", rendreConnexions);
497  document.getElementById("conn-filtre-evt").addEventListener("change", rendreConnexions);
498  document.getElementById("btn-purger-connexions").addEventListener("click", purgerConnexions);
499
500  // Utilisateurs & rôles (admins + arbitres fusionnés)
501  document.getElementById("form-utilisateur").addEventListener("submit", ajouterUtilisateur);
502  document.getElementById("liste-utilisateurs").addEventListener("change", toggleRole);
503  document.getElementById("liste-utilisateurs").addEventListener("click", retirerUtilisateur);
504
505  ["a-recherche", "a-saison"].forEach((id) => {
506    const el = document.getElementById(id);
507    el.addEventListener("input", rendreTableau);
508    el.addEventListener("change", rendreTableau);
509  });
510
511  // Filtres par colonne (délégation sur la ligne d'en-tête).
512  const ligneFiltres = document.getElementById("ligne-filtres");
513  ligneFiltres.addEventListener("input", rendreTableau);
514  ligneFiltres.addEventListener("change", rendreTableau);
515  document.getElementById("btn-reset-filtres").addEventListener("click", reinitialiserFiltres);
516  document.getElementById("btn-export-xlsx").addEventListener("click", exporterXlsx);
517
518  document.getElementById("btn-nouvelle").addEventListener("click", () => ouvrirEdition(null));
519
520  const modale = document.getElementById("modale-edition");
521  modale.querySelectorAll("[data-fermer]").forEach((b) =>
522    b.addEventListener("click", () => modale.classList.remove("ouverte"))
523  );
524  modale.addEventListener("click", (e) => { if (e.target === modale) modale.classList.remove("ouverte"); });
525
526  document.getElementById("form-edition").addEventListener("submit", enregistrer);
527  document.getElementById("btn-supprimer").addEventListener("click", supprimer);
528}
529
530// --- Onglets ---------------------------------------------------------------
531function basculerOnglet(nom) {
532  ongletActif = nom;
533  document.querySelectorAll(".onglet").forEach((x) =>
534    x.classList.toggle("actif", x.dataset.onglet === nom)
535  );
536  const param = nom === "parametres";
537  document.getElementById("zone-parametres").classList.toggle("masque", !param);
538  document.getElementById("admin-filtres").classList.toggle("masque", param);
539  document.querySelector(".tableau-wrap").classList.toggle("masque", param);
540  document.getElementById("btn-nouvelle").classList.toggle("masque", param);
541  document.getElementById("btn-export-xlsx").classList.toggle("masque", param);
542  if (param) {
543    document.getElementById("tableau-vide").classList.add("masque");
544    rendreParametresUI();
545  } else {
546    rendreTableau();
547  }
548}
549
550// ===========================================================================
551//  Paramètres : types d'événement & statuts (CRUD)
552// ===========================================================================
553function toHexColor(c) {
554  return typeof c === "string" && /^#[0-9a-fA-F]{6}$/.test(c) ? c : "#0035cf";
555}
556
557function genererCode(libelle) {
558  const code = (libelle || "")
559    .normalize("NFD").replace(/[̀-ͯ]/g, "") // retire les accents
560    .toUpperCase().replace(/[^A-Z0-9]+/g, " ").trim().slice(0, 40);
561  return code || "STATUT";
562}
563
564function rendreParametresUI() {
565  rendreParamTypes();
566  rendreParamStatuts();
567  chargerUtilisateurs();
568  chargerConnexions();
569}
570
571function rendreParamTypes() {
572  const box = document.getElementById("liste-param-types");
573  box.innerHTML = TYPES.map(ligneParamType).join("");
574}
575function ligneParamType(t) {
576  return `<div class="param-ligne" data-id="${t.id || ""}">
577    <input type="color" value="${toHexColor(t.couleur)}" data-champ="couleur" title="Couleur de la tuile" />
578    <input type="text" value="${echapper(t.nom || "")}" data-champ="nom" placeholder="Nom du type" />
579    <input type="number" value="${t.ordre || 0}" data-champ="ordre" class="param-ordre" title="Ordre d'affichage" />
580    <button class="btn btn-ok btn-sm" data-act="save">Enregistrer</button>
581    <button class="btn btn-danger btn-sm" data-act="del" ${t.id ? "" : "disabled"}>Suppr.</button>
582  </div>`;
583}
584function ajouterLigneType() {
585  document.getElementById("liste-param-types").insertAdjacentHTML(
586    "beforeend",
587    ligneParamType({ couleur: "#0035cf", ordre: (TYPES.length + 1) })
588  );
589}
590async function actionParamType(e) {
591  const btn = e.target.closest("[data-act]");
592  if (!btn) return;
593  const ligne = btn.closest(".param-ligne");
594  const id = ligne.dataset.id;
595  if (btn.dataset.act === "del") {
596    if (!id) { ligne.remove(); return; }
597    if (!confirm("Supprimer ce type d'événement ?")) return;
598    const { error } = await sb.from("types_evenement").delete().eq("id", id);
599    if (error) return alerteAdmin(`Échec : ${error.message}`, true);
600    await rechargerParametres("Type supprimé.");
601  } else if (btn.dataset.act === "save") {
602    const payload = {
603      nom: ligne.querySelector('[data-champ="nom"]').value.trim(),
604      couleur: ligne.querySelector('[data-champ="couleur"]').value,
605      ordre: parseInt(ligne.querySelector('[data-champ="ordre"]').value, 10) || 0,
606    };
607    if (!payload.nom) return alerteAdmin("Le nom du type est obligatoire.", true);
608    const { error } = id
609      ? await sb.from("types_evenement").update(payload).eq("id", id)
610      : await sb.from("types_evenement").insert(payload);
611    if (error) return alerteAdmin(`Échec : ${error.message}`, true);
612    await rechargerParametres("Type enregistré.");
613  }
614}
615
616function rendreParamStatuts() {
617  const box = document.getElementById("liste-param-statuts");
618  box.innerHTML = STATUTS_LISTE.map(ligneParamStatut).join("");
619}
620function ligneParamStatut(s) {
621  return `<div class="param-ligne" data-id="${s.id || ""}" data-code="${echapper(s.code || "")}" data-systeme="${s.systeme ? 1 : 0}">
622    <input type="color" value="${toHexColor(s.couleur)}" data-champ="couleur" title="Couleur de la pastille" />
623    <input type="text" value="${echapper(s.libelle || "")}" data-champ="libelle" placeholder="Libellé" />
624    <label class="param-check" title="Visible dans le calendrier public">
625      <input type="checkbox" data-champ="visible_public" ${s.visible_public ? "checked" : ""} /> Public
626    </label>
627    <input type="number" value="${s.ordre || 0}" data-champ="ordre" class="param-ordre" title="Ordre d'affichage" />
628    ${s.systeme ? '<span class="param-sys" title="Statut protégé">système</span>' : ""}
629    <button class="btn btn-ok btn-sm" data-act="save">Enregistrer</button>
630    <button class="btn btn-danger btn-sm" data-act="del" ${s.systeme || !s.id ? "disabled" : ""}>Suppr.</button>
631  </div>`;
632}
633function ajouterLigneStatut() {
634  document.getElementById("liste-param-statuts").insertAdjacentHTML(
635    "beforeend",
636    ligneParamStatut({ couleur: "#64727e", ordre: (STATUTS_LISTE.length + 1), visible_public: false, systeme: false })
637  );
638}
639async function actionParamStatut(e) {
640  const btn = e.target.closest("[data-act]");
641  if (!btn) return;
642  const ligne = btn.closest(".param-ligne");
643  const id = ligne.dataset.id;
644  if (btn.dataset.act === "del") {
645    if (ligne.dataset.systeme === "1") return;
646    if (!id) { ligne.remove(); return; }
647    if (!confirm("Supprimer ce statut ?")) return;
648    const { error } = await sb.from("statuts").delete().eq("id", id);
649    if (error) return alerteAdmin(`Échec : ${error.message}`, true);
650    await rechargerParametres("Statut supprimé.");
651  } else if (btn.dataset.act === "save") {
652    const libelle = ligne.querySelector('[data-champ="libelle"]').value.trim();
653    if (!libelle) return alerteAdmin("Le libellé du statut est obligatoire.", true);
654    const base = {
655      libelle,
656      couleur: ligne.querySelector('[data-champ="couleur"]').value,
657      ordre: parseInt(ligne.querySelector('[data-champ="ordre"]').value, 10) || 0,
658      visible_public: ligne.querySelector('[data-champ="visible_public"]').checked,
659    };
660    let error;
661    if (id) {
662      ({ error } = await sb.from("statuts").update(base).eq("id", id));
663    } else {
664      // Nouveau statut personnalisé : code dérivé du libellé, non temporel.
665      const payload = { ...base, code: genererCode(libelle), soumis_dates: false, selectionnable: true, systeme: false };
666      ({ error } = await sb.from("statuts").insert(payload));
667    }
668    if (error) {
669      return alerteAdmin(
670        error.code === "23505" ? "Un statut avec ce code existe déjà." : `Échec : ${error.message}`,
671        true
672      );
673    }
674    await rechargerParametres("Statut enregistré.");
675  }
676}
677
678// ===========================================================================
679//  Gestion unifiée des utilisateurs & rôles (Administrateur / Arbitre)
680// ===========================================================================
681async function chargerUtilisateurs() {
682  const box = document.getElementById("liste-utilisateurs");
683  if (!box) return;
684  const { data, error } = await sb.rpc("list_utilisateurs");
685  if (error) {
686    box.innerHTML = `<p class="aide">Gestion des utilisateurs indisponible (exécutez « migration_admins.sql » et « migration_arbitres.sql »).</p>`;
687    return;
688  }
689  if (!data || !data.length) {
690    box.innerHTML = `<p class="aide">Aucun utilisateur avec un rôle. Ajoutez-en un ci-dessous.</p>`;
691    return;
692  }
693  box.innerHTML = data.map(ligneUtilisateur).join("");
694}
695
696function ligneUtilisateur(u) {
697  const moi = u.user_id === moiUserId;
698  return `<div class="param-ligne" data-id="${echapper(u.user_id)}" data-email="${echapper(u.email || "")}">
699    <span style="flex:1;min-width:170px">👤 ${echapper(u.email || u.user_id)}${moi ? " <em style='color:var(--texte-doux)'>(vous)</em>" : ""}</span>
700    <label class="param-check"><input type="checkbox" data-role="admin" ${u.est_admin ? "checked" : ""} ${moi ? "disabled title='Vous ne pouvez pas retirer votre propre rôle admin'" : ""} /> Admin</label>
701    <label class="param-check"><input type="checkbox" data-role="arbitre" ${u.est_arbitre ? "checked" : ""} /> Arbitre</label>
702    <button class="btn btn-danger btn-sm" data-act="del-utilisateur" ${moi ? "disabled" : ""}>Retirer</button>
703  </div>`;
704}
705
706// Active/retire un rôle via les cases à cocher.
707async function toggleRole(e) {
708  const cb = e.target.closest("input[data-role]");
709  if (!cb) return;
710  const ligne = cb.closest(".param-ligne");
711  const email = ligne.dataset.email;
712  const userId = ligne.dataset.id;
713  const actif = cb.checked;
714  let error;
715  if (cb.dataset.role === "admin") {
716    ({ error } = actif
717      ? await sb.rpc("add_admin_by_email", { p_email: email })
718      : await sb.rpc("remove_admin", { p_user_id: userId }));
719  } else {
720    ({ error } = actif
721      ? await sb.rpc("add_arbitre_by_email", { p_email: email })
722      : await sb.rpc("remove_arbitre", { p_user_id: userId }));
723  }
724  if (error) {
725    cb.checked = !actif; // annule visuellement
726    alerteUtil(`Échec : ${error.message}`, true);
727    return;
728  }
729  alerteUtil("Rôles mis à jour.");
730}
731
732// Retire tous les rôles d'un utilisateur.
733async function retirerUtilisateur(e) {
734  const btn = e.target.closest("[data-act='del-utilisateur']");
735  if (!btn) return;
736  const userId = btn.closest(".param-ligne").dataset.id;
737  if (!confirm("Retirer tous les rôles (Admin + Arbitre) de cette personne ?")) return;
738  await sb.rpc("remove_arbitre", { p_user_id: userId });
739  const { error } = await sb.rpc("remove_admin", { p_user_id: userId });
740  if (error) { alerteUtil(`Échec : ${error.message}`, true); return; }
741  chargerUtilisateurs();
742}
743
744// Ajoute un utilisateur (crée le compte si mot de passe) et applique les rôles cochés.
745async function ajouterUtilisateur(e) {
746  e.preventDefault();
747  const btn = document.getElementById("btn-ajout-utilisateur");
748  const email = document.getElementById("u-email").value.trim();
749  const mdp = document.getElementById("u-mdp").value;
750  const roleAdmin = document.getElementById("u-admin").checked;
751  const roleArbitre = document.getElementById("u-arbitre").checked;
752  if (!email) return;
753  if (!roleAdmin && !roleArbitre) { alerteUtil("Cochez au moins un rôle (Admin ou Arbitre).", true); return; }
754
755  btn.disabled = true; btn.textContent = "…";
756
757  // Créer le compte si un mot de passe est fourni (client isolé : ne déconnecte pas l'admin).
758  if (mdp) {
759    const sbTmp = window.supabase.createClient(
760      window.APP_CONFIG.SUPABASE_URL, window.APP_CONFIG.SUPABASE_ANON_KEY,
761      { auth: { persistSession: false, autoRefreshToken: false, storageKey: "sb-inscription-temp" } }
762    );
763    const { error: eSignup } = await sbTmp.auth.signUp({ email, password: mdp });
764    if (eSignup && !/already registered|already been registered/i.test(eSignup.message)) {
765      btn.disabled = false; btn.textContent = "Ajouter";
766      alerteUtil(`Création du compte impossible : ${eSignup.message}`, true);
767      return;
768    }
769  }
770
771  let introuvable = false, echec = "";
772  if (roleAdmin) {
773    const { data, error } = await sb.rpc("add_admin_by_email", { p_email: email });
774    if (error) echec = error.message; else if (data === "introuvable") introuvable = true;
775  }
776  if (!echec && roleArbitre) {
777    const { data, error } = await sb.rpc("add_arbitre_by_email", { p_email: email });
778    if (error) echec = error.message; else if (data === "introuvable") introuvable = true;
779  }
780
781  btn.disabled = false; btn.textContent = "Ajouter";
782  if (echec) { alerteUtil(`Échec : ${echec}`, true); return; }
783  if (introuvable) {
784    alerteUtil(`Aucun compte pour « ${email} ». Renseignez un mot de passe initial pour créer le compte.`, true);
785    return;
786  }
787  document.getElementById("form-utilisateur").reset();
788  alerteUtil(`Utilisateur ajouté.${mdp ? " Selon la configuration Supabase, la personne devra peut-être confirmer son email avant de se connecter." : ""}`);
789  chargerUtilisateurs();
790}
791
792// ===========================================================================
793//  Journal de connexions (réservé aux administrateurs)
794// ===========================================================================
795let connexionsData = []; // dernières entrées chargées (pour filtrage client)
796
797async function chargerConnexions() {
798  const corps = document.getElementById("corps-connexions");
799  if (!corps) return;
800  const { data, error } = await sb.rpc("list_connexions", { p_limit: 200 });
801  if (error) {
802    connexionsData = [];
803    corps.innerHTML = `<tr><td colspan="5" class="aide">Journal indisponible (exécutez « migration_connexions.sql »).</td></tr>`;
804    return;
805  }
806  connexionsData = data || [];
807  remplirFiltreUtilisateurs();
808  rendreConnexions();
809}
810
811// Alimente la liste déroulante « Utilisateur » du journal.
812function remplirFiltreUtilisateurs() {
813  const sel = document.getElementById("conn-filtre-user");
814  if (!sel) return;
815  const courant = sel.value;
816  const emails = [...new Set(connexionsData.map((x) =>
816 x.email).filter(Boolean))]
817    .sort((a, b) => a.localeCompare(b, "fr"));
818  sel.innerHTML = '<option value="">Tous</option>' +
819    emails.map((e) => `<option value="${echapper(e)}">${echapper(e)}</option>`).join("");
820  sel.value = courant;
821}
822
823function rendreConnexions() {
824  const corps = document.getElementById("corps-connexions");
825  if (!corps) return;
826  const fUser = document.getElementById("conn-filtre-user").value;
827  const fEvt = document.getElementById("conn-filtre-evt").value;
828  let liste = connexionsData.slice();
829  if (fUser) liste = liste.filter((x) => x.email === fUser);
830  if (fEvt) liste = liste.filter((x) => (x.evenement || "connexion") === fEvt);
831
832  if (!liste.length) {
833    corps.innerHTML = `<tr><td colspan="5" class="aide">Aucune entrée ne correspond.</td></tr>`;
834    return;
835  }
836  corps.innerHTML = liste.map(ligneConnexion).join("");
837}
838
839function ligneConnexion(x) {
840  const roles = [x.est_admin ? "Admin" : null, x.est_arbitre ? "Arbitre" : null].filter(Boolean).join(" · ") || "—";
841  const dec = (x.evenement || "connexion") === "deconnexion";
842  const badge = dec
843    ? `<span class="badge-off">Déconnexion</span>`
844    : `<span class="badge-on">Connexion</span>`;
845  return `<tr>
846    <td>${echapper(formatDateHeure(x.created_at))}</td>
847    <td>${badge}</td>
848    <td>${echapper(x.email || x.user_id || "—")}</td>
849    <td>${echapper(roles)}</td>
850    <td><small style="color:var(--texte-doux)">${echapper(navigateurCourt(x.user_agent))}</small></td>
851  </tr>`;
852}
853
854// Purge des entrées anciennes (ou de tout le journal).
855async function purgerConnexions() {
856  const jours = parseInt(document.getElementById("conn-purge-periode").value, 10);
857  const tout = jours === 0;
858  const question = tout
859    ? "Supprimer DÉFINITIVEMENT tout le journal de connexions ?"
860    : `Supprimer les entrées de plus de ${jours} jours ?`;
861  if (!confirm(question)) return;
862
863  // Date de coupure calculée côté client (l'appli n'a pas d'horloge serveur ici).
864  const avant = tout ? new Date() : new Date(Date.now() - jours * 86400000);
865  const { data, error } = await sb.rpc("purger_connexions", { p_avant: avant.toISOString() });
866  if (error) { alerteConnexions(`Échec de la purge : ${error.message}`, true); return; }
867  alerteConnexions(`Purge effectuée : ${data ?? 0} entrée(s) supprimée(s).`);
868  await chargerConnexions();
869}
870
871// "2026-08-31T14:05:00Z" -> "31/08/2026 14:05"
872function formatDateHeure(iso) {
873  if (!iso) return "—";
874  const d = new Date(iso);
875  if (isNaN(d)) return String(iso);
876  const p = (n) => String(n).padStart(2, "0");
877  return `${p(d.getDate())}/${p(d.getMonth() + 1)}/${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}`;
878}
879
880// Extrait un nom de navigateur/OS lisible depuis le user-agent.
881function navigateurCourt(ua) {
882  if (!ua) return "—";
883  let nav = "Navigateur";
884  if (/Edg\//.test(ua)) nav = "Edge";
885  else if (/OPR\/|Opera/.test(ua)) nav = "Opera";
886  else if (/Firefox\//.test(ua)) nav = "Firefox";
887  else if (/Chrome\//.test(ua)) nav = "Chrome";
888  else if (/Safari\//.test(ua)) nav = "Safari";
889  let os = "";
890  if (/Windows/.test(ua)) os = "Windows";
891  else if (/Android/.test(ua)) os = "Android";
892  else if (/iPhone|iPad|iOS/.test(ua)) os = "iOS";
893  else if (/Mac OS X|Macintosh/.test(ua)) os = "macOS";
894  else if (/Linux/.test(ua)) os = "Linux";
895  return os ? `${nav} · ${os}` : nav;
896}
897
898function alerteConnexions(msg, erreur = false) {
899  const box = document.getElementById("connexions-alerte");
900  if (!box) return;
901  box.innerHTML = `<div class="alerte ${erreur ? "alerte-err" : "alerte-ok"}">${echapper(msg)}</div>`;
902  if (!erreur) setTimeout(() => (box.innerHTML = ""), 4000);
903}
904
905function alerteUtil(msg, erreur = false) {
906  const box = document.getElementById("utilisateurs-alerte");
907  box.innerHTML = `<div class="alerte ${erreur ? "alerte-err" : "alerte-ok"}">${echapper(msg)}</div>`;
908  if (!erreur) setTimeout(() => (box.innerHTML = ""), 4000);
909}
910
911// Recharge les paramètres depuis Supabase puis rafraîchit toute l'interface.
912async function rechargerParametres(message) {
913  await chargerParametres();
914  peuplerReferentiels();
915  rendreParametresUI();
916  rendreTableau();
917  if (message) alerteAdmin(message);
918}
919
920// --- Filtres : réinitialisation -------------------------------------------
921function reinitialiserFiltres() {
922  document.querySelectorAll("#ligne-filtres [data-filtre], #ligne-filtres [data-oui-non]").forEach((el) => {
923    el.value = "";
924  });
925  document.getElementById("a-statut").value = "";
926  document.getElementById("a-recherche").value = "";
927  document.getElementById("a-saison").value = "";
928  rendreTableau();
929}
930
931// --- Export Excel (.xlsx) du tableau filtré -------------------------------
932function exporterXlsx() {
933  if (typeof XLSX === "undefined") {
934    alerteAdmin("Export Excel indisponible (bibliothèque non chargée, vérifiez votre connexion).", true);
935    return;
936  }
937  const liste = coursesAffichees.slice();
938  if (!liste.length) { alerteAdmin("Aucune course à exporter (le filtre ne renvoie rien).", true); return; }
939
940  const on = (v) => (v ? "Oui" : "Non");
941  const lignes = liste.map((c) => ({
942    "Dates": formatPlage(c.date_debut, c.date_fin),
943    "Début": c.date_debut || "",
944    "Fin": c.date_fin || "",
945    "Saison": c.saison || "",
946    "Événement": c.nom || "",
947    "Club": c.club || "",
948    "Ligue": c.ligue || "",
949    "Lieu": c.lieu || "",
950    "Type": c.type_evenement || "",
951    "Classes": (c.classes || []).join(", "),
952    "Directeur de course": c.directeur_course || "",
953    "Président de jury": c.directeur_jury || "",
954    "ERC (avis de course)": on(c.ers_url),
955    "Avis de course (URL)": c.ers_url || "",
956    "Inscription": on(c.inscriptions_url),
957    "Inscription (URL)": c.inscriptions_url || "",
958    "Résultat": on(c.resultat_url),
959    "Résultat (URL)": c.resultat_url || "",
960    "LM Directeur de course": on(c.lm_directeur_course),
961    "LM Président de jury": on(c.lm_president_jury),
962    "NDF Directeur de course": on(c.ndf_directeur_course),
963    "NDF Président de jury": on(c.ndf_president_jury),
964    "Statut": statutInfoEffectif(c).libelle,
965  }));
966
967  const ws = XLSX.utils.json_to_sheet(lignes);
968  // Largeurs de colonnes lisibles.
969  const enTetes = Object.keys(lignes[0]);
970  ws["!cols"] = enTetes.map((h) => {
971    const maxData = lignes.reduce((m, r) => Math.max(m, String(r[h] || "").length), h.length);
972    return { wch: Math.min(Math.max(maxData + 1, 8), 45) };
973  });
974  const wb = XLSX.utils.book_new();
975  XLSX.utils.book_append_sheet(wb, ws, "Courses");
976  const nom = `calendrier-ffcv_${nowStamp()}.xlsx`;
977  XLSX.writeFile(wb, nom);
978  alerteAdmin(`Export Excel : ${liste.length} course(s) → ${nom}`);
979}
980
981// Horodatage AAAAMMJJ-HHMM pour le nom de fichier.
982function nowStamp() {
983  const d = new Date();
984  const p = (n) => String(n).padStart(2, "0");
985  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
986}
987
988// --- Utilitaire ------------------------------------------------------------
989function alerteAdmin(msg, erreur = false) {
990  const box = document.getElementById("admin-alerte");
991  box.innerHTML = `<div class="alerte ${erreur ? "alerte-err" : "alerte-ok"}">${echapper(msg)}</div>`;
992  if (!erreur) setTimeout(() => (box.innerHTML = ""), 4000);
993}

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.