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.