1// Un seul invariant de collection pour le métier et l'interface. 2const listeSure = globalThis.TennisDomainRules.tableau; 3 4const VENDORS_LAZY = { 5 chartjs: { 6 src: 'assets/vendor/chart-4.4.0.umd.min.js', 7 integrity: 'sha384-e6nUZLBkQ86NJ6TVVKAeSaK8jWa3NhkYWZFomE39AvDbQWeie9PlQqM3pmYW5d1g', 8 disponible: () => !!window.Chart 9 }, 10 jspdf: { 11 src: 'assets/vendor/jspdf-2.5.1.umd.min.js', 12 integrity: 'sha384-JcnsjUPPylna1s1fvi1u12X5qjY5OL56iySh75FdtrwhO/SWXgMjoVqcKyIIWOLk', 13 disponible: () => !!window.jspdf 14 }, 15 pdfjs: { 16 src: 'assets/vendor/pdf-3.11.174.min.js', 17 integrity: 'sha384-/1qUCSGwTur9vjf/z9lmu/eCUYbpOTgSjmpbMQZ1/CtX2v/WcAIKqRv+U1DUCG6e', 18 disponible: () => !!window.pdfjsLib 19 }, 20 pdfexports: { 21 src: 'assets/js/pdf-exports.js', 22 disponible: () => !!window.TennisPdfExports 23 }, 24 changelog: { 25 src: 'assets/js/changelog.js', 26 disponible: () => !!window.TennisChangelog 27 } 28}; 29let chargeurVendor = null; 30const journal = Object.freeze({ 31 warn: (...args) => { if (/^(localhost|127\.0\.0\.1)$/i.test(location.hostname)) globalThis.console?.warn?.(...args); }, 32 error: (...args) => { if (/^(localhost|127\.0\.0\.1)$/i.test(location.hostname)) globalThis.console?.error?.(...args); }, 33 info: (...args) => { if (/^(localhost|127\.0\.0\.1)$/i.test(location.hostname)) globalThis.console?.info?.(...args); } 34}); 35 36async function chargerVendor(cle) { 37 const vendor = VENDORS_LAZY[cle]; 38 if (!vendor) return false; 39 return chargeurVendor.charger(cle, vendor); 40} 41 42async function chargerJsPdf() { 43 return chargerVendor('jspdf'); 44} 45 46async function chargerChartJs() {
47 return chargerVendor('chartjs'); 48} 49 50async function chargerPdfJs() { 51 const disponible = await chargerVendor('pdfjs'); 52 if (disponible) window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'assets/vendor/pdf-worker-3.11.174.min.js'; 53 return disponible; 54} 55 56// ============================================================ 57// POLE COMPETITION TENNIS BOULIAC - v2 avec Supabase 58// ============================================================ 59 60// Validation config 61if (!window.APP_CONFIG || !window.APP_CONFIG.SUPABASE_URL || window.APP_CONFIG.SUPABASE_URL.includes('VOTRE-PROJET')) { 62 document.getElementById('loading-screen').innerHTML = 63 '<div style="text-align:center; padding:24px; color:var(--danger-text)"><h3>â Configuration manquante</h3>' + 64 '<p>Ãdite le fichier <code>config.js</code> pour ajouter ton URL et ta clé Supabase.</p>' + 65 '<p class="text-faint">Voir <code>README-DEPLOIEMENT.md</code> pour les instructions.</p></div>'; 66 throw new Error('Config Supabase manquante'); 67} 68 69const contratsDependancesEssentielles = [ 70 ['métier', globalThis.TennisDomainRules, ['bornesSaison', 'messageErreurUtilisateur']], 71 ['asynchrone', globalThis.TennisAsync, ['avecDelai', 'creerChargeurScripts']], 72 ['données', globalThis.TennisData, ['creerCacheVide', 'updateConfirmee']], 73 ['formats', globalThis.TennisFormatters, ['escapeHtml', 'formatDate']], 74 ['état', globalThis.TennisState, ['creerEtat']], 75 ['archives saisonnières', globalThis.TennisSeasonArchives, ['creerChargeurDerniereDemande', 'chargerDonnees']], 76 ['météo', globalThis.TennisTournamentWeather, ['creerService']], 77 ['tournois TenâUp', globalThis.TennisTournamentsTenup, ['choixTournoisMatch', 'selecteurMatchHtml']], 78 ['avatars', globalThis.TennisAvatars, ['creerService']], 79 ['notifications', globalThis.TennisNotifications, ['creerCentre']], 80 ['emails', globalThis.TennisEmailService, ['creerService']], 81 ['pagination', globalThis.TennisPagination, ['chargerToutesPages']], 82 ['modèles d\'email', globalThis.TennisEmailTemplates, ['creerModeles']], 83 ['rappels', globalThis.TennisReminders, ['creerService']], 84 ['brouillons', globalThis.TennisDrafts, ['creerGestionnaire']], 85 ['thème', globalThis.TennisTheme, ['creerControleurTheme']], 86 ['interface', globalThis.TennisUiCommon, ['creerGestionnaireModale', 'creerGestionnaireRetours']], 87 ['comptes famille', globalThis.TennisFamilyAccounts, ['renderSelector']], 88 ['actions', globalThis.TennisUiGrammar, ['decouperExpressionUI', 'argumentActionUI']], 89 ['iconographie', globalThis.TennisIconography, ['creer']], 90 ['graphiques', globalThis.TennisChartTheme, ['chartColors', 'chartGradient']] 91]; 92const dependancesEssentiellesManquantes = contratsDependancesEssentielles 93 .filter(([, api, methodes]) => !api || methodes.some(methode => typeof api[methode] !== 'function')) 94 .map(([nom]) => nom); 95if (dependancesEssentiellesManquantes.length) { 96 const recoveryKey = 'pcb_module_recovery_v1'; 97 let derniereReprise = 0; 98 try { derniereReprise = Number(sessionStorage.getItem(recoveryKey) || 0); } catch (_) { /* stockage bloqué */ } 99 const repriseRecente = Date.now() - derniereReprise < 60_000; 100 const purgerCacheApplicatifEtRecharger = async () => { 101 try { sessionStorage.setItem(recoveryKey, String(Date.now())); } catch (_) { /* stockage bloqué */ } 102 if (navigator.onLine && 'caches' in window) { 103 const cles = await caches.keys(); 104 await Promise.all(cles.filter(cle => cle.startsWith('pole-tennis-')).map(cle => caches.delete(cle))); 105 } 106 const inscription = await navigator.serviceWorker?.getRegistration?.(); 107 await inscription?.update?.().catch(() => {}); 108 location.reload(); 109 }; 110 const loadingRoot = document.getElementById('loading-screen'); 111 if (loadingRoot) { 112 loadingRoot.style.display = ''; 113 loadingRoot.innerHTML = `<div class="card" style="max-width:430px;margin:24px;padding:22px;text-align:center;border-top:4px solid var(--danger)"> 114 <h3>Une mise à jour doit être terminée</h3> 115 <p class="text-dim">Des fichiers de versions différentes sont encore présents sur cet appareil. Tes données nâont pas été modifiées.</p> 116 <button type="button" class="btn btn-primary btn-full" id="moduleRecoveryButton">Actualiser lâapplication</button> 117 </div>`; 118 document.getElementById('moduleRecoveryButton')?.addEventListener('click', () => purgerCacheApplicatifEtRecharger()); 119 } 120 if (!repriseRecente) setTimeout(() => purgerCacheApplicatifEtRecharger(), 50); 121 throw new Error(`DEPENDANCE_ESSENTIELLE_INDISPONIBLE:${dependancesEssentiellesManquantes.join(',')}`); 122} 123try { sessionStorage.removeItem('pcb_module_recovery_v1'); } catch (_) { /* stockage bloqué */ } 124 125chargeurVendor = TennisAsync.creerChargeurScripts({ 126 document, 127 horloge: window, 128 journal, 129 onErreur: () => toast('Ce module nâa pas pu être chargé. Vérifie ta connexion puis réessaie.', true) 130}); 131 132const db = window.supabase.createClient( 133 window.APP_CONFIG.SUPABASE_URL, 134 window.APP_CONFIG.SUPABASE_ANON_KEY 135); 136 137// Ãtat global 138let session = null; 139let profile = null; // { id, email, role, joueur_id, prenom, nom } 140 141// Ãtat transversal persistant et filtres partagés. 142const etatApp = TennisState.creerEtat({ storage: localStorage, sessionStorage }); 143// Les brouillons doivent survivre à la mise en veille/éviction d'une PWA iOS. 144const gestionnaireBrouillons = TennisDrafts.creerGestionnaire({ storage: localStorage, horloge: window }); 145 146const controleurTheme = TennisTheme.creerControleurTheme({ 147 window, 148 document, 149 storage: localStorage, 150 onMessage: message => { if (typeof toast === 'function') toast(message); }, 151 onChange: () => { 152 if (typeof render === 'function') { try { render(); } catch (_) { /* démarrage */ } } 153 try { 154 const modalEl = document.getElementById('modal'); 155 const modaleOuverte = modalEl?.classList.contains('show') && document.getElementById('pdChartClassement'); 156 if (modaleOuverte && etatApp.joueurDetailId 157 && typeof openPlayerDetail === 'function') openPlayerDetail(etatApp.joueurDetailId); 158 } catch (_) { /* modale absente */ } 159 } 160}); 161function toggleThemeSwitch(event) { return controleurTheme.basculer(event); } 162function updateThemeSwitchIcon() { return controleurTheme.actualiserInterrupteur(); } 163
164controleurTheme.appliquer({ emettre: false }); 165if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', updateThemeSwitchIcon); 166else updateThemeSwitchIcon(); 167controleurTheme.suivreSysteme(); 168function isPoleEnv() { 169 if (!isCoach()) return true; 170 return etatApp.environnement === 'pole' || etatApp.environnement === 'pre_academie'; 171} 172 173function isPreAcademieEnv() { 174 return isCoach() && etatApp.environnement === 'pre_academie'; 175} 176 177function isAcademieEnv() { 178 return isCoach() && etatApp.environnement === 'pole'; 179} 180// Renvoie l'objet événement actif (depuis cache.evenements) ou null si académie. 181function getActiveEvenement() { 182 if (!isCoach()) return null; 183 if (etatApp.environnement === 'pole') return null; 184 return (listeSure(cache.evenements)).find(ev => ev.cle === etatApp.environnement && ev.actif) || null; 185} 186 187// Le raccourci Palmyre reste toujours présent pour le coach, même entre deux 188// éditions. Une archive ne redevient jamais silencieusement l'environnement actif. 189function ouvrirPalmyreCoach() { 190 if (!isCoach()) return; 191 const edition = (listeSure(cache.evenementsTous)).find(ev => ev.actif); 192 if (!edition) { 193 showModal(`<div class="modal-header"><h3 class="modal-title">ðï¸ Palmyre</h3><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 194 <div class="modal-body"><div class="empty"><span class="icon">ðï¸</span><strong>Aucun événement en cours</strong><br><span class="text-faint">Crée ou réactive une édition Palmyre depuis les réglages lorsque tu souhaites reprendre le travail.</span></div> 195 <button class="btn btn-primary btn-full mt-2" data-ui-click="closeModal(); openReglagesFromMenu()">Gérer les éditions Palmyre</button></div>`); 196 return; 197 } 198 setCurrentEnv(edition.cle); 199} 200// Bascule d'environnement et recharge l'écran courant. 201function setCurrentEnv(env) { 202 if (!isCoach()) return; // sécurité : le switch d'environnement est réservé au coach 203 const editionCible = env !== 'pole' && env !== 'pre_academie' 204 ? (listeSure(cache.evenementsTous)).find(ev => ev.cle === env && ev.actif) 205 : null; 206 const saisonCible = editionCible 207 ? TennisDomainRules.saisonPourDate(`${editionCible.date_debut}T12:00:00`) 208 : getSaisonCourante(); 209 if (saisonCible && getSaisonAnalyse() !== saisonCible) { 210 chargerSaisonAnalyse(saisonCible).then(ok => { if (ok) setCurrentEnv(env); }); 211 return; 212 } 213 etatApp.definirEnvironnement(env); 214 appliquerPerimetreMatchs(); 215 cache.alertes = computeAlertes(); 216 cache.bellePerfs = computeBellePerfs(); 217 navigate('dashboard'); 218} 219 220// Nom court de l'événement d'un match (pour l'estampille), ou null si match académie. 221function matchEvenementNom(m) { 222 if (!m || !m.evenement_id) return null; 223 const ev = (listeSure(cache.evenements)).find(e => e.id === m.evenement_id); 224 return ev ? ev.nom : 'Palmyre'; 225} 226// Un joueur est-il "en Palmyre maintenant" ? (participant ET on est dans la 227// fenêtre de dates d'une édition active). Sert à afficher un badge temporaire. 228// Ãdition Palmyre dont l'intervalle de dates couvre une date donnée (YYYY-MM-DD). 229// Sert à rattacher automatiquement un match (saisi ou importé) à l'édition correspondante 230// quand sa date tombe pendant le stage. Contrairement à joueurEnPalmyreMaintenant(), ce 231// test porte sur la date DU MATCH, pas sur la date du jour : un match importé après le 232// stage doit quand même être rattaché s'il a été joué pendant. 233function editionPalmyrePourDate(dateStr) { 234 if (!dateStr) return null; 235 const d = String(dateStr).slice(0, 10); 236 return (listeSure(cache.evenements)).find(e => 237 TennisDomainRules.dateDansIntervalle(d, e.date_debut, e.date_fin)) || null; 238} 239 240function joueurEnPalmyreMaintenant(joueur) { 241 if (!joueur || !joueur.participe_palmyre) return null; 242 const today = dateLocaleISO(new Date()); 243 const ed = (listeSure(cache.evenements)).find(e => 244 TennisDomainRules.dateDansIntervalle(today, e.date_debut, e.date_fin)); 245 return ed || null; 246} 247 248function editionsPlanningPalmyreJoueur(joueur) { 249 return TennisDomainRules.editionsPlanningPalmyreJoueur({ 250 joueur, 251 evenements: cache.evenements, 252 creneaux: cache.creneauxPalmyre, 253 aujourdHui: dateLocaleISO(new Date()) 254 }); 255} 256 257// Un match appartient-il à l'environnement actuellement sélectionné ? 258// En mode académie : tous les matchs comptent. En mode événement : seulement ceux de l'édition. 259function appliquerPerimetreMatchs() { 260 const tous = listeSure(cache.matchesSaison); 261 const evenement = getActiveEvenement(); 262 const options = TennisDomainRules.construireOptionsPerimetreMatchs({ 263 estCoach: isCoach(), environnement: etatApp.environnement, 264 evenementId: evenement?.id || null, joueurs: cache.joueurs, estPreAcademie 265 }); 266 cache.matches = TennisDomainRules.matchsDansPerimetre(tous, options); 267 invaliderIndexMatchs(); 268} 269// Onglet Tournois : vue unique groupée par mois (plus de bascule Liste/Calendrier). 270// Coach : calendarPlayerId = null â tous les joueurs (vue d'ensemble) ; sinon prog d'un joueur. 271// Joueur : calendarMineOnly = true â ses tournois ; false â tous (découverte/inscription). 272// Planning Palmyre côté joueur : true = mes créneaux uniquement, false = tout le planning. 273const { creerCacheVide } = TennisData; 274let cache = creerCacheVide(); 275 276const CLASSEMENTS = TennisDomainRules.CLASSEMENTS; 277 278// Contrats de lecture centralisés dans data.js et exposés localement pour garder 279// les requêtes lisibles sans réintroduire de chaînes dupliquées. 280const { 281 COLONNES_PROFILE, COLONNES_NOTIFICATION, COLONNES_CONFIG, COLONNES_INVITATION, 282 COLONNES_NOTE_COACH, COLONNES_PREFERENCES, COLONNES_CRENEAU_COURS,
283 COLONNES_AFFECTATION_COURS, COLONNES_SEANCE_COURS, COLONNES_PRESENCE, 284 COLONNES_HISTORIQUE_CLASSEMENT, COLONNES_DEMANDE_CLASSEMENT, 285 COLONNES_ENTRAINEMENT, COLONNES_BADGE_JOUEUR, COLONNES_EVENEMENT, 286 COLONNES_CRENEAU_PALMYRE, COLONNES_BILAN_EVENEMENT, COLONNES_DEBRIEF_PALMYRE, 287 COLONNES_AXE_PALMYRE, COLONNES_OBSERVATION_PALMYRE, COLONNES_TOURNOI, 288 COLONNES_INSCRIPTION_TOURNOI, COLONNES_IMPORT_TOURNOI, COLONNES_RAPPROCHEMENT_TOURNOI, 289 COLONNES_BILAN, COLONNES_JOUEUR, COLONNES_MATCH 290} = TennisData.colonnes; 291 292// Ordre canonique d'affichage des groupes (du plus accessible au plus élevé), 293// Utilisé partout où on liste/ordonne les groupes pour éviter 294// l'ordre arbitraire des clés de cache.config. 295const { ORDRE_GROUPES } = TennisData; 296// Retourne les clés de groupes présentes dans la config, dans l'ordre canonique. 297// Les éventuelles clés inconnues sont ajoutées à la fin (filet de sécurité). 298function groupesOrdonnes() { 299 return TennisData.groupesOrdonnes(cache.config); 300} 301 302// Index d'un classement dans l'échelle FFT (0 = NC, plus haut = meilleur) 303function getRankIndex(c) { 304 return TennisDomainRules.classementIndex(c); 305} 306 307// ============================================================ 308// HELPERS 309// ============================================================ 310function $(id) { return document.getElementById(id); } 311function updateRangeValue(element, targetId) { 312 const target = $(targetId); 313 if (target) target.textContent = element?.value ?? ''; 314} 315 316// Associe automatiquement les libellés aux contrôles des formulaires générés. 317// Une grande partie de l'interface est construite par chaînes HTML : ce post-traitement 318// garantit un couple label/for stable sans dupliquer manuellement des dizaines d'IDs. 319let _formControlId = 0; 320function associerLabelsFormulaires(root = document) { 321 root.querySelectorAll('label.form-label:not([for])').forEach(label => { 322 if (label.querySelector('input,select,textarea')) return; // association implicite 323 const zone = label.closest('.form-row') || label.parentElement; 324 const controles = zone 325 ? [...zone.querySelectorAll('input:not([type="hidden"]),select,textarea')] 326 : []; 327 if (controles.length !== 1) { 328 // Les choix rendus sous forme de boutons/pastilles n'ont pas de contrôle natif 329 // unique, et les widgets composites (score, période...) en ont plusieurs : 330 // leur conteneur devient un groupe nommé par le libellé. 331 const groupe = zone && (zone.querySelector('.pill-group') || zone); 332 const interactif = groupe && groupe.querySelector('button,[role="radio"],[role="checkbox"],[data-ui-click],input,select,textarea'); 333 if (!groupe || !interactif) return; 334 if (!label.id) label.id = `form-label-${++_formControlId}`; 335 groupe.setAttribute('role', 'group'); 336 groupe.setAttribute('aria-labelledby', label.id); 337 return; 338 } 339 const controle = controles[0]; 340 if (!controle.id) controle.id = `form-control-${++_formControlId}`; 341 label.htmlFor = controle.id; 342 }); 343} 344 345function auditerLabelsFormulaires(root = document) { 346 return [...root.querySelectorAll('label.form-label')].filter(label => { 347 if (label.querySelector('input,select,textarea')) return false; 348 if (label.htmlFor && document.getElementById(label.htmlFor)) return false; 349 if (!label.id) return true; 350 const zone = label.closest('.form-row') || label.parentElement; 351 const groupe = zone && (zone.querySelector(`[aria-labelledby~="${label.id}"]`) 352 || (zone.getAttribute('aria-labelledby') || '').split(/\s+/).includes(label.id) && zone); 353 return !groupe; 354 }).map(label => ({ texte: label.textContent.trim(), id: label.id || '' })); 355} 356 357// Délégation sécurisée des interactions historiques. Les attributs data-ui-* 358// remplacent les on* HTML, incompatibles avec une CSP stricte. Aucun code n'est 359// évalué dynamiquement : seuls les appels figurant dans cette liste blanche sont 360// autorisés et leurs arguments utilisent une grammaire volontairement limitée. 361const ACTIONS_UI_AUTORISEES = new Set([ 362 'addNoteCoach','ajouterObservation','ajouterObservationGroupe','applyUpdate','archivePlayer', 363 'changeCodeAcces','changeCodeCoach','closeModal','closeModalDirect','closeNouveautes','confirmChangerClassementCoach', 364 'confirmDemanderClassementJoueur','confirmImportClassement','confirmTenupImport','copyCodeFromElement','runTenupSync','openTenupSyncHistory','resolveTourn
364amentMerge', 365 'debouncedSearchRender','deleteAvatar','deleteBilan','deleteBilanPalmyre','deleteCreneau', 366 'deleteCreneauCours','deleteNoteCoach','deleteNotif','deletePlayer','deleteTournoi','dismissUpdateBanner', 367 'doForgotPassword','doImportMatchsTenup','doLogin','doLogout','doSignup','doSignupCoach','editerDebrief', 368 'enregistrerEditObservation','envoyerAxeTravail','exportBilanPalmyrePDF','exportBilanPDF', 369 'exportChartConsentementPDF','exportPlanningPalmyrePDF','exportPlanningPDF','exportPresencesPDF', 370 'exportProgrammePDF','exportRegistreConsentementsPDF','exporterMesDonnees','genererBilanIA','genererDebriefIA', 371 'genererInvitation','genererInvitationDepuisModal','handleAvatarFileSelected','markAllNotifsRead', 372 'markNotifLu','navigate','onAideSearch','onBilanPeriodeChange','onDateNaissanceChange','onNotifClick', 373 'onTenupDrop','onTenupDragLeave','onTenupDragOver','onTenupFileSelect','openAideFromMenu', 374 'openAvatarUpload','openCarnetGroupe','openCarnetObservations', 375 'openChangeEmailModal','openChangePasswordModal','openChangerClassementCoach','openComparaisonEditions', 376 'openContrePerfsModal','openDemanderClassementJoueur','openEditObservation','openInscriptionCoach', 377 'openInvitationsFromMenu','openMatchDetail','openModalAppel','openModalBilan','openModalBilanPalmyre', 378 'openModalCreneau','openModalCreneauCours','openModalEdition','openModalEntrainement', 379 'openModalExportPresences','openModalImportClassement','openModalImportMatchsTenup','openModalImportTenup', 380 'openModalJoueursCreneau','openModalMatch','openModalPlayer','openModalTournoi','openMonProfilModal','openRattacherJoueurModal', 381 'openCoachPerformanceSection','openMyBilanDetail','openMyBilanPalmyreDetail','openNiveauxModal','openNouveautesFromMenu', 382 'openPlayerDetail','openNotePlayer','openPreferencesNotifsModal','openReglagesFromMenu','openValidationModal','ouvrirPalmyreCoach', 383 'ouvrirObsDepuisBilan','previewImportClassement','previewImportMatchs','rattrapageBadgesTousJoueurs', 384 'navigationRetourContextuel','rattacherJoueurInvitation','reactivatePlayer','rejectJoueur','render','resetAllPlayerFilters','retirerDuGroupe','revoquerInvitation','retryAppStart', 385 'resoudreConfirmation', 386 'sauverDebriefEdite','saveBilan','saveBilanPalmyre','saveBrouillonBilanPalmyre','saveCodeAcces','saveCodeCoach','saveCreneau', 387 'saveCreneauCours','saveEdition','saveEmail','saveEntrainement','saveJoueursCreneau','sendPasswordReset', 388 'saveMonProfilTechnique','saveMyBilanComment','savePassword','savePlayer','saveTournoi','selectObsEditTheme', 389 'selectObsGroupeTheme','selectObsTheme','selectPill','selectPillToggle','selectionnerJoueurActif','setAlertFilter','setBadgeVitrine', 390 'setCalendarMineOnly','setCalendarPlayer','setCurrentEnv','setDebriefDate','setGroupeDetail','setMatchResultFilter', 391 'setOverviewSort','setPerfFilter','setPlanningFiltreJoueur','setPlayerFilter','setPlayerFilterField', 392 'setPresence','setPresenceJoueur','openRetardJustification','savePresenceRetard','setRadarBilan','showRgpdPolicy','signupNextStep','supprimerAxe', 393 'supprimerDebrief','supprimerEntrainement','supprimerObservation','supprimerObservationGroupe','demanderSuppressionCompte', 394 'switchAuthTab','toggleAdvancedFilters','toggleAxeFait','toggleDebriefTheme','toggleEditionActive', 395 'toggleFilBadgesGroupe','toggleFiltreTournoi','toggleNotifPref','toggleNotifsPanel', 396 'togglePalmyreParticipation','togglePasswordVisibility','toggleThemeSwitch','toggleTousJoueursExportPresences','toggleTournoiInscription', 397 'toggleUserMenu','traiterDemandeClassement','updateGroupeConfig','updateRangeValue','validateJoueur','verifierMajManuelle' 398]); 399 400if (!globalThis.TennisUiGrammar) { 401 throw new Error('GRAMMAIRE_UI_INDISPONIBLE'); 402} 403const { decouperExpressionUI, argumentActionUI, encoderArgumentTexteUI } = globalThis.TennisUiGrammar; 404 405function appelerActionUI(nom, sourceArgs, element, event) { 406 if (!ACTIONS_UI_AUTORISEES.has(nom)) throw new Error(`Action UI interdite : ${nom}`); 407 const action = globalThis[nom]; 408 if (typeof action !== 'function') throw new Error(`Action UI indisponible : ${nom}`); 409 const args = sourceArgs.trim() 410 ? decouperExpressionUI(sourceArgs, ',').map(arg => argumentActionUI(arg, element, event)) 411 : []; 412 return action(...args); 413} 414
415function executerInstructionUI(instruction, element, event) { 416 const code = instruction.trim(); 417 if (!code) return; 418 if (code === 'event.preventDefault()') { event.preventDefault(); return; } 419 if (code === 'event.stopPropagation()') { event.stopPropagation(); return; } 420 if (code === 'location.reload()') { location.reload(); return; } 421 422 let match = code.match(/^document\.getElementById\((['"])(.*?)\1\)\.click\(\)$/); 423 if (match) { document.getElementById(match[2])?.click(); return; } 424 425 match = code.match(/^\$\((['"])(.*?)\1\)\.textContent\s*=\s*this\.value$/); 426 if (match) { const cible = $(match[2]); if (cible) cible.textContent = element.value; return; } 427 428 match = code.match(/^document\.getElementById\((['"])(.*?)\1\)\.style\.display\s*=\s*this\.value\s*===\s*(['"])(.*?)\3\s*\?\s*(['"])(.*?)\5\s*:\s*(['"])(.*?)\7$/); 429 if (match) { 430 const cible = document.getElementById(match[2]); 431 if (cible) cible.style.display = element.value === match[4] ? match[6] : match[8]; 432 return; 433 } 434 435 match = code.match(/^(playerFilters\.search)\s*=\s*this\.value$/); 436 if (match) { 437 if (match[1] === 'playerFilters.search') playerFilters.search = element.value; 438 return; 439 } 440 441 match = code.match(/^setTimeout\(\(\)\s*=>\s*([A-Za-z_$][\w$]*)\((.*)\),\s*(\d+)\)$/); 442 if (match) { 443 const [, nom, args, delai] = match; 444 setTimeout(() => appelerActionUI(nom, args, element, event), Number(delai)); 445 return; 446 } 447 448 match = code.match(/^([A-Za-z_$][\w$]*)\((.*)\)$/); 449 if (match) { appelerActionUI(match[1], match[2], element, event); return; } 450 throw new Error(`Instruction d'action UI non reconnue : ${code}`); 451} 452 453function gererActionUI(event) { 454 // Dès qu'un champ précédemment signalé redevient valide, retirer son état 455 // d'erreur sans attendre une nouvelle soumission du formulaire. 456 if ((event.type === 'input' || event.type === 'change') 457 && event.target?.matches?.('[aria-invalid="true"]') 458 && typeof event.target.checkValidity === 'function' 459 && event.target.checkValidity()) { 460 event.target.removeAttribute('aria-invalid'); 461 } 462 const attribut = `data-ui-${event.type}`; 463 const element = event.target.closest(`[${attribut}]`); 464 if (!element) return; 465 try { 466 decouperExpressionUI(element.getAttribute(attribut) || '', ';') 467 .forEach(instruction =>
467 executerInstructionUI(instruction, element, event)); 468 document.documentElement.dataset.uiLastError = ''; 469 } catch (erreur) { 470 document.documentElement.dataset.uiLastError = erreur && erreur.message ? erreur.message : String(erreur); 471 journal.error('[action UI]', erreur, element.getAttribute(attribut)); 472 toast('Cette action nâa pas pu être exécutée. Recharge la page puis réessaie.', true); 473 } 474} 475 476// Diagnostic sans exécution, utilisé par la recette automatisée pour repérer une 477// ancienne expression qui ne ferait pas partie de la grammaire autorisée. 478function auditerActionsUI(root = document) { 479 const erreurs = []; 480 const attributs = ['data-ui-click', 'data-ui-change', 'data-ui-input', 'data-ui-submit', 'data-ui-drop', 'data-ui-dragover', 'data-ui-dragleave']; 481 root.querySelectorAll(attributs.map(attr => `[${attr}]`).join(',')).forEach(element => { 482 attributs.forEach(attribut => { 483 if (!element.hasAttribute(attribut)) return; 484 const fauxEvent = { preventDefault() {}, stopPropagation() {} }; 485 decouperExpressionUI(element.getAttribute(attribut) || '', ';').forEach(instruction => { 486 const code = instruction.trim(); 487 if (!code || code === 'event.preventDefault()' || code === 'event.stopPropagation()' || code === 'location.reload()') return; 488 if (/^document\.getElementById\((['"])(.*?)\1\)\.click\(\)$/.test(code)) return; 489 if (/^\$\((['"])(.*?)\1\)\.textContent\s*=\s*this\.value$/.test(code)) return; 490 if (/^document\.getElementById\((['"])(.*?)\1\)\.style\.display\s*=/.test(code)) return; 491 if (/^(playerFilters\.search)\s*=\s*this\.value$/.test(code)) return; 492 const appelDiffere = code.match(/^setTimeout\(\(\)\s*=>\s*([A-Za-z_$][\w$]*)\((.*)\),\s*(\d+)\)$/); 493 const appel = appelDiffere || code.match(/^([A-Za-z_$][\w$]*)\((.*)\)$/); 494 if (!appel || !ACTIONS_UI_AUTORISEES.has(appel[1]) || typeof globalThis[appel[1]] !== 'function') { 495 erreurs.push({ attribut, code, raison: 'action inconnue' }); 496 return; 497 } 498 try { 499 if (appel[2].trim()) decouperExpressionUI(appel[2], ',').forEach(arg => argumentActionUI(arg, element, fauxEvent)); 500 } catch (erreur) { 501 erreurs.push({ attribut, code, raison: erreur.message }); 502 } 503 }); 504 }); 505 }); 506 return erreurs; 507} 508 509function actualiserAuditActionsUI() { 510 associerLabelsFormulaires(); 511 ameliorerSemantiqueActionsUI(); 512 ameliorerEtatsInterface(); 513 ameliorerImagesInterface(); 514 const erreurs = auditerActionsUI(); 515 const erreursSemantiques = auditerSemantiqueActionsUI(); 516 const erreursLabels = auditerLabelsFormulaires(); 517 const erreursEtats = auditerEtatsInterface(); 518 const erreursImages = auditerImagesInterface(); 519 document.documentElement.dataset.uiActionErrors = String(erreurs.length); 520 document.documentElement.dataset.uiSemanticErrors = String(erreursSemantiques.length); 521 document.documentElement.dataset.uiLabelErrors = String(erreursLabels.length); 522 document.documentElement.dataset.uiStateErrors = String(erreursEtats.length); 523 document.documentElement.dataset.uiImageErrors = String(erreursImages.length); 524 if (erreurs.length) journal.warn('[audit actions UI]', erreurs); 525 if (erreursSemantiques.length) journal.warn('[audit sémantique UI]', erreursSemantiques); 526 if (erreursLabels.length) journal.warn('[audit libellés UI]', erreursLabels); 527 if (erreursEtats.length) journal.warn('[audit états UI]', erreursEtats); 528 if (erreursImages.length) journal.warn('[audit images UI]', erreursImages); 529} 530 531const SELECTEUR_ACTION_CLIC = '[data-ui-click]'; 532const TAGS_INTERACTIFS_NATIFS = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY']); 533 534function estInteractifNatif(element) { 535 return !!element && (TAGS_INTERACTIFS_NATIFS.has(element.tagName) || element.isContentEditable); 536} 537 538function ameliorerSemantiqueActionsUI(root = document) { 539 root.querySelectorAll(SELECTEUR_ACTION_CLIC).forEach(element => { 540 if (estInteractifNatif(element)) return; 541 if (!element.hasAttribute('role')) element.setAttribute('role', 'button'); 542 if (!element.hasAttribute('tabindex')) element.setAttribute('tabindex', '0'); 543 }); 544} 545 546function auditerSemantiqueActionsUI(root = document) { 547 return [...root.querySelectorAll(SELECTEUR_ACTION_CLIC)] 548 .filter(element => !estInteractifNatif(element)) 549 .filter(element => element.getAttribute('role') !== 'button' || element.tabIndex < 0) 550 .map(element => ({ tag: element.tagName, action: element.getAttribute('data-ui-click') || '' })); 551} 552 553function ameliorerEtatsInterface(root = document) { 554 root.querySelectorAll('.empty').forEach(element => { 555 if (!element.hasAttribute('role')) element.setAttribute('role', 'status'); 556 if (!element.hasAttribute('aria-live')) element.setAttribute('aria-live', 'polite'); 557 }); 558 root.querySelectorAll('.loading-screen').forEach(element => { 559 element.setAttribute('role', 'status'); 560 element.setAttribute('aria-live', 'polite'); 561 element.setAttribute('aria-busy', 'true'); 562 if (!element.querySelector('.sr-only')) { 563 const texte = document.createElement('span'); 564 texte.className = 'sr-only'; 565 texte.textContent = 'Chargementâ¦'; 566 element.appendChild(texte); 567 } 568 }); 569} 570 571function auditerEtatsInterface(root = document) { 572 const erreurs = []; 573 root.querySelectorAll('.empty').forEach(element => { 574 if (element.getAttribute('role') !== 'status' || element.getAttribute('aria-live') !== 'polite') erreurs.push('empty'); 575 }); 576 root.querySelectorAll('.loading-screen').forEach(element => { 577 if (element.getAttribute('role') !== 'status' || element.getAttribute('aria-busy') !== 'true' || !element.querySelector('.sr-only')) erreurs.push('loading'); 578 }); 579 return erreurs; 580} 581 582function estImageCritique(element) { 583 return !!element.closest('header, .auth-logo-frame') || element.matches('.auth-logo-img, #themeSwitchIcon'); 584} 585 586function ameliorerImagesInterface(root = document) { 587 root.querySelectorAll('img').forEach(element => { 588 if (!element.hasAttribute('alt')) element.setAttribute('alt', ''); 589 if (!element.hasAttribute('decoding')) element.setAttribute('decoding', 'async'); 590 if (!element.hasAttribute('loading')) element.setAttribute('loading', estImageCritique(element) ? 'eager' : 'lazy'); 591 if (estImageCritique(element) && !element.hasAttribute('fetchpriority')) element.setAttribute('fetchpriority', 'high'); 592 }); 593} 594 595function auditerImagesInterface(root = document) { 596 return [...root.querySelectorAll('img')] 597 .filter(element => !element.hasAttribute('alt') || element.getAttribute('decoding') !== 'async' || !element.hasAttribute('loading')) 598 .map(element => ({ src: element.getAttribute('src') || '', contexte: element.className || element.i
598d || element.tagName })); 599} 600 601document.addEventListener('keydown', event => { 602 if (event.key !== 'Enter' && event.key !== ' ') return; 603 const element = event.target.closest?.(SELECTEUR_ACTION_CLIC); 604 if (!element || estInteractifNatif(element)) return; 605 if (event.target !== element && estInteractifNatif(event.target)) return; 606 event.preventDefault(); 607 element.click(); 608}); 609 610['click', 'change', 'input', 'submit', 'drop', 'dragover', 'dragleave'].forEach(type => document.addEventListener(type, gererActionUI)); 611 612const gestionnaireRetours = TennisUiCommon.creerGestionnaireRetours({ 613 window, 614 document, 615 navigator, 616 ids: { toast: 'toast', reseau: 'network-status' }, 617 traduireErreur: TennisDomainRules.messageErreurUtilisateur, 618 journal 619}); 620 621function toast(message, erreur) { return gestionnaireRetours.afficher(message, erreur); } 622 623// Point d'entrée unique pour les erreurs techniques : le détail reste dans la 624// console pour le diagnostic, mais aucun message Supabase/PostgREST brut ne 625// doit atteindre l'interface utilisateur. 626function toastErreur(error) { 627 // FunctionsHttpError conserve le vrai corps JSON dans context. Le lire sur un 628 // clone permet d'afficher 403/429/500 sans perdre la réponse pour le diagnostic. 629 const contexte = error && error.context; 630 if (contexte && typeof contexte.clone === 'function') { 631 contexte.clone().json().then(detail => { 632 const message = detail && (detail.error || detail.message); 633 if (message) gestionnaireRetours.afficherErreur({ ...error, message }); 634 else gestionnaireRetours.afficherErreur(error); 635 }).catch(() => gestionnaireRetours.afficherErreur(error)); 636 return null; 637 } 638 return gestionnaireRetours.afficherErreur(error); 639} 640 641async function updateConfirmee(requete, colonne = 'id') { 642 return TennisData.updateConfirmee(requete, colonne); 643} 644 645function creerIdErreur() { 646 return gestionnaireRetours.identifiantErreur(); 647} 648 649function mettreAJourEtatReseau() { 650 return gestionnaireRetours.actualiserReseau(); 651} 652 653function synchroniserClavierVirtuel() { 654 const viewport = window.visualViewport; 655 if (!viewport) return; 656 const hauteurReference = window.innerHeight || document.documentElement.clientHeight; 657 const clavierOuvert = hauteurReference - viewport.height > 120; 658 document.documentElement.classList.toggle('keyboard-open', clavierOuvert); 659 document.documentElement.style.setProperty('--visual-viewport-height', `${Math.round(viewport.height)}px`); 660 if (clavierOuvert && document.activeElement?.matches?.('input,select,textarea')) { 661 requestAnimationFrame(() => document.activeElement?.scrollIntoView?.({ block: 'center', behavior: 'smooth' })); 662 } 663} 664if (window.visualViewport) { 665 window.visualViewport.addEventListener('resize', synchroniserClavierVirtuel); 666 window.visualViewport.addEventListener('scroll', synchroniserClavierVirtuel); 667 synchroniserClavierVirtuel(); 668} 669 670const escapeHtml = TennisFormatters.escapeHtml; 671 672function avecDelai(promise, delaiMs, code = 'DELAI_DEPASSE') { 673 return TennisAsync.avecDelai(promise, delaiMs, code, window); 674} 675 676// Empêche deux écritures identiques lorsqu'un utilisateur double-clique ou que 677// le réseau répond lentement. Tous les boutons du formulaire sont neutralisés 678// pendant l'écriture, puis restaurés même si Supabase rejette la requête. 679function verrouillerSoumission(form, libelle = 'Enregistrementâ¦') { 680 return TennisUiCommon.verrouillerSoumission(form, libelle); 681} 682 683function deverrouillerSoumission(verrou) { 684 return TennisUiCommon.deverrouillerSoumission(verrou); 685} 686 687async function executerAvecVerrou(conteneur, operation, libelle = 'Enregistrementâ¦') { 688 return TennisUiCommon.executerAvecVerrou(conteneur, operation, libelle); 689} 690 691function formatDate(iso) { 692 return TennisFormatters.formatDate(iso); 693} 694 695function normaliserTelephone(raw) { 696 return TennisFormatters.normaliserTelephone(raw); 697} 698 699function normaliserNomAdversaire(raw) { 700 return TennisDomainRules.normaliserNomAdversaire(raw); 701} 702 703// Clé de comparaison d'un adversaire, gérant les doubles ("Nom1 / Nom2"). 704// On normalise chaque équipier puis on trie les deux, pour que l'ordre des 705// partenaires n'ait pas d'importance non plus. 706function cleAdversaire(adversaire) { 707 return TennisDomainRules.cleAdversaire(adversaire); 708} 709 710// Construit l'URL Ten'Up d'un tournoi à partir de son tenup_code. 711// Le code Ten'Up est de la forme "T 20265933013600163269" : on prend les 6 derniers chiffres 712// â https://tenup.fft.fr/tournoi/163269 713// Si pas de tenup_code (tournoi créé manuellement), fallback sur la recherche. 714function tenupTournoiUrl(tenupCode) { 715 if (!tenupCode) return 'https://tenup.fft.fr/recherche/competitions'; 716 const onlyDigits = String(tenupCode).replace(/\D/g, ''); 717 if (onlyDigits.length < 6) return 'https://tenup.fft.fr/recherche/competitions'; 718 const last6 = onlyDigits.slice(-6); 719 return `https://tenup.fft.fr/tournoi/${last6}`; 720} 721 722// ============================================================ 723// AIDE â Mode d'emploi condensé, affiché dans une modale (menu â Aide) 724// ============================================================ 725// Chaque rubrique a un champ "pour" : 'tous' | 'coach' | 'joueur', utilisé pour 726// filtrer selon le rôle de l'utilisateur connecté. Contenu volontairement CONCIS 727// (l'essentiel par écran). à maintenir quand une fonctionnalité change notablement. 728const AIDE_SECTIONS = [ 729 { pour: 'tous', icone: 'ð', titre: 'Connexion & compte', points: [ 730 'Connecte-toi avec ton email et ton mot de passe (le bouton Åil affiche le mot de passe).', 731 'Mot de passe oublié : utilise le lien sur l\'écran de connexion (l\'email peut être celui d\'un parent pour les plus jeunes).', 732 'Ton menu (ton nom, en haut à droite) : photo de profil, préférences d\'emails, changement de mot de passe, thème clair/sombre.', 733 'L\'app s\'installe sur ton téléphone (ajouter à l\'écran d\'accueil) et garde l\'onglet où tu étais.' 734 ]}, 735 { pour: 'tous', icone: 'ð', titre: 'Notifications', points: [ 736 'La cloche regroupe tes notifications : validations, bilans, perfs, nouveaux tournois, classements.', 737 'Touche une notification pour aller directement à l\'élément concerné.' 738 ]}, 739 740 // ---------------- COACH ---------------- 741 { pour: 'coach', icone: 'ð', titre: 'Tableau de bord', points: [ 742 'Vue d\'ensemble de l\'académie, avec un sélecteur de période (saison ou trimestre).', 743 'Belles perfs (victoires contre mieux classé), alertes triées par criticité (rouge â orange â bleu), activité récente.' 744 ]}, 745 { pour: 'coach', icone: 'ð', titre: 'Groupes', points: [ 746 'Score santé de chaque groupe (activité, victoires, perfs, progression de classement).', 747 'Listes « Locomotives » et « à accompagner » pour repérer dynamiques et difficultés.', 748 'Les alertes de suivi concernent Accession Ãlite et Ãlite ; Tremplin conserve uniquement lâalerte sans groupe.' 749 ]}, 750 { pour: 'coach', icone: 'ð¤', titre: 'Fiche joueur', points: [ 751 'Organisée en catégories : Profil technique, Statistiques (par période), Suivi global, Tournois, Matchs, Notes, Administratif.', 752 'Importe ses matchs et son historique de classement depuis Ten\'Up (copier-coller).', 753 'Les belles perfs affichent le classement du joueur au moment du match.', 754 'Notes du coach : privées, jamais visibles par le joueur ni les parents.' 755 ]}, 756 { pour: 'coach', icone: 'ð', titre: 'Classement', points: [ 757 'Valide ou refuse les changements de classement déclarés (depuis l\'app ou l\'email).', 758 'Une montée notifie tout l\'académie ; une descente t\'est signalée discrètement.' 759 ]}, 760 { pour: 'coach', icone: 'ð¾', titre: 'Matchs & Tournois', points: [ 761 'Saisis les matchs (surface optionnelle). Importe les tournois depuis Ten\'Up.', 762 'à l\'import de tournois, les joueurs dont le profil correspond sont notifiés automatiquement.', 763 'Météo affichée sur les tournois à venir et pendant un tournoi en cours.' 764 ]}, 765 { pour: 'coach', icone: 'ð', titre: 'Cours & Présence', points: [ 766 'Planning : crée tes créneaux (lundiâvendredi), export PDF. Deux créneaux au même horaire s\'affichent côte à côte ; un champ « Coach » indique qui les assure.', 767 'Présence : fais l\'appel (présent / absent / retard), recliquer annule le statut. Export du suivi en PDF.' 768 ]}, 769 { pour: 'coach', icone: 'ð', titre: 'Bilans trimestriels', points: [ 770 'Note chaque joueur sur 5 axes ;
770 les stats sont calculées automatiquement.', 771 'Des rappels t\'avertissent avant la clôture du trimestre.' 772 ]}, 773 { pour: 'coach', icone: 'ðï¸', titre: 'Stage de la Palmyre', points: [ 774 'Bascule en environnement Palmyre via le sélecteur en haut : tout se limite alors aux participants et aux matchs du stage.', 775 'Inscris un joueur en un clic (pastille ðï¸) ; il est prévenu par notification et email.', 776 'Bilans de fin de semaine (5 axes dont savoir-être), carnet d\'observations (avec lieu et classement adverse, modifiables), planning et transport.', 777 'Observations et planning : le lieu se choisit dans la liste des lieux du stage.' 778 ]}, 779 { pour: 'coach', icone: 'ð ï¸', titre: 'Réglages de l\'académie', points: [ 780 'Dans ton menu : groupes, code d\'accès de l\'académie (à donner aux joueurs), RGPD, éditions Palmyre.', 781 'Registre RGPD exportable en PDF (numéros de téléphone affichés en entier).' 782 ]}, 783 784 // ---------------- JOUEUR ---------------- 785 { pour: 'joueur', icone: 'ð¤', titre: 'Ma fiche', points: [ 786 'Ton profil, tes statistiques (assiduité, classement, forme, victoires) et tes matchs.', 787 'Touche la carte « Classement » pour déclarer un changement ; la carte « Matches » pour tout voir.', 788 'Ajoute ta photo en cliquant sur ton avatar.' 789 ]}, 790 { pour: 'joueur', icone: 'ð¾', titre: 'Mes matches', points: [ 791 'Saisis tes matchs (adversaire, score, surface, ressenti) ou importe-les depuis Ten\'Up.', 792 'Filtre par Tous / Victoires / Défaites.' 793 ]}, 794 { pour: 'joueur', icone: 'ðï¸', titre: 'Mes entraînements', points: [ 795 'Déclare tes entraînements perso : ton coach est informé et ça alimente ton assiduité.' 796 ]}, 797 { pour: 'joueur', icone: 'ð¤', titre: 'Mon groupe', points: [ 798 'Objectif collectif, membres, prochains tournois ensemble, belles perfs et dynamique du groupe.' 799 ]}, 800 { pour: 'joueur', icone: 'ð', titre: 'Mes cours', points: [ 801 'Indique à l\'avance si tu seras présent, absent ou en retard. Un rappel arrive si tu n\'as pas répondu.' 802 ]}, 803 { pour: 'joueur', icone: 'ð ', titre: 'Tournois', points: [ 804 'Les tournois sont filtrés selon ton profil (sexe, âge, classement) â filtres activables.', 805 'Vois qui est inscrit, inscris-toi/désinscris-toi, exporte un programme en PDF.' 806 ]}, 807 { pour: 'joueur', icone: 'ð', titre: 'Mes bilans', points: [ 808 'Consulte tes bilans une fois publiés par le coach ; le radar 5 axes montre ta progression.' 809 ]}, 810 { pour: 'joueur', icone: 'ðï¸', titre: 'Palmyre (stage)', points: [ 811 'Si tu es sélectionné, tu reçois une notification et un badge apparaît sur ta fiche.', 812 'Onglet « Mon planning » : uniquement tes créneaux personnels (heure et lieu).', 813 'Tes matchs du stage comptent dans toutes tes statistiques.' 814 ]}, 815 816 { pour: 'tous', icone: 'ð', titre: 'Quelques notions', points: [ 817 'Belle perf : victoire contre un adversaire mieux classé (les forfaits ne comptent pas).', 818 'Contre-perf : défaite contre un adversaire moins bien classé.', 819 'Saison : de septembre à août ; les objectifs de matchs sont calculés au prorata du temps écoulé.' 820 ]} 821]; 822 823 824const APP_VERSION_CURRENT = globalThis.TennisDomainRules?.APP_VERSION || 'inconnue'; 825const CHANGELOG_CURRENT = Object.freeze({ 826 'v4.5.76':{silencieuse:true,items:[]}, 827 'v4.5.75':{silencieuse:true,items:[]}, 828 'v4.5.74':{silencieuse:true,items:[]}, 829 'v4.5.73':{silencieuse:true,items:[]}, 830 'v4.5.72':{silencieuse:true,items:[]}, 831 'v4.5.71':{silencieuse:true,items:[]}, 832 'v4.5.70':{silencieuse:true,items:[]}, 833 'v4.5.69':{silencieuse:true,items:[]}, 834 'v4.5.68':{silencieuse:true,items:[]}, 835 'v4.5.65':{silencieuse:true,items:[]}, 836 'v4.5.64':{silencieuse:true,items:[]}, 837 'v4.5.63':{silencieuse:true,items:[]}, 838 'v4.5.62':{silencieuse:true,items:[]}, 839 'v4.5.61':{silencieuse:true,items:[]}, 840 'v4.5.60':{silencieuse:true,items:[]}, 841 'v4.5.59':{silencieuse:true,items:[]}, 842 'v4.5.58':{silencieuse:true,items:[]}, 843 'v4.5.55':{silencieuse:true,items:[]}, 844 'v4.5.54':{silencieuse:true,items:[]}, 845 'v4.5.53':{silencieuse:true,items:[]}, 846 'v4.5.52':{silencieuse:true,items:[]}, 847 'v4.5.51':{titre:'Inscription Pré-Académie automatique',items:[{pour:'tous',icone:'ð±',texte:'Un joueur de moins de 12 ans tennis rejoint désormais automatiquement la Pré-Académie, sans validation du coach.'}]}, 848 'v4.5.50':{silencieuse:true,titre:'Temps réel adapté au rôle joueur',items:[]}, 849 'v4.5.49':{silencieuse:true,titre:'Accès direct au tableau de bord du groupe',items:[]}, 850 'v4.5.48':{silencieuse:true,titre:'Actualisation temps réel des présence
850s',items:[]}, 851 'v4.5.47':{silencieuse:true,titre:'Planning synchronisé entre coachs',items:[]}, 852 'v4.5.46':{silencieuse:true,titre:'Trousseau iOS dans lâapplication installée',items:[]}, 853 'v4.5.45':{silencieuse:true,titre:'Remplissage du login avec le Trousseau iOS',items:[]}, 854 'v4.5.44':{silencieuse:true,titre:'Liste complète dans la fiche tournoi joueur',items:[]}, 855 'v4.5.43':{silencieuse:true,titre:'Co-inscrits visibles dans le cockpit joueur',items:[]}, 856 'v4.5.42':{silencieuse:true,titre:'Filtres tournois et participants plus lisibles',items:[]}, 857 'v4.5.41':{silencieuse:true,titre:'Filtre des tournois avec inscrits',items:[]}, 858 'v4.5.40':{silencieuse:true,titre:'Participants visibles sur le cockpit joueur',items:[]}, 859 'v4.5.39':{silencieuse:true,titre:'Inscription depuis les tournois du groupe',items:[]}, 860 'v4.5.38':{silencieuse:true,titre:'Rareté des badges par saison',items:[]}, 861 'v4.5.37':{silencieuse:true,titre:'Participants aux tournois du groupe',items:[]}, 862 'v4.5.36':{titre:'Imports TenâUp fiabilisés',items:[ 863 { pour:'joueur', icone:'ð¾', texte:'Lâimport de lâhistorique accepte désormais aussi les matchs libres TenâUp et affiche un diagnostic utile en cas dâéchec.' }, 864 { pour:'tous', icone:'ð', texte:'Les cartes distinguent clairement le titre donné au tournoi de lâépreuve officielle publiée sur TenâUp.' } 865 ]}, 866 'v4.5.35':{titre:'Toutes tes séances et ton engagement récompensé',items:[ 867 { pour:'joueur', icone:'ð', texte:'Lâaccueil affiche toutes tes prochaines séances et ta première réponse de présence rapporte 5 XP.' } 868 ]}, 869 'v4.5.34':{titre:'Un compte parent pour plusieurs enfants',items:[ 870 { pour:'joueur', icone:'ð¨âð©âð§âð¦', texte:'Un parent peut maintenant rattacher plusieurs joueurs à son compte et passer facilement de lâun à lâautre.' } 871 ]}, 872 'v4.5.33':{silencieuse:true,titre:'Diagnostic TenâUp explicite',items:[]}, 873 'v4.5.32':{silencieuse:true,titre:'PDF planning corrigé',items:[]}, 874 'v4.5.30':{silencieuse:true,titre:'Protection du quota TenâUp',items:[]}, 875 'v4.5.29':{silencieuse:true,titre:'Planning coach optimisé',items:[]}, 876 'v4.5.28':{silencieuse:true,titre:'Codes dâinscription différenciés',items:[]}, 877 'v4.5.27':{silencieuse:true,titre:'Planning temps réel',items:[]}, 878 'v4.5.26':{silencieuse:true,titre:'Planning',items:[]}, 879 'v4.5.25':{silencieuse:true,titre:'Synchronisation TenâUp protégée',items:[]}, 880 'v4.5.24':{silencieuse:true,titre:'Compteur de performances',items:[]}, 881 'v4.5.23':{silencieuse:true,titre:'Suivi Palmyre',items:[]}, 882 'v4.5.22':{silencieuse:true,titre:'Alertes Palmyre',items:[]}, 883 'v4.5.21':{silencieuse:true,titre:'Palmyre isolée',items:[]}, 884 'v4.5.20':{silencieuse:true,titre:'Périmètre Palmyre',items:[]}, 885 'v4.5.19':{silencieuse:true,titre:'Retour',items:[]}, 886 'v4.5.18': { silencieuse:true, titre:'Accès aux performances', items:[] }, 887 'v4.5.17': { silencieuse:true, titre:'Navigation du cockpit coach', items:[] }, 888 'v4.5.16': { silencieuse:true, titre:'Nouveau cockpit coach', items:[] }, 889 'v4.5.15': { silencieuse:true, titre:'Ãges tournoi', items:[] }, 890 'v4.5.14': { silencieuse:true, titre:'Filtres FFT', items:[] }, 891 'v4.5.10': { silencieuse:true, titre:'Tournois terminés masqués', items:[] }, 892 'v4.5.9': { silencieuse:true, titre:'Détection des mises à jour accélérée', items:[] }, 893 'v4.5.8': { titre:'La Pré-Académie gagne en autonomie', items:[ 894 { pour:'coach', iconeImage:'assets/v4/menu/invitations.webp', texte:'Une invitation active peut être révoquée et disparaît automatiquement avec la fiche du joueur.' }, 895 { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'La Pré-Académie permet de créer ses plateaux et ne montre que les compétitions adaptées aux jeunes.' }, 896 { pour:'tous', iconeImage:'assets/v4/nav/performances.webp', texte:'Les classements 40/2 et 40/1 sont disponibles, et la Pré-Académie accueille désormais les enfants dès 7 ans.' } 897 ]}, 898 'v4.5.7': { titre:'Les montées sont de très belles performances', items:[ 899 { pour:'coach', iconeImage:'assets/v4/nav/performances.webp', texte:'Le cockpit annonce désormais chaque montée de classement validée c
899omme une très belle perf.' }, 900 { pour:'joueur', iconeImage:'assets/v4/nav/performances.webp', texte:'Le mur de ton groupe célèbre maintenant les montées de classement, en plus des belles victoires.' } 901 ]}, 902 'v4.5.6': { titre:'Le planning Académie reste accessible', items:[ 903 { pour:'coach', iconeImage:'assets/v4/nav/planning.webp', texte:'Le bouton Planning est de nouveau visible en Académie pour créer et gérer les créneaux de cours.' } 904 ]}, 905 'v4.5.5': { titre:'Une seule édition Palmyre à la fois', items:[ 906 { pour:'coach', iconeImage:'assets/v4/nav/palmyre.webp', texte:'Archive lâédition Palmyre en cours avant dâen créer une nouvelle ; chaque édition conserve son propre historique.' } 907 ]}, 908 'v4.5.4': { titre:'Le bilan reste dans le bon environnement', items:[ 909 { pour:'coach', iconeImage:'assets/v4/nav/reports.webp', texte:'Depuis une fiche joueur Palmyre, le bouton Bilan ouvre désormais directement le bilan de la semaine.' }, 910 { pour:'coach', iconeImage:'assets/v4/menu/update.webp', texte:'Lâimport de lâhistorique de classement est désormais réservé aux fiches Académie.' } 911 ]}, 912 'v4.5.3': { titre:'La fiche Palmyre reste centrée sur le stage', items:[ 913 { pour:'coach', iconeImage:'assets/v4/nav/observations.webp', texte:'La fiche joueur Palmyre masque désormais entièrement les notes et la gestion administrative de lâAcadémie.' } 914 ]}, 915 'v4.5.2': { titre:'Une fiche joueur Palmyre plus claire', items:[ 916 { pour:'coach', iconeImage:'assets/v4/nav/observations.webp', texte:'La fiche joueur Palmyre sâappuie uniquement sur les observations du stage, sans doublon avec les notes Académie.' } 917 ]}, 918 'v4.5.1': { titre:'Un suivi Palmyre cohérent pour tous', items:[ 919 { pour:'coach', iconeImage:'assets/v4/progress/objectif_groupe-v2.webp', texte:'Les joueurs sans groupe Académie affichent désormais leur progression de matchs pendant la Palmyre.' } 920 ]}, 921 'v4.5.0': { titre:'Pilotage coach et suivi sportif renforcés', items:[ 922 { pour:'coach', iconeImage:'assets/v4/menu/coach_notes.webp', texte:'Un accès Notes permet de choisir rapidement un joueur ; ses notes privées sont aussi visibles pendant la préparation du bilan.' }, 923 { pour:'coach', iconeImage:'assets/v4/nav/planning.webp', texte:'à la création dâun créneau, tu peux immédiatement choisir les joueurs, et les changements de planning remontent en temps réel.' }, 924 { pour:'tous', iconeImage:'assets/v4/progress/objectif_groupe-v2.webp', texte:'Les axes de travail partagés sont maintenant disponibles toute lâannée en Académie, en plus de la Palmyre.' }, 925 { pour:'coach', iconeImage:'assets/v4/nav/palmyre.webp', texte:'Les éditions Palmyre archivées restent accessibles pour terminer, publier ou consulter les bilans.' }, 926 { pour:'coach', iconeImage:'assets/v4/nav/presence.webp', texte:'Les notifications de cours signalent uniquement les absences et retards utiles.' } 927 ]}, 928 'v4.4.10': { titre:'Tous les tournois détaillés en une seule synchronisation', items:[ 929 { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Une seule synchronisation parcourt maintenant tout le catalogue TenâUp et signale honnêtement toute fiche inaccessible.' }, 930 { pour:'joueur', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Les épreuves officielles améliorent la sélection des tournois selon ton sexe, ton âge et ton classement.' } 931 ] }, 932 'v4.4.9': { titre:'Des tournois fiables et réellement à jour', items:[ 933 { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'La synchronisation TenâUp distingue clairement un traitement terminé, partiel ou interrompu et complète en priorité les fiches sans épreuves.' }, 934 { pour:'joueur', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Le catalogue propose uniquement les tournois auxquels il est encore pertinent de sâinscrire ; un tournoi en cours reste accessible dans « Mes tournois ».' } 935 ] }, 936 'v4.4.8': { titre:'Davantage de contrôle pour le coach', items:[ 937 { pour:'coach', iconeImage:'assets/v4/nav/pre_academie.webp', texte:'Lâespace Pré
937-Académie reste visible toute lâannée, même lorsquâaucun joueur nây est actuellement rattaché.' }, 938 { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Le catalogue TenâUp peut être synchronisé immédiatement ; le bilan indique aussi les fiches dont les épreuves ne sont pas encore publiées.' } 939 ] }, 940 'v4.4.7': { titre:'Une interface cohérente et des matchs mieux qualifiés', items:[ 941 { pour:'tous', iconeImage:'assets/v4/menu/update.webp', texte:'La typographie est désormais identique sur tous les écrans, pour les joueurs comme pour les coachs.' }, 942 { pour:'tous', iconeImage:'assets/v4/nav/matches.webp', texte:'Un match libre homologué FFT peut maintenant être déclaré sans lâassocier à un tournoi.' } 943 ] }, 944 'v4.4.6': { titre:'Les fiches tournoi sont plus compactes', items:[ 945 { pour:'tous', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Un repère FFT discret et la distance depuis Bouliac rendent chaque tournoi plus facile à lire.' } 946 ] }, 947 'v4.4.5': { titre:'Lâaccès aux tournois est rétabli', items:[ 948 { pour:'tous', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Les listes de tournois sâouvrent de nouveau normalement pour les joueurs et les coachs.' } 949 ] }, 950 'v4.4.4': { titre:'Des tournois mieux ciblés et plus lisibles', items:[ 951 { pour:'joueur', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Les propositions de tournois tiennent compte des épreuves réelles : sexe, âge et classement, tout en laissant accessibles les catégories Senior.' }, 952 { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Chaque tournoi importé depuis TenâUp affiche ses épreuves détaillées et un repère FFT immédiatement identifiable.' } 953 ] }, 954 'v4.4.3': { titre:'Les nouveautés sont propres à chaque compte', items:[ 955 { pour:'tous', iconeImage:'assets/v4/menu/whats_new.webp', texte:'Lire les nouveautés sur un compte ne les masque plus pour les autres profils utilisés sur le même appareil.' } 956 ] },
957 'v4.4.2': { titre:'Les tournois suivent la saison sportive', items:[ 958 { pour:'tous', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Le catalogue affiche uniquement les tournois de la saison sportive en cours ; les saisons précédentes sont archivées.' } 959 ] }, 960 'v4.4.1': { titre:'Les tournois TenâUp restent à jour automatiquement', items:[ 961 { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Le catalogue public TenâUp est désormais contrôlé toutes les 6 heures dans un rayon de 50 km autour de Bouliac.' } 962 ] }, 963 'v4.4.0': { titre:'Les tournois TenâUp arrivent automatiquement', items:[ 964 { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Les tournois TenâUp alimentent désormais automatiquement le catalogue, tout en conservant lâimport manuel et le contrôle des doublons.' } 965 ] } 966}); 967globalThis.TennisCurrentChangelog = CHANGELOG_CURRENT; 968 969let CHANGELOG = null; 970let promesseChangelog = null; 971 972async function chargerChangelog() { 973 if (CHANGELOG) return CHANGELOG; 974 if (promesseChangelog) return promesseChangelog; 975 promesseChangelog = (async () => {
976 const disponible = await chargerVendor('changelog'); 977 CHANGELOG = Object.freeze({ ...(disponible ? globalThis.TennisChangelog : {}), ...(globalThis.TennisCurrentChangelog || {}) }); 978 return CHANGELOG; 979 })(); 980 try { 981 return await promesseChangelog; 982 } finally { 983 promesseChangelog = null; 984 } 985} 986 987// ============================================================ 988// MODULE MÃTÃO â Géocodage + Open-Meteo (gratuit, sans clé API) 989// ============================================================ 990// Affiche la météo prévue pour un tournoi entre J-7 et J-0. 991// Cache localStorage pour éviter les appels répétés (24h pour géocodage, 1h pour météo). 992 993const METEO_CACHE_PREFIX = 'pcb_meteo_v2_'; // v2 : invalide les anciens échecs (avant dico Gironde) 994const GEO_CACHE_PREFIX = 'pcb_geo_v3_'; // v3 : dico Gironde local prioritaire 995const METEO_TTL_MS = 60 * 60 * 1000; // 1 heure pour les données météo 996const GEO_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 jours pour le géocodage (les villes ne bougent pas) 997 998// ============================================================ 999// DICTIONNAIRE LOCAL DES COMMUNES DE GIRONDE 1000// ============================================================ 1001// L'académie est à Bouliac (33) : la quasi-totalité des tournois sont girondins. 1002// Le format Ten'Up dégrade les noms (accents perdus, "ST" abrégé, apostrophes en espaces), 1003// ce qui fait échouer le géocodage API. Ce dictionnaire local résout ces cas 1004// instantanément et sans ambiguïté. Coordonnées [lat, lon]. 1005// Clé = nom normalisé (sans accent, majuscules, ST/STE abrégés, séparateurs unifiés). 1006const COMMUNES_GIRONDE = { 1007 'AMBARES ET LAGRAVE': [44.9274, -0.4914], 1008 'ANDERNOS LES BAINS': [44.7457, -1.1036], 1009 'ARCACHON': [44.6613, -1.1725], 1010 'ARTIGUES PRES BORDEAUX': [44.8589, -0.4972], 1011 'AUDENGE': [44.6837, -1.0134], 1012 'BAZAS': [44.4316, -0.2134], 1013 'BEGLES': [44.8076, -0.5514], 1014 'BIGANOS': [44.6442, -0.9784], 1015 'BLANQUEFORT': [44.9106, -0.6376], 1016 'BORDEAUX': [44.8412, -0.5805], 1017 'BOULIAC': [44.8193, -0.5027], 1018 'BRUGES': [44.8756, -0.6056], 1019 'CADAUJAC': [44.7552, -0.5302], 1020 'CANEJAN': [44.763, -0.6544], 1021 'CARBON BLANC': [44.895, -0.5018], 1022 'CASTELNAU DE MEDOC': [45.0269, -0.7991], 1023 'CENON': [44.8561, -0.5335], 1024 'CESTAS': [44.7447, -0.6819], 1025 'CREON': [44.7746, -0.3482], 1026 'CUBZAC LES PONTS': [44.9712, -0.4536], 1027 'EYSINES': [44.8844, -0.6514], 1028 'FARGUES ST HILAIRE': [44.8238, -0.445], 1029 'FLOIRAC': [44.8306, -0.5268], 1030 'GRADIGNAN': [44.7736, -0.6139], 1031 'GUJAN MESTRAS': [44.636, -1.0681], 1032 'LA BREDE': [44.6833, -0.5333], 1033 'LA REOLE': [44.5824, -0.0386], 1034 'LA TESTE DE BUCH': [44.6328, -1.1451], 1035 'LACANAU': [44.9791, -1.0796], 1036 'LANGON': [44.5528, -0.2499], 1037 'LATRESNE': [44.7852, -0.4966], 1038 'LE BOUSCAT': [44.8649, -0.5986], 1039 'LE HAILLAN': [44.8716, -0.6769], 1040 'LE PIAN MEDOC': [44.9552, -0.6702], 1041 'LE TEICH': [44.6339, -1.0244], 1042 'LEGE CAP FERRET': [44.8, -1.15], 1043 'LEOGNAN': [44.729, -0.6005], 1044 'LIBOURNE': [44.9145, -0.2419], 1045 'LORMONT': [44.8765, -0.5192], 1046 'MARTIGNAS SUR JALLE': [44.8451, -0.783], 1047 'MERIGNAC': [44.8425, -0.6451], 1048 'PESSAC': [44.8056, -0.6324], 1049 'PODENSAC': [44.6509, -0.3557], 1050 'ST ANDRE DE CUBZAC': [44.9945, -0.4444], 1051 'ST LOUBES': [44.9172, -0.4286], 1052 'ST MEDARD D EYRANS': [44.7167, -0.5167], 1053 'ST MEDARD EN JALLES': [44.8955, -0.7178], 1054 'STE FOY LA GRANDE': [44.8407, 0.2172], 1055 'TALENCE': [44.8085, -0.5891], 1056 'TARGON': [44.7351, -0.2637], 1057 'TRESSES': [44.8488, -0.4638], 1058 'VILLENAVE D ORNON': [44.7794, -0.5671] 1059}; 1060 1061// Normalise un nom de lieu en clé de dictionnaire : 1062// sans accent, majuscules, apostrophes/tirets â espaces, SAINTâST, SAINTEâSTE. 1063// "Saint-Médard-d'Eyrans" et "ST MEDARD D EYRANS" donnent la même clé. 1064const serviceMeteoTournois = TennisTournamentWeather.creerService({ 1065 fetchFn: (...args) => fetch(...args), storage: localStorage, document, 1066 domainRules: TennisDomainRules, escapeHtml, dateLocaleISO, 1067 journal, 1068 coordonneesLocales: cle => COMMUNES_GIRONDE[cle] || null, 1069 maintenant: () => new Date(), geoPrefix: GEO_CACHE_PREFIX, 1070 meteoPrefix: METEO_CACHE_PREFIX, geoTtlMs: GEO_TTL_MS, meteoTtlMs: METEO_TTL_MS 1071}); 1072const dateMeteoTournoi = serviceMeteoTournois.dateMeteoTournoi; 1073const hydrateMeteoPlaceholders = serviceMeteoTournois.hydrateMeteoPlaceholders; 1074const hydrateMeteoEnrichie = serviceMeteoTournois.hydrateMeteoEnrichie; 1075// ============================================================ 1076// FILTRE TOURNOIS PAR GENRE â Approche textuelle souple 1077// ============================================================ 1078// Le champ tournois.categorie est un texte libre. On scanne les mots-clés. 1079// Par défaut, un tournoi sans aucun marqueur de genre est gardé (par sécurité, 1080// on n'exclut jamais un tournoi par défaut). 1081 1082// Détecte le(s) genre(s) présent(s) dans un texte de catégorie. 1083// Retourne { hasMasculin, hasFeminin, hasNone } 1084function detectGenresTournoi(categorieText) { 1085 return TennisDomainRules.detectGenresTournoi(categorieText); 1086} 1087 1088function detectAgeTournoi(categorieText) { 1089 return TennisDomainRules.detectAgeTournoi(categorieText); 1090} 1091 1092// Extrait la fourchette de classement d'un libellé de tournoi. 1093// Renvoie { min, max } en INDICES de l'échelle CLASSEMENTS (min = moins bien classé, 1094// max = mieux classé), ou hasNone si rien détecté. 1095function detectClassementTournoi(categorieText) { 1096 return TennisDomainRules.detectClassementTournoi(categorieText); 1097} 1098 1099function inscritsNomsPole(tournoi) { 1100 const ids = listeSure(tournoi.inscrits); 1101 const annu = cache.annuaire || {}; 1102 return ids 1103 .map(id => { 1104 if (annu[id]) return annu[id]; 1105 const j = joueurById(id); 1106 if (!j) return null; 1107 return `${j.prenom}${j.nom ? ' ' + j.nom.charAt(0).toUpperCase() + '.' : ''}`; 1108 }) 1109 .filter(Boolean) 1110 .sort((a, b) => a.localeCompare(b, 'fr')); 1111} 1112 1113function inscritsNomsPoleCollectifs(tournoi) {
1114 const ligneCollective = listeSure(cache.prochainsTournoisGroupe).find(ligne => String(ligne.tournoi_id) === String(tournoi.id)); 1115 return ligneCollective && listeSure(ligneCollective.participants).length 1116 ? listeSure(ligneCollective.participants) 1117 : inscritsNomsPole(tournoi); 1118} 1119 1120function autresInscritsNomsPole(tournoi, joueurId) { 1121 const noms = inscritsNomsPoleCollectifs(tournoi); 1122 const joueur = joueurById(joueurId); 1123 const nomJoueur = (cache.annuaire || {})[joueurId] || (joueur ? `${joueur.prenom}${joueur.nom ? ' ' + joueur.nom[0].toUpperCase() + '.' : ''}` : ''); 1124 return [...new Set(noms.filter(Boolean))].filter(nom => nom !== nomJoueur).sort((a, b) => a.localeCompare(b, 'fr')); 1125} 1126 1127// Correspondance globale selon les critères ACTIFS (objet filtresTournoi). 1128// Découpe le champ catégorie en sous-épreuves. Un tournoi peut proposer plusieurs 1129// catégories séparées par ·, |, ; ou retour à la ligne (ex. "15/16 SM · 13/14 SD"). 1130// Le tournoi correspond-il au joueur, selon les critères actifs ? 1131// Gère les catégories MULTIPLES : le tournoi correspond si AU MOINS UNE sous-épreuve 1132// correspond aux 3 critères. Le nom du tournoi fournit un contexte commun (genre/âge 1133// parfois indiqués uniquement dans le titre), combiné à chaque sous-catégorie. 1134function tournoiCorrespondAuJoueur(tournoi, joueur, criteres) { 1135 return TennisDomainRules.tournoiCorrespondAuJoueur(tournoi, joueur, criteres, getSaisonCourante()); 1136} 1137 1138// Ãtat des filtres tournoi (par critère), persisté et isolé par compte. 1139// Par défaut pour un joueur : les 3 critères actifs (sexe + âge + classement). 1140function getFiltresTournoi() { 1141 const estJoueur = profile?.role === 'joueur' && !!profile.joueur_id; 1142 return etatApp.lireFiltresTournoi(profile?.id, estJoueur); 1143} 1144// Active/désactive un critère et redessine 1145function toggleFiltreTournoi(critere) { 1146 const estJoueur = profile?.role === 'joueur' && !!profile.joueur_id; 1147 if (!etatApp.basculerFiltreTournoi(profile?.id, critere, estJoueur)) return; 1148 if (etatApp.ecran === 'calendar') $('app').innerHTML = renderCalendar(); 1149} 1150 1151// Applique les filtres tournoi (selon critères actifs) à une liste de tournois. 1152function filterTournoisParGenre(tournois) { 1153 // Coach : pas de filtrage automatique 1154 if (isCoach()) return tournois; 1155 if (!profile || !profile.joueur_id) return tournois; 1156 const j = joueurById(profile.joueur_id); 1157 if (!j) return tournois; 1158 const tournoisDuProfil = estPreAcademie(j) 1159 ? tournois.filter(t => TennisDomainRules.tournoiPourJoueurPreAcademie(t, j, getSaisonCourante())) 1160 : tournois; 1161 const criteres = getFiltresTournoi(); 1162 if (!criteres.sexe && !criteres.age && !criteres.classement) return tournoisDuProfil; 1163 return tournoisDuProfil.filter(t => tournoiCorrespondAuJoueur(t, j, criteres)); 1164} 1165 1166const avatarService = TennisAvatars.creerService({ 1167 db, document, ImageCtor: Image, URLApi: URL, escapeHtml, 1168 getProfile: () => profile, getJoueurs: () => cache.joueurs, 1169 joueurById, isCoach, toast, toastErreur, demanderConfirmation, 1170 closeModal, updateHeaderAvatar, render, maintenant: () => Date.now() 1171}); 1172function initials(prenom, nom) { return avatarService.initiales(prenom, nom); } 1173function avatarDisplayUrl(joueur) { return avatarService.urlAffichage(joueur); } 1174async function chargerUrlsAvatarsSignees() { return avatarService.chargerUrlsSignees(); } 1175async function rafraichirAvatarsAuPremierPlan() { 1176 if (!session || !_appInitialized) return; 1177 const modifies = await avatarService.chargerUrlsSignees(); 1178 if (!modifies) return; 1179 avatarService.actualiserDom(); 1180 updateHeaderAvatar(); 1181} 1182function avatarHtml(joueur, size = 'md') { return avatarService.html(joueur, size); } 1183function updateHeaderPalmyreBadge() { 1184 const roleEl = $('user-role'); 1185 if (!roleEl || !profile) return; 1186 // Seulement pour un joueur (le coach a son sélecteur d'environnement) 1187 if (profile.role === 'joueur' && profile.joueur_id) { 1188 const j = (listeSure(cache.joueurs)).find(x => x.id === profile.joueur_id); 1189 const ed = joueurEnPalmyreMaintenant(j); 1190 if (ed) { 1191 roleEl.innerHTML = `<span style="display:inline-block; background:var(--orange); color:#fff; padding:1px 7px; border-radius:10px; font-size:10px; font-weight:700; letter-spacing:0.3px">ðï¸ ${escapeHtml(ed.nom)}</span>`;
1192 return; 1193 } 1194 } 1195 // Sinon, rôle normal 1196 roleEl.textContent = profile.role; 1197} 1198 1199function updateHeaderAvatar() { 1200 const slot = $('user-avatar'); 1201 if (!slot || !profile) return; 1202 1203 // Prénom/nom à utiliser pour les initiales : le profile en priorité 1204 let prenom = profile.prenom || ''; 1205 let nom = profile.nom || ''; 1206 let photoUrl = null; 1207 1208 // Si joueur, on tente d'utiliser sa photo 1209 if (profile.role === 'joueur' && profile.joueur_id) { 1210 const j = (listeSure(cache.joueurs)).find(x => x.id === profile.joueur_id); 1211 if (j) { 1212 prenom = j.prenom || prenom; 1213 nom = j.nom || nom; 1214 photoUrl = avatarDisplayUrl(j); 1215 } 1216 } 1217 1218 if (photoUrl) { 1219 slot.style.backgroundImage = `url('${photoUrl}')`; 1220 slot.textContent = ''; 1221 } else { 1222 slot.style.backgroundImage = ''; 1223 slot.textContent = initials(prenom, nom); 1224 } 1225} 1226 1227// ============================================================ 1228// UPLOAD PHOTO DE PROFIL â modale, compression et envoi Supabase Storage 1229// ============================================================ 1230 1231// Ouvre la modale d'upload pour le joueur donné 1232// (le joueur peut modifier sa propre photo, le coach peut modifier celle de n'importe quel joueur) 1233function openAvatarUpload(joueurId) { 1234 const j = joueurById(joueurId); 1235 if (!j) { toast('Joueur introuvable', true); return; } 1236 1237 // Permission : le joueur lui-même OU un coach 1238 const peutModifier = isCoach() || (profile && profile.joueur_id === joueurId); 1239 if (!peutModifier) { toast('Tu ne peux modifier que ta propre photo', true); return; } 1240 1241 const html = `<div class="modal-header"><h3 class="modal-title" style="display:flex;align-items:center;gap:10px"><img class="modal-title-art" src="assets/v4/nav/camera.webp" alt="">Photo de profil</h3><button class="modal-close" data-ui-click="closeModalDirect()">Ã</button></div> 1242 <div style="text-align:center; padding:8px 0"> 1243 <div style="display:inline-block; position:relative"> 1244 ${avatarHtml(j, 120)} 1245 </div> 1246 <div class="text-faint" style="font-size:12px; margin-top:8px">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</div> 1247 </div> 1248 1249 <div class="card" style="padding:14px; margin-top:12px"> 1250 <div class="text-dim" style="font-size:13px; margin-bottom:10px"> 1251 Formats acceptés : JPG, PNG, WebP. Taille max : 5 Mo (compression automatique). 1252 </div> 1253 <input type="file" id="avatarFileInput" accept="image/jpeg,image/png,image/webp" style="width:100%; padding:8px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px" data-ui-change="handleAvatarFileSelected(event, ${escapeHtml(encoderArgumentTexteUI(joueurId))})"> 1254 <div id="avatarUploadStatus" class="text-faint mt-2" style="font-size:12px; text-align:center"></div> 1255 </div> 1256 1257 ${j.photo_url ? `<div class="mt-2" style="text-align:center"> 1258 <button class="btn btn-sm btn-danger" data-ui-click="deleteAvatar(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img class="btn-action-art" src="assets/v4/menu/player_archive.webp" alt="">Supprimer la photo (revenir aux initiales)</button> 1259 </div>` : ''} 1260 1261 <div class="flex gap-1 mt-2"> 1262 <button class="btn flex-1" data-ui-click="closeModalDirect()">Fermer</button> 1263 </div>`; 1264 1265 showModal(html); 1266} 1267 1268// Compresse une image côté client (canvas) pour rester sous ~200 Ko 1269// Retourne un Blob JPEG prêt à l'upload 1270async function handleAvatarFileSelected(event, joueurId) { 1271 return avatarService.traiterFichier(event, joueurId); 1272} 1273async function deleteAvatar(joueurId) { 1274 return avatarService.supprimer(joueurId); 1275} 1276function isCoach() { 1277 return profile && profile.role === 'coach'; 1278} 1279 1280// ============================================================ 1281// SAISON / TRIMESTRE (FFT : septâaoût) 1282// ============================================================ 1283function getSaisonCourante() { 1284 return TennisDomainRules.saisonPourDate(new Date()); 1285} 1286 1287function getTrimestreCourant() { 1288 return TennisDomainRules.trimestrePourDate(new Date()); 1289} 1290 1291function getTrimestreDates(saison, trimestre) { 1292 return TennisDomainRules.trimestreDates(saison, trimestre); 1293} 1294 1295function getTrimestreLabel(t) { 1296 return { T1: 'T1 · Sept-Nov', T2: 'T2 · Déc-Fév', T3: 'T3 · Mars-Mai', T4: 'T4 · Juin-Août', ANNUEL: 'ð Annuel' }[t] || t; 1297} 1298 1299// ============================================================ 1300// PÃRIODE D'ANALYSE (filtre réutilisable) â saison chargée à la demande 1301// ============================================================ 1302// Ãtat de période par écran (défaut = trimestre en cours).
1303etatApp.initialiserPeriodes(getTrimestreCourant()); 1304const periodes = etatApp.periodes; 1305function setPeriodeMesEntrainements(p) { etatApp.definirPeriode('mesEntrainements', p); render(); } 1306 1307function getSaisonAnalyse() { 1308 if (!etatApp.saisonAnalyse) etatApp.definirSaisonAnalyse(getSaisonCourante()); 1309 return etatApp.saisonAnalyse || getSaisonCourante(); 1310} 1311 1312function getPeriodeDates(periode) { 1313 if (!isPoleEnv()) { 1314 const ev = getActiveEvenement(); 1315 if (ev) { 1316 let debut = ev.date_debut || '2000-01-01'; 1317 let fin = ev.date_fin || '2100-12-31'; 1318 (listeSure(cache.matchesSaison)).forEach(m => { 1319 if (!m.date || m.evenement_id !== ev.id) return; 1320 const d = String(m.date).slice(0, 10); 1321 if (d < debut) debut = d; 1322 if (d > fin) fin = d; 1323 }); 1324 return [debut, fin]; 1325 } 1326 return ['2000-01-01', '2100-12-31']; 1327 } 1328 const saison = getSaisonAnalyse(); 1329 if (periode === 'saison') return TennisDomainRules.bornesSaison(saison); 1330 return getTrimestreDates(saison, periode); 1331} 1332 1333// Libellé court pour affichage 1334function getPeriodeLabel(periode) { 1335 // En mode événement (Palmyre), il n'y a pas de trimestre : on parle de la semaine. 1336 if (!isPoleEnv()) return 'Toute la semaine'; 1337 if (periode === 'saison') return 'Saison complète'; 1338 return getTrimestreLabel(periode); 1339} 1340
1341function renderSaisonSelector() { 1342 if (!isPoleEnv()) return ''; 1343 const saisonAnalyse = getSaisonAnalyse(); 1344 return TennisSeasonArchives.rendreSelecteurSaison({ 1345 saisonCourante: getSaisonCourante(), 1346 saisonsDisponibles: cache.saisonsDisponibles, 1347 saisonSelectionnee: saisonAnalyse, 1348 bornesSaison: TennisDomainRules.bornesSaison 1349 }); 1350} 1351 1352// Composant : barre de sélection de période. `onChangeFn` identifie une action 1353// explicitement autorisée, déclenchée par le gestionnaire partagé ci-dessous. 1354function renderPeriodeSelector(courant, onChangeFn, options = {}) { 1355 // En mode événement (Palmyre), pas de sélecteur de période : on affiche toute 1356 // la semaine de l'édition. On montre juste un libellé. 1357 if (!isPoleEnv()) { 1358 const ev = getActiveEvenement(); 1359 return `<div class="card mb-2" style="padding:8px; text-align:center"> 1360 <div class="text-faint" style="font-size:12px">ðï¸ ${ev ? escapeHtml(ev.nom) : 'Palmyre'} · toute la semaine</div> 1361 </div>`; 1362 } 1363 const saisonAnalyse = getSaisonAnalyse(); 1364 return TennisSeasonArchives.rendreBarrePeriode({ 1365 selecteurSaison: options.afficherSaison === false ? '' : renderSaisonSelector(), 1366 periodeCourante: courant, 1367 action: onChangeFn, 1368 libellePeriode: getPeriodeLabel(courant), 1369 saison: saisonAnalyse 1370 }); 1371} 1372 1373// Bornes saison courante complète (réutilisé pour les stats "année tennistique") 1374function getSaisonDates() { 1375 return getPeriodeDates('saison'); 1376} 1377 1378// Le démarrage ne charge que la saison sportive active. Les archives restent 1379// disponibles via leurs écrans dédiés, mais ne grossissent plus le payload à 1380// chaque ouverture au fil des années. 1381function getBornesChargementSaison(saison = getSaisonAnalyse()) { 1382 return TennisDomainRules.bornesSaison(saison); 1383} 1384 1385async function chargerPresencesSaison(saison = getSaisonAnalyse()) { 1386 const [debut, fin] = getBornesChargementSaison(saison); 1387 return TennisSeasonArchives.chargerPresences({ 1388 db, colonnesPresence: COLONNES_PRESENCE, debut, fin, chargerToutesPages 1389 }); 1390} 1391 1392async function executerChargementSaisonAnalyse(saison) { 1393 if (!TennisDomainRules.bornesSaison(saison)[0]) return false; 1394 if (saison === getSaisonAnalyse() && cache.matchesSaison.length) return true; 1395 1396 return (async () => { 1397 const [debut, fin] = getBornesChargementSaison(saison); 1398 toast(`Chargement de la saison ${saison}â¦`); 1399 const resultat = await TennisSeasonArchives.chargerDonnees({ 1400 db, 1401 contrats: { matches: COLONNES_MATCH, entrainements: COLONNES_ENTRAINEMENT, seances: COLONNES_SEANCE_COURS }, 1402 debut, 1403 fin, 1404 chargerToutesPages, 1405 chargerPresencesSaison: () => chargerPresencesSaison(saison) 1406 }); 1407 if (resultat.error) throw resultat.error; 1408 const { matches: matchesRes, entrainements: entrainementsRes, seances: seancesRes, presences: presencesRes } = resultat; 1409 1410 cache.matchesSaison = listeSure(matchesRes.data); 1411 cache.entrainements = listeSure(entrainementsRes.data); 1412 cache.seancesCours = listeSure(seancesRes.data); 1413 cache.presences = listeSure(presencesRes.data); 1414 etatApp.definirSaisonAnalyse(saison); 1415 appliquerPerimetreMatchs(); 1416 invaliderIndexMatchs(); 1417 cache.alertes = computeAlertes(); 1418 cache.bellePerfs = computeBellePerfs(); 1419 render(); 1420 if (etatApp.joueurDetailId && document.getElementById('modal')?.classList.contains('show')) { 1421 openPlayerDetail(etatApp.joueurDetailId); 1422 } 1423 toast(`Saison ${saison} chargée`); 1424 return true; 1425 })().catch(error => { 1426 journal.error('[saison] chargement impossible', error); 1427 toast('Impossible de charger cette saison pour le moment.', true); 1428 render(); 1429 return false; 1430 }); 1431} 1432 1433const chargerSaisonAnalyse = TennisSeasonArchives.creerChargeurDerniereDemande(executerChargementSaisonAnalyse); 1434 1435document.addEventListener('change', event => { 1436 const select = event.target.closest('[data-saison-analyse]'); 1437 if (!select) return; 1438 chargerSaisonAnalyse(select.value); 1439}); 1440 1441// Setters de période (rerendent l'écran courant) 1442function setPeriodeDashboard(p) { etatApp.definirPeriode('dashboard', p); render(); }
1443function setPeriodePerfs(p) { etatApp.definirPeriode('perfs', p); render(); } 1444function setPeriodePlayerDetail(p) { etatApp.definirPeriode('playerDetail', p); if (etatApp.joueurDetailId) openPlayerDetail(etatApp.joueurDetailId); } 1445function setPeriodeMyProfile(p) { etatApp.definirPeriode('myProfile', p); render(); } 1446function setPeriodeGroupe(p) { etatApp.definirPeriode('groupe', p); render(); } 1447 1448const PERIODE_HANDLERS = { 1449 setPeriodeDashboard, 1450 setPeriodePerfs, 1451 setPeriodePlayerDetail, 1452 setPeriodeMyProfile, 1453 setPeriodeGroupe, 1454 setPeriodeMesEntrainements 1455}; 1456 1457document.addEventListener('click', event => { 1458 const bouton = event.target.closest('[data-periode-handler][data-periode-value]'); 1459 if (!bouton) return; 1460 const action = PERIODE_HANDLERS[bouton.dataset.periodeHandler]; 1461 if (action) action(bouton.dataset.periodeValue); 1462}); 1463 1464// Détecte un match gagné/perdu par forfait (WO / walkover). Un WO n'est pas un 1465// résultat sportif : on l'exclut notamment du calcul des contre-perfs. 1466// On cherche les variantes courantes dans le score et les notes. 1467function estWalkover(match) { 1468 return TennisDomainRules.estWalkover(match); 1469} 1470 1471// Comptage de matchs pour les STATISTIQUES : les forfaits (WO) sont exclus. Un WO n'est 1472// pas un match joué (l'adversaire ne s'est pas présenté), il ne doit donc jamais compter 1473// ni comme match joué, ni comme victoire, ni dans le taux de victoire. Les WO restent en 1474// revanche visibles dans les LISTES de matchs (ils ont bien eu lieu dans le calendrier). 1475function matchsHorsWO(liste) { 1476 return TennisDomainRules.matchsSportifs(liste); 1477} 1478// Renvoie {total, victoires, defaites} en ignorant les WO. Base commune à tous les écrans. 1479function comptesMatchs(liste) { 1480 return TennisDomainRules.comptesMatchs(liste); 1481} 1482 1483// Un WO reste consultable dans l'historique mais ne doit jamais être présenté 1484// visuellement comme une victoire ou une défaite jouée. 1485function resultatMatchHtml(match) { 1486 if (estWalkover(match)) return '<span class="match-result-wo">WO</span>'; 1487 const classe = match && match.victoire ? 'win' : 'loss'; 1488 const libelle = match && match.victoire ? 'V' : 'D'; 1489 return `<span class="${classe}">${libelle} ${escapeHtml((match && match.score) || '')}</span>`; 1490} 1491 1492// Définition canonique d'une belle perf : victoire sportive, simple ou double, 1493// contre le meilleur adversaire du match, avec les classements valides à la date. 1494function detailBellePerf(match, joueurId) { 1495 return TennisDomainRules.detailBellePerf(match, classementADate(joueurId, match && match.date)); 1496} 1497 1498// Contre-perfs d'un joueur sur la saison : défaites contre un adversaire de 1499// classement strictement INFÃRIEUR (un adversaire qu'il aurait dû battre). Miroir des 1500// belles perfs. Une défaite à classement équivalent n'est PAS une contre-perf, et 1501// les défaites par forfait (WO) sont exclues (pas un résultat sportif). 1502// Renvoie les matchs concernés, triés du plus récent au plus ancien. 1503function computeContrePerfs(joueurId, periode) { 1504 const j = joueurById(joueurId); 1505 if (!j) return []; 1506 const [debut, fin] = periode ? getPeriodeDates(periode) : getSaisonDates(); 1507 const matches = matchsDuJoueur(joueurId).map(match => ({ 1508 ...match, 1509 classement_joueur_date: classementADate(joueurId, match.date) 1510 })); 1511 return TennisDomainRules.contrePerfsSurPeriode(matches, debut, fin); 1512} 1513 1514// Nombre de matchs d'un joueur sur la saison en cours 1515function matchesSaisonJoueur(joueurId) { 1516 // En mode événement (Palmyre), cache.matches est déjà restreint à l'édition : 1517 // on compte tous ces matchs sans filtre de saison. 1518 if (!isPoleEnv()) { 1519 return matchsHorsWO(matchsDuJoueur(joueurId)).length; 1520 } 1521 const [debut, fin] = getSaisonDates(); 1522 return matchsHorsWO(matchsDuJoueur(joueurId).filter(m => TennisDomainRules.dateDansIntervalle(m.date, debut, fin))).length; 1523} 1524 1525// Modal : liste des contre-perfs d'un joueur (sur la période), chacune ouvre le match 1526function openContrePerfsModal(joueurId, periode) { 1527 const j = joueurById(joueurId); 1528 if (!j) { toast('Joueur introuvable', true); return; } 1529 const cps = computeContrePerfs(joueurId, periode);
1530 const labelPeriode = periode ? getPeriodeLabel(periode) : 'Saison complète'; 1531 1532 let html = `<div class="modal-header"> 1533 <h3 class="modal-title">Contre-perfs de ${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</h3> 1534 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 1535 </div> 1536 <div class="text-faint mb-2" style="font-size:12px"> 1537 ${escapeHtml(labelPeriode)} · défaites contre un adversaire moins bien classé (${cps.length}). Touche un match pour l'ouvrir. 1538 </div>`; 1539 1540 if (cps.length === 0) { 1541 html += `<div class="empty"><span class="icon">â</span>Aucune contre-perf cette saison.</div>`; 1542 } else { 1543 html += `<div class="card" style="padding:8px">`; 1544 cps.forEach((m, i) => { 1545 html += `<div class="match-row clickable" data-ui-click="openModalMatch(${escapeHtml(encoderArgumentTexteUI(m.id))})" style="${i < cps.length - 1 ? 'border-bottom:1px solid var(--border)' : ''}" title="Ouvrir ce match"> 1546 <div class="match-result-row"> 1547 <span class="match-opponent">vs ${escapeHtml(m.adversaire)} <span class="text-faint">(${escapeHtml(m.adversaire_class || '?')})</span></span> 1548 <span class="loss">D ${escapeHtml(m.score || '')}</span> 1549 </div> 1550 <div class="match-meta">${escapeHtml(m.tournoi || 'Match')} · ${formatDate(m.date)}</div> 1551 </div>`; 1552 }); 1553 html += `</div>`; 1554 } 1555 1556 showModal(html); 1557} 1558 1559function getProrataObjectif() { 1560 const ordre = ['T1', 'T2', 'T3', 'T4']; 1561 const courant = getTrimestreCourant(); 1562 const index = ordre.indexOf(courant); 1563 if (index < 0) return TennisDomainRules.fractionIntervalleEcoulee(...getSaisonDates(), Date.now()); 1564 const acquis = ordre.slice(0, index).reduce((total, periode) => total + TennisDomainRules.POIDS_SAISONNIERS[periode], 0); 1565 return Math.min(1, acquis + TennisDomainRules.POIDS_SAISONNIERS[courant] * getProrataPeriode(courant)); 1566} 1567 1568function getProrataPeriode(periode) { 1569 const [debutStr, finStr] = getPeriodeDates(periode); 1570 return TennisDomainRules.fractionIntervalleEcoulee(debutStr, finStr, Date.now(), 0.001); 1571} 1572 1573// Vrai si on est dans le 1er tiers de la période EN COURS. Sert à neutraliser le 1574// label d'alerte d'un groupe en tout début de trimestre (score mécaniquement bas). 1575// Retourne false pour une période passée (déjà terminée) ou future. 1576function estDebutDePeriode(periode) { 1577 const [debutStr, finStr] = getPeriodeDates(periode); 1578 return TennisDomainRules.estPremierTiersIntervalle(debutStr, finStr, new Date()); 1579} 1580 1581// Poids saisonniers des objectifs trimestriels de matchs. 1582// Plutôt qu'une moyenne plate (25 %/trimestre), l'objectif annuel d'un groupe est 1583// réparti selon la densité réelle de tournois dans un rayon de 30 km autour de 1584// Bouliac. Source : exports Ten'Up adultes + jeunes (saison 2025-2026, 595 tournois 1585// uniques), chaque tournoi compté comme 1 unité répartie au prorata de ses jours 1586// d'ouverture par trimestre. Un trimestre creux en tournois porte moins d'objectif, 1587// un trimestre dense en porte plus. La somme des poids vaut 1. 1588// T1 sept-nov : 22 % · T2 déc-fév : 16 % · T3 mars-mai : 30 % · T4 juin-août : 32 % 1589// Poids d'un trimestre (fraction de l'objectif annuel). Repli sur 0.25 (moyenne 1590// plate) si la clé est inconnue, par sécurité. 1591function getObjectifBasePeriode(objectifAnnuel, periode) { 1592 return TennisDomainRules.objectifBasePeriode(objectifAnnuel, periode); 1593} 1594 1595// Objectif de matchs attendu pour un groupe sur une période donnée, proratisé par 1596// l'avancement dans la période (getProrataPeriode, inchangé). 1597function getObjectifPeriode(objectifAnnuel, periode) { 1598 return getObjectifBasePeriode(objectifAnnuel, periode) * getProrataPeriode(periode); 1599} 1600 1601// Objectif PLEIN d'une période arrondi à l'ENTIER, AFFICHABLE. 1602// Pour les trimestres, on utilise un arrondi CUMULATIF : on arrondit le cumul 1603// (T1, puis T1+T2, â¦) et on prend les écarts. Ainsi T1+T2+T3+T4 = objectif annuel 1604// EXACTEMENT (les arrondis indépendants pouvaient faire ±1 sur la somme). 1605// Saison : objectif annuel entier. 1606function getObjectifBasePeriodeEntier(objectifAnnuel, periode) { 1607 return TennisDomainRules.objectifBasePeriodeEntier(objectifAnnuel, periode); 1608} 1609 1610// Récupère les matches d'un joueur sur une période bilan 1611function matchesEntreDate(joueurId, dateDebut, dateFin) { 1612 // Utilisée uniquement pour des CALCULS de stats (bilans, synthèses, séries). Les WO en 1613 // sont exclus : un forfait n'est pas un match joué. Les listes d'affichage n'utilisent 1614 // pas cette fonction, les WO y restent donc visibles. 1615 return matchsHorsWO(matchsDuJoueur(joueurId).filter(m => 1616 TennisDomainRules.dateDansIntervalle(m.date, dateDebut, dateFin) 1617 )); 1618} 1619 1620// ============================================================ 1621// NOTIFICATIONS 1622// ============================================================ 1623const centreNotifications = TennisNotifications.creerCentre({ 1624 db, cache, getSession: () => session, element: $, escapeHtml, 1625 encoderArgument: encoderArgumentTexteUI, toastErreur, 1626 colonnes: COLONNES_NOTIFICATION, maintenant: () => Date.now() 1627}
1627); 1628async function loadNotifications() { return centreNotifications.charger(); } 1629function updateNotifBadge() { return centreNotifications.actualiserBadge(); } 1630function toggleNotifsPanel(event) { return centreNotifications.basculerPanneau(event); } 1631function toggleUserMenu(e) { 1632 if (e) { e.stopPropagation(); } 1633 const menu = $('userMenu'); 1634 if (!menu) return; 1635 const isOpen = menu.style.display !== 'none'; 1636 // Fermer aussi le panneau notifs s'il était ouvert 1637 const notifsPanel = $('notifsPanel'); 1638 if (notifsPanel) notifsPanel.style.display = 'none'; 1639 // L'entrée "Réglages de l'académie" n'est visible que pour le coach 1640 // Sous-titre de "Mon profil" : le coach n'a pas de profil technique 1641 const profilSub = $('userMenuProfilSub'); 1642 if (profilSub) profilSub.textContent = isCoach() ? 'Réglages, invitations, compte' : 'Profil technique, email, mot de passe'; 1643 menu.style.display = isOpen ? 'none' : 'block'; 1644} 1645 1646// Ouvre les réglages de l'académie depuis le menu utilisateur (coach) 1647function openReglagesFromMenu() { 1648 const menu = $('userMenu'); 1649 if (menu) menu.style.display = 'none'; 1650 navigate('settings'); 1651} 1652 1653// Ouvre les invitations depuis le menu utilisateur (coach) 1654function openInvitationsFromMenu() { 1655 const menu = $('userMenu'); 1656 if (menu) menu.style.display = 'none'; 1657 navigate('invitations'); 1658} 1659 1660// Ouvre la modale des préférences de notifications (joueur ET coach) 1661// La modale détecte le rôle et affiche les bonnes prefs. 1662function openPreferencesNotifsModal() { 1663 // Fermer le menu utilisateur 1664 const menu = $('userMenu'); 1665 if (menu) menu.style.display = 'none'; 1666 1667 // Construire le contenu selon le rôle 1668 const html = `<div class="modal-header"> 1669 <h3 class="modal-title">ð Mes préférences d'emails</h3> 1670 <button class="modal-close" data-ui-click="closeModalDirect()">Ã</button> 1671 </div> 1672 ${renderPreferencesNotifs(isCoach() ? 'coach' : 'joueur', { noTitle: true })} 1673 <div class="flex gap-1 mt-2"> 1674 <button class="btn flex-1" data-ui-click="closeModal()">Fermer</button> 1675 </div>`; 1676 showModal(html); 1677} 1678 1679// ============================================================ 1680// NOUVEAUTÃS (What's New) â modale cumulative affichée après mise à jour 1681// ============================================================ 1682const LAST_SEEN_VERSION_KEY = 'pcb_last_seen_version'; 1683 1684// Plusieurs comptes (joueur et coach) utilisent souvent le même appareil. 1685// La version vue doit donc être isolée par utilisateur ET par rôle : une lecture 1686// côté joueur ne peut plus acquitter silencieusement les nouveautés du coach. 1687function cleDerniereVersionVue() { 1688 const userId = session?.user?.id || profile?.id || 'anonyme'; 1689 const role = isCoach() ? 'coach' : 'joueur'; 1690 return `${LAST_SEEN_VERSION_KEY}:${userId}:${role}`; 1691} 1692 1693function lireDerniereVersionVue() { 1694 try { return localStorage.getItem(cleDerniereVersionVue()); } catch (e) { return null; } 1695} 1696 1697function memoriserDerniereVersionVue(version = APP_VERSION_CURRENT) { 1698 try { localStorage.setItem(cleDerniereVersionVue(), version); } catch (e) { /* ignore */ } 1699} 1700 1701// Compare deux versions "vX.Y.Z". Retourne -1 si a<b, 0 si égal, 1 si a>b. 1702function compareVersions(a, b) { 1703 return TennisDomainRules.comparerVersions(a, b); 1704} 1705 1706// Retourne la liste des versions du CHANGELOG strictement postérieures à `depuis` 1707// (ou toutes si `depuis` est null), triées de la plus récente à la plus ancienne. 1708function entreeNouveauteVisiblePourRole(entry, role) { 1709 return TennisDomainRules.entreeNouveauteVisiblePourRole(entry, role); 1710} 1711 1712function versionsDepuis(depuis, role) { 1713 return TennisDomainRules.versionsDepuisChangelog(CHANGELOG, depuis, role); 1714} 1715 1716// Affiche la modale des nouveautés pour une liste de versions (groupées par version). 1717// Si versionsList est vide, ne fait rien. 1718function renderNouveautesModal(versionsList) { 1719 const menu = $('userMenu'); 1720 if (menu) menu.style.display = 'none'; 1721 1722 const role = isCoach() ? 'coach' : 'joueur'; 1723 1724 // Construire les blocs par version, en ne gardant que les versions ayant au moins 1725 // un item pour ce rôle 1726 const blocs = []; 1727 versionsList.forEach(v => { 1728 const entry = CHANGELOG[v]; 1729 if (!entreeNouveauteVisiblePourRole(entry, role)) return; 1730 const items = entry.items.filter(it => it && (it.pour === 'tous' || it.pour === role)); 1731 if (items.length > 0) blocs.push({ version: v, titre: entry.titre, items }); 1732 }); 1733 1734 if (blocs.length === 0) { 1735 // Rien à montrer pour ce rôle : on mémorise quand même pour ne pas re-tenter à chaque chargement 1736 memoriserDerniereVersionVue(); 1737 return; 1738 } 1739 1740 // Combien de versions cumulées ? (pour le sous-titre) 1741 const multi = blocs.length > 1; 1742 1743 let html = `<div class="modal-header"> 1744 <h3 class="modal-title" style="display:flex;align-items:center;gap:10px"><img class="modal-title-art" src="assets/v4/menu/whats_new.webp" alt=""> Nouveautés</h3> 1745 <button class="modal-close" data-ui-click="closeNouveautes()">Ã</button> 1746 </div>`; 1747 1748 if (multi) { 1749 html += `<div style="text-align:center; margin-bottom:14px; font-size:13px; color:var(--text-dim)"> 1750 Voici tout ce qui a changé depuis ta dernière visite ð 1751 </div>`; 1752 } 1753 1754 blocs.forEach(bloc => { 1755 html += `<div style="text-align:${multi ? 'left' : 'center'}; margin:${multi ? '16px 0 10px' : '0 0 14px'}">
1756 <span style="display:inline-block; background:var(--orange); color:#fff; font-size:12px; font-weight:600; padding:3px 12px; border-radius:12px">Version ${escapeHtml(bloc.version)}</span> 1757 <span style="font-size:14px; font-weight:600; margin-left:8px">${escapeHtml(bloc.titre)}</span> 1758 </div>`; 1759 bloc.items.forEach(it => { 1760 html += `<div class="card" style="padding:12px; margin-bottom:8px; display:flex; gap:12px; align-items:flex-start"> 1761 ${it.iconeImage 1762 ? `<img class="menu-art-icon" src="${escapeHtml(it.iconeImage)}" alt="" style="width:38px;height:38px;flex-shrink:0">` 1763 : `<div style="font-size:22px; flex-shrink:0; line-height:1.2">${escapeHtml(it.icone || 'â¨')}</div>`} 1764 <div style="font-size:13px; line-height:1.5">${escapeHtml(it.texte)}</div> 1765 </div>`; 1766 }); 1767 }); 1768 1769 html += `<div class="flex gap-1 mt-2"> 1770 <button class="btn btn-primary flex-1" data-ui-click="closeNouveautes()">J'ai compris ð</button> 1771 </div>`; 1772 1773 showModal(html); 1774} 1775 1776// Ferme la modale et mémorise la version courante comme "vue" 1777function closeNouveautes() { 1778 memoriserDerniereVersionVue(); 1779 closeModal(); 1780} 1781 1782// Vérifie au démarrage s'il faut afficher les nouveautés (versions non vues) 1783// Garde : évite que checkNouveautes s'exécute plusieurs fois (onAuthChange peut être 1784// appelé 2 fois au démarrage : appel direct + événement onAuthStateChange). 1785let _nouveautesChecked = false; 1786let _nouveautesRetries = 0; 1787 1788function reprogrammerNouveautes() { 1789 _nouveautesChecked = false; 1790 if (_nouveautesRetries >= 2) return; 1791 _nouveautesRetries += 1; 1792 setTimeout(() => checkNouveautes(), 3000 * _nouveautesRetries); 1793} 1794 1795async function checkNouveautes() { 1796 if (_nouveautesChecked) return; // déjà traité dans ce chargement de page 1797 _nouveautesChecked = true; 1798 1799 let lastSeen = lireDerniereVersionVue(); 1800 1801 // Une valeur locale corrompue n'est ni un nouvel utilisateur ni une ancienne 1802 // version : on la répare sans ouvrir l'intégralité de l'historique. 1803 if (lastSeen !== null && !TennisDomainRules.versionValide(lastSeen)) { 1804 memoriserDerniereVersionVue(); 1805 return; 1806 } 1807 1808 // Cas 1 â Aucune version mémorisée. 1809 // Cela couvre DEUX situations indistinguables côté navigateur : 1810 // a) un utilisateur réellement nouveau, 1811 // b) un utilisateur existant qui découvre le système de nouveautés pour la 1re fois. 1812 // Pour ne pas priver les utilisateurs existants du changelog, on montre les nouveautés 1813 // les plus récentes disponibles. On prend les entrées du changelog jusqu'à 2 versions 1814 // en arrière pour donner un aperçu utile sans noyer (et sans dépendre du fait que la 1815 // version courante ait ou non sa propre entrée â cas d'un simple c
1815orrectif). 1816 if (!lastSeen) { 1817 if (!await chargerChangelog()) { 1818 reprogrammerNouveautes(); 1819 return; 1820 } 1821 const role = isCoach() ? 'coach' : 'joueur'; 1822 const toutes = versionsDepuis(null, role); 1823 const aMontrer = toutes.slice(0, 2); // les 2 entrées les plus récentes 1824 if (aMontrer.length > 0) { 1825 setTimeout(() => renderNouveautesModal(aMontrer), 800); 1826 } else { 1827 memoriserDerniereVersionVue(); 1828 } 1829 return; 1830 } 1831 1832 // Cas 2 â Déjà à jour : rien à faire 1833 if (compareVersions(lastSeen, APP_VERSION_CURRENT) >= 0) return; 1834 1835 if (!await chargerChangelog()) { 1836 reprogrammerNouveautes(); 1837 return; 1838 } 1839 1840 // Cas 3 â Mise à jour : on montre toutes les versions ratées (cumulatif) 1841 const role = isCoach() ? 'coach' : 'joueur'; 1842 const aMontrer = versionsDepuis(lastSeen, role); 1843 if (aMontrer.length === 0) { 1844 memoriserDerniereVersionVue(); 1845 return; 1846 } 1847 1848 setTimeout(() => renderNouveautesModal(aMontrer), 800); 1849} 1850 1851// Ouvre les nouveautés depuis le menu utilisateur : on montre la dernière version 1852// qui a RÃELLEMENT du contenu pour le rôle de l'utilisateur (la version courante peut
1853// être un simple correctif sans entrée, ou une entrée qui ne concerne pas ce rôle â 1854// on ne veut jamais d'une modale vide). 1855async function openNouveautesFromMenu() { 1856 if (!await chargerChangelog()) { 1857 toast('Les nouveautés sont momentanément indisponibles'); 1858 return; 1859 } 1860 const role = isCoach() ? 'coach' : 'joueur'; 1861 const cible = TennisDomainRules.derniereVersionVisibleChangelog(CHANGELOG, role); 1862 if (!cible) { 1863 toast('Aucune nouveauté à afficher pour le moment'); 1864 return; 1865 } 1866 renderNouveautesModal([cible]); 1867} 1868 1869// Ãtat de la recherche dans la modale d'aide. 'var' (et non 'let') pour éviter toute 1870// zone morte temporelle si la fonction est appelée tôt. 1871var _aideRecherche = ''; 1872 1873// Ouvre la modale d'aide (mode d'emploi condensé), filtrée selon le rôle. 1874function openAideFromMenu() { 1875 const menu = $('userMenu'); 1876 if (menu) menu.style.display = 'none'; 1877 _aideRecherche = ''; 1878 renderAideModal(); 1879} 1880 1881function onAideSearch(v) { 1882 _aideRecherche = v || ''; 1883 const cont = $('aideContenu'); 1884 if (cont) cont.innerHTML = renderAideContenu(); 1885} 1886 1887// Construit le HTML des rubriques d'aide pour le rôle courant, filtrées par recherche. 1888function renderAideContenu() { 1889 const role = isCoach() ? 'coach' : 'joueur'; 1890 const q = _aideRecherche.trim().toLowerCase(); 1891 const sections = AIDE_SECTIONS.filter(s => s.pour === 'tous' || s.pour === role); 1892 1893 let html = ''; 1894 let nbAffichees = 0; 1895 sections.forEach(s => { 1896 // Filtre recherche : on garde la rubrique si le titre OU un point matche 1897 let points = s.points; 1898 if (q) { 1899 const titreMatch = s.titre.toLowerCase().includes(q); 1900 if (!titreMatch) points = s.points.filter(p => p.toLowerCase().includes(q)); 1901 if (!titreMatch && points.length === 0) return; // rien dans cette rubrique 1902 } 1903 nbAffichees++; 1904 html += `<div class="card" style="padding:14px; margin-bottom:10px"> 1905 <div style="display:flex; align-items:center; gap:10px; margin-bottom:8px"> 1906 <span style="font-size:22px">${s.icone}</span> 1907 <strong style="font-size:15px">${escapeHtml(s.titre)}</strong> 1908 </div> 1909 <ul style="margin:0; padding-left:20px; display:flex; flex-direction:column; gap:6px"> 1910 ${points.map(p => `<li style="font-size:13px; line-height:1.5">${escapeHtml(p)}</li>`).join('')} 1911 </ul> 1912 </div>`; 1913 }); 1914 1915 if (nbAffichees === 0) { 1916 html = `<div class="text-faint text-center" style="padding:24px; font-style:italic">Aucune rubrique ne correspond à ta recherche.</div>`; 1917 } 1918 return html; 1919} 1920 1921function renderAideModal() { 1922 const role = isCoach() ? 'coach' : 'joueur'; 1923 const sousTitre = role === 'coach' 1924 ? 'Tout ce que tu peux faire en tant que coach.' 1925 : 'Tout ce que tu peux faire dans l\'application.'; 1926 1927 let html = `<div class="modal-header"> 1928 <h3 class="modal-title">â Aide â Mode d'emploi</h3> 1929 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 1930 </div> 1931 <div style="text-align:center; font-size:13px; color:var(--text-dim); margin-bottom:12px">${sousTitre}</div> 1932 <input type="search" id="aideRechercheInput" data-ui-input="onAideSearch(this.value)" placeholder="Rechercher une fonctionnalitéâ¦" style="width:100%; margin-bottom:14px" autocomplete="off"> 1933 <div id="aideContenu">${renderAideContenu()}</div> 1934 <div class="flex gap-1 mt-2"> 1935 <button class="btn btn-primary flex-1" data-ui-click="closeModal()">Fermer</button> 1936 </div>`; 1937 showModal(html); 1938} 1939 1940// Ouvre la modale de changement de mot de passe (utilisateur connecté) 1941// Modale "Mon profil" : regroupe profil technique (éditable par le joueur), 1942// adresse email, mot de passe et préférences d'emails. 1943function openMonProfilModal() { 1944 const menu = $('userMenu'); 1945 if (menu) menu.style.display = 'none'; 1946 1947 const estJoueur = !!(profile && profile.joueur_id) && !isCoach(); 1948 const j = estJoueur ? joueurById(profile.joueur_id) : null; 1949 1950 let html = `<div class="modal-header"> 1951 <h3 class="modal-title">ð¤ Mon profil</h3> 1952 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 1953 </div> 1954 <div class="modal-body">`; 1955 1956 // Profil technique éditable (uniquement pour un joueur) 1957 if (estJoueur && j) { 1958 html += TennisFamilyAccounts.renderSelector(); 1959 html += `<div class="section-title">ð¯ Mon profil technique</div> 1960 <div class="form-row"><label class="form-label">Mes points forts <span class="text-faint" style="font-weight:400">(séparés par virgule)</span></label> 1961 <input type="text" id="monProfilPointsForts" value="${escapeHtml((listeSure(j.points_forts)).join(', '))}" placeholder="Coup droit, service, mental..."></div> 1962 <div class="form-row"><label class="form-label">Mes axes de progrès <span class="text-faint" style="font-weight:400">(séparés par virgule)</span></label> 1963 <input type="text" id="monProfilAxes" value="${escapeHtml((listeSure(j.axes_progres)).join(', '))}" placeholder="Volée, retour, endurance..."></div> 1964 <div class="mt-1"><button class="btn btn-success btn-full" data-ui-click="saveMonProfilTechnique()">Enregistrer mon profil technique</button></div>`; 1965 } 1966 1967 // Gestion de l'académie (coach uniquement) : réglages + invitations 1968 if (isCoach()) { 1969 html += `<div class="section-title"><img class="section-art-icon" src="assets/v4/menu/academy_management.webp" alt="">Gestion de l'académie</div> 1970 <div style="display:flex; flex-direction:column; gap:8px"> 1971 <button class="btn btn-full" data-ui-click="closeModal(); openReglagesFromMenu()"><img class="btn-art-icon" src="assets/v4/menu/academy_settings.webp" alt="">Réglages de l'académie</button> 1972 <button class="btn btn-full" data-ui-click="closeModal(); openInvitationsFromMenu()"><img class="btn-art-icon" src="assets/v4/menu/invitations.webp" alt="">Invitations</button> 1973 </div>`; 1974 } 1975 1976 // Accès aux réglages de compte 1977 html += `<div class="section-title" style="margin-top:18px"><img class="section-art-icon" src="assets/v4/menu/account.webp" alt="">
1977Mon compte</div> 1978 <div style="display:flex; flex-direction:column; gap:8px">`; 1979 if (estJoueur) { 1980 html += `<button class="btn btn-full" data-ui-click="openChangeEmailModal()"><img class="btn-art-icon" src="assets/v4/menu/email_preferences.webp" alt="">Mon adresse email</button>`; 1981 } 1982 html += `<button class="btn btn-full" data-ui-click="openChangePasswordModal()"><img class="btn-art-icon" src="assets/v4/menu/password.webp" alt="">Changer mon mot de passe</button> 1983 <button class="btn btn-full" data-ui-click="openPreferencesNotifsModal()"><img class="btn-art-icon" src="assets/v4/menu/email_preferences.webp" alt="">Préférences d'emails</button> 1984 ${estJoueur ? `<button class="btn btn-full" data-ui-click="exporterMesDonnees()"><img class="btn-art-icon" src="assets/v4/menu/consent_download.webp" alt="">Exporter mes données</button> 1985 <button class="btn btn-full" data-ui-click="demanderSuppressionCompte()" style="color:var(--danger)"><img class="btn-art-icon" src="assets/v4/menu/player_archive.webp" alt="">Demander la suppression de mon compte</button>` : ''} 1986 </div>`; 1987 1988 html += `</div>`; 1989 showModal(html); 1990} 1991 1992function getFamilyAccountsContext() { 1993 return { db, joueurs: listeSure(cache.joueurs), userId: session?.user?.id, activeId: profile?.joueur_id, 1994 escapeHtml, encoderArgumentTexteUI, messageErreurUtilisateur: TennisDomainRules.messageErreurUtilisateur }; 1995} 1996 1997// Portabilité RGPD : export local des seules données visibles et rattachées au 1998// joueur connecté. Le fichier n'est transmis à aucun tiers et ne contient ni 1999// jeton de session, ni données d'autres membres de l'académie. 2000function exporterMesDonnees() { 2001 if (!profile?.joueur_id) { toast('Export disponible depuis un compte joueur.', true); return; } 2002 const joueurId = profile.joueur_id; 2003 const joueur = joueurById(joueurId); 2004 if (!joueur) { toast('Ta fiche joueur est temporairement indisponible.', true); return; } 2005 const seanceIds = new Set((listeSure(cache.presences)).filter(p => p.joueur_id === joueurId).map(p => p.seance_id)); 2006 const exportRgpd = { 2007 exporte_le: new Date().toISOString(), 2008 politique_confidentialite_version: RGPD_POLICY_VERSION, 2009 compte: { email: session?.user?.email || null, role: profile.role || 'joueur' }, 2010 fiche_joueur: joueur, 2011 matches: (listeSure(cache.matchesSaison)).filter(m => m.joueur_id === joueurId), 2012 entrainements_libres: (listeSure(cache.entrainements)).filter(e => e.joueur_id === joueurId), 2013 presences: (listeSure(cache.presences)).filter(p => p.joueur_id === joueurId), 2014 seances_associees: (listeSure(cache.seancesCours)).filter(s => seanceIds.has(s.id)), 2015 bilans: (listeSure(cache.bilans)).filter(b => b.joueur_id === joueurId), 2016 bilans_evenement: (listeSure(cache.bilansEvenement)).filter(b => b.joueur_id === joueurId), 2017 axes_travail: (listeSure(cache.axesPalmyre)).filter(a => a.joueur_id === joueurId), 2018 badges: (listeSure(cache.badgesJoueurs)).filter(b => b.joueur_id === joueurId), 2019 historique_classement: (listeSure(cache.classementHistorique)).filter(h => h.joueur_id === joueurId), 2020 tournois: (listeSure(cache.tournois)).filter(t => (listeSure(t.inscrits)).includes(joueurId)).map(t => ({ 2021 id: t.id, nom: t.nom, date_debut: t.date_debut, date_fin: t.date_fin, lieu: t.lieu, categorie: t.categorie 2022 })) 2023 }; 2024 const blob = new Blob([JSON.stringify(exportRgpd, null, 2)], { type: 'application/json;charset=utf-8' }); 2025 const url = URL.createObjectURL(blob); 2026 const lien = document.createElement('a'); 2027 const nom = `${joueur.prenom || 'joueur'}-${joueur.nom || 'tennis-bouliac'}`.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); 2028 lien.href = url; 2029 lien.download = `mes-donnees-tennis-bouliac-${nom || 'joueur'}.json`; 2030 document.body.appendChild(lien); 2031 lien.click(); 2032 lien.remove(); 2033 setTimeout(() => URL.revokeObjectURL(url), 1000); 2034 toast('Ton export de données a été téléchargé.'); 2035} 2036 2037// L'effacement n'est volontairement pas automatique : les données sportives 2038// d'un mineur peuvent être liées à des bilans et obligations de conservation. 2039// La demande est néanmoins self-service, authentifiée et adressée aux coachs. 2040async function demanderSuppressionCompte() { 2041 if (!profile?.joueur_id) { toast('Demande disponible depuis un compte joueur.', true); return; } 2042 const confirme = await demanderConfirmation( 2043 'Ta demande sera transmise aux responsables de lâacadémie. Ils vérifieront avec toi ou ton représentant légal les données à effacer et celles qui doivent être conservées légalement.', 2044 { titre: 'Demander la suppression du compte', libelle: 'Envoyer ma demande', danger: true } 2045 ); 2046 if (!confirme) return; 2047 const { error } = await db.functions.invoke('send-email', {
2048 body: { event: 'account_deletion_request', resource_id: profile.joueur_id } 2049 }); 2050 if (error) { toastErreur(error); return; } 2051 toast('Ta demande de suppression a bien été transmise.'); 2052} 2053 2054// Sauvegarde du profil technique modifié par le joueur (sa propre fiche). 2055async function saveMonProfilTechnique() { 2056 if (!profile || !profile.joueur_id) { toast('Action réservée aux joueurs', true); return; } 2057 const pfEl = document.getElementById('monProfilPointsForts'); 2058 const axEl = document.getElementById('monProfilAxes'); 2059 const toArr = (s) => (s || '').split(',').map(x => x.trim()).filter(Boolean); 2060 const obj = { 2061 points_forts: toArr(pfEl ? pfEl.value : ''), 2062 axes_progres: toArr(axEl ? axEl.value : '') 2063 }; 2064 const execution = await executerAvecVerrou($('modalBody'), 2065 () => updateConfirmee(db.from('joueurs').update(obj).eq('id', profile.joueur_id))); 2066 if (execution.ignoree) return; 2067 const { error } = execution.valeur; 2068 if (error) { toastErreur(error); return; } 2069 closeModal(); 2070 toast('â Profil technique mis à jour'); 2071 await reloadTables(['joueurs']); 2072 render(); 2073} 2074 2075function openChangePasswordModal() { 2076 // Fermer le menu utilisateur 2077 const menu = $('userMenu'); 2078 if (menu) menu.style.display = 'none'; 2079 2080 const html = `<div class="modal-header"> 2081 <h3 class="modal-title">ð Changer mon mot de passe</h3> 2082 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 2083 </div> 2084 <div class="card" style="padding:14px"> 2085 <div class="text-faint mb-2" style="font-size:12px"> 2086 Saisis ton nouveau mot de passe (minimum 6 caractères) puis confirme-le. Tu resteras connecté(e) après le changement. 2087 </div> 2088 <div class="form-row"> 2089 <label class="form-label">Nouveau mot de passe *</label> 2090 <div class="pwd-wrap"> 2091 <input type="password" id="pwdNew" autocomplete="new-password" minlength="6" placeholder="6 caractères minimum"> 2092 <button type="button" class="pwd-toggle" data-ui-click="togglePasswordVisibility(this)" aria-label="Afficher le mot de passe" tabindex="-1">ðï¸</button> 2093 </div> 2094 </div> 2095 <div class="form-row"> 2096 <label class="form-label">Confirmer le nouveau mot de passe *</label> 2097 <div class="pwd-wrap"> 2098 <input type="password" id="pwdConfirm" autocomplete="new-password" minlength="6" placeholder="Re-saisis le même mot de passe"> 2099 <button type="button" class="pwd-toggle" data-ui-click="togglePasswordVisibility(this)" aria-label="Afficher le mot de passe" tabindex="-1">ðï¸</button> 2100 </div> 2101 </div> 2102 <div id="pwdError" class="text-faint" style="font-size:12px; color:var(--danger-text); display:none; margin-top:6px"></div> 2103 </div> 2104 <div class="flex gap-1 mt-2"> 2105 <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button> 2106 <button class="btn btn-primary flex-1" data-ui-click="savePassword()"><img class="btn-action-art" src="assets/v4/menu/password.webp" alt="">Mettre à jour</button> 2107 </div>`; 2108 showModal(html); 2109 2110 // Focus auto sur le premier champ 2111 setTimeout(() => { 2112 const inp = $('pwdNew'); 2113 if (inp) inp.focus(); 2114 }, 50); 2115} 2116 2117// Sauvegarde du nouveau mot de passe (utilisateur connecté OU mode recovery) 2118async function savePassword() { 2119 const newPwd = ($('pwdNew') || {}).value || ''; 2120 const confirmPwd = ($('pwdConfirm') || {}).value || ''; 2121 const errBox = $('pwdError'); 2122 2123 // Affiche un message d'erreur dans la modale (pas un toast pour rester contextuel) 2124 const showErr = (msg) => { 2125 if (errBox) { 2126 errBox.textContent = 'â ï¸ ' + msg; 2127 errBox.style.display = 'block'; 2128 } else { 2129 toast(msg, true); 2130 } 2131 }; 2132 2133 if (errBox) errBox.style.display = 'none'; 2134 2135 if (!newPwd || newPwd.length < 6) { 2136 showErr('Le mot de passe doit faire au moins 6 caractères'); 2137 return; 2138 } 2139 if (newPwd !== confirmPwd) { 2140 showErr('Les 2 mots de passe ne correspondent pas'); 2141 return; 2142 } 2143 2144 const execution = await executerAvecVerrou($('modalBody'), 2145 () => db.auth.updateUser({ password: newPwd }), 'Mise à jourâ¦'); 2146 if (execution.ignoree) return; 2147 const { error } = execution.valeur; 2148 if (error) { 2149 const idErreur = creerIdErreur(); 2150 journal.error(`[mot de passe ${idErreur}]`, error); 2151 showErr(`${TennisDomainRules.messageErreurUtilisateur(error.message)} (réf. ${idErreur})`); 2152 return; 2153 } 2154 2155 toast('Mot de passe mis à jour ð'); 2156 closeModal(); 2157 // Si on était en mode recovery (lien email), on nettoie le hash de l'URL 2158 if (window.location.hash && window.location.hash.includes('type=recovery')) { 2159 history.replaceState(null, '', window.location.pathname); 2160 } 2161} 2162 2163// ============================================================ 2164// GESTION DE L'EMAIL DE CONNEXION (joueur) 2165// ============================================================ 2166// Le joueur peut voir et modifier l'email de SON compte de connexion (auth.users), 2167// par ex. le jour où il a sa propre adresse au lieu de celle d'un parent. 2168// à chaque demande de changement, le parent (parent_email de la fiche) est prévenu 2169// par email, par transparence (les joueurs sont mineurs). 2170function openChangeEmailModal() { 2171 const menu = $('userMenu'); 2172 if (menu) menu.style.display = 'none'; 2173 2174 const emailActuel = (session && session.user && session.user.email) || ''; 2175 const j = profile && profile.joueur_id ? joueurById(profile.joueur_id) : null; 2176 const parentEmail = j && j.parent_email ? j.parent_email : null; 2177 // Indique au joueur si son compte utilise actuellement l'email d'un parent. 2178 const utiliseEmailParent = parentEmail && emailActuel 2179 && parentEmail.trim().toLowerCase() === emailActuel.trim().toLowerCase(); 2180 2181 const html = `<div class="modal-header"> 2182 <h3 class="modal-title">âï¸ Mon adresse email</h3> 2183 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 2184 </div> 2185 <div class="card" style="padding:14px"> 2186 <div class="form-row"> 2187 <label class="form-label">Email de connexion actuel</label> 2188 <input type="email" value="${escapeHtml(emailActuel)}" disabled style="opacity:0.7"> 2189 </div> 2190 ${utiliseEmailParent ? `<div class="text-faint" style="font-size:12px; margin:4px 0 12px; padding:8px 10px; background:var(--accent-bg,#e8eef7); border-radius:6px">â¹ï¸ Ton compte utilise actuellement l'adresse d'un parent. Si tu as ta propre adresse email, tu peux la renseigner ci-dessous.</div>` : ''} 2191 <div class="form-row"> 2192 <label class="form-label">Nouvelle adresse email *</label> 2193 <input type="email" id="emailNew" autocomplete="email" placeholder="[email protected]"> 2194 </div> 2195 <div class="text-faint" style="font-size:12px; margin-top:6px"> 2196 Tu recevras un email de confirmation à ta nouvelle adresse : le changement ne sera effectif qu'après avoir cliqué le lien dedans. 2197 ${parentEmail ? `Un email d'information sera aussi envoyé à tes parents (${escapeHtml(parentEmail)}).` : ''} 2198 </div> 2199 <div id="emailError" class="text-faint" style="font-size:12px; color:var(--danger-text); display:none; margin-top:6px"></div> 2200 </div> 2201 <div class="flex gap-1 mt-2"> 2202 <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button> 2203 <button class="btn btn-primary flex-1" data-ui-click="saveEmail()"><img class="btn-action-art" src="assets/v4/menu/email_preferences.webp" alt="">
2203Mettre à jour</button> 2204 </div>`; 2205 showModal(html); 2206 setTimeout(() => { const i = $('emailNew'); if (i) i.focus(); }, 50); 2207} 2208 2209async function saveEmail() { 2210 const nouvel = (($('emailNew') || {}).value || '').trim(); 2211 const errBox = $('emailError'); 2212 const showErr = (msg) => { if (errBox) { errBox.textContent = 'â ï¸ ' + msg; errBox.style.display = 'block'; } else { toast(msg, true); } }; 2213 if (errBox) errBox.style.display = 'none'; 2214 2215 // Validation basique de l'email 2216 if (!nouvel || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(nouvel)) { 2217 showErr('Saisis une adresse email valide.'); 2218 return; 2219 } 2220 const emailActuel = (session && session.user && session.user.email) || ''; 2221 if (nouvel.toLowerCase() === emailActuel.trim().toLowerCase()) { 2222 showErr('C\'est déjà ton adresse de connexion actuelle.'); 2223 return; 2224 } 2225 2226 // 1) Demander le changement à Supabase (envoie un mail de confirmation à la nouvelle adresse) 2227 const execution = await executerAvecVerrou($('modalBody'), 2228 () => db.auth.updateUser({ email: nouvel }), 'Envoiâ¦'); 2229 if (execution.ignoree) return; 2230 const { error } = execution.valeur; 2231 if (error) { 2232 const idErreur = creerIdErreur(); 2233 journal.error(`[changement email ${idErreur}]`, error); 2234 showErr(`${TennisDomainRules.messageErreurUtilisateur(error.message)} (réf. ${idErreur})`); 2235 return; 2236 } 2237 2238 // 2) Prévenir le parent via un événement serveur. Le navigateur ne choisit ni 2239 // le destinataire ni le contenu : l'Edge Function relit la fiche du joueur. 2240 try { 2241 const j = profile && profile.joueur_id ? joueurById(profile.joueur_id) : null; 2242 const parentEmail = j && j.parent_email ? j.parent_email : null; 2243 if (parentEmail && parentEmail.includes('@')) { 2244 await sendEmailEvent('parent_email_change', j.id); 2245 } 2246 } catch (e) { journal.warn('[changement email] notif parent:', e); } 2247 2248 closeModal(); 2249 toast('Demande envoyée : confirme via le lien reçu sur ta nouvelle adresse âï¸'); 2250} 2251 2252function renderNotifsList() { return centreNotifications.rendre(); } 2253async function markNotifLu(notifId) { return centreNotifications.marquerLue(notifId); } 2254async function onNotifClick(notifId, lienType, lienId) { 2255 const n = cache.notifications.find(x => x.id === notifId); 2256 await centreNotifications.marquerLue(notifId, false); 2257 // Naviguer vers la cible 2258 toggleNotifsPanel(); 2259 if (lienType === 'joueur' && lienId) { 2260 if (isCoach()) { 2261 const j = joueurById(lienId); 2262 if (j && j.statut === 'en_attente_validation') { 2263 navigate('validations'); 2264 setTimeout(() => openValidationModal(lienId), 200); 2265 } else { 2266 navigate('players'); 2267 setTimeout(() => openPlayerDetail(lienId), 200); 2268 } 2269 } else { 2270 navigate('my'); 2271 } 2272 } else if (lienType === 'bilan' && lienId) { 2273 if (isCoach()) { 2274 navigate('bilans'); 2275 setTimeout(() => openModalBilan(lienId), 200); 2276 } else { 2277 navigate('mybilans'); 2278 setTimeout(() => openMyBilanDetail(lienId), 200); 2279 } 2280 } else if (lienType === 'tournoi' && lienId) { 2281 navigate('calendar'); 2282 setTimeout(() => openModalTournoi(lienId), 200); 2283 } else if (n && n.type === 'nouveau_tournoi') { 2284 // Plusieurs tournois pertinents : on ouvre juste l'onglet Tournois (pas de modal unique) 2285 navigate('calendar'); 2286 } else if (lienType === 'match' && lienId) { 2287 // Belle perf signalée au coach â ouvrir le match 2288 setTimeout(() => openModalMatch(lienId), 200); 2289 } else if (lienType === 'entrainement' && lienId) { 2290 // Entraînement déclaré â retrouver son joueur et ouvrir sa fiche coach. 2291 const entrainement = entrainementById(lienId); 2292 if (isCoach() && entrainement && entrainement.joueur_id) { 2293 navigate('players'); 2294 setTimeout(() => openPlayerDetail(entrainement.joueur_id), 200); 2295 } else { 2296 navigate(isCoach() ? 'players' : 'mytrainings'); 2297 } 2298 } else if (lienType === 'seance') { 2299 // Rappel de présence joueur â onglet Mes cours. 2300 navigate(isCoach() ? 'planning' : 'mescours'); 2301 if (isCoach() && lienId) setTimeout(() => openModalAppel(lienId), 200); 2302 } else if (lienType === 'presence' && lienId) { 2303 // Déclaration joueur â ouvrir directement l'appel de la séance côté coach. 2304 const presence = (listeSure(cache.presences)).find(item => item.id === lienId); 2305 if (isCoach() && presence) { 2306 navigate('planning'); 2307 setTimeout(() => openModalAppel(presence.seance_id), 200); 2308 } else { 2309 navigate(isCoach() ? 'planning' : 'mescours'); 2310 } 2311 } else if (lienType === 'bilans') { 2312 // Rappel clôture trimestre â onglet Bilans (vue coach) 2313 navigate('bilans'); 2314 } 2315 // Refresh list (pour mà j UI lu) 2316 renderNotifsList(); 2317} 2318 2319async function markAllNotifsRead() { return centreNotifications.toutMarquerLu(); } 2320async function deleteNotif(notifId) { return centreNotifications.supprimer(notifId); } 2321// ============================================================ 2322// EMAIL MAILER â Envoi via Edge Function Supabase + Resend 2323// ============================================================ 2324// La fonction sendEmail appelle l'Edge Function "send-email" qui se charge 2325// d'envoyer le mail via Resend. Tous les emails sont silencieux : si l'envoi 2326// échoue (Resend down, config manquante, etc.), l'app continue à fonctionner. 2327// On logge juste l'erreur dans la console. 2328 2329const EMAIL_FROM_LABEL = 'Académie de Tennis Bouliac'; 2330const APP_PUBLIC_URL = window.location.origin; 2331const serviceEmail = TennisEmailService.creerService({ 2332 db, getSession: () => session, getPreferences: () => cache.preferencesNotifs, 2333 computeAge, escapeHtml, appUrl: APP_PUBLIC_URL, fromLabel: EMAIL_FROM_LABEL, 2334 logger: journal 2335}); 2336async function sendEmailEvent(event, resourceId) { return serviceEmail.envoyerEvenement(event, resourceId); } 2337// ============================================================ 2338// Templates et déclencheurs spécifiques 2339// ============================================================ 2340 2341const modelesEmail = TennisEmailTemplates.creerModeles({ 2342 serviceEmail, escapeHtml, formatDate, tenupUrl: tenupTournoiUrl, 2343 appUrl: APP_PUBLIC_URL, getProfile: () => profile, getSession: () => session, 2344 getPreferences: () => cache.preferencesNotifs 2345}); 2346async function emailBilanPublie(bilan, joueur) { return modelesEmail.bilanPublie(bilan, joueur); } 2347async function emailBilanPalmyrePublie(joueur, edition, stats) { return modelesEmail.bilanPalmyre(joueur, edition, stats); } 2348async function emailInscriptionValidee(joueur, groupeNom) { return modelesEmail.inscriptionValidee(joueur, groupeNom); } 2349async function emailRappelTournoi(tournoi, joueur, joursAvant) { return modelesEmail.rappelTournoi(tournoi, joueur, joursAvant); } 2350async function emailRappelSaisieMatch(tournoi, joueur) { return modelesEmail.rappelSaisie(tournoi, joueur); } 2351async function emailMatchAjouteParCoach(match, joueur) { return modelesEmail.matchAjoute(match, joueur); } 2352// Notifie le coach d'une belle perf (in-app via fonction SECURITY DEFINER, + email).
2353// match : objet match qu'on vient d'insérer ; joueur : le joueur ; matchId : id inséré. 2354async function notifierPerfCoach(match, joueur, matchId) { 2355 if (!joueur || joueur.groupe === 'tremplin') return; 2356 try { 2357 const { data, error } = await db.rpc('notifier_perf_coach', { 2358 p_joueur_id: joueur.id, 2359 p_adversaire: match.adversaire || '', 2360 p_adversaire_class: match.adversaire_class || '', 2361 p_score: match.score || '', 2362 p_match_id: matchId 2363 }); 2364 if (error) { journal.warn('[perf coach] notif error:', error); return; } 2365 // La RPC est l'autorité métier : aucune ligne retournée signifie que le 2366 // résultat n'est pas une performance éligible, donc aucun email non plus. 2367 if (Array.isArray(data) && data.length) await sendEmailEvent('perf_coach', matchId); 2368 } catch (e) { 2369 journal.warn('[perf coach] exception:', e); 2370 } 2371} 2372 2373async function emailPerfNotable(match, joueur, ecart) { return modelesEmail.bellePerf(match, joueur, ecart); } 2374 2375// ============================================================ 2376// RAPPELS TOURNOIS â généré côté client au chargement 2377// ============================================================ 2378// Pour chaque tournoi à venir entre 25 et 30 jours, créer une notif 2379// "rappel_tournoi" pour les inscrits, sauf si elle existe déjà . 2380// Cette fonction est idempotente : appelable plusieurs fois sans doublons. 2381 2382const serviceRappels = TennisReminders.creerService({ 2383 db, cache, getSession: () => session, getProfile: () => profile, isCoach, 2384 joueurById, formatDate, emailRappelTournoi, emailRappelSaisieMatch, 2385 emailRappelBilanTrimestre, getSaisonCourante, getTrimestreCourant, 2386 getTrimestreDates, domainRules: TennisDomainRules, estPreAcademie, 2387 maintenant: () => Date.now() 2388}); 2389async function checkAndCreateTournoiReminders() { return serviceRappels.verifierTournois(); } 2390async function checkAndCreateSaisieMatchReminders() { return serviceRappels.verifierSaisieMatchs(); } 2391async function checkAndCreateTrimestreReminders() { return serviceRappels.verifierTrimestre(); } 2392// Email coach : modèle isolé et préférences centralisées. 2393async function emailRappelBilanTrimestre(seuil, saison, trimestre, restantsAfaire, nbJoueursActifs) { 2394 return modelesEmail.rappelBilanCoach(seuil, saison, trimestre, restantsAfaire, nbJoueursActifs); 2395} 2396 2397// ============================================================ 2398// GRAPHIQUES (Chart.js) 2399// ============================================================ 2400const chartInstances = {}; // pour pouvoir détruire les anciens charts 2401const { chartColors, chartGradient } = globalThis.TennisChartTheme; 2402 2403// Chart.js pèse environ 200 Ko et n'est utile que sur quelques écrans. Le 2404// charger au premier canvas rencontré accélère la connexion, l'accueil et les 2405// parcours sans statistiques. Le chargeur partagé déduplique les appels quand 2406// plusieurs graphiques apparaissent simultanément. 2407function avecChartJs(canvasId, relancer) { 2408 if (window.Chart) return true; 2409 chargerChartJs().then(disponible => { 2410 if (disponible && document.getElementById(canvasId)) relancer(); 2411 }); 2412 return false; 2413} 2414 2415function destroyChart(id) { 2416 if (chartInstances[id]) { 2417 try { chartInstances[id].destroy(); } catch(e) {} 2418 delete chartInstances[id]; 2419 } 2420} 2421 2422// Un changement d'écran remplace le contenu de #app. Libérer les graphiques 2423// dont le canvas a disparu évite de conserver contextes, écouteurs et données 2424// pendant toute la session, particulièrement coûteux sur téléphone. 2425function destroyChartsHorsDom() { 2426 Object.keys(chartInstances).forEach(id => { 2427 if (!document.getElementById(id)) destroyChart(id); 2428 }); 2429} 2430 2431// ------------------------------------------------------------ 2432// Graphique évolution classement d'un joueur 2433// ------------------------------------------------------------ 2434// On reconstruit l'évolution depuis les bilans + classement initial+actuel 2435function renderClassementChart(canvasId, joueur) { 2436 destroyChart(canvasId); 2437 const canvas = document.getElementById(canvasId); 2438 if (!canvas || !avecChartJs(canvasId, () => renderClassementChart(canvasId, joueur))) return; 2439 2440 // Source de l'évolution : on privilégie l'historique réel des changements de 2441 // classement (chaque changement daté). à défaut, on retombe sur les bilans. 2442 const histo = historiqueClassementDuJoueur(joueur.id) 2443 .slice() 2444 .sort((a, b) => (a.date_changement || '').localeCompare(b.date_changement || '')); 2445 2446 const labels = []; 2447 const values = []; 2448 2449 if (histo.length > 0) { 2450 // Premier point : l'ancien classement du tout premier changement (point de départ) 2451 const premier = histo[0]; 2452 if (premier.ancien_classement) { 2453 labels.push(formatDate(premier.date_changement)); 2454 values.push(getRankIndex(premier.ancien_classement)); 2455 } 2456 histo.forEach(h => { 2457 labels.push(formatDate(h.date_changement)); 2458 values.push(getRankIndex(h.nouveau_classement)); 2459 }); 2460 // Point actuel (si différent du dernier historisé, sinon on évite le doublon) 2461 const dernierIdx = histo.length ? getRankIndex(histo[histo.length - 1].nouveau_classement) : null; 2462 if (getRankIndex(joueur.classement || 'NC') !== dernierIdx) { 2463 labels.push('Aujourd\'hui'); 2464 values.push(getRankIndex(joueur.classement || 'NC')); 2465 } 2466 } else { 2467 // Pas d'historique : on reconstruit depuis les bilans (ancien comportement)
2468 const bilans = cache.bilans 2469 .filter(b => b.joueur_id === joueur.id && (b.classement_debut || b.classement_fin)) 2470 .sort((a, b) => (a.saison + a.trimestre).localeCompare(b.saison + b.trimestre)); 2471 2472 if (bilans.length === 0) { 2473 canvas.parentElement.innerHTML = '<div class="text-faint text-center" style="padding:20px; font-size:13px">Pas encore d\'historique de classement.<br>L\'évolution apparaîtra dès le premier changement de classement validé.</div>'; 2474 return; 2475 } 2476 bilans.forEach(b => { 2477 if (b.classement_debut) { 2478 labels.push(`${b.trimestre} ${b.saison.split('-')[0]} (début)`); 2479 values.push(getRankIndex(b.classement_debut)); 2480 } 2481 if (b.classement_fin) { 2482 labels.push(`${b.trimestre} ${b.saison.split('-')[0]} (fin)`); 2483 values.push(getRankIndex(b.classement_fin)); 2484 } 2485 }); 2486 labels.push('Aujourd\'hui'); 2487 values.push(getRankIndex(joueur.classement || 'NC')); 2488 } 2489 2490 const { gridColor, textColor, tooltipBg, pointBorder, lineOrange, winColor } = chartColors(); 2491 2492 // Axe Y : les valeurs sont des index ENTIERS dans CLASSEMENTS (paliers discrets). 2493 // On borne l'axe aux classements réellement atteints (avec 1 cran de marge) et on 2494 // force un pas de 1, sinon Chart.js place des graduations intermédiaires (5.5, 6.5â¦) 2495 // qui, une fois arrondies, affichent deux fois le même classement (« 30/2 30/2 »). 2496 const valsValides = values.filter(v => typeof v === 'number' && !isNaN(v)); 2497 let yMin = valsValides.length ? Math.max(0, Math.min(...valsValides) - 1) : 0; 2498 let yMax = valsValides.length ? Math.min(CLASSEMENTS.length - 1, Math.max(...valsValides) + 1) : CLASSEMENTS.length - 1; 2499 if (yMin === yMax) { yMin = Math.max(0, yMin - 1); yMax = Math.min(CLASSEMENTS.length - 1, yMax + 1); } 2500 2501 chartInstances[canvasId] = new Chart(canvas, { 2502 type: 'line', 2503 data: { 2504 labels, 2505 datasets: [{ 2506 label: 'Classement', 2507 data: values, 2508 borderColor: lineOrange, 2509 backgroundColor: ctx => chartGradient(ctx, 'rgba(255,121,0,.48)', 'rgba(223,255,53,.025)'), 2510 fill: true, 2511 tension: 0.42, 2512 borderWidth: 3, 2513 pointRadius: 5, 2514 pointHoverRadius: 8,
2515 pointBackgroundColor: winColor, 2516 pointBorderColor: pointBorder, 2517 pointBorderWidth: 1.5 2518 }] 2519 }, 2520 options: { 2521 responsive: true, 2522 maintainAspectRatio: false, 2523 plugins: { 2524 legend: { display: false }, 2525 tooltip: { 2526 backgroundColor: tooltipBg, 2527 borderColor: lineOrange, 2528 borderWidth: 1, 2529 callbacks: { 2530 label: (ctx) => 'Classt : ' + (CLASSEMENTS[ctx.parsed.y] || ctx.parsed.y) 2531 } 2532 } 2533 }, 2534 scales: { 2535 y: { 2536 min: yMin, 2537 max: yMax, 2538 ticks: { 2539 color: textColor, 2540 stepSize: 1, 2541 autoSkip: false, 2542 callback: (v) => (Number.isInteger(v) ? (CLASSEMENTS[v] || '') : '') 2543 }, 2544 grid: { color: gridColor } 2545 }, 2546 x: { 2547 ticks: { color: textColor, maxRotation: 45, minRotation: 0 }, 2548 grid: { display: false } 2549 } 2550 } 2551 } 2552 }); 2553} 2554 2555// ------------------------------------------------------------ 2556// Radar des 5 axes â compare jusqu'à 3 bilans 2557// ------------------------------------------------------------ 2558// Construit la liste unifiée des bilans d'un joueur (compét + Palmyre), triée du 2559// plus récent au plus ancien, avec un descripteur normalisé pour le radar. 2560function bilansUnifiesJoueur(joueurId) { 2561 const liste = []; 2562 // Bilans compét (non brouillon, évalués) 2563 (listeSure(cache.bilans)) 2564 .filter(b => b.joueur_id === joueurId && b.statut !== 'brouillon' && b.note_performance != null) 2565 .forEach(b => liste.push({ 2566 id: 'c_' + b.id, 2567 type: 'compet', 2568 label: `${b.trimestre} ${b.saison}`, 2569 tri: b.saison + b.trimestre, 2570 labels: ['Performance', 'Volume', 'Technique / Tactique', 'Physique', 'Mental'], 2571 data: [b.note_performance, b.note_volume, b.note_technique, b.note_physique, b.note_mental] 2572 })); 2573 // Bilans Palmyre (publiés) 2574 (listeSure(cache.bilansEvenement)) 2575 .filter(b => b.joueur_id === joueurId && b.statut === 'publie' && b.note_performance != null) 2576 .forEach(b => { 2577 const ev = (listeSure(cache.evenements)).find(e => e.id === b.evenement_id) 2578 || (listeSure(cache.evenementsTous)).find(e => e.id === b.evenement_id); 2579 liste.push({ 2580 id: 'p_' + b.id, 2581 type: 'palmyre', 2582 label: `ðï¸ ${ev ? ev.nom : 'Palmyre'}`, 2583 tri: (ev && ev.date_debut) ? ev.date_debut : '9999', 2584 labels: ['Performance', 'Technique / Tactique', 'Physique', 'Mental', 'Savoir-être / vivre'], 2585 data: [b.note_performance, b.note_technique, b.note_physique, b.note_mental, b.note_savoir_etre] 2586 }); 2587 });
2588 // Tri du plus récent au plus ancien 2589 liste.sort((a, b) => b.tri.localeCompare(a.tri)); 2590 return liste; 2591} 2592 2593function setRadarBilan(canvasId, bilanId, joueurId) { 2594 etatApp.definirSelectionRadar(canvasId, bilanId); 2595 const j = joueurById(joueurId); 2596 if (j) renderRadarBilans(canvasId, j); 2597} 2598 2599function renderRadarBilans(canvasId, joueur) { 2600 destroyChart(canvasId); 2601 const canvas = document.getElementById(canvasId); 2602 if (!canvas || !avecChartJs(canvasId, () => renderRadarBilans(canvasId, joueur))) return; 2603 2604 const bilans = bilansUnifiesJoueur(joueur.id); 2605 const selectorHost = document.getElementById(canvasId + '_selector'); 2606 2607 if (bilans.length === 0) { 2608 if (selectorHost) selectorHost.innerHTML = ''; 2609 canvas.parentElement.innerHTML = '<div class="text-faint text-center" style="padding:20px; font-size:13px">Aucun bilan évalué.<br>Le radar apparaîtra dès qu\'un bilan sera publié.</div>'; 2610 return; 2611 } 2612 2613 // Options "Ãvolution" (superposition des 3 derniers bilans d'un même type). 2614 const bilansCompet = bilans.filter(b => b.type === 'compet'); 2615 const bilansPalmyre = bilans.filter(b => b.type === 'palmyre'); 2616 const options = []; 2617 if (bilansCompet.length >= 2) options.push({ id: 'evo_compet', label: 'ð Ãvolution compét (3 derniers)' }); 2618 if (bilansPalmyre.length >= 2) options.push({ id: 'evo_palmyre', label: 'ð Ãvolution Palmyre (3 derniers)' }); 2619 // Puis les bilans individuels 2620 bilans.forEach(b => options.push({ id: b.id, label: b.label })); 2621 2622 // Sélection courante (défaut = évolution compét si dispo, sinon le plus récent) 2623 let selId = etatApp.lireSelectionRadar(canvasId); 2624 if (!selId || !options.some(o => o.id === selId)) { 2625 selId = options[0].id; 2626 etatApp.definirSelectionRadar(canvasId, selId); 2627 } 2628 2629 // Sélecteur (affiché seulement s'il y a plus d'une option) 2630 if (selectorHost) { 2631 if (options.length > 1) { 2632 selectorHost.innerHTML = `<div class="form-row" style="margin-bottom:8px"> 2633 <select data-ui-change="setRadarBilan(${escapeHtml(encoderArgumentTexteUI(canvasId))}, this.value, ${escapeHtml(encoderArgumentTexteUI(joueur.id))})" style="width:100%"> 2634 ${options.map(o => `<option value="${o.id}" ${o.id === selId ? 'selected' : ''}>${escapeHtml(o.label)}</option>`).join('')} 2635 </select> 2636 </div>`; 2637 } else { 2638 selectorHost.innerHTML = `<div class="text-faint" style="font-size:12px; margin-bottom:8px">${escapeHtml(options[0].label)}</div>`; 2639 } 2640 } 2641 2642 // Construction des labels (axes) + datasets selon la sélection. 2643 let labels, datasets; 2644 const { gridColor, textColor, tooltipBg, pointBorder, purpleColor, blueColor, tealColor, lineOrange } = chartColors(); 2645 const couleursEvo = [purpleColor, blueColor, tealColor]; // du plus ancien au plus récent 2646 2647 if (selId === 'evo_compet' || selId === 'evo_palmyre') { 2648 // Superposition des 3 derniers bilans du type choisi (chronologique). 2649 const source = (selId === 'evo_compet' ? bilansCompet : bilansPalmyre).slice(0, 3).reverse(); 2650 labels = source[0].labels; // tous les bilans d'un même type partagent les mêmes axes 2651 datasets = source.map((b, idx) => ({ 2652 label: b.label, 2653 data: b.data, 2654 borderColor: couleursEvo[idx], 2655 backgroundColor: couleursEvo[idx] + '22', 2656 borderWidth: 2,
2657 pointBackgroundColor: couleursEvo[idx], 2658 pointRadius: 4 2659 })); 2660 } else { 2661 // Un seul bilan sélectionné. 2662 const sel = bilans.find(b => b.id === selId) || bilans[0]; 2663 const couleur = sel.type === 'palmyre' ? lineOrange : blueColor; 2664 labels = sel.labels; 2665 datasets = [{ 2666 label: sel.label, 2667 data: sel.data, 2668 borderColor: couleur, 2669 backgroundColor: couleur + '33', 2670 borderWidth: 2, 2671 pointBackgroundColor: couleur, 2672 pointRadius: 4 2673 }]; 2674 } 2675 2676 chartInstances[canvasId] = new Chart(canvas, { 2677 type: 'radar', 2678 data: { labels, datasets }, 2679 options: { 2680 responsive: true, 2681 maintainAspectRatio: false, 2682 plugins: { 2683 legend: { 2684 position: 'bottom', 2685 labels: { color: textColor, font: { size: 11 }, boxWidth: 12 } 2686 }, 2687 tooltip: { 2688 backgroundColor: tooltipBg, 2689 borderColor: lineOrange, 2690 borderWidth: 1 2691 } 2692 }, 2693 scales: { 2694 r: { 2695 min: 0, 2696 max: 10, 2697 ticks: { 2698 stepSize: 2, 2699 color: textColor, 2700 backdropColor: 'transparent', 2701 font: { size: 10 } 2702 }, 2703 grid: { color: gridColor }, 2704 angleLines: { color: gridColor }, 2705 pointLabels: { color: textColor, font: { size: 11 } } 2706 } 2707 } 2708 } 2709 }); 2710} 2711 2712// ------------------------------------------------------------ 2713// ð¡ï¸ Forme actuelle â Taux victoires sur fenêtre glissante (10 derniers) 2714// ------------------------------------------------------------ 2715function renderFormeChart(canvasId, joueur) { 2716 destroyChart(canvasId); 2717 const canvas = document.getElementById(canvasId); 2718 if (!canvas || !avecChartJs(canvasId, () => renderFormeChart(canvasId, joueur))) return; 2719 2720 const matches = matchsHorsWO(cache.matches 2721 .filter(m => m.joueur_id === joueur.id)) 2722 .slice() 2723 .sort((a, b) => (a.date || '').localeCompare(b.date || '')); 2724 2725 if (matches.length < 3) { 2726 canvas.parentElement.innerHTML = '<div class="text-faint text-center" style="padding:20px; font-size:13px">Tu as besoin d\'au moins 3 matches pour calculer ta forme actuelle.</div>'; 2727 return; 2728 } 2729 2730 const WINDOW = 10; 2731 const labels = []; 2732 const values = []; 2733 2734 matches.forEach((m, i) => { 2735 if (i < 2) return; 2736 const debut = Math.max(0, i - WINDOW + 1); 2737 const fenetre = matches.slice(debut, i + 1); 2738 const taux = comptesMatchs(fenetre).tauxVictoire; 2739 labels.push(formatDate(m.date)); 2740 values.push(taux); 2741 }); 2742 2743 const { gridColor, textColor, tooltipBg, pointBorder, lineOrange, winColor } = chartColors(); 2744 2745 chartInstances[canvasId] = new Chart(canvas, { 2746 type: 'line', 2747 data: { 2748 labels, 2749 datasets: [{ 2750 label: 'Taux victoires', 2751 data: values, 2752 borderColor: lineOrange, 2753 backgroundColor: ctx => chartGradient(ctx, 'rgba(223,255,53,.38)', 'rgba(255,121,0,.025)'), 2754 fill: true, 2755 tension: 0.46, 2756 borderWidth: 3, 2757 pointRadius: 4, 2758 pointHoverRadius: 8,
2759 pointBackgroundColor: winColor, 2760 pointBorderColor: pointBorder, 2761 pointBorderWidth: 2 2762 }] 2763 }, 2764 options: { 2765 responsive: true, 2766 maintainAspectRatio: false, 2767 plugins: { 2768 legend: { display: false }, 2769 tooltip: { callbacks: { label: ctx => `Forme : ${ctx.parsed.y}% de victoires` } } 2770 }, 2771 scales: { 2772 x: { ticks: { color: textColor, maxTicksLimit: 6, font: { size: 10 } }, grid: { color: gridColor } }, 2773 y: { min: 0, max: 100, ticks: { color: textColor, callback: v => v + '%' }, grid: { color: gridColor } } 2774 } 2775 } 2776 }); 2777 2778 // Explication claire sous le graphique (compréhensible de tous), ajoutée une seule 2779 // fois quel que soit l'écran qui affiche la courbe (fiche joueur ou vue coach). 2780 const carte = canvas.parentElement; 2781 if (carte && !carte.parentElement.querySelector('.forme-legende')) { 2782 const legende = document.createElement('div'); 2783 legende.className = 'forme-legende text-faint'; 2784 legende.style.cssText = 'font-size:11px; text-align:center; margin:-2px 0 8px; line-height:1.3'; 2785 legende.textContent = 'Taux de victoires sur les 10 derniers matches'; 2786 carte.parentElement.insertBefore(legende, carte.nextSibling); 2787 } 2788} 2789 2790// ------------------------------------------------------------ 2791// ð Activité par mois (histogramme V/D) 2792// ------------------------------------------------------------ 2793function renderActiviteChart(canvasId, joueur, bornes) { 2794 destroyChart(canvasId); 2795 const canvas = document.getElementById(canvasId); 2796 if (!canvas || !avecChartJs(canvasId, () => renderActiviteChart(canvasId, joueur, bornes))) return; 2797 2798 const matches = matchsHorsWO(matchsDuJoueur(joueur.id)); 2799 if (matches.length === 0) { 2800 canvas.parentElement.innerHTML = '<div class="text-faint text-center" style="padding:20px; font-size:13px">Aucun match saisi pour le moment.</div>'; 2801 return; 2802 } 2803 2804 // Si des bornes [debut, fin] sont fournies, on génère les mois de cette plage. 2805 // Sinon, comportement par défaut : 12 derniers mois glissants. 2806 const mois = []; 2807 if (bornes && bornes[0] && bornes[1]) { 2808 let [yD, mD] = bornes[0].split('-').map(Number); 2809 const [yF, mF] = bornes[1].split('-').map(Number); 2810 let garde = 0; 2811 while ((yD < yF || (yD === yF && mD <= mF)) && garde < 24) { 2812 const d = new Date(yD, mD - 1, 1); 2813 mois.push({ 2814 key: yD + '-' + String(mD).padStart(2, '0'), 2815 label: d.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' }), 2816 v: 0, d: 0 2817 }); 2818 mD++; if (mD > 12) { mD = 1; yD++; } 2819 garde++; 2820 } 2821 } else { 2822 const now = new Date(); 2823 for (let i = 11; i >= 0; i--) { 2824 const d = new Date(now.getFullYear(), now.getMonth() - i, 1); 2825 mois.push({ 2826 key: d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'), 2827 label: d.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' }), 2828 v: 0, d: 0 2829 }); 2830 } 2831 } 2832 2833 matches.forEach(m => { 2834 if (!m.date) return; 2835 const key = m.date.slice(0, 7); 2836 const slot = mois.find(x => x.key === key); 2837 if (slot) { 2838 if (m.victoire) slot.v++; 2839 else slot.d++; 2840 } 2841 }); 2842 2843 const { gridColor, textColor, tooltipBg, pointBorder, lineOrange, winColor, lossColor } = chartColors(); 2844 2845 chartInstances[canvasId] = new Chart(canvas, { 2846 type: 'bar', 2847 data: { 2848 labels: mois.map(m => m.label), 2849 datasets: [ 2850 { label: 'Victoires', data: mois.map(m => m.v), backgroundColor: winColor, borderColor: winColor, borderWidth:1, borderRadius: 10, borderSkipped:false }, 2851 { label: 'Défaites', data: mois.map(m => m.d), backgroundColor: lossColor, borderColor: lossColor, borderWidth:1, borderRadius: 10, borderSkipped:false } 2852 ] 2853 }, 2854 options: { 2855 responsive: true, 2856 maintainAspectRatio: false, 2857 plugins: { 2858 legend: { labels: { color: textColor, boxWidth: 12, font: { size: 11 } } }, 2859 tooltip: { 2860 mode: 'index', 2861 callbacks: { 2862 footer: items => { 2863 const total = items.reduce((s, it) => s + it.parsed.y, 0); 2864 return total > 0 ? 'Total : ' + total + ' match' + (total > 1 ? 'es' : '') : ''; 2865 } 2866 } 2867 } 2868 }, 2869 scales: { 2870 x: { stacked: true, ticks: { color: textColor, font: { size: 10 } }, grid: { display: false } }, 2871 y: { stacked: true, beginAtZero: true, ticks: { color: textColor, stepSize: 1, precision: 0 }, grid: { color: gridColor } } 2872 } 2873 } 2874 }); 2875} 2876 2877// ------------------------------------------------------------ 2878// ð Cumul des victoires depuis le début de saison 2879// ------------------------------------------------------------ 2880function renderCumulChart(canvasId, joueur, bornes) { 2881 destroyChart(canvasId); 2882 const canvas = document.getElementById(canvasId); 2883 if (!canvas || !avecChartJs(canvasId, () => renderCumulChart(canvasId, joueur, bornes))) return; 2884 2885 // Si des bornes [debut, fin] sont fournies (vue coach avec sélecteur de période), 2886 // on filtre dessus. Sinon, comportement par défaut = saison en cours (fiche joueur). 2887 let matchesPeriode;
2888 if (bornes && bornes[0] && bornes[1]) { 2889 matchesPeriode = matchsHorsWO(cache.matches 2890 .filter(m => m.joueur_id === joueur.id && TennisDomainRules.dateDansIntervalle(m.date, bornes[0], bornes[1]))) 2891 .slice() 2892 .sort((a, b) => (a.date || '').localeCompare(b.date || '')); 2893 } else { 2894 const [debutSaison, finSaison] = getSaisonDates(); 2895 matchesPeriode = matchsHorsWO(cache.matches 2896 .filter(m => m.joueur_id === joueur.id && TennisDomainRules.dateDansIntervalle(m.date, debutSaison, finSaison))) 2897 .slice() 2898 .sort((a, b) => (a.date || '').localeCompare(b.date || '')); 2899 } 2900 const matchesSaison = matchesPeriode; 2901 2902 if (matchesSaison.length === 0) { 2903 canvas.parentElement.innerHTML = '<div class="text-faint text-center" style="padding:20px; font-size:13px">Aucun match sur cette période.</div>'; 2904 return; 2905 } 2906 2907 const labels = []; 2908 const cumulV = []; 2909 const cumulD = []; 2910 let v = 0, d = 0; 2911 2912 matchesSaison.forEach(m => { 2913 if (m.victoire) v++; else d++; 2914 labels.push(formatDate(m.date)); 2915 cumulV.push(v); 2916 cumulD.push(d); 2917 }); 2918 2919 const { gridColor, textColor, tooltipBg, pointBorder, lineOrange, winColor, lossColor } = chartColors(); 2920 2921 chartInstances[canvasId] = new Chart(canvas, { 2922 type: 'line', 2923 data: { 2924 labels, 2925 datasets: [ 2926 { 2927 label: 'ð Victoires cumulées', data: cumulV, 2928 borderColor: winColor, backgroundColor: ctx => chartGradient(ctx,'rgba(223,255,53,.3)','rgba(223,255,53,.01)'), 2929 borderWidth: 3, fill: true, tension: 0.4, pointRadius: 4, pointBackgroun
2929dColor: lossColor, pointBorderColor: pointBorder, pointBorderWidth:2 2930 }, 2931 { 2932 label: 'Défaites cumulées', data: cumulD, 2933 borderColor: lossColor, borderWidth: 2, borderDash: [7, 6], 2934 fill: false, tension: 0.2, pointRadius: 0 2935 } 2936 ] 2937 }, 2938 options: { 2939 responsive: true, 2940 maintainAspectRatio: false, 2941 plugins: { 2942 legend: { labels: { color: textColor, boxWidth: 14, font: { size: 11 } } }, 2943 tooltip: { mode: 'index', callbacks: { title: items => 'Au ' + items[0].label } } 2944 }, 2945 scales: { 2946 x: { ticks: { color: textColor, maxTicksLimit: 6, font: { size: 10 } }, grid: { color: gridColor } }, 2947 y: { beginAtZero: true, ticks: { color: textColor, stepSize: 1, precision: 0 }, grid: { color: gridColor } } 2948 } 2949 } 2950 }); 2951} 2952 2953// ------------------------------------------------------------ 2954// Graphique volume matches par mois (dashboard global) 2955// ------------------------------------------------------------ 2956function renderVolumeChart(canvasId) { 2957 destroyChart(canvasId); 2958 const canvas = document.getElementById(canvasId); 2959 if (!canvas || !avecChartJs(canvasId, () => renderVolumeChart(canvasId))) return; 2960 2961 // Grouper les matches par mois sur la SAISON TENNISTIQUE analysée (sept â août), 2962 // et non sur les 12 derniers mois glissants (cohérence avec le reste de l'app). 2963 const saison = getSaisonAnalyse(); 2964 const [a1, a2] = saison.split('-').map(s => parseInt(s)); 2965 const buckets = {}; 2966 // Sept (mois 8) de a1 â Août (mois 7) de a2 : 12 mois dans l'ordre de la saison 2967 for (let i = 0; i < 12; i++) { 2968 const moisIndex = (8 + i) % 12; // 8,9,10,11,0,1,...,7 2969 const annee = (8 + i) < 12 ? a1 : a2; 2970 const key = `${annee}-${String(moisIndex + 1).padStart(2, '0')}`; 2971 buckets[key] = { matches: 0, victoires: 0 }; 2972 } 2973 2974 matchsHorsWO(cache.matches).forEach(m => { 2975 const d = new Date(m.date); 2976 const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; 2977 if (buckets[key]) { 2978 buckets[key].matches++; 2979 if (m.victoire) buckets[key].victoires++; 2980 } 2981 }); 2982 2983 const labels = Object.keys(buckets).map(k => { 2984 const [y, m] = k.split('-'); 2985 return ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'][parseInt(m)-1] + ' ' + y.slice(2); 2986 }); 2987 const matchesData = Object.values(buckets).map(b => b.matches); 2988 const victoiresData = Object.values(buckets).map(b => b.victoires); 2989 2990 const { gridColor, textColor, tooltipBg, pointBorder, lineOrange, winColor, lossColor } = chartColors(); 2991 2992 chartInstances[canvasId] = new Chart(canvas, { 2993 type: 'bar', 2994 data: { 2995 labels, 2996 datasets: [ 2997 { 2998 label: 'Défaites', 2999 data: matchesData.map((m, i) => m - victoiresData[i]), 3000 backgroundColor: lossColor, 3001 borderColor: lossColor, 3002 borderWidth: 1, 3003 borderRadius: 10, 3004 borderSkipped: false 3005 }, 3006 { 3007 label: 'Victoires', 3008 data: victoiresData, 3009 backgroundColor: winColor, 3010 borderColor: winColor, 3011 borderWidth: 1, 3012 borderRadius: 10, 3013 borderSkipped: false 3014 } 3015 ] 3016 }, 3017 options: { 3018 responsive: true, 3019 maintainAspectRatio: false, 3020 plugins: { 3021 legend: { 3022 position: 'bottom', 3023 labels: { color: textColor, font: { size: 11 }, boxWidth: 12 } 3024 }, 3025 tooltip: { 3026 backgroundColor: tooltipBg, 3027 borderColor: lineOrange, 3028 borderWidth: 1 3029 } 3030 }, 3031 scales: { 3032 y: { 3033 stacked: true, 3034 ticks: { color: textColor, stepSize: 5 }, 3035 grid: { color: gridColor } 3036 }, 3037 x: { 3038 stacked: true, 3039 ticks: { color: textColor, font: { size: 10 } }, 3040 grid: { display: false } 3041 } 3042 } 3043 } 3044 }); 3045} 3046 3047const gestionnaireModale = TennisUiCommon.creerGestionnaireModale({ 3048 window, 3049 document, 3050 ids: { fond: 'modal', contenu: 'modalBody', application: 'app-wrapper' }, 3051 apresContenu: body => { 3052 associerLabelsFormulaires(body); 3053 actualiserAuditActionsUI(); 3054 }, 3055 apresFermeture: () => { 3056 if (_renderEnAttente) { 3057 _renderEnAttente = false;
3058 try { render(); } catch (_) { /* rendu différé devenu inutile */ } 3059 } 3060 } 3061}); 3062 3063function showModal(html) { return gestionnaireModale.ouvrir(html); } 3064 3065const CLE_ETAT_NAVIGATION = 'pcb-navigation-v1'; 3066let _navigationDepuisHistorique = false; 3067 3068function etatNavigationCourant(supplement = {}) { 3069 return { 3070 cle: CLE_ETAT_NAVIGATION, 3071 ecran: etatApp.ecran || (isCoach() ? 'dashboard' : 'my'), 3072 environnement: etatApp.environnement || 'pole', 3073 saisonAnalyse: getSaisonAnalyse(), 3074 scrollY: Math.max(0, Math.round(window.scrollY || 0)), 3075 cockpitPilotage: typeof cockpitPilotageTab === 'string' ? cockpitPilotageTab : 'todo', 3076 ...supplement 3077 }; 3078} 3079 3080function assurerEtatNavigationInitial() { 3081 if (history.state?.cle === CLE_ETAT_NAVIGATION) return; 3082 history.replaceState(etatNavigationCourant(), '', window.location.href); 3083} 3084 3085function memoriserContexteNavigation() { 3086 assurerEtatNavigationInitial(); 3087 const precedent = history.state || {}; 3088 history.replaceState(etatNavigationCourant({ modal: precedent.modal || null }), '', window.location.href); 3089} 3090 3091function enregistrerDetailNavigation(type, id) { 3092 if (_navigationDepuisHistorique || !id) return; 3093 assurerEtatNavigationInitial(); 3094 if (history.state?.modal?.type === type && String(history.state.modal.id) === String(id)) return; 3095 memoriserContexteNavigation(); 3096 history.pushState(etatNavigationCourant({ modal: { type, id: String(id) } }), '', window.location.href); 3097} 3098 3099function boutonRetourDetail(libelle = 'Retour') { 3100 return `<button type="button" class="modal-context-back" data-ui-click="navigationRetourContextuel()" aria-label="${escapeHtml(libelle)}">â <span>${escapeHtml(libelle)}</span></button>`; 3101} 3102 3103let _confirmationResolve = null; 3104function demanderConfirmation(message, options = {}) { 3105 // Une seule confirmation peut être active : fermer proprement une éventuelle 3106 // attente précédente au lieu de laisser sa Promise suspendue. 3107 if (_confirmationResolve) { 3108 const precedente = _confirmationResolve; 3109 _confirmationResolve = null; 3110 precedente(false); 3111 } 3112 const titre = options.titre || 'Confirmer cette action'; 3113 const libelle = options.libelle || 'Confirmer'; 3114 const danger = options.danger !== false; 3115 showModal(`<div class="modal-header"> 3116 <h3 class="modal-title">${escapeHtml(titre)}</h3> 3117 <button class="modal-close" data-ui-click="resoudreConfirmation(false)" aria-label="Annuler et fermer">Ã</button> 3118 </div> 3119 <div style="font-size:14px;line-height:1.55">${escapeHtml(String(message || '')).replace(/\n/g, '<br>')}</div> 3120 <div class="flex gap-1 mt-2"> 3121 <button class="btn flex-1" data-ui-click="resoudreConfirmation(false)" autofocus>Annuler</button> 3122 <button class="btn flex-1 ${danger ? 'btn-danger' : 'btn-primary'}" data-ui-click="resoudreConfirmation(true)">${escapeHtml(libelle)}</button> 3123 </div>`); 3124 return new Promise(resolve => { _confirmationResolve = resolve; }); 3125} 3126 3127function resoudreConfirmation(accepte) { 3128 const resolve = _confirmationResolve; 3129 _confirmationResolve = null; 3130 closeModal(); 3131 if (resolve) resolve(!!accepte); 3132} 3133 3134function closeModalDirect() { 3135 if (_confirmationResolve) { 3136 const resolve = _confirmationResolve; 3137 _confirmationResolve = null; 3138 resolve(false); 3139 } 3140 gestionnaireModale.fermer(); 3141} 3142 3143function closeModal() { 3144 if (!_navigationDepuisHistorique && history.state?.cle === CLE_ETAT_NAVIGATION && history.state?.modal) { 3145 history.back(); 3146 return; 3147 } 3148 closeModalDirect(); 3149} 3150 3151function navigationRetourContextuel() { 3152 if (history.state?.cle === CLE_ETAT_NAVIGATION && history.state?.modal) { 3153 history.back(); 3154 return; 3155 } 3156 if (history.length > 1) { 3157 history.back(); 3158 return; 3159 } 3160 closeModalDirect(); 3161 navigate(isCoach() ? 'dashboard' : 'my'); 3162} 3163 3164window.addEventListener('popstate', async event => { 3165 const cible = event.state; 3166 if (!cible || cible.cle !== CLE_ETAT_NAVIGATION) return; 3167 _navigationDepuisHistorique = true; 3168 try { 3169 closeModalDirect(); 3170 const environnementChange = cible.environnement && cible.environnement !== etatApp.environnement; 3171 if (environnementChange) { 3172 etatApp.definirEnvironnement(cible.environnement); 3173 } 3174 const saisonRechargee = cible.saisonAnalyse && cible.saisonAnalyse !== getSaisonAnalyse() 3175 ? await chargerSaisonAnalyse(cible.saisonAnalyse) : false;
3176 if (environnementChange && !saisonRechargee) { 3177 appliquerPerimetreMatchs(); 3178 cache.alertes = computeAlertes(); 3179 cache.bellePerfs = computeBellePerfs(); 3180 } 3181 if (typeof cible.cockpitPilotage === 'string') cockpitPilotageTab = cible.cockpitPilotage; 3182 if (cible.ecran && etatApp.definirEcran(cible.ecran)) { 3183 buildNavMenu(); 3184 render(); 3185 } 3186 if (cible.modal?.type === 'player') openPlayerDetail(cible.modal.id); 3187 else if (cible.modal?.type === 'match') openModalMatch(cible.modal.id); 3188 requestAnimationFrame(() => window.scrollTo(0, Number(cible.scrollY) || 0)); 3189 } finally { 3190 _navigationDepuisHistorique = false; 3191 } 3192}); 3193 3194document.addEventListener('keydown', event => { 3195 if (event.key === 'Escape' && $('modal')?.classList.contains('show')) { 3196 // Passer par closeModal afin de résoudre aussi une éventuelle confirmation. 3197 event.preventDefault(); 3198 closeModal(); 3199 return; 3200 } 3201 gestionnaireModale.gererClavier(event); 3202}); 3203 3204function selectPill(pill, groupId) { 3205 const group = $(groupId); 3206 if (!group) return; 3207 group.querySelectorAll('.pill').forEach(p => { 3208 p.classList.remove('active'); 3209 p.setAttribute('aria-pressed', 'false'); 3210 }); 3211 pill.classList.add('active'); 3212 pill.setAttribute('aria-pressed', 'true'); 3213} 3214 3215// Variante pour un choix OPTIONNEL : recliquer sur la pill active la désélectionne 3216// (permet de laisser le groupe sans valeur). Utilisé pour la surface de jeu. 3217function selectPillToggle(pill, groupId) { 3218 const group = $(groupId); 3219 if (!group) return; 3220 const wasActive = pill.classList.contains('active'); 3221 group.querySelectorAll('.pill').forEach(p => { 3222 p.classList.remove('active'); 3223 p.setAttribute('aria-pressed', 'false'); 3224 }); 3225 if (!wasActive) { 3226 pill.classList.add('active'); 3227 pill.setAttribute('aria-pressed', 'true'); 3228 } 3229} 3230 3231// ============================================================ 3232// AUTHENTIFICATION 3233// ============================================================ 3234function switchAuthTab(tab) { 3235 document.querySelectorAll('.auth-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); 3236 $('form-login').style.display = tab === 'login' ? '' : 'none'; 3237 $('form-signup').style.display = tab === 'signup' ? '' : 'none'; 3238 const coachLink = $('coachSignupLink'); 3239 if (coachLink) coachLink.style.display = tab === 'signup' ? 'block' : 'none'; 3240 $('auth-error').classList.remove('show'); 3241} 3242 3243function authError(msg) { 3244 const el = $('auth-error'); 3245 el.textContent = msg; 3246 el.classList.add('show'); 3247 // Le message est placé en haut de l'écran : sur le formulaire multi-étapes 3248 // (inscription), l'utilisateur est souvent scrollé en bas et ne le verrait pas. 3249 // On l'amène donc à l'écran, et on double d'un toast flottant pour être sûr. 3250 try { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {} 3251 if (typeof toast === 'function') toast('â ï¸ ' + msg); 3252} 3253 3254// Bascule l'affichage d'un champ mot de passe (bouton Åil). 3255// Le bouton appelle cette fonction ; on bascule le type de l'input frère. 3256function togglePasswordVisibility(btn) { 3257 const wrap = btn.closest('.pwd-wrap'); 3258 if (!wrap) return; 3259 const input = wrap.querySelector('input'); 3260 if (!input) return; 3261 if (input.type === 'password') { 3262 input.type = 'text'; 3263 btn.textContent = 'ð'; 3264 btn.setAttribute('aria-label', 'Masquer le mot de passe'); 3265 } else { 3266 input.type = 'password'; 3267 btn.textContent = 'ðï¸'; 3268 btn.setAttribute('aria-label', 'Afficher le mot de passe'); 3269 } 3270} 3271 3272async function doLogin(e) { 3273 e.preventDefault(); 3274 $('auth-error').classList.remove('show'); 3275 const f = e.target; 3276 const btn = f.querySelector('button[type=submit]'); 3277 btn.disabled = true; btn.textContent = 'Connexion...'; 3278 3279 const { error } = await db.auth.signInWithPassword({ 3280 email: f.email.value.trim(), 3281 password: f.password.value 3282 }); 3283 3284 btn.disabled = false; btn.textContent = 'Se connecter'; 3285 3286 if (error) { 3287 authError('Email ou mot de passe incorrect'); 3288 return; 3289 } 3290 // Le listener onAuthStateChange prendra le relais 3291} 3292 3293// Navigation entre les étapes d'inscription 3294function libelleChampInscription(input) { 3295 const label = input.closest('.form-row, label')?.querySelector('label, span'); 3296 const texte = (label?.textContent || input.name || 'champ').replace(/\s*\*.*$/, '').trim(); 3297 return texte || 'champ obligatoire'; 3298} 3299 3300function signalerErreursInscription(erreurs) { 3301 if (!erreurs.length) return false;
3302 erreurs.forEach(({ champ }) => champ?.setAttribute('aria-invalid', 'true')); 3303 const libelles = [...new Set(erreurs.map(({ message, champ }) => message || libelleChampInscription(champ)))]; 3304 authError(`Vérifie les champs suivants : ${libelles.join(' · ')}`); 3305 const premier = erreurs.find(({ champ }) => champ && typeof champ.focus === 'function')?.champ; 3306 if (premier) { 3307 premier.focus({ preventScroll: true }); 3308 premier.scrollIntoView({ behavior: 'smooth', block: 'center' }); 3309 try { premier.reportValidity(); } catch (_) {} 3310 } 3311 return true; 3312} 3313 3314function signupNextStep(stepNumber) { 3315 const form = $('form-signup'); 3316 if (!form) return; 3317 // Ne jamais déduire l'étape active de la sérialisation de l'attribut style : 3318 // le navigateur peut écrire « display: none; » et rendre le sélecteur fragile. 3319 const currentStep = Array.from(form.querySelectorAll('[data-step]')) 3320 .find(step => !step.hidden && getComputedStyle(step).display !== 'none'); 3321 3322 // Validation : vérifier les champs requis de l'étape courante avant de continuer 3323 if (currentStep) { 3324 const requiredInputs = currentStep.querySelectorAll('input[required], select[required]'); 3325 const erreurs = Array.from(requiredInputs) 3326 .filter(inp => !inp.checkValidity()) 3327 .map(champ => ({ champ })); 3328 if (erreurs.length && stepNumber > parseInt(currentStep.dataset.step)) { 3329 signalerErreursInscription(erreurs); 3330 return; // Bloquer l'avance si validation échoue 3331 } 3332 } 3333 3334 // Afficher l'étape demandée, cacher les autres 3335 form.querySelectorAll('[data-step]').forEach(s => { 3336 const active = s.dataset.step == stepNumber; 3337 s.hidden = !active; 3338 s.style.display = active ? '' : 'none'; 3339 }); 3340 3341 // Quand on arrive sur l'étape 3, appliquer la logique mineur/majeur 3342 if (stepNumber == 3) { 3343 const dateInp = form.querySelector('input[name="date_naissance"]'); 3344 applyMinorLogic(dateInp ? ageFromDate(dateInp.value) : null); 3345 } 3346 3347 // Scroll vers le haut du formulaire 3348 setTimeout(() => form.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50); 3349} 3350 3351const RGPD_POLICY_VERSION = '2026-08-24-v3'; 3352 3353function showRgpdPolicy(e) { 3354 if (e) e.preventDefault(); 3355 const version = RGPD_POLICY_VERSION; 3356 const html = `<div class="modal-header"> 3357 <h3 class="modal-title">Politique de confidentialité</h3> 3358 <button class="modal-close" data-ui-click="closeModal()" aria-label="Fermer">Ã</button> 3359 </div> 3360 <div class="text-faint" style="font-size:11px;margin-bottom:14px">Académie de Tennis Bouliac · version ${version} · mise à jour du 24 août 2026</div> 3361 <div class="card" style="line-height:1.55"> 3362 <div class="sub-title">Données collectées</div> 3363 <p>Identité (prénom, nom, date de naissance, sexe), données sportives (classement, matchs, performances), coordonnées et contact parental, photos de profil facultatives, ainsi que les informations volontairement saisies comme les notes et ressentis.</p> 3364 <div class="sub-title">Finalités et bases légales</div> 3365 <p>Le suivi sportif, l'organisation des cours, tournois et stages ainsi que la gestion du compte sont nécessaires à l'accompagnement demandé au club. La sécurité, la prévention des abus et les communications indispensables reposent sur l'intérêt légitime du club. Les usages facultatifs, notamment la photo de profil et l'assistance à la rédaction par intelligence artificielle, reposent sur le consentement applicable et peuvent être refusés ou retirés.</p> 3366 <div class="sub-title">Conservation</div> 3367 <p>Pendant la durée d'inscription à l'académie, puis un an après le départ.</p> 3368 <div class="sub-title">Tes droits</div> 3369 <p>Accès, rectification, suppression, limitation, portabilité et opposition. Pour les exercer, contacte le coach principal ou le responsable du club par les coordonnées officielles communiquées lors de l'inscription. Une réclamation peut également être déposée auprès de la CNIL sur <a href="https://www.cnil.fr" target="_blank" rel="noopener noreferrer">cnil.fr</a>.</p> 3370 <div class="sub-title">Hébergement et sous-traitants</div> 3371 <p>L'application et ses journaux techniques sont distribués par Netlify. Les données métier sont hébergées par Supabase dans une région de l'Union européenne. Les emails de service sont transmis par Resend, qui reçoit uniquement les adresses, identités et contenus nécessaires à leur acheminement. Pour les fonctions optionnelles de proposition de bilan individuel et de débrief, les seules données sportives nécessaires sont transmises à Anthropic PBC, fournisseur d'intelligence artificielle établi aux Ãtats-Unis.</p> 3372 <p>Ce transfert relève des conditions commerciales et de l'accord de traitement des données applicables au service API Anthropic, qui intègre les clauses contractuelles types. Les propositions générées ne constituent jamais une décision automatisée : elles sont systématiquement relues, modifiables et validées par un entraîneur avant tout enregistrement ou publication.</p> 3373 <p>
3373Aucune donnée n'est envoyée à Anthropic lors de la simple consultation de l'application. L'envoi intervient uniquement lorsqu'un coach déclenche explicitement l'assistance à la rédaction.</p> 3374 <div class="sub-title">Responsable du traitement</div> 3375 <p>Académie de Tennis Bouliac. Le contact RGPD nominatif et son adresse doivent être confirmés par le bureau du club avant la prochaine campagne d'inscription.</p> 3376 </div> 3377 <button class="btn btn-primary btn-full" data-ui-click="closeModal()">J'ai compris</button>`; 3378 showModal(html); 3379} 3380 3381// Calcul d'âge depuis date_naissance 3382function ageFromDate(dateStr) { 3383 return TennisDomainRules.ageCivil(dateStr, new Date()); 3384} 3385 3386// Réagit au changement de date de naissance : adapte l'étape 3 (mineur/majeur) 3387function onDateNaissanceChange() { 3388 const form = $('form-signup'); 3389 if (!form) return; 3390 const dateInp = form.querySelector('input[name="date_naissance"]'); 3391 if (!dateInp) return; 3392 applyMinorLogic(ageFromDate(dateInp.value)); 3393} 3394 3395function applyMinorLogic(age) { 3396 const minorSection = $('minorSection'); 3397 const parentalConsent = $('parentalConsentLabel'); 3398 const ageStatus = $('ageStatus'); 3399 const ageStatusText = $('ageStatusText'); 3400 if (!minorSection || !parentalConsent || !ageStatus || !ageStatusText) return; 3401 3402 if (age === null) { 3403 minorSection.style.display = 'none'; 3404 parentalConsent.style.display = 'none'; 3405 setRequired(minorSection, false); 3406 parentalConsent.querySelector('input').required = false; 3407 ageStatusText.innerHTML = 'Renseigne ta date de naissance à l\'étape précédente pour adapter ce formulaire.'; 3408 ageStatus.style.borderLeft = ''; 3409 return; 3410 } 3411 3412 if (age < 18) { 3413 minorSection.style.display = ''; 3414 parentalConsent.style.display = 'flex'; 3415 setRequired(minorSection, true); 3416 parentalConsent.querySelector('input').required = true; 3417 ageStatusText.innerHTML = `Tu as <strong>${age} ans</strong> (mineur). Les coordonnées d'un parent/tuteur et son accord sont obligatoires.`; 3418 // Pré-remplir l'email parent avec l'email du compte s'il est vide : pour un enfant 3419 // sans adresse propre, l'email saisi à l'étape 1 est en pratique celui du parent. 3420 const form = $('form-signup'); 3421 const emailCompte = form ? (form.querySelector('input[name="email"]') || {}).value : ''; 3422 const parentEmailInp = form ? form.querySelector('input[name="parent_email"]') : null; 3423 if (parentEmailInp && !parentEmailInp.value && emailCompte) { 3424 parentEmailInp.value = emailCompte; 3425 } 3426 } else { 3427 minorSection.style.display = 'none'; 3428 parentalConsent.style.display = 'none'; 3429 setRequired(minorSection, false); 3430 parentalConsent.querySelector('input').required = false; 3431 ageStatusText.innerHTML = `Tu as <strong>${age} ans</strong> (majeur). Seul ton consentement RGPD est requis.`; 3432 } 3433} 3434 3435// Rend obligatoires (ou pas) tous les inputs d'un conteneur 3436function setRequired(container, isRequired) { 3437 if (!container) return; 3438 container.querySelectorAll('input').forEach(inp => { 3439 if (isRequired) inp.setAttribute('required', ''); 3440 else inp.removeAttribute('required'); 3441 }); 3442} 3443 3444// Inscription coach : modale allégée (email, mot de passe, prénom, nom, code coach). 3445// Un coach n'a aucune donnée joueur (pas de classement, date de naissance, etc.). 3446function openInscriptionCoach(e) { 3447 if (e) e.preventDefault(); 3448 const html = `<div class="modal-header"><h3 class="modal-title">ð¤ Créer un compte coach</h3><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 3449 <div class="modal-body"> 3450 <div class="text-dim" style="font-size:13px; margin-bottom:14px">Réservé aux coachs. Il te faut le <strong>code coach</strong> communiqué par un coach de l'académie.</div> 3451 <div id="coach-signup-error" class="auth-error" style="margin-bottom:10px"></div> 3452 <form id="form-signup-coach" data-ui-submit="doSignupCoach(event)"> 3453 <div class="form-group"><label>Prénom</label><input type="text" name="prenom" required autocomplete="given-name"></div> 3454 <div class="form-group"><label>Nom</label><input type="text" name="nom" required autocomplete="family-name"></div> 3455 <div class="form-group"><label>Email</label><input type="email" name="email" required autocomplete="email"></div> 3456 <div class="form-group"><label>Mot de passe</label> 3457 <div class="pwd-wrap" style="position:relative"> 3458 <input type="password" name="password" required minlength="6" autocomplete="new-password"> 3459 <button type="button" class="pwd-toggle" data-ui-click="togglePasswordVisibility(this)" aria-label="Afficher le mot de passe">ðï¸</button> 3460 </div> 3461 </div> 3462 <div class="form-group"><label>Code coach</label><input type="text" name="coach_code" required autocomplete="off" placeholder="Donné par un coach" style="text-transform:uppercase; letter-spacing:1px"></div> 3463 <button type="submit" class="btn btn-primary btn-full mt-1">Créer mon compte coach</button> 3464 </form> 3465 </div>`; 3466 showModal(html); 3467} 3468 3469async function doSignupCoach(e) { 3470 e.preventDefault(); 3471 const f = e.target; 3472 const errEl = $('coach-signup-error'); 3473 const showErr = (m) => { if (errEl) { errEl.textContent = m; errEl.classList.add('show'); } }; 3474 if (errEl) errEl.classList.remove('show'); 3475 const btn = f.querySelector('button[type=submit]'); 3476 btn.disabled = true; btn.textContent = 'Création...'; 3477 3478 const { error } = await db.auth.signUp({ 3479 email: f.email.value.trim(), 3480 password: f.password.value, 3481 options: { 3482 data: { 3483 coach_access_code: f.coach_code.value.trim().toUpperCase(), 3484 prenom: f.prenom.value.trim(), 3485 nom: f.nom.value.trim() 3486 } 3487 } 3488 }); 3489 3490 btn.disabled = false; btn.textContent = 'Créer mon compte coach'; 3491 3492 if (error) { 3493 const msg = (error.message || '').toLowerCase();
3494 if (msg.includes('coach')) showErr('Code coach invalide.'); 3495 else if (msg.includes('registered') || msg.includes('already')) showErr('Un compte existe déjà avec cet email.'); 3496 else showErr(TennisDomainRules.messageErreurUtilisateur(error.message)); 3497 return; 3498 } 3499 3500 closeModal(); 3501 toast('Compte coach créé ! Vérifie tes emails pour confirmer, puis connecte-toi.'); 3502 switchAuthTab('login'); 3503} 3504 3505async function doSignup(e) { 3506 e.preventDefault(); 3507 $('auth-error').classList.remove('show'); 3508 const f = e.target; 3509 const btn = f.querySelector('button[type=submit]'); 3510 3511 const age = ageFromDate(f.date_naissance.value); 3512 const isMineur = age !== null && age < 18; 3513 const erreurs = []; 3514 3515 if (!f.consentement_rgpd.checked) { 3516 erreurs.push({ champ: f.consentement_rgpd, message: 'politique de confidentialité' }); 3517 } 3518 if (isMineur) { 3519 if (!f.consentement_parental.checked) { 3520 erreurs.push({ champ: f.consentement_parental, message: 'consentement parental' }); 3521 } 3522 if (!f.parent_nom.value.trim()) { 3523 erreurs.push({ champ: f.parent_nom, message: 'nom du parent ou tuteur' }); 3524 } 3525 if (!f.parent_telephone.value.trim()) { 3526 erreurs.push({ champ: f.parent_telephone, message: 'téléphone du parent' }); 3527 } 3528 if (!f.parent_email.value.trim() || !f.parent_email.checkValidity()) { 3529 erreurs.push({ champ: f.parent_email, message: 'email valide du parent' }); 3530 } 3531 } 3532 if (signalerErreursInscription(erreurs)) return; 3533 3534 btn.disabled = true; btn.textContent = 'Création...'; 3535 3536 const codePole = f.code_pole.value.trim().toUpperCase(); 3537 const codeJoueur = /^[A-HJ-NP-Z2-9]{8}$/.test(codePole); 3538 3539 const { data: signupData, error } = await db.auth.signUp({ 3540 email: f.email.value.trim(), 3541 password: f.password.value, 3542 options: { 3543 data: { 3544 ...(codeJoueur ? { invitation_code: codePole } : { pole_access_code: codePole }), 3545 prenom: f.prenom.value.trim(), 3546 nom: f.nom.value.trim(), 3547 date_naissance: f.date_naissance.value, 3548 sexe: f.sexe.value, 3549 telephone: normaliserTelephone(f.telephone.value), 3550 classement: f.classement.value, 3551 points_forts: f.points_forts.value.trim(), 3552 axes_progres: f.axes_progres.value.trim(), 3553 parent_nom: isMineur ? f.parent_nom.value.trim() : '', 3554 parent_telephone: isMineur ? normaliserTelephone(f.parent_telephone.value) : '', 3555 parent_email: isMineur ? f.parent_email.value.trim() : '', 3556 consentement_rgpd: f.consentement_rgpd.checked, 3557 consentement_parental: isMineur ? f.consentement_parental.checked : false, 3558 politique_confidentialite_version: RGPD_POLICY_VERSION 3559 } 3560 } 3561 }); 3562 3563 btn.disabled = false; btn.textContent = 'Créer mon compte'; 3564 3565 const emailDejaUtilise = !error && signupData?.user && Array.isArray(signupData.user.identities) 3566 && signupData.user.identities.length === 0; 3567 if (error || emailDejaUtilise) { 3568 const messageErreur = error?.message || ''; 3569 if (messageErreur && (messageErreur.toLowerCase().includes('pole') || messageErreur.toLowerCase().includes('académie'))) { 3570 authError('Code d\'accès à l\'académie invalide. Demande-le à ton coach.'); 3571 } else if (messageErreur && messageErreur.toLowerCase().includes('invitation')) {
3572 authError('Code d\'invitation invalide ou expiré'); 3573 } else if (emailDejaUtilise || (messageErreur && (messageErreur.toLowerCase().includes('already') || messageErreur.toLowerCase().includes('registered')))) { 3574 authError('Cet email appartient déjà au compte familial : il ne faut pas créer un deuxième compte. Reviens sur Connexion avec cet email, puis ouvre Mon profil â Mes enfants â Ajouter un enfant et saisis son code joueur.'); 3575 } else { 3576 authError(TennisDomainRules.messageErreurUtilisateur(messageErreur)); 3577 } 3578 return; 3579 } 3580 3581 const ageTennisInscription = TennisDomainRules.ageTennistique({ date_naissance: f.date_naissance.value }, getSaisonCourante()); 3582 toast(ageTennisInscription !== null && ageTennisInscription < 12 3583 ? 'Compte créé ! Bienvenue en Pré-Académie.' 3584 : 'Compte créé ! En attente de validation par le coach.'); 3585 switchAuthTab('login'); 3586 signupNextStep(1); 3587} 3588 3589async function doLogout() { 3590 if (!(await demanderConfirmation('Se déconnecter ?', { titre: 'Déconnexion', libelle: 'Se déconnecter', danger: false }))) return; 3591 await db.auth.signOut(); 3592} 3593 3594function doForgotPassword(e) { 3595 if (e) e.preventDefault(); 3596 const emailCourant = document.querySelector('#form-login input[name="email"]')?.value.trim() || ''; 3597 showModal(`<div class="modal-header"> 3598 <h3 class="modal-title">Mot de passe oublié</h3> 3599 <button class="modal-close" data-ui-click="closeModal()" aria-label="Fermer">Ã</button> 3600 </div> 3601 <p class="text-dim" style="font-size:13px;line-height:1.45">Saisis l'adresse email de ton compte. Tu recevras un lien sécurisé pour choisir un nouveau mot de passe.</p> 3602 <div class="form-row"> 3603 <label class="form-label" for="resetEmail">Adresse email</label> 3604 <input type="email" id="resetEmail" value="${escapeHtml(emailCourant)}" autocomplete="email" required> 3605 </div> 3606 <div class="flex gap-1 mt-2"> 3607 <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button> 3608 <button class="btn btn-primary flex-1" data-ui-click="sendPasswordReset()">Envoyer le lien</button> 3609 </div>`); 3610} 3611 3612async function sendPasswordReset() { 3613 const input = $('resetEmail'); 3614 const email = input?.value.trim() || ''; 3615 if (!email || !input.checkValidity()) { 3616 input?.focus(); 3617 toast('Saisis une adresse email valide.', true); 3618 return; 3619 } 3620 const { error } = await db.auth.resetPasswordForEmail(email, { 3621 redirectTo: window.location.origin + window.location.pathname 3622 }); 3623 if (error) { 3624 toastErreur(error); 3625 } else { 3626 closeModal(); 3627 toast('Email envoyé. Vérifie ta boîte mail (et les spams).'); 3628 } 3629} 3630 3631// Détection du retour depuis un lien de reset password 3632async function handlePasswordRecovery() { 3633 // Supabase met le type=recovery dans l'URL hash après clic sur le lien email 3634 const hash = window.location.hash; 3635 if (hash && hash.includes('type=recovery')) { 3636 // On laisse l'app se charger puis on ouvre la modale propre (au lieu de l'ancien prompt) 3637 setTimeout(() => openChangePasswordModal(), 500); 3638 } 3639} 3640 3641async function loadProfile() { 3642 if (!session) return null; 3643 const { data, error } = await db 3644 .from('profiles') 3645 .select(COLONNES_PROFILE) 3646 .eq('id', session.user.id) 3647 .single(); 3648 if (error) { 3649 journal.error('loadProfile error:', error); 3650 throw new Error('PROFIL_CHARGEMENT_IMPOSSIBLE'); 3651 } 3652 return data; 3653} 3654 3655// ============================================================ 3656// CHARGEMENT DES DONNÃES (selon rôle) 3657// ============================================================ 3658// Alias direct : aucune couture locale ne peut réintroduire une première page 3659// tronquée tout en contournant le module de pagination testé. 3660const chargerToutesPages = TennisPagination.chargerToutesPages; 3661 3662// Un coach charge la table complète. Un joueur passe par une RPC qui ne renvoie 3663// jamais les inscriptions des autres joueurs : le tableau `inscrits` contient 3664// uniquement son propre identifiant lorsqu'il est inscrit. 3665function requeteTournoisVisibles() { 3666 if (!isCoach()) return db.rpc('tournois_catalogue_joueur'); 3667 const [debutSaison, finSaison] = TennisDomainRules.bornesSaison(getSaisonCourante()); 3668 return db.from('tournois').select(COLONNES_TOURNOI) 3669 .is('archived_at', null) 3670 .gte('date_fin', debutSaison) 3671 .lte('date_debut', finSaison) 3672 .order('date_debut'); 3673} 3674 3675async function loadAllData() { 3676 const donneesIndisponibles = new Set(); 3677 if (!etatApp.saisonAnalyse) etatApp.definirSaisonAnalyse(getSaisonCourante()); 3678 // Valider la saison mémorisée AVANT de borner les grosses lectures. Une 3679 // ancienne valeur de session créée par les suggestions historiques ne doit 3680 // pas ouvrir l'application sur une saison vide ni générer de fausses alertes. 3681 let saisonsInitiales = { data: [], error: null }; 3682 try { 3683 saisonsInitiales = await Promise.resolve(db.rpc('saisons_sportives_disponibles')); 3684 } catch (error) { 3685 saisonsInitiales = { data: [], error }; 3686 } 3687 const saisonsDetectees = new Set([ 3688 getSaisonCourante(), 3689 ...((listeSure(saisonsInitiales.data)).map(ligne =>
3689 ligne.saison).filter(Boolean)) 3690 ]); 3691 if (!saisonsDetectees.has(getSaisonAnalyse())) etatApp.definirSaisonAnalyse(getSaisonCourante()); 3692 const [saisonDebutChargement, saisonFinChargement] = getBornesChargementSaison(); 3693 // RLS filtre automatiquement selon le rôle/identité 3694 const queries = [ 3695 chargerToutesPages(() => db.from('joueurs').select(COLONNES_JOUEUR).order('nom')), 3696 chargerToutesPages(() => db.from('matches').select(COLONNES_MATCH).gte('date', saisonDebutChargement).lte('date', saisonFinChargement).order('date', { ascending: false })), 3697 isCoach() 3698 ? chargerToutesPages(() => requeteTournoisVisibles()) 3699 : requeteTournoisVisibles(), 3700 db.from('config').select(COLONNES_CONFIG).eq('cle', 'groupes').single(), 3701 chargerToutesPages(() => db.from('bilans').select(COLONNES_BILAN).order('saison', { ascending: false }).order('trimestre', { ascending: false })) 3702 ]; 3703 const queryLabels = ['joueurs', 'matches', 'tournois', 'configuration', 'bilans']; 3704 const inscriptionsTournoisIdx = queries.length; 3705 queries.push(chargerToutesPages(() => db.from('player_tournament_registrations').select(COLONNES_INSCRIPTION_TOURNOI).order('created_at', { ascending: false }))); 3706 queryLabels.push('inscriptions aux tournois'); 3707 const importsTournoisIdx = isCoach() ? queries.length : -1; 3708 if (isCoach()) { 3709 queries.push(chargerToutesPages(() => db.from('tournament_import_runs').select(COLONNES_IMPORT_TOURNOI).order('started_at', { ascending: false }).limit(30))); 3710 queryLabels.push('historique de synchronisation TenâUp'); 3711 } 3712 const rapprochementsTournoisIdx = isCoach() ? queries.length : -1; 3713 if (isCoach()) { 3714 queries.push(chargerToutesPages(() => db.from('tournament_merge_candidates').select(COLONNES_RAPPROCHEMENT_TOURNOI).eq('statut', 'pending').order('created_at', { ascending: false }))); 3715 queryLabels.push('rapprochements de tournois'); 3716 } 3717 // Les invitations ne sont accessibles qu'au coach (RLS) 3718 if (isCoach()) { 3719 queries.push(chargerToutesPages(() => db.from('invitations').select(COLONNES_INVITATION).order('created_at', { ascending: false }))); 3720 queryLabels.push('invitations'); 3721 } 3722 3723 // Codes d'accès (académie + coach), même table config, en une requête. 3724 // Ils ne sont chargés que pour un coach ; la RLS reste l'autorité serveur. 3725 const codeAccesQuery = isCoach() ? db.from('config').select(COLONNES_CONFIG).in('cle', ['code_acces_pole', 'code_acces_coach']) : null; 3726 if (codeAccesQuery) { queries.push(codeAccesQuery); queryLabels.push('codes dâaccès'); } 3727 3728 // Notes coach â uniquement les coachs y ont accès (RLS protège déjà , mais on évite l'appel inutile) 3729 const notesQuery = isCoach() ? chargerToutesPages(() => db.from('notes_joueur').select(COLONNES_NOTE_COACH).order('created_at', { ascending: false })) : null; 3730 if (notesQuery) { queries.push(notesQuery); queryLabels.push('notes coach'); } 3731 3732 // Préférences notifications â pour le coach on charge TOUTES les prefs (pour pouvoir vérifier celles des joueurs avant d'envoyer un email) 3733 // Pour un joueur, on charge uniquement sa propre ligne 3734 const prefsQuery = isCoach() 3735 ? chargerToutesPages(() => db.from('preferences_notifications').select(COLONNES_PREFERENCES)) 3736 : chargerToutesPages(() => db.from('preferences_notifications').select(COLONNES_PREFERENCES).eq('user_id', session.user.id)); 3737 queries.push(prefsQuery); 3738 queryLabels.push('préférences de notifications'); 3739 3740 // Dynamique du groupe (fonctions sécurisées) â uniquement pour les joueurs. 3741 // Renvoie des agrégats + un mur de belles perfs + objectif collectif + prochains 3742 // tournois du groupe, sans exposer les lignes individuelles. Le coach n'en a pas besoin. 3743 let dynGroupeQuery = null, perfsGroupeQuery = null, monteesGroupeQuery = null, objectifGroupeQuery = null, prochainsTournoisGroupeQuery = null, membresGroupeQuery = null; 3744 if (!isCoach()) { 3745 const saisonG = getSaisonCourante(); 3746 const trimG = getTrimestreCourant(); 3747 const [dDebG, dFinG] = getTrimestreDates(saisonG, trimG); 3748 // Bornes de saison (sept â août) 3749 const [sa1, sa2] = saisonG.split('-'); 3750 const saisonDebut = `${sa1}-09-01`; 3751 const saisonFin = `${sa2}-08-31`; 3752 dynGroupeQuery = db.rpc('dynamique_mon_groupe', { date_debut: dDebG, date_fin: dFinG }); 3753 perfsGroupeQuery = db.rpc('perfs_mon_groupe', { date_debut: dDebG, date_fin: dFinG }); 3754 monteesGroupeQuery = db.rpc('montees_mon_groupe', { date_debut: dDebG, date_fin: dFinG }); 3755 objectifGroupeQuery = db.rpc('objectif_collectif_groupe', { saison_debut: s
3755aisonDebut, saison_fin: saisonFin }); 3756 prochainsTournoisGroupeQuery = db.rpc('prochains_tournois_groupe'); 3757 membresGroupeQuery = db.rpc('membres_de_mon_groupe', { saison_debut: saisonDebut, saison_fin: saisonFin }); 3758 } 3759 if (dynGroupeQuery) { 3760 queries.push(dynGroupeQuery, perfsGroupeQuery, monteesGroupeQuery, objectifGroupeQuery, prochainsTournoisGroupeQuery, membresGroupeQuery); 3761 queryLabels.push('dynamique du groupe', 'performances du groupe', 'montées du groupe', 'objectif collectif', 'tournois du groupe', 'membres du groupe'); 3762 } 3763 3764 // Historique de classement (RLS : coach voit tout, joueur voit le sien) + demandes en attente 3765 const histoIdx = queries.length; 3766 queries.push(chargerToutesPages(() => db.from('classement_historique').select(COLONNES_HISTORIQUE_CLASSEMENT).order('date_changement', { ascending: true }))); 3767 queryLabels.push('historique de classement'); 3768 queries.push(chargerToutesPages(() => db.from('classement_demande').select(COLONNES_DEMANDE_CLASSEMENT).eq('statut', 'en_attente').order('created_at', { ascending: false }))); 3769 queryLabels.push('demandes de classement'); 3770 3771 // Entraînements perso (RLS : coach voit tout, joueur voit les siens) 3772 const entrainementsIdx = queries.length; 3773 queries.push(chargerToutesPages(() => db.from('entrainements').select(COLONNES_ENTRAINEMENT).gte('date', saisonDebutChargement).lte('date', saisonFinChargement).order('date', { ascending: false }))); 3774 queryLabels.push('entraînements libres'); 3775 3776 // Badges débloqués (fil d'actualité, lisible par tout l'académie) 3777 const badgesJoueursIdx = queries.length; 3778 queries.push(chargerToutesPages(() => db.from('badges_joueurs').select(COLONNES_BADGE_JOUEUR).order('obtenu_le', { ascending: false }))); 3779 queryLabels.push('badges'); 3780 3781 // Présence aux cours â planning type (RLS : coach tout, joueur ce qui le concerne) 3782 const creneauxCoursIdx = queries.length; 3783 queries.push(chargerToutesPages(() => db.from('creneaux_cours').select(COLONNES_CRENEAU_COURS).order('jour_semaine').order('heure_debut'))); 3784 queryLabels.push('créneaux de cours'); 3785 queries.push(chargerToutesPages(() => db.from('creneaux_cours_joueurs').select(COLONNES_AFFECTATION_COURS))); 3786 queryLabels.push('affectations aux cours'); 3787 3788 const pSeancesGen = Promise.all([getLundiSemaine(),getLundiSemaine(Date.now()+6048e5)].map(p_lundi=>db.rpc('generer_seances_semaine',{p_lundi}).then(({error})=>{if(error)journal.warn('[seances] génération:',error);}).catch(error=>journal.warn('[seances] génération:',error)))); 3789 3790 const results = await Promise.all(queries.map(query => Promise.resolve(query).catch(error => ({ data: null, error })))); 3791 // Une donnée secondaire indisponible conserve son fallback pour ne pas bloquer 3792 // toute l'application, mais l'erreur ne doit jamais rester silencieuse : elle 3793 // apparaît dans la console avec l'index de la requête pour permettre le diagnostic. 3794 results.forEach((result, index) => { 3795 if (result && result.error) { 3796 const label = queryLabels[index] || `données complémentaires #${index + 1}`; 3797 donneesIndisponibles.add(label); 3798 journal.warn(`[chargement ${label}]`, result.error); 3799 } 3800 }); 3801 let cursor = 6 + (isCoach() ? 2 : 0); // socle + inscriptions + journaux TenâUp coach 3802 const [joueursRes, matchesRes, tournoisRes, configRes, bilansRes] = results; 3803 // Les requêtes Supabase résolvent souvent avec { error } plutôt que de rejeter la 3804 // promesse. Sans ce contrôle, une panne pouvait être interprétée comme des tableaux 3805 // vides et produire une application incohérente au lieu de l'écran Réessayer. 3806 const critiqueErreur = [joueursRes, matchesRes].find(result => result && result.error); 3807 if (critiqueErreur) throw new Error('CHARGEMENT_DONNEES_CRITIQUES'); 3808 const invitationsRes = isCoach() ? results[cursor++] : null; 3809 const codeAccesRes = isCoach() ? results[cursor++] : null; 3810 const notesRes = isCoach() ? results[cursor++] : null; 3811 const prefsRes = results[cursor++]; 3812 const dynGroupeRes = !isCoach() ? results[cursor++] : null; 3813 const perfsGroupeRes = !isCoach() ? results[cursor++] : null; 3814 const monteesGroupeRes = !isCoach() ? results[cursor++] : null; 3815 const objectifGroupeRes = !isCoach() ? results[cursor++] : null; 3816 const prochainsTournoisGroupeRes = !isCoach() ? results[cursor++] : null; 3817 const membresGroupeRes = !isCoach() ? results[cursor++] : null; 3818 3819 cache.joueurs = listeSure(joueursRes.data); 3820 cache.matchesSaison = listeSure(matchesRes.data); // tous les matchs visibles de la saison chargée, avant filtre d'environnement 3821 cache.matches = listeSure(matchesRes.data); // périmètre courant (recalculé ci-dessous) 3822 cache.tournois = TennisDomainRules.tournoisDansSaison(tournoisRes.data, getSaisonCourante()) 3823 .filter(tournoi => !tournoi.archived_at); 3824 cache.inscriptionsTournois = listeSure(results[inscriptionsTournoisIdx]?.data); 3825 cache.importsTournois = importsTournoisIdx >= 0 ? listeSure(results[importsTournoisIdx]?.data) : []; 3826 cache.rapprochementsTournois = rapprochementsTournoisIdx >= 0 ? listeSure(results[rapprochementsTournoisIdx]?.data) : [];
3827 cache.config = configRes.data ? configRes.data.valeur : { 3828 elite: { nom: 'Ãlite', objectifMatches: 50, places: 6 }, 3829 accession: { nom: 'Accession Ãlite', objectifMatches: 40, places: 8 }, 3830 tremplin: { nom: 'Tremplin', objectifMatches: 30, places: 8 }, 3831 tremplin: { nom: 'Tremplin', objectifMatches: 30, places: 8 } 3832 }; 3833 if (cache.config) delete cache.config.espoirs; 3834 cache.invitations = listeSure((invitationsRes && invitationsRes.data)); 3835 cache.bilans = listeSure((bilansRes && bilansRes.data)); 3836 // codeAccesRes.data est un tableau (académie + coach). On extrait chaque code. 3837 const codesArr = listeSure((codeAccesRes && codeAccesRes.data)); 3838 const codePoleRow = codesArr.find(r => r.cle === 'code_acces_pole'); 3839 const codeCoachRow = codesArr.find(r => r.cle === 'code_acces_coach'); 3840 // Sécurité : une configuration absente doit échouer explicitement. Un code de 3841 // secours embarqué dans le bundle serait public et ne constituerait aucun secret. 3842 cache.codeAccesPole = codePoleRow && codePoleRow.valeur ? String(codePoleRow.valeur) : ''; 3843 cache.codeAccesCoach = codeCoachRow && codeCoachRow.valeur ? String(codeCoachRow.valeur) : ''; 3844 cache.notesCoach = listeSure((notesRes && notesRes.data)); 3845 3846 // Préférences notifications : on indexe par user_id pour accès rapide 3847 cache.preferencesNotifs = {}; 3848 (listeSure((prefsRes && prefsRes.data))).forEach(p => { 3849 cache.preferencesNotifs[p.user_id] = p; 3850 }); 3851 3852 // Dynamique du groupe (agrégats sécurisés + mur de perfs + objectif + prochains tournois, côté joueur). 3853 cache.dynamiqueGroupe = (dynGroupeRes && dynGroupeRes.data && dynGroupeRes.data[0]) || null; 3854 cache.perfsGroupe = listeSure((perfsGroupeRes && perfsGroupeRes.data)); 3855 cache.monteesGroupe = listeSure((monteesGroupeRes && monteesGroupeRes.data)); 3856 cache.objectifGroupe = (objectifGroupeRes && objectifGroupeRes.data && objectifGroupeRes.data[0]) || null; 3857 cache.prochainsTournoisGroupe = listeSure((prochainsTournoisGroupeRes && prochainsTournoisGroupeRes.data)); 3858 cache.membresGroupe = listeSure((membresGroupeRes && membresGroupeRes.data)); 3859 3860 // Historique de classement + demandes en attente (indices fixés à l'ajout des requêtes) 3861 cache.classementHistorique = listeSure(results[histoIdx] && results[histoIdx].data); 3862 cache.classementDemandes = listeSure(results[histoIdx + 1] && results[histoIdx + 1].data); 3863 cache.entrainements = listeSure(results[entrainementsIdx] && results[entrainementsIdx].data); 3864 cache.badgesJoueurs = listeSure(results[badgesJoueursIdx] && results[badgesJoueursIdx].data); 3865 cache.creneauxCours = listeSure(results[creneauxCoursIdx] && results[creneauxCoursIdx].data); 3866 cache.creneauxCoursJoueurs = listeSure(results[creneauxCoursIdx + 1] && results[creneauxCoursIdx + 1].data); 3867 // ââ PERFORMANCE : ces requêtes étaient auparavant enchaînées les unes après les 3868 // autres (chaque await attendait le précédent), ce qui ajoutait autant d'allers-retours 3869 // réseau en série au démarrage. On les lance TOUTES EN PARALLÃLE : le temps total 3870 // devient celui de la plus lente, au lieu de leur somme. 3871 // Toutes les lectures indépendantes démarrent immédiatement. Seule la lecture 3872 // des séances attend leur génération idempotente ; elle ne doit pas retarder 3873 // l'annuaire, les événements, les présences ou les données Palmyre. 3874 const pSeances = pSeancesGen.then(() => chargerToutesPages(() => db.from('seances_cours').select(COLONNES_SEANCE_COURS).gte('date', saisonDebutChargement).lte('date', saisonFinChargement).order('date', { ascending: false }))); 3875 const pPresences = chargerPresencesSaison(); 3876 const pSaisons = Promise.resolve(saisonsInitiales); 3877 const pAnnuaire = db.rpc('annuaire_pole_minimal'); 3878 // Le coach charge déjà toutes les éditions : inutile de requêter une seconde fois 3879 // les seules éditions actives, elles seront dérivées localement du même résultat. 3880 const pEvActifs = isCoach() 3881 ? Promise.resolve({ data: null }) 3882 : chargerToutesPages(() => db.from('evenements').select(COLONNES_EVENEMENT).eq('actif', true).order('date_debut', { ascending: false })); 3883 const pEvTous = isCoach() ? chargerToutesPages(() => db.from('evenements').select(COLONNES_EVENEMENT).order('date_debut', { ascending: false })) : Promise.resolve({ data: null }); 3884 const pBilansEv = chargerToutesPages(() => db.from('bilans_evenement').select(COLONNES_BILAN_EVENEMENT)); 3885 const pDebriefs = isCoach() 3886 ? chargerToutesPages(() => db.from('debriefs_palmyre').select(COLONNES_DEBRIEF_PALMYRE).order('date', { ascending: false })) 3887 : Promise.resolve({ data: [], error: null }); 3888 const pAxes = chargerToutesPages(() => db.from('axes_travail_palmyre').select(COLONNES_AXE_PALMYRE).order('created_at', { ascending: false })); 3889 const pObs = isCoach() ? chargerToutesPages(() => db.from('observations_palmyre').select(COLONNES_OBSERVATION_PALMYRE).order('created_at', { ascending: false })) : Promise.resolve({ data: [] }); 3890 const pCreneaux = chargerToutesPages(() => db.from('creneaux_palmyre').select(COLONNES_CRENEAU_PALMYRE).order('jour', { ascending: true })); 3891 3892 // NOTE IMPORTANTE : les query builders Supabase sont des "thenables" (ils ont .then()) 3893 // mais PAS de méthode .catch(). On les enveloppe donc dans Promise.resolve(...) avant 3894 // d'attacher un .catch(), sinon : "TypeError: .catch is not a function". 3895 const safe = async (q, label, fallback) => { 3896 try { 3897 const result = await Promise.resolve(q); 3898 if (result && result.error) { 3899 donneesIndisponibles.add(label); 3900 journal.warn('[' + label + '] indisponible:', result.error); 3901 return fallback; 3902 } 3903 return result || fallback; 3904 } catch (error) { 3905 donneesIndisponibles.add(label); 3906 journal.warn('[' + label + '] indisponible:', error); 3907 return fallback; 3908 } 3909 }; 3910 3911 const [rAnnuaire, rEvActifs, rEvTous, rBilansEv, rDebriefs, rAxes, rObs, rCreneaux, rSeances, rPresences, rSaisons] = await Promise.all([ 3912 safe(pAnnuaire, 'annuaire', { data: null }), 3913 safe(pEvActifs, 'evenements', { data: [] }), 3914 safe(pEvTous, 'evenements tous', { data: null }), 3915 safe(pBilansEv, 'bilans_evenement', { data: [] }), 3916 safe(pDebriefs, 'debriefs_palmyre', { data: [] }), 3917 safe(pAxes, 'axes_travail_palmyre', { data: [] }), 3918 safe(pObs, 'observations_palmyre', { data: [] }), 3919 safe(pCreneaux, 'creneaux_palmyre', { data: [] }), 3920 safe(pSeances, 'seances_cours', { data: [] }), 3921 safe(pPresences, 'presences', { data: [] }), 3922 safe(pSaisons, 'saisons sportives', { data: [] }) 3923 ]); 3924 3925 cache.seancesCours = listeSure((rSeances && rSeances.data)); 3926 cache.presences = listeSure((rPresences && rPresences.data)); 3927 cache.saisonsDisponibles = [...new Set([ 3928 getSaisonCourante(),
3929 ...(listeSure((rSaisons && rSaisons.data))).map(ligne => ligne.saison).filter(Boolean), 3930 ...cache.bilans.map(bilan => bilan.saison).filter(Boolean) 3931 ])].filter(saison => TennisDomainRules.bornesSaison(saison)[0]).sort((a, b) => b.localeCompare(a)); 3932 3933 cache.annuaire = {}; 3934 cache.annuaireGroupe = {}; 3935 (listeSure((rAnnuaire && rAnnuaire.data))).forEach(p => { 3936 cache.annuaire[p.id] = p.initiale ? `${p.prenom} ${p.initiale}.` : p.prenom; 3937 if (p.groupe) cache.annuaireGroupe[p.id] = p.groupe; 3938 }); 3939 3940 cache.evenementsTous = (rEvTous && rEvTous.data) || listeSure((rEvActifs && rEvActifs.data)); 3941 cache.evenements = isCoach() 3942 ? cache.evenementsTous 3943 : (listeSure((rEvActifs && rEvActifs.data))); 3944 3945 cache.bilansEvenement = listeSure((rBilansEv && rBilansEv.data)); 3946 3947 // Débriefs du soir (synthèses IA) et observations Palmyre â coach uniquement (RLS). 3948 cache.debriefsPalmyre = listeSure((rDebriefs && rDebriefs.data)); 3949 cache.observationsPalmyre = listeSure((rObs && rObs.data)); 3950 3951 // Axes de travail Palmyre (partagés avec le joueur) : coach ET joueur (RLS filtre). 3952 cache.axesPalmyre = listeSure((rAxes && rAxes.data)); 3953 3954 // Créneaux Palmyre (planning & logistique du stage). RLS : coach voit tout, 3955 // joueur voit les siens. 3956 cache.creneauxPalmyre = listeSure((rCreneaux && rCreneaux.data)); 3957 cache.donneesIndisponibles = [...donneesIndisponibles]; 3958 3959 // Une édition archivée reste chargée pour ses bilans, mais ne peut plus être 3960 // utilisée comme environnement de travail. 3961 etatApp.validerEnvironnement((listeSure(cache.evenements)).filter(evenement => evenement.actif).map(evenement => evenement.cle)); 3962 3963 // Applique le périmètre de matchs selon l'environnement courant, puis recalcule 3964 // les caches dérivés (alertes, belles perfs) sur ce périmètre. 3965 appliquerPerimetreMatchs(); 3966 cache.alertes = computeAlertes(); 3967 cache.bellePerfs = computeBellePerfs(); 3968 await chargerUrlsAvatarsSignees(); 3969} 3970 3971async function reloadTables(tables) { 3972 const [saisonDebutChargement, saisonFinChargement] = getBornesChargementSaison(); 3973 const map = { 3974 matches: { q: () => db.from('matches').select(COLONNES_MATCH).gte('date', saisonDebutChargement).lte('date', saisonFinChargement).order('date', { ascending: false }), set: d => { cache.matchesSaison = d; } }, 3975 entrainements: { q: () => db.from('entrainements').select(COLONNES_ENTRAINEMENT).gte('date', saisonDebutChargement).lte('date', saisonFinChargement).order('date', { ascending: false }), set: d => { cache.entrainements = d; } }, 3976 presences: { q: () => chargerPresencesSaison(), set: d => { cache.presences = d; } }, 3977 seances_cours: { q: () => db.from('seances_cours').select(COLONNES_SEANCE_COURS).gte('date', saisonDebutChargement).lte('date', saisonFinChargement).order('date', { ascending: false }), set: d => { cache.seancesCours = d; } }, 3978 creneaux_cours: { q: () => db.from('creneaux_cours').select(COLONNES_CRENEAU_COURS).order('jour_semaine').order('heure_debut'), set: d => { cache.creneauxCours = d; } }, 3979 creneaux_cours_joueurs:{ q: () => db.from('creneaux_cours_joueurs').select(COLONNES_AFFECTATION_COURS), set: d => { cache.creneauxCoursJoueurs = d; } }, 3980 tournois: { q: () => requeteTournoisVisibles(), set: d => { 3981 cache.tournois = TennisDomainRules.tournoisDansSaison(d, getSaisonCourante()) 3982 .filter(tournoi => !tournoi.archived_at); 3983 } }, 3984 bilans: { q: () => db.from('bilans').select(COLONNES_BILAN).order('saison', { ascending: false }).order('trimestre', { ascending: false }), set: d => { cache.bilans = d; } }, 3985 notes_joueur: { q: () => isCoach() ? db.from('notes_joueur').select(COLONNES_NOTE_COACH).order('created_at', { ascending: false }) : Promise.resolve({ data: [], error: null }), set: d => { cache.notesCoach = d; } }, 3986 joueurs: { q: () => db.from('joueurs').select(COLONNES_JOUEUR).order('nom'), set: d => { cache.joueurs = d; } }, 3987 notifications: { q: () => db.from('notifications').select(COLONNES_NOTIFICATION).eq('user_id', session?.user?.id).order('created_at', { ascending: false }).limit(50), set: d => { cache.notifications = d; } }, 3988 inscriptions_tournois: { q: () => db.from('player_tournament_registrations').select(COLONNES_INSCRIPTION_TOURNOI).order('created_at', { ascending: false }), set: d => { cache.inscriptionsTournois = d; } }, 3989 imports_tournois: { q: () => isCoach() ? db.from('tournament_import_runs').select(COLONNES_IMPORT_TOURNOI).order('started_at', { ascending: false }).limit(30) : Promise.resolve({ data:[], error:null }), set: d => { cache.importsTournois = d; } }, 3990 invitations: { q: () => isCoach() ? db.from('invitations').select(COLONNES_INVITATION).order('created_at', { ascending: false }) : Promise.resolve({ data: [], error: null }), set: d => { cache.invitations = d; } }, 3991 preferences_notifications: { q: () => isCoach() ? db.from('preferences_notifications').select(COLONNES_PREFERENCES) : db.from('preferences_notifications').select(COLONNES_PREFERENCES).eq('user_id', session.user.id), set: d => { cache.preferencesNotifs = Object.fromEntries(d.map(p => [p.user_id, p])); } }, 3992 classement_historique: { q: () => db.from('classement_historique').select(COLONNES_HISTORIQUE_CLASSEMENT).order('date_changement', { ascending: true }), set: d => { cache.classementHistorique = d; } }, 3993 classement_demandes: { q: () => db.from('classement_demande').select(COLONNES_DEMANDE_CLASSEMENT).eq('statut', 'en_attente').order('created_at', { ascending: false }), set: d => { cache.classementDemandes = d; } }, 3994 creneaux_palmyre: { q: () => db.from('creneaux_palmyre').select(COLONNES_CRENEAU_PALMYRE).order('jour', { ascending: true }), set: d => { cache.creneauxPalmyre = d; } }, 3995 observations_palmyre: { q: () => db.from('observations_palmyre').select(COLONNES_OBSERVATION_PALMYRE).order('created_at', { ascending: false }), set: d => { cache.observationsPalmyre = d; } }, 3996 axes_travail_palmyre: { q: () => db.from('axes_travail_palmyre').select(COLONNES_AXE_PALMYRE).order('created_at', { ascending: false }), set: d => { cache.axesPalmyre = d; } }, 3997 badges_joueurs: { q: () => db.from('badges_joueurs').select(COLONNES_BADGE_JOUEUR).order('obtenu_le', { ascending: false }), set: d => { cache.badgesJoueurs = d; } }, 3998 bilans_evenement: { q: () => db.from('bilans_evenement').select(COLONNES_BILAN_EVENEMENT), set
3998: d => { cache.bilansEvenement = d; } }, 3999 debriefs_palmyre: { q: () => isCoach() ? db.from('debriefs_palmyre').select(COLONNES_DEBRIEF_PALMYRE).order('date', { ascending: false }) : Promise.resolve({ data: [], error: null }), set: d => { cache.debriefsPalmyre = d; } }, 4000 evenements: { q: () => db.from('evenements').select(COLONNES_EVENEMENT).order('date_debut', { ascending: false }), set: d => { cache.evenementsTous = d; cache.evenements = isCoach() ? d : d.filter(ev => ev.actif); } }, 4001 config: { q: () => db.from('config').select(COLONNES_CONFIG).in('cle', isCoach() ? ['groupes', 'code_acces_coach'] : ['groupes']), set: d => { 4002 const groupes = d.find(row => row.cle === 'groupes'); 4003 if (groupes && groupes.valeur) cache.config = groupes.valeur; 4004 if (cache.config) delete cache.config.espoirs; 4005 if (isCoach()) { 4006 const codeCoach = d.find(row => row.cle === 'code_acces_coach'); 4007 cache.codeAccesPole = ''; 4008 if (codeCoach) cache.codeAccesCoach = String(codeCoach.valeur || '').trim().toUpperCase(); 4009 } 4010 } } 4011 }; 4012 const cibles = tables.filter(t => map[t]); 4013 if (cibles.length === 0) return; 4014 const sansPagination = new Set(['notifications', 'config', 'presences']); 4015 const results = await Promise.all(cibles.map(t => Promise.resolve( 4016 sansPagination.has(t) ? map[t].q() : chargerToutesPages(map[t].q) 4017 ).catch(error => ({ data: null, error })))); 4018 const echecs = []; 4019 cibles.forEach((t, i) => { 4020 const result = results[i]; 4021 if (!result || result.error) { 4022 echecs.push(t); 4023 journal.warn('[rechargement] ' + t + ' conservé dans le cache :', result && result.error); 4024 return; 4025 } 4026 map[t].set(listeSure(result.data)); 4027 }); 4028 const donneesIndisponibles = new Set(cache.donneesIndisponibles || []); 4029 cibles.forEach(t => { 4030 if (echecs.includes(t)) donneesIndisponibles.add(t); 4031 else donneesIndisponibles.delete(t); 4032 }); 4033 cache.donneesIndisponibles = [...donneesIndisponibles]; 4034 if (echecs.length) { 4035 toast(`Enregistrement effectué, mais lâaffichage de ${echecs.join(', ')} nâa pas pu être actualisé. Ne ressaisis pas la donnée : reconnecte-toi puis recharge.`, true); 4036 } 4037 4038 if (cibles.includes('matches')) { 4039 appliquerPerimetreMatchs(); 4040 cache.alertes = computeAlertes(); 4041 cache.bellePerfs = computeBellePerfs(); 4042 } 4043 if (cibles.includes('joueurs') || cibles.includes('classement_historique')) { 4044 cache.alertes = computeAlertes(); 4045 cache.bellePerfs = computeBellePerfs(); 4046 } 4047 if (cibles.includes('joueurs') && !echecs.includes('joueurs')) { 4048 await chargerUrlsAvatarsSignees(); 4049 updateHeaderAvatar(); 4050 } 4051 // Notifications : rafraîchir le badge. 4052 if (cibles.includes('notifications') && typeof updateNotifBadge === 'function') { 4053 updateNotifBadge(); 4054 } 4055 return { reussis: cibles.filter(t => !echecs.includes(t)), echecs }; 4056} 4057 4058// Progression de classement d'un joueur sur une période, en nombre d'échelons gagnés 4059// (positif = montée). Basée sur l'historique des changements validés. 4060// On somme les variations d'index sur la période ; un retour en arrière annule. 4061function progressionClassementPeriode(joueurId, periode) { 4062 const [debut, fin] = periode ? getPeriodeDates(periode) : getSaisonDates(); 4063 return TennisDomainRules.progressionClassementSurPeriode( 4064 historiqueClassementDuJoueur(joueurId), 4065 debut, 4066 fin 4067 ); 4068} 4069 4070// Classement d'un joueur à UNE DATE donnée (YYYY-MM-DD), reconstitué depuis l'historique. 4071// On prend le classement de l'entrée d'historique la plus récente antérieure ou égale à 4072// la date du match. Si aucune (match antérieur à tout historique), on retombe sur le 4073// classement actuel du joueur. Sert à évaluer perfs/contre-perfs avec le bon classement 4074// d'époque plutôt que le classement actuel. 4075function classementADate(joueurId, dateMatch) { 4076 const j = joueurById(joueurId); 4077 if (!j) return 'NC'; 4078 return TennisDomainRules.classementADate(j, historiqueClassementDuJoueur(joueurId), dateMatch); 4079} 4080 4081// Statistiques de ressenti sur un ensemble de matchs. 4082// Le ressenti (m.ressenti) est une note déclarative de 1 à 5 (ððððð¥), facultative. 4083// Retourne un objet riche, ou { renseigne:false } si aucun match noté. 4084// IMPORTANT : info de LECTURE uniquement â ne jamais injecter dans le score de groupe 4085// ni les alertes (donnée subjective/déclarative). 4086function emojiRessenti(note) { 4087 return TennisDomainRules.emojiRessenti(note); 4088} 4089function statsRessenti(matchs) { 4090 return TennisDomainRules.statistiquesRessenti(matchs); 4091} 4092 4093// Petit bloc HTML réutilisable affichant les stats de ressenti. 4094function renderRessentiBloc(stats) { 4095 if (!stats || !stats.renseigne) { 4096 return `<div class="text-faint" style="font-size:12px; padding:8px 0">Ressenti non renseigné sur cette période.</div>`; 4097 } 4098 const flecheColor = stats.tendance === 'hausse' ? 'var(--success-text, #1d8a5a)' : stats.tendance === 'baisse' ? 'var(--danger-text, #c0392b)' : 'var(--text-faint, #8a9794)'; 4099 const tendanceLabel = stats.tendance === 'hausse' ? 'en hausse' : stats.tendance === 'baisse' ? 'en baisse' : 'stable'; 4100 return `<div class="feeling-strip"> 4101 <div class="feeling-score"><span class="feeling-gauge" style="--score:${stats.moyenne}"><strong>${stats.moyenneTexte}</strong></span><div><strong>${stats.moyenneTexte}/5</strong><div class="text-faint" style="font-size:11px">${stats.nbNotes} match${stats.nbNotes > 1 ? 's' : ''} noté${stats.nbNotes > 1 ? 's' : ''}</div></div></div> 4102 ${stats.moyVictoireTexte != null ? `<div class="feeling-split" style="--tone:#1d9e75">En victoire <strong>${stats.moyVictoireTexte}</strong></div>` : ''} 4103 ${stats.moyDefaiteTexte != null ? `<div class="feeling-split" style="--tone:#ff7900">En défaite <strong>${stats.moyDefaiteTexte}</strong></div>` : ''} 4104 ${stats.deltaTendance !== 0 || stats.nbNotes >= 4 ? `<div class="trend-signal" style="color:${flecheColor}">${tendanceLabel}</div>` : ''} 4105 </div>`; 4106} 4107 4108// ============================================================ 4109// BADGES (gamification) â calcul à la volée sur la saison en cours 4110// ============================================================ 4111// Aucune donnée stockée : tout se recalcule depuis matches/entrainements/classement. 4112// Paliers : bronze â argent â or â diamant. Certains badges sont "uniques" (exploit). 4113const BADGE_PALIERS = ['ð¥', 'ð¥', 'ð¥', 'ð']; 4114const BADGE_PALIERS_NOMS = ['Bronze', 'Argent', 'Or', 'Diamant']; 4115 4116// Catalogue : chaque badge a une piste, un libellé, une icône, et soit des paliers 4117// (seuils), soit un déclenchement unique (type 'exploit'). 4118const BADGES_CATALOGUE = [ 4119 // --- Piste ASSIDUITà --- 4120 { id: 'heures', piste: 'Assiduité', icone: 'â±ï¸', label: 'Heures d\'entraînement', unite: 'h', paliers: [20, 50, 100, 200] }, 4121 { id: 'seances', piste: 'Assiduité', icone: 'ð¾', label: 'Séances déclarées', unite: '', paliers: [10, 25, 50, 100] }, 4122 { id: 'sans_faute', piste: 'Assiduité', icone: 'ð', label: 'Régularité', unite: ' sem.', paliers: [4, 12, 26, 52] }, 4123 // --- Piste COMPÃTITEUR --- 4124 { id: 'matchs', piste: 'Compétiteur', icone: 'ð½', label: 'Matchs joués', unite: '', paliers: [10, 25, 50, 100] }, 4125 { id: 'victoires', piste: 'Compétiteur', icone: 'â ', label: 'Victoires', unite: '', paliers: [5, 15, 30, 60] }, 4126 { id: 'serie', piste: 'Compétiteur', icone: 'ð¥', label: 'Série de victoires', unite: ' d\'affilée', paliers: [3, 5, 10] }, 4127 // --- Piste PERFORMANCE --- 4128 { id: 'perfs', piste: 'Performance', icone: 'â', label: 'Belles perfs', unite: '', paliers: [1, 5, 15, 30] }, 4129 { id: 'belles_defaites', piste: 'Performance', icone: 'ð¥', label: 'Beaux combats', unite: '', paliers: [1, 5, 15, 30] }, 4130 // --- Badge spécial relatif au groupe --- 4131 { id: 'objectif_groupe', piste: 'Objectif', icone: 'ð ', label: 'Ma part dans l\'objectif du groupe', desc: 'Atteindre ton objectif de matchs individuel, qui compte pour le groupe', type: 'exploit' }, 4132 // --- Piste INSOLITES (one-shot, fun) --- 4133 { id: 'semaine_parfaite', piste: 'Insolites', icone: 'ð¯', label: 'Semaine parfaite', desc: '7 entraînements en 7 jours', type: 'exploit' }, 4134 { id: 'dragon', piste: 'Insolites', icone: 'ð', label: 'Tombeur de dragon', desc: 'Battre un j
4134oueur classé 3 échelons ou plus au-dessus', type: 'exploit' }, 4135 { id: 'remontada', piste: 'Insolites', icone: 'ð¢', label: 'Montagnes russes', desc: 'Gagner 3 matchs après avoir perdu le 1er set', type: 'exploit' }, 4136 { id: 'mur', piste: 'Insolites', icone: 'ð§±', label: 'Le Mur', desc: '5 victoires d\'affilée sans perdre un set', type: 'exploit' }, 4137 { id: 'tornade', piste: 'Insolites', icone: 'ðªï¸', label: 'Tornade', desc: 'Gagner un match 6/0 6/0', type: 'exploit' }, 4138 { id: 'onfire', piste: 'Insolites', icone: 'ð¥', label: 'On fire', desc: '3 belles perfs dans le même mois', type: 'exploit' }, 4139 { id: 'marathonien', piste: 'Insolites', icone: 'ð', label: 'Marathonien', desc: '5 matchs ou plus dans la même semaine', type: 'exploit' }, 4140 { id: 'mental_acier', piste: 'Insolites', icone: 'ðª', label: 'Mental en acier', desc: 'Gagner un match en 3 sets avec un dernier set au tie-break (7/6)', type: 'exploit' }, 4141 { id: 'infatigable', piste: 'Insolites', icone: 'ð', label: 'Infatigable', desc: '3 matchs dans la même journée', type: 'exploit' }, 4142 { id: 'matin', piste: 'Insolites', icone: 'â°', label: 'Lève-tôt', desc: 'Un entraînement commencé avant 8h du matin', type: 'exploit' }, 4143 { id: 'extreme', piste: 'Insolites', icone: 'ð ', label: 'Assidu de l\'extrême', desc: '10 entraînements dans le mois', type: 'exploit' }, 4144 { id: 'ascension2', piste: 'Insolites', icone: 'ðï¸', label: 'L\'ascension', desc: 'Monter de 2 échelons ou plus dans la saison', type: 'exploit' } 4145]; 4146 4147const { badgeArtHtml, rankArtHtml, progressArtHtml, levelArtHtml, PISTE_ART_ASSETS, sportNavIcon, sportUiIcon, matchChoiceIcon, EMOJI_ICON_KEYS } = TennisIconography.creer({ escapeHtml, badgePalierNoms: BADGE_PALIERS_NOMS }); 4148 4149// Parse un score "6/4 6/1" â [{moi, adv}, ...]. Tolérant (ignore les sets mal formés). 4150function parseScoreSets(scoreStr) { 4151 return TennisDomainRules.parseScoreSets(scoreStr); 4152} 4153 4154// "Belle défaite" (beau combat) : une DÃFAITE en simple (hors WO) contre un 4155// adversaire MIEUX classé (au moins 1 échelon au-dessus) où le joueur a tout de même 4156// pris AU MOINS UN SET. Symétrique de la belle perf, pour valoriser le combat. 4157// Nécessite un score exploitable (sets lisibles) â d'où l'intérêt de la saisie 4158// structurée qui garantit un format propre. 4159function estBelleDefaite(m, joueurId) { 4160 return TennisDomainRules.estBelleDefaite(m, classementADate(joueurId, m && m.date)); 4161} 4162 4163// Calcule la plus longue série de victoires (matchs triés par date, WO ignorés). 4164function plusLongueSerieVictoires(matchs) { 4165 return TennisDomainRules.plusLongueSerieVictoires(matchs); 4166} 4167 4168// Retourne, pour un joueur et une saison, l'état de chaque badge. 4169// Adaptateur UI : collecte les données du cache puis confie toutes les règles de 4170// déblocage, paliers et progression au moteur métier pur et testé. 4171function calculerBadges(joueurId, saison) { 4172 const joueur = joueurById(joueurId); 4173 if (!joueur) return []; 4174 const saisonCible = saison || getSaisonAnalyse(); 4175 const morceaux = saisonCible.match(/^(\d{4})-(\d{4})$/); 4176 if (!morceaux) return []; 4177 const debut = `${morceaux[1]}-09-01`; 4178 const fin = `${morceaux[2]}-08-31`; 4179 const matches = matchesEntreDate(joueurId, debut, fin).map(match => ({ 4180 ...match, 4181 classement_joueur_date: classementADate(joueurId, match.date) 4182 })); 4183 const entrainements = entrainementsDuJoueur(joueurId) 4184 .filter(item => TennisDomainRules.dateDansIntervalle(item.date, debut, fin)); 4185 return TennisDomainRules.calculerBadgesDepuisDonnees({ 4186 matches, 4187 entrainements, 4188 classementDebut: classementADate(joueurId, debut), 4189 classementFin: classementADate(joueurId, fin), 4190 objectifMatches: (cache.config[joueur.groupe] && cache.config[joueur.groupe].objectifMatches) || 0, 4191 catalogue: BADGES_CATALOGUE, 4192 iconesPaliers: BADGE_PALIERS, 4193 nomsPaliers: BADGE_PALIERS_NOMS 4194 }); 4195} 4196 4197const XP_BAREME = { 4198 entrainement: 10, // déclarer une séance 4199 presenceDeclaree: 5, 4200 matchJoue: 20, // jouer un match (quel que soit le résultat) 4201 matchGagne: 15, // bonus victoire 4202 perfParEchelon: 20, // une belle perf rapporte 20 XP par échelon d'écart 4203 perfMaxEchelons: 12, // filet anti-erreur de saisie : 240 XP maximum par perf 4204 belleDefaite: 15, // beau combat : défaite en ayant pris un set à plus fort 4205 badgeInsolite: 50, // débloquer un insolite 4206 palier: { 1: 40, 2: 80, 3: 150, 4: 300 } // bronze, argent, or, diamant 4207}; 4208 4209const NIVEAUX = [ 4210 { niveau: 1, titre: 'Débutant', seuil: 0 }, 4211 { niveau: 2, titre: 'Apprenti', seuil: 100 }, 4212 { niveau: 3, titre: 'Joueur confirmé', seuil: 250 }, 4213 { niveau: 4, titre: 'Compétiteur', seuil: 450 }, 4214 { niveau: 5, titre: 'Compétiteur aguerri', seuil: 700 }, 4215 { niveau: 6, titre: 'Battant', seuil: 1000 }, 4216 { niveau: 7, titre: 'Pilier de l\'académie', seuil: 1400 }, 4217 { niveau: 8, titre: 'Redoutable', seuil: 1900 }, 4218 { niveau: 9, titre: 'Maître du court', seuil: 2500 }, 4219 { niveau: 10, titre: 'Champion', seuil: 3200 }, 4220 { niveau: 11, titre: 'Champion d\'élite', seuil: 4100 }, 4221 { niveau: 12, titre: 'Légende du club', seuil: 5200 } 4222]; 4223 4224function calculerXP(joueurId, saison) { 4225 saison = saison || getSaisonAnalyse(); 4226 // Les badges sont dérivés des données sportives sources : l'XP ne dépend 4227 // jamais d'une auto-attribution persistée par le navigateur. 4228 const badgesCalcules = calculerBadges(joueurId, saison).flatMap(badge => { 4229 if (!badge.debloque) return []; 4230 if (badge.type === 'exploit') return [{ badge_id: badge.id, palier: null, saison }]; 4231 return Array.from({ length: badge.niveau || 0 }, (_, index) => ({ 4232 badge_id: badge.id, palier: index + 1, saison 4233 })); 4234 }); 4235 return TennisDomainRules.calculerXPDepuisDonnees({ 4236 saison, 4237 bareme: XP_BAREME, 4238 catalogue: BADGES_CATALOGUE, 4239 entrainements: entrainementsDuJoueur(joueurId), 4240 presences: presencesDuJoueur(joueurId).map(p => ({ ...p, date: seanceCoursById(p.seance_id)?.date })), 4241 matches: matchsDuJoueur(joueurId).map(match => ({ 4242 ...match, 4243 classement_joueur_date: classementADate(joueurId, match.date) 4244 })), 4245 badges: badgesCalcules 4246 }); 4247} 4248 4249// Détermine le niveau atteint pour un total d'XP, + progression ver
4249s le suivant. 4250function niveauDepuisXP(xp) { 4251 return TennisDomainRules.niveauDepuisXP(xp, NIVEAUX); 4252} 4253 4254// Détecte les badges nouvellement débloqués pour un joueur et les enregistre dans 4255// badges_joueurs (alimente le fil d'actualité de l'académie). Idempotent : n'insère que 4256// ce qui n'existe pas déjà (contrainte d'unicité en base + filtre local). 4257// Pour les badges à paliers, on enregistre chaque palier franchi (palier = niveau). 4258async function synchroniserBadges(joueurId) { 4259 if (!joueurId) return; 4260 // Les joueurs voient leurs badges calculés immédiatement, mais seule une 4261 // session coach persiste les médailles dans le fil collectif. 4262 if (!isCoach()) return; 4263 const saison = getSaisonCourante(); 4264 const badges = calculerBadges(joueurId, saison); 4265 const dejaEnreg = badgesDuJoueurPourSaison(joueurId, saison); 4266 4267 const aInserer = []; 4268 badges.forEach(b => { 4269 if (!b.debloque) return; 4270 if (b.type === 'exploit') { 4271 const existe = dejaEnreg.some(x => x.badge_id === b.id && x.palier == null); 4272 if (!existe) aInserer.push({ joueur_id: joueurId, badge_id: b.id, palier: null, saison }); 4273 } else { 4274 // Enregistrer chaque palier atteint jusqu'au niveau courant 4275 for (let p = 1; p <= b.niveau; p++) { 4276 const existe = dejaEnreg.some(x => x.badge_id === b.id && x.palier === p); 4277 if (!existe) aInserer.push({ joueur_id: joueurId, badge_id: b.id, palier: p, saison }); 4278 } 4279 } 4280 }); 4281 4282 if (aInserer.length === 0) return; 4283 const { data, error } = await db.rpc('coach_inserer_badges', { p_badges: aInserer }).select(COLONNES_BADGE_JOUEUR); 4284 if (error) { journal.warn('[badges] sync:', error); return; } 4285 // Mettre à jour le cache local 4286 if (data && data.length) cache.badgesJoueurs = [...data, ...(listeSure(cache.badgesJoueurs))]; 4287} 4288 4289// Synchronise les badges ET célèbre le joueur : nouveaux badges + montée de niveau. 4290// `niveauAvant` = numéro de niveau avant l'action (capturé par l'appelant avant save). 4291// Renvoie le nombre de nouveaux badges. Déclenche les animations (enchaînées). 4292async function synchroniserBadgesEtCelebrer(joueurId, etatAvant) { 4293 // etatAvant : { niveau, xp } capturé AVANT l'action (ou null si pas le joueur lui-même). 4294 // Rétrocompat : si un simple nombre est passé, on le traite comme le niveau avant. 4295 let niveauAvant = null, xpAvant = null; 4296 if (etatAvant != null) { 4297 if (typeof etatAvant === 'object') { niveauAvant = etatAvant.niveau; xpAvant = etatAvant.xp; } 4298 else { niveauAvant = etatAvant; } 4299 } 4300 4301 const cleAvant = new Set((listeSure(cache.badgesJoueurs)) 4302 .filter(x => x.joueur_id === joueurId) 4303 .map(x => `${x.badge_id}|${x.palier == null ? '' : x.palier}`)); 4304 await synchroniserBadges(joueurId); 4305 const apresList = badgesDuJoueur(joueurId); 4306 const nouveauxRows = apresList.filter(x => !cleAvant.has(`${x.badge_id}|${x.palier == null ? '' : x.palier}`)); 4307 4308 const xpApres = calculerXP(joueurId).xp; 4309 4310 // Popup flottant "+X XP" : feedback immédiat, affiché en premier. 4311 if (xpAvant != null) { 4312 const gain = xpApres - xpAvant; 4313 if (gain > 0) animerGainXP(gain); 4314 } 4315 4316 const file = []; // file d'animations à enchaîner (badges puis level-up) 4317 4318 if (nouveauxRows.length > 0) { 4319 nouveauxRows.forEach(row => { 4320 const def = BADGES_CATALOGUE.find(b => b.id === row.badge_id); 4321 if (!def) return; 4322 const estPalier = row.palier != null && def.paliers; 4323 file.push({ 4324 type: 'badge', 4325 badgeId: def.id, 4326 icone: def.icone, 4327 label: def.label, 4328 palierNom: estPalier ? (BADGE_PALIERS_NOMS[row.palier - 1] || '') : '', 4329 palierIcone: estPalier ? (BADGE_PALIERS[row.palier - 1] || '') : '', 4330 palierIndex: estPalier ? row.palier - 1 : null, 4331 insolite: def.piste === 'Insolites' 4332 }); 4333 }); 4334 } 4335 4336 // Montée de niveau : comparer le niveau avant/après l'action 4337 if (niveauAvant != null) { 4338 const nApres = niveauDepuisXP(xpApres); 4339 if (nApres.niveau > niveauAvant) { 4340 file.push({ type: 'levelup', niveau: nApres.niveau, titre: nApres.titre }); 4341 } 4342 } 4343 4344 // On laisse le popup XP vivre seul un court instant avant les grosses animations. 4345 if (file.length > 0) { 4346 if (xpAvant != null && (xpApres - xpAvant) > 0) setTimeout(() => celebrerProgression(file), 900); 4347 else celebrerProgression(file); 4348 } 4349 return nouveauxRows.length; 4350} 4351 4352// ⨠Popup flottant "+X XP" : surgit au centre, façon lointain â grossit en 4353// s'approchant, puis monte et s'estompe. Purement visuel, n'empêche aucune action. 4354function animerGainXP(montant) { 4355 const el = document.createElement('div'); 4356 el.textContent = `+${montant} XP`; 4357 el.style.cssText = `position:fixed; top:42%; left:50%; z-index:99998; pointer-events:none; 4358 font-size:42px; font-weight:900; color:#FFD23F; white-space:nowrap; 4359 text-shadow:0 2px 12px rgba(243,146,0,0.7), 0 0 24px rgba(255,210,63,0.5); 4360 transform:translate(-50%,-50%) scale(0.2); opacity:0; 4361 animation:gainXpFloat 1.6s cubic-bezier(0.18,0.7,0.3,1) forwards;`; 4362 document.body.appendChild(el); 4363 setTimeout(() => el.remove(), 1700); 4364} 4365 4366// Enchaîne une file de célébrations (badges puis montée de niveau). 4367function celebrerProgression(file) { 4368 if (!file || !file.length) return; 4369 if (document.getElementById('deblocageOverlay')) return; 4370 const badges = file.filter(f => f.type === 'badge'); 4371 const levelup = file.find(f => f.type === 'levelup'); 4372 // On affiche d'abord les badges, puis le level-up à la suite. 4373 if (badges.length > 0) { 4374 celebrerDeblocage(badges, levelup ? () => celebrerLevelUp(levelup) : null); 4375 } else if (levelup) { 4376 celebrerLevelUp(levelup); 4377 } 4378} 4379 4380// ð Animation plein écran de déblocage de badge(s). 4381// Fond sombre, rayons de lumière rotatifs, badge géant qui pulse, confettis. 4382// Si plusieurs badges, on les enchaîne. Tap n'importe où pour passer/fermer. 4383function celebrerDeblocage(badges, onFini) { 4384 if (!badges || !badges.length) { if (onFini) onFini(); return; } 4385 // Ãviter les superpositions : si une célébration est déjà à l'écran, on ne réempile pas. 4386 if (document.getElementById('deblocageOverlay')) return; 4387 4388 let index = 0;
4389 const overlay = document.createElement('div'); 4390 overlay.id = 'deblocageOverlay'; 4391 overlay.style.cssText = `position:fixed; inset:0; z-index:99999; display:flex; align-items:center; justify-content:center; 4392 background:radial-gradient(circle at center, rgba(20,18,30,0.92), rgba(8,6,12,0.97)); backdrop-filter:blur(3px); 4393 opacity:0; transition:opacity 0.3s ease; overflow:hidden; cursor:pointer;`; 4394 4395 // Génère les confettis (petits rectangles colorés qui tombent en tournant) 4396 const couleurs = ['#F39200', '#1D9E75', '#378ADD', '#7F77DD', '#E94F64', '#FFD23F']; 4397 let confettisHtml = ''; 4398 for (let i = 0; i < 70; i++) { 4399 const c = couleurs[i % couleurs.length]; 4400 const left = Math.random() * 100; 4401 const delay = Math.random() * 0.6; 4402 const dur = 1.8 + Math.random() * 1.6; 4403 const size = 6 + Math.random() * 8; 4404 const rot = Math.random() * 360; 4405 confettisHtml += `<div style="position:absolute; top:-20px; left:${left}%; width:${size}px; height:${size * 0.4}px; 4406 background:${c}; opacity:0; transform:rotate(${rot}deg); 4407 animation:confettiFall ${dur}s linear ${delay}s infinite;"></div>`; 4408 } 4409 4410 function contenuBadge(b) { 4411 const visuel = b.insolite 4412 ? badgeArtHtml(b.badgeId, { size: 210, label: b.label }) 4413 : (b.palierIndex != null ? rankArtHtml(b.palierIndex, { size: 210 }) : sportUiIcon('trophy', '', 'ui-icon-art')); 4414 const sousTitre = b.palierNom 4415 ? `<div style="font-size:16px; color:#dfff35; font-weight:800; margin-top:6px;letter-spacing:1.5px;text-transform:uppercase">${escapeHtml(b.palierNom)}</div>` 4416 : (b.insolite ? `<div style="font-size:13px; color:#bdb6d8; font-weight:600; letter-spacing:1px; margin-top:2px">BADGE INSOLITE</div>` : ''); 4417 return ` 4418 <div class="deblocage-rayons"></div> 4419 <div style="position:relative; z-index:2; text-align:center; padding:0 24px"> 4420 <div style="font-size:13px; color:#dfff35; font-weight:900; letter-spacing:3px; text-transform:uppercase; margin-bottom:10px; animation:deblFadeDown 0.6s ease both">Nouveau trophée débloqué</div> 4421 <div class="deblocage-badge" style="width:210px;height:210px;margin:auto;line-height:1;filter:drop-shadow(0 22px 35px rgba(255,121,0,.42))">${visuel}</div> 4422 <div style="font-family:'Bricolage Grotesque',sans-serif;font-size:30px;font-weight:900;color:#fff;margin-top:8px;text-transform:uppercase;animation:deblFadeUp 0.6s ease 0.15s both">${escapeHtml(b.label)}</div> 4423 ${sousTitre} 4424 ${badges.length > 1 ? `<div style="font-size:12px; color:#8a84a0; margin-top:20px">${index + 1} / ${badges.length} · touche pour continuer</div>` : `<div style="font-size:12px; color:#8a84a0; margin-top:20px">touche pour fermer</div>`} 4425 </div>`; 4426 } 4427 4428 function afficher() { 4429 overlay.innerHTML = `<div style="position:absolute; inset:0; pointer-events:none">${confettisHtml}</div>` + contenuBadge(badges[index]); 4430 } 4431 4432 function suivant() { 4433 index++; 4434 if (index >= badges.length) { 4435 overlay.style.opacity = '0'; 4436 setTimeout(() => { overlay.remove(); if (onFini) onFini(); }, 300); 4437 } else { 4438 afficher(); 4439 } 4440 } 4441 4442 overlay.onclick = suivant; 4443 afficher(); 4444 document.body.appendChild(overlay); 4445 // forcer le fade-in 4446 requestAnimationFrame(() => { overlay.style.opacity = '1'; }); 4447 // Vibration légère si supportée (pas de son) 4448 if (navigator.vibrate) { try { navigator.vibrate(60); } catch (e) {} } 4449} 4450 4451// ð Animation plein écran de montée de niveau. Style « ascension » : fond sombre, 4452// rayons, numéro de niveau géant qui surgit, titre du niveau, confettis dorés. 4453function celebrerLevelUp(info) { 4454 if (!info) return; 4455 if (document.getElementById('deblocageOverlay')) return; 4456 4457 const overlay = document.createElement('div'); 4458 overlay.id = 'deblocageOverlay'; 4459 overlay.style.cssText = `position:fixed; inset:0; z-index:99999; display:flex; align-items:center; justify-content:center; 4460 background:radial-gradient(circle at center, rgba(30,22,8,0.94), rgba(8,6,12,0.98)); backdrop-filter:blur(3px); 4461 opacity:0; transition:opacity 0.3s ease; overflow:hidden; cursor:pointer;`; 4462 4463 const couleurs = ['#F39200', '#FFD23F', '#FFB30F', '#FFE08A', '#E8920A']; 4464 let confettisHtml = ''; 4465 for (let i = 0; i < 80; i++) { 4466 const c = couleurs[i % couleurs.length]; 4467 const left = Math.random() * 100;
4468 const delay = Math.random() * 0.6; 4469 const dur = 1.8 + Math.random() * 1.6; 4470 const size = 6 + Math.random() * 9; 4471 const rot = Math.random() * 360; 4472 confettisHtml += `<div style="position:absolute; top:-20px; left:${left}%; width:${size}px; height:${size * 0.4}px; 4473 background:${c}; opacity:0; transform:rotate(${rot}deg); 4474 animation:confettiFall ${dur}s linear ${delay}s infinite;"></div>`; 4475 } 4476 4477 overlay.innerHTML = ` 4478 <div style="position:absolute; inset:0; pointer-events:none">${confettisHtml}</div> 4479 <div class="deblocage-rayons" style="background:repeating-conic-gradient(from 0deg, rgba(255,210,63,0.16) 0deg 8deg, transparent 8deg 16deg)"></div> 4480 <div style="position:relative; z-index:2; text-align:center; padding:0 24px"> 4481 <div style="font-size:14px; color:#FFD23F; font-weight:800; letter-spacing:4px; text-transform:uppercase; margin-bottom:14px; animation:deblFadeDown 0.6s ease both">⬠Niveau supérieur â¬</div> 4482 <div class="deblocage-badge" style="font-size:96px; line-height:1; font-weight:900; color:#FFD23F; text-shadow:0 0 30px rgba(255,178,15,0.7)">${info.niveau}</div> 4483 <div style="font-size:13px; color:#bdb6d8; font-weight:600; letter-spacing:1px; margin-top:8px">NIVEAU ${info.niveau}</div> 4484 <div style="font-size:26px; font-weight:800; color:#fff; margin-top:6px; animation:deblFadeUp 0.6s ease 0.15s both">${escapeHtml(info.titre)}</div> 4485 <div style="font-size:12px; color:#8a84a0; margin-top:22px">touche pour fermer</div> 4486 </div>`; 4487 4488 overlay.onclick = () => { overlay.style.opacity = '0'; setTimeout(() => overlay.remove(), 300); }; 4489 document.body.appendChild(overlay); 4490 requestAnimationFrame(() => { overlay.style.opacity = '1'; }); 4491 if (navigator.vibrate) { try { navigator.vibrate([60, 40, 80]); } catch (e) {} } 4492} 4493 4494// Rattrapage (coach uniquement) : calcule et enregistre les badges de TOUS les 4495// joueurs actifs. Utile pour les joueurs qui n'ont rien déclaré depuis l'arrivée 4496// des badges. Insère uniquement ce qui manque (la contrainte d'unicité protège 4497// des doublons). Nécessite la policy RLS "badges_joueurs insertion coach". 4498async function rattrapageBadgesTousJoueurs() { 4499 if (!isCoach()) { toast('Réservé au coach', true); return; } 4500 const btn = $('btnRattrapageBadges'); 4501 if (btn) { btn.disabled = true; btn.textContent = 'â³ Calcul en cours...'; } 4502 4503 const saison = getSaisonCourante(); 4504 const joueurs = (listeSure(cache.joueurs)).filter(j => j.statut === 'actif'); 4505 const dejaParJoueur = {}; 4506 (listeSure(cache.badgesJoueurs)).forEach(x => { 4507 if (x.saison !== saison) return; 4508 (dejaParJoueur[x.joueur_id] = dejaParJoueur[x.joueur_id] || []).push(x); 4509 }); 4510 4511 const aInserer = []; 4512 joueurs.forEach(j => { 4513 const badges = calculerBadges(j.id, saison); 4514 const deja = dejaParJoueur[j.id] || []; 4515 badges.forEach(b => { 4516 if (!b.debloque) return; 4517 if (b.type === 'exploit') { 4518 if (!deja.some(x => x.badge_id === b.id && x.palier == null)) { 4519 aInserer.push({ joueur_id: j.id, badge_id: b.id, palier: null, saison }); 4520 } 4521 } else { 4522 for (let p = 1; p <= b.niveau; p++) { 4523 if (!deja.some(x => x.badge_id === b.id && x.palier === p)) { 4524 aInserer.push({ joueur_id: j.id, badge_id: b.id, palier: p, saison }); 4525 } 4526 } 4527 } 4528 }); 4529 }); 4530 4531 if (aInserer.length === 0) { 4532 toast('Tous les badges sont déjà à jour â '); 4533 if (btn) { btn.disabled = false; btn.textContent = 'ð Recalculer les badges de tous les joueurs'; } 4534 return; 4535 } 4536 4537 // Insertion par lots de 200 pour éviter une requête trop grosse 4538 let inseres = 0, erreur = null; 4539 for (let i = 0; i < aInserer.length; i += 200) { 4540 const lot = aInserer.slice(i, i + 200); 4541 const { data, error } = await db.from('badges_joueurs').insert(lot).select(COLONNES_BADGE_JOUEUR); 4542 if (error) { erreur = error; break; } 4543 if (data) inseres += data.length; 4544 } 4545 4546 if (erreur) { 4547 toastErreur(erreur); 4548 if (btn) { btn.disabled = false; btn.textContent = 'ð Recalculer les badges de tous les joueurs'; } 4549 return; 4550 } 4551 4552 await reloadTables(['badges_joueurs']); 4553 toast(`â ${inseres} badge${inseres > 1 ? 's' : ''} enregistré${inseres > 1 ? 's' : ''} pour l'académie`); 4554 if (btn) { btn.disabled = false; btn.textContent = 'ð Recalculer les badges de tous les joueurs'; } 4555 render(); 4556} 4557 4558// Version silencieuse, lancée automatiquement à l'ouverture de l'app par un coach. 4559// Enregistre les badges déjà mérités par tous les joueurs, sans toast ni bouton. 4560// Tourne une seule fois par session (flag _rattrapageBadgesFait). 4561let _rattrapageBadgesFait = false;
4562function attendrePlageInactive() { 4563 return new Promise(resolve => { 4564 if (typeof requestIdleCallback === 'function') { 4565 requestIdleCallback(() => resolve(), { timeout: 120 }); 4566 } else { 4567 setTimeout(resolve, 0); 4568 } 4569 }); 4570} 4571async function rattrapageBadgesAuto() { 4572 if (_rattrapageBadgesFait) return; 4573 if (!isCoach()) return; // seul le coach a accès aux données de tous les joueurs (RLS) 4574 _rattrapageBadgesFait = true; 4575 4576 const saison = getSaisonCourante(); 4577 const joueurs = (listeSure(cache.joueurs)).filter(j => j.statut === 'actif'); 4578 const dejaParJoueur = {}; 4579 (listeSure(cache.badgesJoueurs)).forEach(x => { 4580 if (x.saison !== saison) return; 4581 (dejaParJoueur[x.joueur_id] = dejaParJoueur[x.joueur_id] || []).push(x); 4582 }); 4583 4584 const aInserer = []; 4585 for (let joueurIndex = 0; joueurIndex < joueurs.length; joueurIndex++) { 4586 const j = joueurs[joueurIndex]; 4587 const badges = calculerBadges(j.id, saison); 4588 const deja = dejaParJoueur[j.id] || []; 4589 badges.forEach(b => { 4590 if (!b.debloque) return; 4591 if (b.type === 'exploit') { 4592 if (!deja.some(x => x.badge_id === b.id && x.palier == null)) { 4593 aInserer.push({ joueur_id: j.id, badge_id: b.id, palier: null, saison }); 4594 } 4595 } else { 4596 for (let p = 1; p <= b.niveau; p++) { 4597 if (!deja.some(x => x.badge_id === b.id && x.palier === p)) { 4598 aInserer.push({ joueur_id: j.id, badge_id: b.id, palier: p, saison }); 4599 } 4600 } 4601 } 4602 }); 4603 // Le rattrapage est une tâche de fond : rendre régulièrement la main au 4604 // navigateur évite de bloquer navigation, scroll et gestes sur mobile. 4605 if ((joueurIndex + 1) % 4 === 0) await attendrePlageInactive(); 4606 } 4607 4608 if (aInserer.length === 0) return; 4609 let inseres = 0; 4610 for (let i = 0; i < aInserer.length; i += 200) { 4611 const lot = aInserer.slice(i, i + 200); 4612 const { data, error } = await db.from('badges_joueurs').insert(lot).select(COLONNES_BADGE_JOUEUR); 4613 if (error) { journal.warn('[rattrapage auto]', error); break; } 4614 if (data) inseres += data.length; 4615 } 4616 if (inseres > 0) { 4617 await reloadTables(['badges_joueurs']); 4618 demanderRender(); 4619 } 4620} 4621 4622// Coach : ouvre un modal pour changer directement le classement d'un joueur 4623function openChangerClassementCoach(joueurId) { 4624 const j = joueurById(joueurId); 4625 if (!j) { toast('Joueur introuvable', true); return; } 4626 const html = `<div class="modal-header"> 4627 <h3 class="modal-title">Changer le classement</h3> 4628 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 4629 </div> 4630 <div class="text-faint mb-2" style="font-size:13px">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)} â classement actuel : <strong>${escapeHtml(j.classement || 'NC')}</strong></div> 4631 <div class="form-row"> 4632 <label class="form-label">Nouveau classement</label> 4633 <select id="nouveauClassementCoach"> 4634 ${CLASSEMENTS.map(c => `<option value="${c}" ${j.classement === c ? 'selected' : ''}>${c}</option>`).join('')} 4635 </select> 4636 </div> 4637 <div class="text-faint" style="font-size:11px; margin-top:6px">Une montée déclenchera une notification à tout l'académie ð</div> 4638 <div style="margin-top:16px; display:flex; gap:8px; justify-content:flex-end"> 4639 <button class="btn" style="background:var(--bg-input)" data-ui-click="closeModal()">Annuler</button> 4640 <button class="btn" data-ui-click="confirmChangerClassementCoach(${escapeHtml(encoderArgumentTexteUI(j.id))})">Appliquer</button> 4641 </div>`; 4642 showModal(html); 4643} 4644 4645async function confirmChangerClassementCoach(joueurId) { 4646 const nouveau = $('nouveauClassementCoach').value; 4647 const execution = await executerAvecVerrou($('modalBody'), 4648 () => db.rpc('coach_set_classement', { p_joueur_id: joueurId, p_nouveau: nouveau }), 'Applicationâ¦'); 4649 if (execution.ignoree) return; 4650 const { error } = execution.valeur; 4651 if (error) { journal.error(error); toastErreur(error); return; } 4652 closeModal(); 4653 toast('Classement mis à jour'); 4654 await reloadTables(['joueurs', 'classement_historique', 'classement_demandes']); 4655 render(); 4656 loadNotifications(); 4657} 4658 4659// Joueur : ouvre un modal pour PROPOSER un nouveau classement (validation coach requise) 4660function openDemanderClassementJoueur() { 4661 const jid = profile && profile.joueur_id; 4662 const j = jid ? joueurById(jid) : null; 4663 if (!j) { toast('Profil introuvable', true); return; } 4664 // Demande déjà en attente ? 4665 const enAttente = (listeSure(cache.classementDemandes)).find(d => d.joueur_id === j.id && d.statut === 'en_attente'); 4666 const html = `<div class="modal-header"> 4667 <h3 class="modal-title">Déclarer un changement de classement</h3> 4668 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 4669 </div> 4670 <div class="text-faint mb-2" style="font-size:13px">Classement actuel : <strong>${escapeHtml(j.classement || 'NC')}</strong></div> 4671 ${enAttente ? `<div class="alert warning" style="margin-bottom:12px"><span class="icon">â³</span><div class="alert-content">Tu as déjà demandé le classement ${escapeHtml(enAttente.nouveau_classement)}. Ton classement officiel reste ${escapeHtml(j.classement || 'NC')} jusqu'à sa publication FFT et sa vérification par le coach${enAttente.date_publication_prevue ? `, à partir du ${formatDate(enAttente.date_publication_prevue)}` : ''}.</div></div>` : ''} 4672 <div class="form-row"> 4673 <label class="form-label">Mon nouveau classement</label> 4674 <select id="nouveauClassementJoueur"> 4675 ${CLASSEMENTS.map(c => `<option value="${c}" ${j.classement === c ? 'selected' : ''}>${c}</option>`).join('')} 4676 </select> 4677 </div> 4678 <div class="text-faint" style="font-size:11px; margin-top:6px">Ta demande sera enregistrée comme provisoire. Le coach pourra confirmer le classement officiel après sa publication FFT, le premier mardi du mois suivant.</div> 4679 <div style="margin-top:16px; display:flex; gap:8px; justify-content:flex-end"> 4680 <button class="btn" style="background:var(--bg-input)" data-ui-click="closeModal()">Annuler</button> 4681 <button class="btn" data-ui-click="confirmDemanderClassementJoueur()">Envoyer la demande</button> 4682 </div>`; 4683 showModal(html); 4684} 4685 4686async function confirmDemanderClassementJoueur() { 4687 const nouveau = $('nouveauClassementJoueur').value; 4688 const execution = await executerAvecVerrou($('modalBody'), 4689 () => db.functions.invoke('demander-classement', { 4690 body: { nouveau_classement: nouveau } 4691 }), 'Envoiâ¦'); 4692 if (execution.ignoree) return; 4693 const { error } = execution.valeur; 4694 if (error) { journal.error(error); toastErreur(error); return; } 4695 closeModal(); 4696 toast('Demande provisoire enregistrée'); 4697 4698 await reloadTables(['classement_demandes']); 4699 render(); 4700} 4701 4702// Coach : valider ou refuser une demande de classement 4703async function traiterDemandeClassement(demandeId, accepter) { 4704 const demande = (listeSure(cache.classementDemandes)).find(d => d.id === demandeId); 4705 if (accepter && demande && !TennisDomainRules.classementPeutEtreConfirme(demande.date_publication_prevue, new Date())) { 4706 toast(`Classement vérifiable à partir du ${formatDate(demande.date_publication_prevue)}.`, true); 4707 return; 4708 } 4709 const { error } = await db.rpc('coach_traiter_demande_classement', { p_demande_id: demandeId, p_accepter: accepter }); 4710 if (error) { journal.error(error); toastErreur(error); return; } 4711 toast(accepter ? 'Classement officiel confirmé' : 'Demande refusée'); 4712 await reloadTables(['joueurs', 'classement_historique', 'classement_demandes']); 4713 render(); 4714 loadNotifications(); 4715} 4716 4717 4718// Une perf = victoire (m.victoire = true) contre un adversaire dont le classement 4719// est strictement supérieur à celui du joueur (au sens FFT, plus haut dans CLASSEMENTS). 4720// Période : trimestre en cours (saison FFT sept-août). 4721 4722function computeBellePerfs(periode) { 4723 if (!isCoach()) return []; 4724
4725 const p = periode || getTrimestreCourant(); 4726 const [debut, fin] = getPeriodeDates(p); 4727 4728 return TennisDomainRules.listerBellesPerfs(cache.matches, debut, fin, { 4729 joueur: match => joueurById(match.joueur_id), 4730 classement: (match, joueur) => classementADate(joueur.id, match.date) 4731 }).filter(perf => perf.joueur && joueurDansEnvCourant(perf.joueur)); 4732} 4733 4734 4735function computeAlertes() { 4736 if (!isCoach()) return []; 4737 if (isPreAcademieEnv()) return []; 4738 if (isPoleEnv() && getSaisonAnalyse() !== getSaisonCourante()) return []; 4739 const palmyre = !isPoleEnv(); 4740 const joueurs = joueursActifsDansEnvCourant(); 4741 if (palmyre) return TennisDomainRules.trierAlertes(TennisDomainRules.calculerAlertesPalmyre(joueurs, cache.matches)); 4742 const maintenant = Date.now(); 4743 const aujourdHui = dateLocaleISO(new Date(maintenant)); 4744 const debutSaison = TennisDomainRules.bornesSaison(getSaisonCourante())[0]; 4745 const prorataObjectif = getProrataObjectif(); 4746 const alertes = joueurs.flatMap(joueur => TennisDomainRules.calculerAlertesJoueur({ 4747 joueur, 4748 groupe: cache.config[joueur.groupe], 4749 preAcademie: estPreAcademie(joueur), 4750 matches: matchsDuJoueur(joueur.id), 4751 matchsSaison: matchesSaisonJoueur(joueur.id), 4752 opportunitesJouables: listeSure(cache.tournois).filter(tournoi => tournoi.date_debut >= debutSaison && tournoi.date_debut <= aujourdHui && tournoiCorrespondAuJoueur(tournoi, joueur, { sexe:true, age:true, classement:true })).length, 4753 prorataObjectif, 4754 maintenant, 4755 debutSaison 4756 })); 4757 return TennisDomainRules.trierAlertes(alertes); 4758} 4759 4760// Libellés humains pour les types d'alertes 4761const ALERT_TYPE_LABELS = { 4762 sous_engagement: 'Sous-engagement', 4763 defaites_consecutives: 'Défaites consécutives', 4764 faible_taux_victoires: 'Taux de victoires faible', 4765 inactivite: 'Inactivité prolongée', 4766 aucun_match: 'Aucun match', 4767 sans_groupe: 'Sans groupe' 4768}; 4769 4770// Index idâjoueur pour un lookup O(1) au lieu de parcourir le tableau à chaque appel. 4771// Reconstruit automatiquement si la référence du tableau ou sa taille change 4772// (couvre les cas loadAllData, ajout/suppression de joueur). 4773let _joueurIndexRef = null; 4774let _joueurIndexSize = -1; 4775let _joueurIndex = new Map(); 4776function joueurById(id) { 4777 if (!id) return undefined; 4778 if (cache.joueurs !== _joueurIndexRef || cache.joueurs.length !== _joueurIndexSize) { 4779 _joueurIndex = new Map(cache.joueurs.map(j => [j.id, j])); 4780 _joueurIndexRef = cache.joueurs; 4781 _joueurIndexSize = cache.joueurs.length; 4782 } 4783 return _joueurIndex.get(id); 4784} 4785 4786// Index de l'historique de classement par joueur. Les perfs et contre-perfs 4787// consultent le classement à la date de chaque match : sans cet index, chacune de 4788// ces consultations reparcourt l'historique complet de toute l'académie. 4789let _historiqueClassementRef = null; 4790let _historiqueClassementSize = -1; 4791let _historiqueClassementParJoueur = new Map(); 4792function historiqueClassementDuJoueur(joueurId) { 4793 const historique = listeSure(cache.classementHistorique); 4794 if (historique !== _historiqueClassementRef || historique.length !== _historiqueClassementSize) { 4795 _historiqueClassementParJoueur = new Map(); 4796 historique.forEach(changement => { 4797 if (!_historiqueClassementParJoueur.has(changement.joueur_id)) { 4798 _historiqueClassementParJoueur.set(changement.joueur_id, []); 4799 } 4800 _historiqueClassementParJoueur.get(changement.joueur_id).push(changement); 4801 }); 4802 _historiqueClassementRef = historique; 4803 _historiqueClassementSize = historique.length; 4804 } 4805 return _historiqueClassementParJoueur.get(joueurId) || []; 4806} 4807 4808// Index des matchs par joueur : évite un parcours complet de cache.matches pour 4809// chaque joueur dans les dashboards, alertes et scores de groupe. 4810let _matchsIndexRef = null; 4811let _matchsIndexSize = -1; 4812let _matchsParJoueur = new Map(); 4813// Invalidation explicite : évite de dépendre uniquement du changement de référence 4814// ou de longueur du tableau. Toute évolution future qui modifierait un match en place 4815// pourra appeler ce garde-fou sans connaître l'implémentation de l'index.
4816function invaliderIndexMatchs() { 4817 _matchsIndexRef = null; 4818 _matchsIndexSize = -1; 4819 _matchsParJoueur = new Map(); 4820} 4821function matchsDuJoueur(joueurId) { 4822 if (cache.matches !== _matchsIndexRef || cache.matches.length !== _matchsIndexSize) { 4823 _matchsParJoueur = new Map(); 4824 cache.matches.forEach(match => { 4825 if (!_matchsParJoueur.has(match.joueur_id)) _matchsParJoueur.set(match.joueur_id, []); 4826 _matchsParJoueur.get(match.joueur_id).push(match); 4827 }); 4828 _matchsIndexRef = cache.matches; 4829 _matchsIndexSize = cache.matches.length; 4830 } 4831 return _matchsParJoueur.get(joueurId) || []; 4832} 4833 4834// Les badges alimentent XP, niveau, profil et synchronisation. Leur dédoublonnage 4835// ne doit pas être recalculé à chaque rendu ou pour chaque joueur du dashboard. 4836let _badgesIndexRef = null; 4837let _badgesIndexSize = -1; 4838let _badgesParJoueurSaison = new Map(); 4839function assurerIndexBadges() { 4840 const badges = listeSure(cache.badgesJoueurs); 4841 if (badges === _badgesIndexRef && badges.length === _badgesIndexSize) return; 4842 _badgesParJoueurSaison = new Map(); 4843 TennisDomainRules.dedoublonnerBadges(badges).forEach(badge => { 4844 const cle = `${badge.joueur_id}|${badge.saison}`; 4845 if (!_badgesParJoueurSaison.has(cle)) _badgesParJoueurSaison.set(cle, []); 4846 _badgesParJoueurSaison.get(cle).push(badge); 4847 }); 4848 _badgesIndexRef = badges; 4849 _badgesIndexSize = badges.length; 4850} 4851function badgesDuJoueurPourSaison(joueurId, saison) { 4852 assurerIndexBadges(); 4853 return _badgesParJoueurSaison.get(`${joueurId}|${saison}`) || []; 4854} 4855function badgesDuJoueur(joueurId) { 4856 return groupCollection('badgesParJoueur', 'badgesJoueurs', b => b.joueur_id).get(joueurId) || []; 4857} 4858function matchsCompletsDuJoueur(joueurId) { 4859 return groupCollection('matchesSaisonParJoueur', 'matchesSaison', m => m.joueur_id).get(joueurId) || []; 4860} 4861 4862// Index légers pour les collections consultées plusieurs fois dans les écrans 4863// bilans, assiduité et présence. Ils restent dérivés du cache : aucune seconde 4864// source de vérité, et une nouvelle référence/longueur provoque leur reconstruction. 4865const _collectionIndexes = new Map(); 4866function indexCollection(cacheKey, keyFn) { 4867 const collection = cache[cacheKey] || []; 4868 const precedent = _collectionIndexes.get(cacheKey); 4869 if (!precedent || precedent.ref !== collection || precedent.size !== collection.length) { 4870 const index = new Map(); 4871 collection.forEach(item => { 4872 const key = keyFn(item); 4873 if (key != null) index.set(key, item); 4874 }); 4875 _collectionIndexes.set(cacheKey, { ref: collection, size: collection.length, index }); 4876 return index; 4877 } 4878 return precedent.index; 4879} 4880 4881function seanceCoursById(id) { 4882 return indexCollection('seancesCours', s => s.id).get(id); 4883} 4884function creneauCoursById(id) { 4885 return indexCollection('creneauxCours', c => c.id).get(id); 4886} 4887 4888const _groupedCollectionIndexes = new Map(); 4889function groupCollection(indexName, cacheKey, keyFn) { 4890 const collection = cache[cacheKey] || []; 4891 const precedent = _groupedCollectionIndexes.get(indexName); 4892 if (!precedent || precedent.ref !== collection || precedent.size !== collection.length) { 4893 const groupes = new Map(); 4894 collection.forEach(item => { 4895 const key = keyFn(item); 4896 if (key == null) return; 4897 if (!groupes.has(key)) groupes.set(key, []); 4898 groupes.get(key).push(item); 4899 }); 4900 _groupedCollectionIndexes.set(indexName, { ref: collection, size: collection.length, groupes }); 4901 return groupes; 4902 } 4903 return precedent.groupes; 4904} 4905 4906function creneauPalmyreById(id) { 4907 return indexCollection('creneauxPalmyre', c => c.id).get(id); 4908} 4909function creneauxPalmyreDuJoueurEvenement(joueurId, evenementId) { 4910 return groupCollection('creneauxPalmyreParJoueurEvenement', 'creneauxPalmyre', c => `${c.joueur_id}|${c.evenement_id}`) 4911 .get(`${joueurId}|${evenementId}`) || []; 4912} 4913function creneauxPalmyreDeEvenement(evenementId) { 4914 return groupCollection('creneauxPalmyreParEvenement', 'creneauxPalmyre', c => c.evenement_id) 4915 .get(evenementId) || []; 4916} 4917function axesPalmyreDuJoueur(joueurId) { 4918 return groupCollection('axesPalmyreParJoueur', 'axesPalmyre', a => a.joueur_id).get(joueurId) || []; 4919} 4920function bilansPalmyreDuJoueur(joueurId) { 4921 return groupCollection('bilansPalmyreParJoueur', 'bilansEvenement', b => b.joueur_id).get(joueurId) || []; 4922} 4923function observationsPalmyreDuJoueurEvenement(joueurId, evenementId) { 4924 return groupCollection('observationsPalmyreParJoueurEvenement', 'observationsPalmyre', o => `${o.joueur_id || ''}|${o.evenement_id}`) 4925 .get(`${joueurId || ''}|${evenementId}`) || []; 4926} 4927function observationsPalmyreDuJour(evenementId, date) { 4928 return groupCollection('observationsPalmyreParEvenementJour', 'observationsPalmyre', o => `${o.evenement_id}|${String(o.created_at || '').slice(0, 10)}`) 4929 .get(`${evenementId}|${date}`) || []; 4930} 4931function observationPalmyreById(id) { 4932 return indexCollection('observationsPalmyre', o => o.id).get(id); 4933} 4934function bilanPalmyreDuJoueurEvenement(joueurId, evenementId) { 4935 return indexCollection('bilansEvenement', b => `${b.joueur_id}|${b.evenement_id}`).get(`${joueurId}|${evenementId}`) || null; 4936} 4937function debriefPalmyreDuJour(evenementId, date) { 4938 return indexCollection('debriefsPalmyre', d => `${d.evenement_id}|${d.date}`).get(`${evenementId}|${date}`) || null; 4939} 4940 4941let _affectationsCoursRef = null; 4942let _affectationsCoursSize = -1; 4943let _joueursParCreneau = new Map(); 4944let _joueursAvecCours = new Set(); 4945function assurerIndexAffectationsCours() { 4946 const affectations = listeSure(cache.creneauxCoursJoueurs); 4947 if (affectations === _affectationsCoursRef && affectations.length === _affectationsCoursSize) return; 4948 _joueursParCreneau = new Map(); 4949 _joueursAvecCours = new Set(); 4950 affectations.forEach(a => { 4951 if (!_joueursParCreneau.has(a.creneau_id)) _joueursParCreneau.set(a.creneau_id, []); 4952 _joueursParCreneau.get(a.creneau_id).push(a.joueur_id); 4953 _joueursAvecCours.add(a.joueur_id); 4954 }); 4955 _affectationsCoursRef = affectations; 4956 _affectationsCoursSize = affectations.length; 4957} 4958function idsJoueursDuCreneau(creneauId) { 4959 assurerIndexAffectationsCours(); 4960 return _joueursParCreneau.get(creneauId) || []; 4961} 4962 4963let _presencesRef = null; 4964let _presencesSize = -1; 4965let _presencesParSeance = new Map(); 4966let _presencesParJoueur = new Map(); 4967let _presenceParSeanceJoueur = new Map(); 4968function assurerIndexPresences() { 4969 const presences = listeSure(cache.presences); 4970 if (presences === _presencesRef && presences.length === _presencesSize) return; 4971 _presencesParSeance = new Map(); 4972 _presencesParJoueur = new Map(); 4973 _presenceParSeanceJoueur = new Map();
4974 presences.forEach(p => { 4975 if (!_presencesParSeance.has(p.seance_id)) _presencesParSeance.set(p.seance_id, []); 4976 if (!_presencesParJoueur.has(p.joueur_id)) _presencesParJoueur.set(p.joueur_id, []); 4977 _presencesParSeance.get(p.seance_id).push(p); 4978 _presencesParJoueur.get(p.joueur_id).push(p); 4979 _presenceParSeanceJoueur.set(`${p.seance_id}:${p.joueur_id}`, p); 4980 }); 4981 _presencesRef = presences; 4982 _presencesSize = presences.length; 4983} 4984function presencesBrutesDeSeance(seanceId) { 4985 assurerIndexPresences(); 4986 return _presencesParSeance.get(seanceId) || []; 4987} 4988function presencesDuJoueur(joueurId) { 4989 assurerIndexPresences(); 4990 return _presencesParJoueur.get(joueurId) || []; 4991} 4992function presenceDeSeancePourJoueur(seanceId, joueurId) { 4993 assurerIndexPresences(); 4994 return _presenceParSeanceJoueur.get(`${seanceId}:${joueurId}`); 4995} 4996 4997let _entrainementsRef = null; 4998let _entrainementsSize = -1; 4999let _entrainementsParJoueur = new Map(); 5000let _entrainementParId = new Map(); 5001function assurerIndexEntrainements() { 5002 const entrainements = listeSure(cache.entrainements); 5003 if (entrainements !== _entrainementsRef || entrainements.length !== _entrainementsSize) { 5004 _entrainementsParJoueur = new Map(); 5005 _entrainementParId = new Map(); 5006 entrainements.forEach(e => { 5007 if (!_entrainementsParJoueur.has(e.joueur_id)) _entrainementsParJoueur.set(e.joueur_id, []); 5008 _entrainementsParJoueur.get(e.joueur_id).push(e); 5009 _entrainementParId.set(e.id, e); 5010 }); 5011 _entrainementsRef = entrainements; 5012 _entrainementsSize = entrainements.length; 5013 } 5014} 5015function entrainementsDuJoueur(joueurId) { 5016 assurerIndexEntrainements(); 5017 return _entrainementsParJoueur.get(joueurId) || []; 5018} 5019function entrainementById(id) { 5020 assurerIndexEntrainements(); 5021 return _entrainementParId.get(id); 5022} 5023 5024let _bilansRef = null; 5025let _bilansSize = -1; 5026let _bilanParJoueurSaisonTrimestre = new Map(); 5027let _bilanParId = new Map(); 5028let _bilansParJoueur = new Map(); 5029function assurerIndexBilans() { 5030 const bilans = listeSure(cache.bilans); 5031 if (bilans !== _bilansRef || bilans.length !== _bilansSize) { 5032 _bilanParJoueurSaisonTrimestre = new Map(); 5033 _bilanParId = new Map(); 5034 _bilansParJoueur = new Map(); 5035 bilans.forEach(b => { 5036 _bilanParJoueurSaisonTrimestre.set(`${b.joueur_id}:${b.saison}:${b.trimestre}`, b); 5037 _bilanParId.set(b.id, b); 5038 if (!_bilansParJoueur.has(b.joueur_id)) _bilansParJoueur.set(b.joueur_id, []); 5039 _bilansParJoueur.get(b.joueur_id).push(b); 5040 }); 5041 _bilansRef = bilans; 5042 _bilansSize = bilans.length; 5043 } 5044} 5045function bilanDuJoueur(joueurId, saison, trimestre) { 5046 assurerIndexBilans(); 5047 return _bilanParJoueurSaisonTrimestre.get(`${joueurId}:${saison}:${trimestre}`); 5048} 5049function bilanById(id) { 5050 assurerIndexBilans(); 5051 return _bilanParId.get(id); 5052} 5053function bilansDuJoueur(joueurId) { 5054 assurerIndexBilans(); 5055 return _bilansParJoueur.get(joueurId) || []; 5056} 5057 5058// Mémoïsation de statsJoueur : le calcul refait un filter complet de cache.matches 5059// à chaque appel. On cache les résultats par joueur, invalidés si cache.matches change 5060// (référence ou taille différente â rechargement ou ajout/suppression de match). 5061let _statsRef = null; 5062let _statsSize = -1; 5063let _statsCache = new Map(); 5064function statsJoueur(id) { 5065 if (cache.matches !== _statsRef || cache.matches.length !== _statsSize) { 5066 _statsCache = new Map(); 5067 _statsRef = cache.matches; 5068 _statsSize = cache.matches.length; 5069 } 5070 if (_statsCache.has(id)) return _statsCache.get(id); 5071 5072 const ms = matchsDuJoueur(id); 5073 // WO exclus des compteurs (pas un match joué). derniers5 garde tout (affichage). 5074 const compte = comptesMatchs(ms); 5075 const result = { 5076 ...compte, 5077 derniers5: ms.slice(0, 5) 5078 }; 5079 _statsCache.set(id, result); 5080 return result; 5081} 5082 5083// ============================================================ 5084// NAVIGATION & RENDU 5085// ============================================================ 5086function iconifyUi(root) { 5087 if (!root) return; 5088 const symbolRe = /(?:\p{Extended_Pictographic}\uFE0F?|[ââââ â¬â¬])/gu; 5089 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); 5090 const nodes = []; 5091 while (walker.nextNode()) { 5092 const node = walker.currentNode; 5093 if (!node.parentElement || node.parentElement.closest('script,style,textarea,option,svg,.ui-icon')) continue; 5094 symbolRe.lastIndex = 0; 5095 if (symbolRe.test(node.nodeValue || '')) nodes.push(node); 5096 }
5097 nodes.forEach(node => { 5098 const text = node.nodeValue || ''; 5099 const fragment = document.createDocumentFragment(); 5100 let last = 0; 5101 text.replace(symbolRe, (symbol, offset) => { 5102 if (offset > last) fragment.appendChild(document.createTextNode(text.slice(last, offset))); 5103 const holder = document.createElement('span'); 5104 holder.innerHTML = sportUiIcon(EMOJI_ICON_KEYS[symbol] || 'spark'); 5105 fragment.appendChild(holder.firstElementChild); 5106 last = offset + symbol.length; 5107 return symbol; 5108 }); 5109 if (last < text.length) fragment.appendChild(document.createTextNode(text.slice(last))); 5110 node.replaceWith(fragment); 5111 }); 5112} 5113 5114let sportIconObserver = null; 5115function startSportIconObserver() { 5116 if (!document.body || sportIconObserver) return; 5117 iconifyUi(document.body); 5118 sportIconObserver = new MutationObserver(records => { 5119 sportIconObserver.disconnect(); 5120 records.forEach(record => { 5121 record.addedNodes.forEach(node => { 5122 if (node.nodeType === Node.TEXT_NODE) iconifyUi(node.parentElement); 5123 else if (node.nodeType === Node.ELEMENT_NODE && !node.closest('.ui-icon')) iconifyUi(node); 5124 }); 5125 }); 5126 sportIconObserver.observe(document.body, { childList: true, subtree: true, characterData: true }); 5127 }); 5128 sportIconObserver.observe(document.body, { childList: true, subtree: true, characterData: true }); 5129} 5130setTimeout(startSportIconObserver, 0); 5131 5132function navLabelText(label) { 5133 return String(label || '').replace(/^\S+\s+/, ''); 5134} 5135 5136function renderMobileRoleNav(items) { 5137 const host = $('mobileRoleNav'); 5138 if (!host) return; 5139 const entry = (key, label, iconKey = key, action = '') => { 5140 const active = !action && key === etatApp.ecran; 5141 const attr = action ? `data-mobile-action="${escapeHtml(action)}"` : `data-mobile-screen="${key}"`; 5142 return `<button type="button" class="mobile-role-item ${active ? 'active' : ''} ${action ? 'action' : ''}" ${attr}>${sportNavIcon(iconKey)}<span>${escapeHtml(label)}</span></button>`; 5143 }; 5144 if (isCoach()) { 5145 if (isAcademieEnv()) { 5146 host.innerHTML = entry('dashboard', 'Dashboard', 'dashboard') 5147 + entry('presence', 'Présences', 'presence') 5148 + entry('planningcours', 'Planning', 'planning') 5149 + entry('bilans', 'Bilans', 'reports') 5150 + entry('matches', 'Matches', 'matches'); 5151 host.querySelectorAll('[data-mobile-screen]').forEach(button => { 5152 button.addEventListener('click', () => navigate(button.dataset.mobileScreen)); 5153 }); 5154 host.dataset.availableScreens = items.map(([key]) => key).join(','); 5155 return; 5156 } 5157 const disponibles = new Set(items.map(([key]) => key)); 5158 const groupeScreen = disponibles.has('groupes') ? 'groupes' : 'matches'; 5159 const groupeLabel = groupeScreen === 'groupes' ? 'Groupes' : 'Matches'; 5160 const groupeIcon = groupeScreen === 'groupes' ? 'group' : 'matches'; 5161 const alerteScreen = disponibles.has('alertes') ? 'alertes' : 'perfs'; 5162 host.innerHTML = entry('dashboard', 'Vue globale', 'dashboard') 5163 + entry('players', 'Joueurs', 'players') 5164 + entry(groupeScreen, groupeLabel, groupeIcon) 5165 + entry(alerteScreen, alerteScreen === 'alertes' ? 'Alertes' : 'Perfs', alerteScreen === 'alertes' ? 'alerts' : 'performances') 5166 + entry('more', 'Plus', 'more', 'more'); 5167 } else { 5168 const joueurConnecte = profile?.joueur_id ? joueurById(profile.joueur_id) : null; 5169 const navigationPreAcademie = !!joueurConnecte && estPreAcademie(joueurConnecte); 5170 host.innerHTML = entry('my', 'Accueil', 'profile') 5171 + entry('myprofile', 'Progression', 'performances') 5172 + entry('matches', 'Mes matchs', 'matches') 5173 + entry('calendar', 'Tournois', 'tournaments') 5174 + (navigationPreAcademie ? '' : entry('more', 'Plus', 'more', 'more')); 5175 } 5176 host.querySelectorAll('[data-mobile-screen]').forEach(button => { 5177 button.addEventListener('click', () => navigate(button.dataset.mobileScreen)); 5178 }); 5179 host.querySelectorAll('[data-mobile-action]').forEach(button => { 5180 button.addEventListener('click', () => { 5181 if (button.dataset.mobileAction === 'more') openMobileMoreMenu(); 5182 }); 5183 }); 5184 host.dataset.availableScreens = items.map(([key]) => key).join(','); 5185} 5186 5187function openMobileMoreMenu() { 5188 const items = []; 5189 if (isCoach()) { 5190 const candidates = [ 5191 ['perfs', 'Performances', 'performances', 'Belles perfs et montées au classement'], 5192 ['matches', 'Matches', 'matches', 'Historique sportif des joueurs'], 5193 ['calendar', 'Tournois', 'tournaments', 'Créer et suivre les propositions'], 5194 ['presence', 'Présences', 'presence', 'Faire lâappel des groupes'], 5195 ['bilans', 'Bilans', 'reports', 'Préparer les synthèses joueurs'], 5196 ['planningcours', 'Planning', 'planning', 'Organiser les cours de lâacadémie'], 5197 ['planning', 'Planning', 'planning', 'Organiser la semaine du stage'], 5198 ['observations', 'Observations', 'observations', 'Retrouver les notes du staff'], 5199 ['debrief', 'Débrief', 'debrief', 'Synthèse collective de la journée'], 5200 ['groupes', 'Groupes', 'group', 'Effectifs, niveaux et objectifs'], 5201 ['alertes', 'Alertes', 'alerts', 'Priorités et joueurs à accompagner'], 5202 ['overview', 'Analyse détaillée', 'dashboard', 'Toutes les statistiques de lâacadémie'], 5203 ['transport', 'Transport', 'transport', 'Organiser les déplacements'] 5204 ]; 5205 const disponibles = new Set((($('mobileRoleNav') || {}).dataset || {}).availableScreens?.split(',') || []); 5206 const dejaVisible = new Set(['dashboard', 'players']); 5207 if (disponibles.has('groupes')) dejaVisible.add('groupes'); else dejaVisible.add('matches'); 5208 if (disponibles.has('alertes')) dejaVisible.add('alertes'); else dejaVisible.add('perfs');
5209 candidates.forEach(item => { 5210 if (disponibles.has(item[0]) && !dejaVisible.has(item[0])) items.push(item); 5211 }); 5212 } else { 5213 const candidates = [ 5214 ['mygroupe', 'Mon groupe', 'group', 'Joueurs et dynamique collective'], ['badges', 'Mes badges', 'badges', 'Récompenses et paliers'], 5215 ['mescours', 'Planning Académie', 'presence', 'Mes cours, horaires et présences'], ['entrainements', 'Mes séances libres', 'training', 'Entraînements personnels déclarés'], 5216 ['mybilans', 'Mes bilans', 'reports', 'Synthèses partagées par le coach'], ['monplanning', 'Planning Palmyre', 'planning', 'Programme de mon stage'] 5217 ]; 5218 const disponibles = new Set((($('mobileRoleNav') || {}).dataset || {}).availableScreens?.split(',') || []); 5219 candidates.forEach(item => { if (disponibles.has(item[0])) items.push(item); }); 5220 } 5221 const html = `<div class="modal-header"><div><div class="ux-home-kicker">${isCoach() ? 'Outils coach' : 'Mon espace'}</div><h3 class="modal-title">Plus</h3></div><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 5222 <div class="modal-body"><div class="ux-more-grid">${items.map(([screen, label, icon, description]) => `<button type="button" class="ux-more-btn" data-ui-click="closeModal(); navigate(${escapeHtml(encoderArgumentTexteUI(screen))})">${sportNavIcon(icon)}<span><strong>${escapeHtml(label)}</strong><small>${escapeHtml(description || '')}</small></span></button>`).join('')}</div></div>`; 5223 showModal(html); 5224} 5225 5226function buildNavMenu() { 5227 // Pré-Académie est un environnement permanent : il reste accessible même 5228 // lorsqu'aucun joueur n'y est actuellement rattaché. 5229 const items = []; 5230 if (isCoach()) { 5231 const palmyre = !isPoleEnv(); // édition Palmyre 5232 const preAca = isPreAcademieEnv(); // catégorie Pré-Académie 5233 const academie = isAcademieEnv(); // pôle compétition « classique » 5234 items.push(['dashboard', 'ð Dashboard']); 5235 // Validations + Alertes : Académie uniquement (ni Pré-Académie ni Palmyre) 5236 if (academie) { 5237 const enAttente = cache.joueurs.filter(j => j.statut === 'en_attente_validation').length; 5238 if (enAttente > 0) { 5239 items.push(['validations', `ð à valider (${enAttente})`]); 5240 } 5241 const nbAlertes = (listeSure(cache.alertes)).length; 5242 items.push(['alertes', `ð¨ Alertes${nbAlertes > 0 ? ' (' + nbAlertes + ')' : ''}`]); 5243 } 5244 // Perfs : partout (la Pré-Académie conserve la gamification) 5245 const nbPerfs = (listeSure(cache.bellePerfs)).length; 5246 items.push(['perfs', `ð¥ Perfs${nbPerfs > 0 ? ' (' + nbPerfs + ')' : ''}`]); 5247 // Groupes, Vue d'ensemble, Bilans : masqués en Pré-Académie (pas de groupes ni de bilans) 5248 if (!preAca) items.push(['groupes', 'ð Groupes']); 5249 if (!preAca) items.push(['overview', 'ð Vue d\'ensemble']); 5250 items.push(['players', 'ð¥ Joueurs']); 5251 items.push(['matches', 'ð¾ Matches']); 5252 // Tournois : Académie + Pré-Académie (les tournois Palmyre sont gérés sur place) 5253 if (!palmyre) { 5254 items.push(['calendar', 'ð Tournois']); 5255 } 5256 if (!preAca) items.push(['bilans', 'ð Bilans']); 5257 // Présence : Académie uniquement. Planning : Académie + Pré-Académie. 5258 if (academie) items.push(['presence', 'â Présence']); 5259 if (academie) items.push(['planningcours', 'ð Planning']); 5260 // Observations : carnet de notes du coach, en mode Palmyre uniquement 5261 if (palmyre) { 5262 items.push(['observations', 'ð Observations']); 5263 items.push(['debrief', 'ð Débrief']); 5264 items.push(['planning', 'ð Planning']); 5265 items.push(['transport', 'ð Transport']); 5266 } 5267 // Invitations : déplacé dans le menu utilisateur (plus dans la barre) 5268 } else { 5269 const monJoueur = profile && profile.joueur_id ? joueurById(profile.joueur_id) : null; 5270 const estPreAca = monJoueur && estPreAcademie(monJoueur); 5271 // Le joueur a-t-il un bilan Palmyre publié ? 5272 const aBilanPalmyre = (listeSure(cache.bilansEvenement)).some(b => b.joueur_id === (profile && profile.joueur_id) && b.statut === 'publie'); 5273 // Le planning opérationnel ne reste visible que pour une édition en cours ou à venir 5274 // à laquelle le joueur est rattaché. Les anciens créneaux ne rouvrent jamais une archive. 5275 const monJoueurPalmyre = profile && profile.joueur_id ? joueurById(profile.joueur_id) : null; 5276 const aPlanningPalmyre = editionsPlanningPalmyreJoueur(monJoueurPalmyre).length > 0; 5277 items.push(['my', 'ð¤ Accueil']); 5278 items.push(['myprofile', 'ð Progression']); 5279 if (!estPreAca) items.push(['mygroupe', 'ð¤ Mon groupe']); 5280 if (!estPreAca) items.push(['mescours', 'ð Planning Académie']); 5281 items.push(['calendar', 'ð Tournois']); 5282 items.push(['matches', 'ð¾ Mes matches']); 5283 if (!estPreAca) items.push(['entrainements', 'ðï¸ Mes séances libres']); 5284 if (!estPreAca) items.push(['badges', 'ð Mes badges']); 5285 if (!estPreAca || aBilanPalmyre) items.push(['mybilans', 'ð Mes bilans']); 5286 // Planning Palmyre : exceptionnel (stage), placé en fin de liste, visible seulement si programmé 5287 if (aPlanningPalmyre) items.push(['monplanning', 'ðï¸ Planning Palmyre']); 5288 } 5289 const alertePrimaire = isAcademieEnv() ? 'alertes' : 'perfs'; 5290 const planningPrimaire = isPoleEnv() ? 'planningcours' : 'planning'; 5291 const navigationPrincipale = isCoach() 5292 ? (isAcademieEnv() ? [ 5293 ['dashboard', 'Dashboard', 'dashboard'], ['presence', 'Présences', 'presence'], 5294 ['planningcours', 'Planning', 'planning'], 5295 ['bilans', 'Bilans', 'reports'], ['matches', 'Matches', 'matches']
5296 ] : [ 5297 ['dashboard', 'Vue globale', 'dashboard'], ['players', 'Joueurs', 'players'], 5298 [alertePrimaire, isAcademieEnv() ? 'Alertes' : 'Perfs', isAcademieEnv() ? 'alerts' : 'perfs'], 5299 ...(!isPreAcademieEnv() ? [[planningPrimaire, 'Planning', 'planning']] : []) 5300 ]) 5301 : [ 5302 ['my', 'Accueil', 'profile'], ['myprofile', 'Progression', 'performances'], 5303 ['matches', 'Mes matchs', 'matches'], ['calendar', 'Tournois', 'tournaments'] 5304 ]; 5305 const iconeNavigation = { 5306 dashboard: 'dashboard', validations: 'alerts', alertes: 'alerts', perfs: 'performances', 5307 groupes: 'group', overview: 'dashboard', players: 'players', matches: 'matches', 5308 calendar: 'tournaments', bilans: 'reports', presence: 'presence', planningcours: 'planning', 5309 observations: 'observations', notes: 'observations', debrief: 'debrief', planning: 'planning', transport: 'transport', 5310 my: 'profile', myprofile: 'performances', mygroupe: 'group', mescours: 'presence', 5311 entrainements: 'training', badges: 'badges', mybilans: 'reports', monplanning: 'planning' 5312 }; 5313 const ordreJoueurLarge = ['my', 'myprofile', 'matches', 'calendar', 'mygroupe', 'badges', 'mescours', 'entrainements', 'mybilans', 'monplanning']; 5314 const itemsParEcran = new Map(items.map(([key, label]) => [key, navLabelText(label)])); 5315 const ordreLarge = isCoach() 5316 ? (isAcademieEnv() 5317 ? navigationPrincipale.map(([key]) => key) 5318 : [...navigationPrincipale.map(([key]) => key), ...items.map(([key]) => key)]) 5319 : ordreJoueurLarge; 5320 const vus = new Set(); 5321 const navigationLarge = ordreLarge 5322 .filter(key => itemsParEcran.has(key) && !vus.has(key) && vus.add(key)) 5323 .map(key => [key, itemsParEcran.get(key), iconeNavigation[key] || 'dashboard']); 5324 $('nav-menu').innerHTML = navigationLarge.map(([key, label, icon]) => 5325 `<button class="nav-btn ${key === etatApp.ecran ? 'active' : ''}" data-screen="${key}">${sportNavIcon(icon)}<span>${escapeHtml(label)}</span></button>` 5326 ).join(''); 5327 document.querySelectorAll('.nav-btn').forEach(b => { 5328 b.addEventListener('click', () => navigate(b.dataset.screen)); 5329 }); 5330 renderMobileRoleNav(items); 5331 5332 // Sélecteur d'environnement (Académie / éditions Palmyreâ¦) â coach uniquement pour l'instant, 5333 // et seulement s'il existe au moins un événement actif. 5334 renderEnvSelector(); 5335 5336 // Rafraîchir l'affichage de la version dans le footer du menu (asynchrone, silencieux) 5337 loadAppVersion(); 5338} 5339 5340// Affiche le sélecteur d'environnement dans le header. Le raccourci Palmyre est 5341// permanent ; il ouvre l'édition active ou explique clairement qu'il n'y en a pas. 5342function renderEnvSelector() { 5343 const host = $('envSelector'); 5344 if (!host) return; 5345 // Le sélecteur d'environnement est permanent pour le coach : Pré-Académie 5346 // ne disparaît pas lors du passage annuel si sa population devient vide. 5347 if (!isCoach()) { 5348 host.style.display = 'none'; 5349 host.innerHTML = ''; 5350 return; 5351 } 5352 host.style.display = 'block'; 5353 const pill = (cle, label, actif, iconKey) => 5354 `<button class="env-pill ${actif ? 'active' : ''}" data-ui-click="setCurrentEnv(${escapeHtml(encoderArgumentTexteUI(cle))})">${sportNavIcon(iconKey)}<span>${escapeHtml(label)}</span></button>`; 5355 let html = `<div class="env-bar">`; 5356 // Ordre : Pré-Académie · Académie · Palmyre 5357 html += pill('pre_academie', 'Pré-Académie', etatApp.environnement === 'pre_academie', 'pre_academie'); 5358 html += pill('pole', 'Académie', etatApp.environnement === 'pole', 'pole'); 5359 html += `<button class="env-pill ${getActiveEvenement() ? 'active' : ''}" data-ui-click="ouvrirPalmyreCoach()">${sportNavIcon('event')}<span>Palmyre</span></button>`; 5360 html += `</div>`; 5361 host.innerHTML = html; 5362} 5363 5364function navigate(screen, keepFilter = false, groupeDetail = null) { 5365 const ecranPrecedent = etatApp.ecran || (isCoach() ? 'dashboard' : 'my'); 5366 if (!_navigationDepuisHistorique) memoriserContexteNavigation(); 5367 const ecransCoach = new Set([ 5368 'dashboard', 'validations', 'alertes', 'perfs', 'groupes', 'overview', 5369 'players', 'notes', 'bilans', 'observations', 'debrief', 'presence', 5370 'planningcours', 'planning', 'transport', 'invitations', 'settings' 5371 ]);
5372 if (!isCoach() && ecransCoach.has(screen)) { 5373 journal.warn('[navigation] écran coach refusé :', screen); 5374 screen = 'my'; 5375 keepFilter = false; 5376 } 5377 const joueurConnecte = !isCoach() && profile?.joueur_id ? joueurById(profile.joueur_id) : null; 5378 if (joueurConnecte && estPreAcademie(joueurConnecte)) { 5379 const ecransPreAcademie = new Set(['my', 'myprofile', 'matches', 'calendar']); 5380 if (!ecransPreAcademie.has(screen)) { 5381 journal.warn('[navigation] écran masqué en Pré-Académie :', screen); 5382 screen = 'my'; 5383 keepFilter = false; 5384 } 5385 } 5386 const ecransOperationnelsCourants = new Set(['validations', 'alertes', 'presence', 'planningcours', 'mescours']); 5387 if (isPoleEnv() && ecransOperationnelsCourants.has(screen) && getSaisonAnalyse() !== getSaisonCourante()) { 5388 chargerSaisonAnalyse(getSaisonCourante()).then(ok => { if (ok) navigate(screen, keepFilter); }); 5389 return; 5390 } 5391 if (screen === 'players' && !keepFilter) { 5392 etatApp.reinitialiserVueJoueurs(); 5393 } 5394 if (screen === 'groupes') { 5395 etatApp.definirGroupeDetail(groupeDetail); 5396 } 5397 if (!etatApp.definirEcran(screen)) return; 5398 if (!_navigationDepuisHistorique && screen !== ecranPrecedent) { 5399 history.pushState(etatNavigationCourant({ ecran: screen, scrollY: 0, modal: null }), '', window.location.href); 5400 } 5401 buildNavMenu(); 5402 render(); 5403 window.scrollTo(0, 0); 5404} 5405 5406async function refresh() { 5407 await loadAllData(); 5408 render(); 5409} 5410 5411// Un joueur non validé ne voit qu'un écran d'attente et ne peut utiliser aucune fonction. 5412function joueurEnAttente() { 5413 if (isCoach()) return false; 5414 const j = (profile && profile.joueur_id) ? joueurById(profile.joueur_id) : null; 5415 return !!(j && j.statut === 'en_attente_validation'); 5416} 5417function renderEcranAttenteValidation(j) { 5418 const prenom = j && j.prenom ? escapeHtml(j.prenom) : ''; 5419 return `<div class="empty" style="padding-top:60px"> 5420 <span class="icon" style="font-size:48px">â³</span> 5421 <h2 style="font-size:18px; color:var(--text); margin:16px 0 8px">Inscription en cours de validation</h2> 5422 <div style="max-width:400px; margin:0 auto; font-size:14px; line-height:1.6"> 5423 Salut <strong>${prenom}</strong> ! Ton inscription a bien été enregistrée.<br><br> 5424 Ton coach va prochainement examiner ta fiche et t'affecter à un groupe (Tremplin, Accession Ãlite ou Ãlite).<br><br> 5425 <span class="text-faint">Tu pourras utiliser l'application (matches, entraînementsâ¦) dès que ton inscription sera validée. Reviens dans quelques jours, ou contacte ton coach.</span> 5426 </div> 5427 </div>`; 5428} 5429 5430let dernierEcranRendu = ''; 5431function render() { 5432 const app = $('app'); 5433 const restaurerPosition = dernierEcranRendu === etatApp.ecran; 5434 const positionAvantRendu = restaurerPosition ? window.scrollY : 0; 5435 const focusAvantRendu = restaurerPosition && document.activeElement?.id ? document.activeElement.id : ''; 5436 app.dataset.screen = etatApp.ecran || ''; 5437 app.dataset.role = isCoach() ? 'coach' : 'joueur'; 5438 // Garde globale : un joueur non validé â écran d'attente, aucune fonction accessible. 5439 if (joueurEnAttente()) { 5440 app.innerHTML = renderEcranAttenteValidation(joueurById(profile.joueur_id)); 5441 destroyChartsHorsDom(); 5442 actualiserAuditActionsUI(); 5443 return; 5444 } 5445 if (etatApp.ecran === 'dashboard') app.innerHTML = renderDashboard(); 5446 else if (etatApp.ecran === 'validations') app.innerHTML = renderValidations(); 5447 else if (etatApp.ecran === 'alertes') app.innerHTML = renderAlertes(); 5448 else if (etatApp.ecran === 'perfs') app.innerHTML = renderPerfs(); 5449 else if (etatApp.ecran === 'groupes') app.innerHTML = renderGroupes(); 5450 else if (etatApp.ecran === 'overview') app.innerHTML = renderOverview(); 5451 else if (etatApp.ecran === 'players') app.innerHTML = renderPlayers(); 5452 else if (etatApp.ecran === 'notes') app.innerHTML = renderNotesCoach(); 5453 else if (etatApp.ecran === 'my') app.innerHTML = renderPlayerHome(); 5454 else if (etatApp.ecran === 'myprofile') app.innerHTML = renderPlayerProgression(); 5455 else if (etatApp.ecran === 'mygroupe') app.innerHTML = renderMyGroupeScreen(); 5456 else if (etatApp.ecran === 'matches') app.innerHTML = renderMatches();
5457 else if (etatApp.ecran === 'entrainements') app.innerHTML = renderMesEntrainements(); 5458 else if (etatApp.ecran === 'bilans') app.innerHTML = isPoleEnv() ? renderBilansCoach() : renderBilansPalmyre(); 5459 else if (etatApp.ecran === 'observations') app.innerHTML = renderObservationsPalmyre(); 5460 else if (etatApp.ecran === 'debrief') app.innerHTML = renderDebriefPalmyre(); 5461 else if (etatApp.ecran === 'presence') app.innerHTML = renderPresenceCours(); 5462 else if (etatApp.ecran === 'planningcours') app.innerHTML = renderPlanningCours(); 5463 else if (etatApp.ecran === 'mescours') app.innerHTML = renderMesCours(); 5464 else if (etatApp.ecran === 'planning') app.innerHTML = renderPlanningPalmyre(); 5465 else if (etatApp.ecran === 'transport') app.innerHTML = renderTransportPalmyre(); 5466 else if (etatApp.ecran === 'monplanning') app.innerHTML = renderMonPlanningJoueur(); 5467 else if (etatApp.ecran === 'mybilans') app.innerHTML = renderMyBilans(); 5468 else if (etatApp.ecran === 'badges') app.innerHTML = renderMesBadges(); 5469 else if (etatApp.ecran === 'calendar') app.innerHTML = renderCalendar(); 5470 else if (etatApp.ecran === 'invitations') app.innerHTML = renderInvitations(); 5471 else if (etatApp.ecran === 'settings') app.innerHTML = renderSettings(); 5472 5473 // Le DOM de l'écran précédent vient d'être remplacé : libérer toutes les 5474 // instances dont le canvas n'existe plus avant de créer les nouveaux charts. 5475 destroyChartsHorsDom(); 5476 5477 const bandeauPartiel = renderBandeauDonneesPartielles(); 5478 if (bandeauPartiel) app.insertAdjacentHTML('afterbegin', bandeauPartiel); 5479 5480 associerLabelsFormulaires(app); 5481 actualiserAuditActionsUI(); 5482 5483 // Post-render : graphiques inline 5484 setTimeout(() => { 5485 if (etatApp.ecran === 'dashboard' && document.getElementById('dashChartVolume')) { 5486 renderVolumeChart('dashChartVolume'); 5487 } 5488 if ((etatApp.ecran === 'my' || etatApp.ecran === 'myprofile') && profile.joueur_id) { 5489 const j = joueurById(profile.joueur_id); 5490 if (j) { 5491 const bornesMP = getPeriodeDates(periodes.myProfile); 5492 if (document.getElementById('myChartClassement')) renderClassementChart('myChartClassement', j); 5493 if (document.getElementById('myChartRadar')) renderRadarBilans('myChartRadar', j); 5494 if (document.getElementById('myChartForme')) renderFormeChart('myChartForme', j); 5495 if (document.getElementById('myChartActivite')) renderActiviteChart('myChartActivite', j, bornesMP); 5496 if (document.getElementById('myChartCumul')) renderCumulChart('myChartCumul', j, etatApp.ecran === 'myprofile' ? null : bornesMP); 5497 } 5498 } 5499 // Hydratation météo : remplit les placeholders <div id="meteoCompact_*"> 5500 hydrateMeteoPlaceholders(); 5501 if (restaurerPosition) { 5502 window.scrollTo({ top: positionAvantRendu, behavior: 'auto' }); 5503 if (focusAvantRendu) document.getElementById(focusAvantRendu)?.focus({ preventScroll: true }); 5504 } 5505 }, 30); 5506 5507 const fab = $('fab'); 5508 fab.style.display = 'none'; 5509 dernierEcranRendu = etatApp.ecran; 5510} 5511 5512function renderBandeauDonneesPartielles() { 5513 const indisponibles = listeSure(cache.donneesIndisponibles); 5514 if (indisponibles.length === 0) return ''; 5515 const labels = indisponibles.map(label => escapeHtml(String(label).replace(/_/g, ' '))).join(', '); 5516 return `<div class="partial-data-banner" role="status" aria-live="polite"> 5517 <div><strong>Données temporairement partielles</strong>Impossible dâactualiser : ${labels}. Les autres écrans restent utilisables ; réessaie lorsque la connexion est meilleure.</div> 5518 </div>`; 5519} 5520 5521function renderUxScreenHeading(kicker, titre) { 5522 return `<div class="ux-screen-heading"><div class="ux-home-kicker">${escapeHtml(kicker)}</div><h2>${escapeHtml(titre)}</h2></div>`; 5523} 5524 5525function estCreneauDuCoachConnecte(creneau) { 5526 if (!creneau || !creneau.coach || !profile) return false; 5527 const normaliser = TennisDomainRules.normaliserTexteRecherche; 5528 const coach = normaliser(creneau.coach); 5529 return coach === normaliser(profile.prenom || '') || coach === normaliser(`${profile.prenom || ''} ${profile.nom || ''}`.trim()); 5530} 5531 5532function renderCoachCockpit(joueursActifs, matchsPeriode, alertes) { 5533 const comptes = comptesMatchs(matchsPeriode); 5534 const perfsCockpit = computeBellePerfs(periodes.dashboard); 5535 const joueursCockpitIds = new Set(joueursActifs.map(joueur => joueur.id)); 5536 const demandesClassementCockpit = isPoleEnv() ? (listeSure(cache.classementDemandes)) 5537 .filter(d => d.statut === 'en_attente' && joueursCockpitIds.has(d.joueur_id)) 5538 .sort((a, b) => (a.date_publication_prevue || '').localeCompare(b.date_publication_prevue || '')) 5539 .slice(0, 4) : []; 5540 const prochainesSeances = isAcademieEnv() ? (listeSure(cache.seancesCours)) 5541 .filter(s => s.date >= dateLocaleISO(new Date()) && estCreneauDuCoachConnecte(creneauCoursById(s.creneau_id))) 5542 .sort((a, b) => (a.date || '').localeCompare(b.date || '')) : [];
5543 const rendezVous = prochainesSeances.slice(0, 3) 5544 .map(seance => ({ seance, creneau: creneauCoursById(seance.creneau_id) })) 5545 .filter(item => item.creneau); 5546 const ordreSeverite = { danger: 0, warning: 1, info: 2 }; 5547 const priorites = (listeSure(alertes)).slice().sort((a, b) => (ordreSeverite[a.type] ?? 9) - (ordreSeverite[b.type] ?? 9)).slice(0, 4); 5548 const monteesCockpit = monteesClassementPeriode(periodes.dashboard); 5549 const joueursCockpitEnAttente = !isPoleEnv() ? [] : (listeSure(cache.joueurs)) 5550 .filter(joueur => joueur.statut === 'en_attente_validation' && joueurDansEnvCourant(joueur)); 5551 const bilansCockpit = isPoleEnv() 5552 ? (listeSure(cache.bilans)).filter(bilan => joueursCockpitIds.has(bilan.joueur_id) && bilan.saison === getSaisonCourante()) 5553 : (listeSure(cache.bilansEvenement)).filter(bilan => joueursCockpitIds.has(bilan.joueur_id) && bilan.evenement_id === getActiveEvenement()?.id); 5554 const bilansAPreparer = bilansCockpit.filter(bilan => bilan.statut === 'brouillon').length; 5555 const ecranPlanning = isPoleEnv() ? 'planningcours' : 'planning'; 5556 const groupesCockpit = isAcademieEnv() ? ['tremplin', 'accession', 'elite'].map(key => { 5557 const joueurs = joueursActifs.filter(j => j.groupe === key); 5558 const ids = new Set(joueurs.map(j => j.id)); 5559 const matchs = matchsPeriode.filter(m => ids.has(m.joueur_id)); 5560 const stats = comptesMatchs(matchs); 5561 const alertesGroupe = (listeSure(alertes)).filter(a => a.groupe === key).length; 5562 return { key, nom: (cache.config[key] || {}).nom || key, joueurs: joueurs.length, matchs: stats.total, taux: stats.total ? stats.tauxVictoire : null, alertes: alertesGroupe }; 5563 }) : []; 5564 const nomEnvironnement = isPreAcademieEnv() ? 'Pré-Académie' : (isAcademieEnv() ? 'Académie' : 'Palmyre'); 5565 const decisionCards = [ 5566 ...joueursCockpitEnAttente.slice(0, 2).map(joueur => ({ 5567 titre: `${joueur.prenom || ''} ${joueur.nom || ''}`.trim() || 'Nouveau joueur', detail: 'Compte complet, en attente de validation et dâaffectation', badge: 'à valider', kind: 'validation', type: 'warning' 5568 })), 5569 ...priorites.slice(0, Math.max(0, 4 - joueursCockpitEnAttente.length)).map(alerte => { 5570 const joueur = joueurById(alerte.joueurId) || {}; 5571 return { titre: `${joueur.prenom || 'Joueur'} ${joueur.nom || ''}`.trim(), detail: alerte.text || 'Suivi joueur à consulter', badge: ALERT_TYPE_LABELS[alerte.alertType] || 'à étudier', kind: 'joueur', joueurId: alerte.joueurId, type: alerte.type || 'info' }; 5572 }) 5573 ]; 5574 if (bilansAPreparer && decisionCards.length < 4) decisionCards.push({ titre: `${bilansAPreparer} bilan${bilansAPreparer > 1 ? 's' : ''} à finaliser`, detail: 'Les synthèses enregistrées en brouillon sont prêtes à être relues', badge: 'à traiter', kind: 'bilans', type: 'warning' }); 5575 return `<section class="ux-coach-cockpit" aria-labelledby="coach-home-title"> 5576 <div class="ux-home-heading"><div><div class="ux-home-kicker">Pilotage ${escapeHtml(nomEnvironnement)}</div><h2 class="ux-home-title" id="coach-home-title">Mon cockpit</h2><div class="ux-coach-subtitle">Les décisions utiles, sans chercher dans toute l'application.</div></div><button class="ux-section-cta neutral" data-ui-click="navigate('overview')"><img src="assets/v4/nav/dashboard.webp" alt=""><span>Vue détaillée</span><b>â</b></button></div> 5577 ${renderPeriodeSelector(periodes.dashboard, 'setPeriodeDashboard', { afficherSaison: false })} 5578 <div class="ux-summary-grid coach"> 5579 <button class="ux-summary-card coach matches" data-ui-click="navigate('matches')"><img src="assets/v4/nav/matches.webp" alt=""><div><div class="ux-summary-label">Matches joués</div><div class="ux-summary-value">${comptes.total}</div><div class="ux-summary-detail">${escapeHtml(getPeriodeLabel(periodes.dashboard))}</div></div></button> 5580 <button class="ux-summary-card coach wins" data-ui-click="navigate('matches')"><img src="assets/v4/nav/performances.webp" alt=""><div><div class="ux-summary-label">Taux de victoires</div><div class="ux-summary-value">${comptes.total ? comptes.tauxVictoire + '%' : 'â'}</div><div class="ux-summary-detail">WO exclus des statistiques</div></div></button> 5581 <button class="ux-summary-card coach players" data-ui-click="navigate('players')"><img src="assets/v4/nav/players.webp" alt=""><div><div class="ux-summary-label">Joueurs actifs</div><div class="ux-summary-value">${joueursActifs.length}</div><div class="ux-summary-detail">${isAcademieEnv() ? 'Tous les groupes' : nomEnvironnement} â</div></div></button> 5582 <button class="ux-summary-card coach alerts ${(listeSure(alertes)).length ? 'has-alerts' : ''}" data-ui-click="navigate('alertes')"><img src="assets/v4/nav/alerts.webp" alt=""><div><div class="ux-summary-label">Alertes</div><div class="ux-summary-value">${(listeSure(alertes)).length}</div><div class="ux-summary-detail">${(listeSure(alertes)).length ? 'à traiter maintenant â' : 'Aucune urgence'}</div></div></button> 5583 </div> 5584 <div class="ux-cockpit-workspace"> 5585 <section class="ux-cockpit-panel ux-steering-panel" aria-labelledby="cockpit-steering-title"> 5586 <div class="ux-cockpit-panel-head"><div><strong id="cockpit-steering-title">Pilotage rapide</strong><span>Priorités joueurs préparées pour toi</span></div>${(listeSure(alertes)).length ? `<button class="ux-section-cta danger" data-ui-click="navigate('alertes')">Voir les ${(listeSure(alertes)).length} alertes</button>` : `<b>${decisionCards.length} décision${decisionCards.length > 1 ? 's' : ''}</b>`}</div> 5587 <div class="ux-cockpit-tabs" aria-label="Catégories de pilotage"><span class="active">à traiter</span><button data-ui-click="navigate('alertes')">à surveiller</button><button data-ui-click="openCoachPerformanceSection('belles')">Progressions</button></div> 5588 ${decisionCards.length ? `<div class="ux-decision-list">${decisionCards.map(decision => { 5589 const contenu = `<i aria-hidden="true"></i><span><strong>${escapeHtml(decision.titre)}</strong><small>${escapeHtml(decision.detail)}</small></span><em>${escapeHtml(decision.badge)} â</em>`; 5590 if (decision.kind === 'validation') return `<button class="ux-decision-row ${escapeHtml(decision.type)}" data-ui-click="navigate('validations')">${contenu}</button>`; 5591 if (decision.kind === 'bilans') return `<button class="ux-decision-row ${escapeHtml(decision.type)}" data-ui-click="navigate('bilans')">${contenu}</button>`; 5592 return `<button class="ux-decision-row ${escapeHtml(decision.type)}" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(decision.joueurId))})">${contenu}</button>`; 5593 }).join('')}</div>` : `<div class="ux-cockpit-empty"><strong>Tout est à jour</strong><span>Aucune décision urgente dans ${escapeHtml(nomEnvironnement)}.</span></div>`} 5594 </section> 5595 <div class="ux-cockpit-side"> 5596 <section class="ux-cockpit-panel" aria-labelledby="cockpit-actions-title"> 5597 <div class="ux-cockpit-panel-head"><div><strong id="cockpit-actions-title">Actions essentielles</strong><span>Les raccourcis utiles dans cet environnement</span></div></div> 5598 <div class="ux-actions coach ${isPoleEnv() ? 'has-notes' : ''}"> 5599 <button class="ux-action primary" data-ui-click="navigate('players')"><img src="assets/v4/nav/players.webp" alt=""><span>Suivre mes joueurs<small>Fiches, progression et objectifs</small></span></button> 5600 ${isPoleEnv() ? `<button class="ux-action" data-ui-click="navigate('calendar')">
5600<img src="assets/v4/nav/tournaments.webp" alt=""><span>Tournois<small>Liste, synchronisation et création</small></span></button>` : ''} 5601 ${isAcademieEnv() 5602 ? `<button class="ux-action" data-ui-click="navigate('presence')"><img src="assets/v4/nav/presence.webp" alt=""><span>Gérer les présences<small>Présents, absents et retards</small></span></button>` 5603 : (!isPoleEnv() 5604 ? `<button class="ux-action" data-ui-click="navigate('debrief')"><img src="assets/v4/nav/debrief.webp" alt=""><span>Préparer le débrief<small>Synthèse de la journée intensive</small></span></button>` 5605 : '')} 5606 ${isPoleEnv() ? `<button class="ux-action" data-ui-click="navigate('notes')"><img src="assets/v4/menu/coach_notes.webp" alt=""><span>Notes joueurs<small>Saisir et consulter les notes privées</small></span></button>` : ''} 5607 </div> 5608 </section> 5609 <section class="ux-cockpit-panel ux-progress-overview" aria-labelledby="cockpit-progress-title"> 5610 <div class="ux-cockpit-panel-head"><div><strong id="cockpit-progress-title">Signaux de progression</strong><span>Sans action obligatoire · ${perfsCockpit.length + monteesCockpit.length} ${perfsCockpit.length + monteesCockpit.length > 1 ? 'signaux' : 'signal'}</span></div><button class="ux-section-cta compact success" data-ui-click="navigate('perfs')"><span>Toutes les performances</span><b>â</b></button></div> 5611 <button data-ui-click="openCoachPerformanceSection('belles')"><span>Belles perfs<small>${perfsCockpit.length ? `${perfsCockpit.length} signalée${perfsCockpit.length > 1 ? 's' : ''} sur la période` : 'Aucune sur cette période'}</small></span><em>${perfsCockpit.length} â</em></button> 5612 ${isPoleEnv() ? `<button data-ui-click="openCoachPerformanceSection('montees')"><span>Très belles perfs<small>Montées de classement officielles depuis la dernière publication FFT</small></span><em>${monteesCockpit.length} â</em></button>` : ''} 5613 ${demandesClassementCockpit.length ? `<button data-ui-click="navigate('perfs')"><span>Classements à vérifier<small>à vérifier sur TenâUp après publication</small></span><em>${demandesClassementCockpit.length} â</em></button>` : ''} 5614 </section> 5615 </div> 5616 </div> 5617 ${groupesCockpit.length ? `<div class="ux-coach-section-head"><div><div class="ux-home-kicker">Lecture collective</div><div class="sub-title">Santé des groupes</div></div><button class="ux-section-cta compact" data-ui-click="navigate('groupes')"><img src="assets/v4/nav/group.webp" alt=""><span>Ouvrir les groupes</span><b>â</b></button></div><div class="ux-group-pulse-grid">${groupesCockpit.map(g => `<button class="ux-group-pulse ${escapeHtml(g.key)}" data-ui-click="navigate('groupes', true, ${escapeHtml(encoderArgumentTexteUI(g.key))})"><div class="ux-group-pulse-top"><strong>${escapeHtml(g.nom)}</strong>${g.alertes ? `<span class="ux-group-alert-count">${g.alertes} alerte${g.alertes > 1 ? 's' : ''}</span>` : '<span class="ux-group-ok">à jour</span>'}</div><div class="ux-group-pulse-stats"><span><b>${g.joueurs}</b> joueurs</span><span><b>${g.matchs}</b> matchs</span><span><b>${g.taux === null ? 'â' : g.taux + '%'}</b> victoires</span></div></button>`).join('')}</div>` : ''} 5618 ${rendezVous.length ? `<div class="ux-coach-section-head"><div><div class="ux-home-kicker">Organisation</div><div class="sub-title">Prochaines séances par groupe · mes créneaux</div></div><button class="ux-section-cta compact" data-ui-click="navigate(${escapeHtml(encoderArgumentTexteUI(ecranPlanning))})"><img src="assets/v4/nav/planning.webp" alt=""><span>Tout le planning</span><b>â</b></button></div><div class="ux-upcoming-grid">${rendezVous.map(({ seance, creneau }) => { const d = new Date(`${seance.date}T12:00:00`); return `<button class="ux-course-card ux-course-button" data-ui-click="navigate(${escapeHtml(encoderArgumentTexteUI(ecranPlanning))})"><div class="ux-course-date"><strong>${d.getDate()}</strong>${d.toLocaleDateString('fr-FR', { month: 'short' }).replace('.', '')}</div><div><div class="ux-course-title">${escapeHtml(creneau.nom || 'Groupe')}</div><div class="ux-course-meta">${escapeHtml(creneau.heure_debut || '')}â${escapeHtml(creneau.heure_fin || '')} · ð¤ ${escapeHtml(creneau.coach || 'à attribuer')}</div></div><span>â</span></button>`; }).join('')}</div>` : ''} 5619 </section>`; 5620} 5621 5622function renderDashboard() { 5623 if (!isCoach()) return renderPlayerHome(); 5624 // Séparer joueurs actifs et en attente 5625 const joueursActifs = joueursActifsDansEnvCourant(); 5626 const joueursEnAttente = cache.joueurs.filter(j => j.statut === 'en_attente_validation'); 5627 const donneesEnv = TennisDomainRules.donneesDashboardEnvironnement(joueursActifs, cache.matches, isPoleEnv()); 5628 const joueursCompet = donneesEnv.joueursCompet; 5629 // En mode Palmyre, le compteur "joueurs" = participants à l'édition.
5630 const totalJoueurs = donneesEnv.totalJoueurs; 5631 const now = Date.now(); 5632 5633 // Matchs de l'académie sur le périmètre courant. 5634 const matchesCompet = donneesEnv.matchesCompet; 5635 5636 // Période d'analyse. 5637 // - En mode académie : filtrage par dates de la période sélectionnée (trimestre/saison). 5638 // - En mode Palmyre : on compte TOUS les matchs du périmètre (déjà restreints à 5639 // l'édition via appliquerPerimetreMatchs), sans filtre de date â un match peut 5640 // être rattaché à l'édition même si sa date est hors de la fenêtre théorique. 5641 let matchsPeriode; 5642 if (isPoleEnv()) { 5643 const [pDebut, pFin] = getPeriodeDates(periodes.dashboard); 5644 matchsPeriode = matchesCompet.filter(m => TennisDomainRules.dateDansIntervalle(m.date, pDebut, pFin)); 5645 } else { 5646 matchsPeriode = matchesCompet; 5647 } 5648 // WO exclus des compteurs (un forfait n'est pas un match joué). 5649 const comptePeriode = comptesMatchs(matchsPeriode); 5650 const matchesPeriodeCount = comptePeriode.total; 5651 const tauxVictoirePeriode = comptePeriode.tauxVictoire; 5652 5653 const totalMatches = comptesMatchs(matchesCompet).total; 5654 const alertes = cache.alertes; 5655 5656 let html = ''; 5657 html += renderCoachCockpit(joueursActifs, matchsPeriode, alertes); 5658 html += `<details class="ux-dashboard-details" style="margin-top:24px"><summary class="btn btn-full">Afficher le tableau de bord détaillé</summary><div style="margin-top:16px">`; 5659 5660 // Bandeau d'environnement Palmyre (contexte visuel quand on n'est pas sur l'académie) 5661 if (!isPoleEnv()) { 5662 const ev = getActiveEvenement(); 5663 const nbParticipants = joueursActifs.length; 5664 html += `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)"> 5665 <div style="font-weight:700; font-size:15px">ðï¸ ${ev ? escapeHtml(ev.nom) : 'Palmyre'}</div> 5666 <div class="text-faint" style="font-size:12px; margin-top:2px">Vue sur toute la semaine · ${nbParticipants} participant${nbParticipants > 1 ? 's' : ''} · seuls les matchs de l'événement sont comptés</div> 5667 </div>`; 5668 } 5669 5670 if (isPoleEnv() && joueursEnAttente.length > 0) { 5671 html += `<div class="alert warning" style="margin-bottom:16px; cursor:pointer; align-items:center" data-ui-click="navigate('validations')"> 5672 <span class="icon">ð</span> 5673 <div class="alert-content"> 5674 <strong>${joueursEnAttente.length} nouvelle${joueursEnAttente.length > 1 ? 's' : ''} inscription${joueursEnAttente.length > 1 ? 's' : ''} à valider</strong> 5675 <div style="font-size:11px; margin-top:2px">Touche pour examiner et affecter à un groupe â</div> 5676 </div> 5677 </div>`; 5678 } 5679 5680 // Demandes provisoires : le classement officiel ne change qu'après la 5681 // publication FFT et une vérification explicite du coach sur Ten'Up. 5682 const joueursEnvIds = new Set(joueursActifs.map(joueur => joueur.id)); 5683 const demandesClass = isPoleEnv() ? (listeSure(cache.classementDemandes)) 5684 .filter(d => d.statut === 'en_attente' && joueursEnvIds.has(d.joueur_id)) : []; 5685 if (demandesClass.length > 0) { 5686 html += `<div class="card" style="margin-bottom:16px; border-left:3px solid var(--orange)"> 5687 <div class="section-title" style="margin-top:0">ð Classements à vérifier (${demandesClass.length})</div>`; 5688 demandesClass.forEach(d => { 5689 const jr = joueurById(d.joueur_id); 5690 const nom = jr ? `${jr.prenom} ${jr.nom}` : 'Joueur'; 5691 const montee = getRankIndex(d.nouveau_classement) > getRankIndex(d.ancien_classement); 5692 const disponible = TennisDomainRules.classementPeutEtreConfirme(d.date_publication_prevue, new Date()); 5693 html += `<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; padding:8px 0; border-bottom:1px solid var(--border)"> 5694 <div style="font-size:14px"> 5695 <strong>${escapeHtml(nom)}</strong> demande <strong>${escapeHtml(d.ancien_classement || 'NC')}</strong> â <strong class="${montee ? 'success' : ''}">${escapeHtml(d.nouveau_classement)}</strong> ${montee ? 'ð' : ''} 5696 <div class="text-faint" style="font-size:11px;margin-top:3px">${disponible ? 'Publié : vérifie le classement sur TenâUp avant confirmation.' : `Publication FFT prévue le ${formatDate(d.date_publication_prevue)}.`}</div> 5697 </div> 5698 <div style="display:flex; gap:6px"> 5699 ${disponible ? `<button class="btn btn-sm" data-ui-click="traiterDemandeClassement(${escapeHtml(encoderArgumentTexteUI(d.id))}, true)">â Confirmer officiel</button>` : `<button class="btn btn-sm" disabled title="Disponible le ${formatDate(d.date_publication_prevue)}">
5699â³ En attente FFT</button>`} 5700 <button class="btn btn-sm btn-danger" data-ui-click="traiterDemandeClassement(${escapeHtml(encoderArgumentTexteUI(d.id))}, false)">â Refuser</button> 5701 </div> 5702 </div>`; 5703 }); 5704 html += `</div>`; 5705 } 5706 5707 // Sélecteur de période 5708 html += renderPeriodeSelector(periodes.dashboard, 'setPeriodeDashboard', { afficherSaison: false }); 5709 5710 const nbPerfsDash = computeBellePerfs(periodes.dashboard).length; 5711 html += ` 5712 <div class="stat-grid"> 5713 <div class="stat-card clickable" data-ui-click="navigate('players')" title="Voir la liste des joueurs"><div class="stat-label">${isPoleEnv() ? 'Joueurs actifs' : 'Participants'}</div><div class="stat-value orange">${totalJoueurs}</div><div class="stat-sub">${isAcademieEnv() ? 'académie compét â' : (isPreAcademieEnv() ? 'pré-académie â' : 'à la Palmyre â')}</div></div> 5714 <div class="stat-card clickable" data-ui-click="navigate('matches')" title="Voir tous les matches"><div class="stat-label">${isPoleEnv() ? `Matches ${periodes.dashboard === 'saison' ? 'saison' : periodes.dashboard}` : 'Matches semaine'}</div><div class="stat-value">${matchesPeriodeCount}</div><div class="stat-sub">${isPoleEnv() ? `${totalMatches} au total â` : 'sur le stage â'}</div></div> 5715 <div class="stat-card clickable" data-ui-click="navigate('matches')" title="Voir le détail des matches"><div class="stat-label">Taux victoires</div><div class="stat-value ${isAcademieEnv() && tauxVictoirePeriode >= 55 ? 'success' : ''}">${tauxVictoirePeriode}%</div><div class="stat-sub">${isAcademieEnv() ? 'objectif 55% â' : (isPreAcademieEnv() ? 'sur la période â' : 'sur le stage â')}</div></div> 5716 ${isAcademieEnv() 5717 ? `<div class="stat-card ${alertes.length > 0 ? 'clickable' : ''}" ${alertes.length > 0 ? `data-ui-click="navigate('alertes')"` : ''} title="${alertes.length > 0 ? 'Voir le détail des alertes' : ''}"><div class="stat-label">Alertes</div><div class="stat-value ${alertes.length > 0 ? 'danger' : ''}">${alertes.length}</div><div class="stat-sub">${alertes.length > 0 ? 'voir détail â' : 'à traiter'}</div></div>` 5718 : `<div class="stat-card clickable" data-ui-click="navigate('perfs')" title="Voir les perfs"><div class="stat-label">Perfs</div><div class="stat-value success">${nbPerfsDash}</div><div class="stat-sub">belles perfs â</div></div>`} 5719 </div> 5720 `; 5721 5722 // Section "Groupes" : uniquement en Académie (pas de groupes en Pré-Académie ni en Palmyre) 5723 if (isAcademieEnv()) { 5724 html += `<div class="section-title">Groupes</div><div class="group-grid">`; 5725 ['tremplin','accession','elite'].forEach(key => { 5726 const g = cache.config[key]; 5727 if (!g) return; 5728 const count = joueursActifs.filter(j => j.groupe === key).length; 5729 const pct = g.places > 0 ? Math.min(100, Math.round((count / g.places) * 100)) : 0; 5730 const cls = pct >= 90 ? '' : pct >= 50 ? 'warning' : 'danger'; 5731 html += `<div class="group-card ${escapeHtml(key)} clickable" data-ui-click="setPlayerFilter(${escapeHtml(encoderArgumentTexteUI(key))}); navigate('players', true)" title="Voir les joueurs de ce groupe"><span class="badge badge-${escapeHtml(key)}">${escapeHtml(g.nom)}</span> 5732 <div class="group-count">${count}<span class="total"> / ${g.places} places</span></div> 5733 <div class="progress-bar"><div class="progress-fill ${cls}" style="width:${pct}%"></div></div> 5734 <div class="text-faint" style="font-size:11px; margin-top:6px">Objectif ${g.objectifMatches} matches/an â</div></div>`; 5735 }); 5736 html += `</div>`; 5737 } 5738 5739 // ====================== BELLES PERFS (au-dessus des alertes) ====================== 5740 const bellePerfs = computeBellePerfs(periodes.dashboard); 5741 if (bellePerfs.length > 0) { 5742 html += `<div class="section-title" style="display:flex; justify-content:space-between; align-items:center"> 5743 <span style="display:inline-flex;align-items:center;gap:8px">${progressArtHtml('perfs',{size:34})} Belles perfs <span class="text-faint" style="font-weight:400; text-transform:none; letter-spacing:0; font-size:12px">â ${getPeriodeLabel(periodes.dashboard)}</span></span> 5744 ${bellePerfs.length > 5 ? `<button class="btn btn-sm" data-ui-click="navigate('perfs')" style="text-transform:none; letter-spacing:0; font-size:11px">Voir toutes (${bellePerfs.length}) â</button>` : ''} 5745 </div>`;
5746 bellePerfs.slice(0, 5).forEach(p => { 5747 const groupBadge = p.groupe 5748 ? `<span class="badge badge-${p.groupe}" style="font-size:10px; margin-left:6px">${escapeHtml((cache.config[p.groupe] || {}).nom || p.groupe)}</span>` 5749 : ''; 5750 const ecartLabel = p.ecart >= 3 ? ' <span class="badge" style="background:#dfff35;color:#071d1f;font-size:10px">exceptionnelle</span>' : ''; 5751 html += `<div class="alert success" data-ui-click="openModalMatch(${escapeHtml(encoderArgumentTexteUI(p.match.id))})" style="margin-bottom:6px; cursor:pointer"> 5752 ${progressArtHtml('perfs',{size:42})} 5753 <div class="alert-content"> 5754 <span><strong>${escapeHtml(p.joueur.prenom)} ${escapeHtml(p.joueur.nom)}</strong> (${escapeHtml(p.classementEpoque || p.joueur.classement)}) bat <strong>${escapeHtml(p.adversaire)}</strong> (${escapeHtml(p.adversaire_class)})${p.estDouble ? ` <span class="format-double-badge">Double</span>` : ''}${ecartLabel}</span>${groupBadge} 5755 <div class="text-faint" style="font-size:11px; margin-top:2px">${formatDate(p.date)}${p.estDouble ? ' · double' : ''}${p.score ? ' · ' + escapeHtml(p.score) : ''}</div> 5756 </div> 5757 </div>`; 5758 }); 5759 } 5760 5761 if (isPoleEnv() && alertes.length > 0) { 5762 html += `<div class="section-title" style="display:flex; justify-content:space-between; align-items:center"> 5763 <span>Alertes</span> 5764 ${alertes.length > 5 ? `<button class="btn btn-sm" data-ui-click="navigate('alertes')" style="text-transform:none; letter-spacing:0; font-size:11px">Voir toutes (${alertes.length}) â</button>` : ''} 5765 </div>`;
5766 alertes.slice(0, 5).forEach(a => { 5767 const groupBadge = a.groupe 5768 ? `<span class="badge badge-${a.groupe}" style="font-size:10px; margin-left:6px">${escapeHtml((cache.config[a.groupe] || {}).nom || a.groupe)}</span>` 5769 : ''; 5770 html += `<div class="alert ${a.type}" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(a.joueurId))})" style="margin-bottom:6px"> 5771 ${a.alertType === 'inactivite' ? `<span class="inactivity-art">${progressArtHtml('inactivite',{size:42})}</span>` : progressArtHtml(a.art || 'objectif_groupe',{size:42})} 5772 <div class="alert-content"><span>${escapeHtml(a.text)}</span>${groupBadge}</div></div>`; 5773 }); 5774 } 5775 5776 const derniers = matchesCompet.slice(0, 5); 5777 if (derniers.length > 0) { 5778 html += `<div class="section-title">Activité matches <span class="text-faint" style="font-weight:400; text-transform:none; letter-spacing:0; font-size:12px">â saison ${getSaisonAnalyse()}</span></div>`; 5779 html += `<div class="card" style="height:240px; padding:14px"><canvas id="dashChartVolume"></canvas></div>`; 5780 } 5781 5782 if (derniers.length > 0) { 5783 html += `<div class="section-title">Derniers matches</div><div class="card">`; 5784 derniers.forEach(m => { 5785 const j = joueurById(m.joueur_id); 5786 if (!j) return; 5787 html += `<div class="match-row clickable" data-ui-click="openModalMatch(${escapeHtml(encoderArgumentTexteUI(m.id))})" title="Cliquer pour voir/modifier ce match"> 5788 <div class="match-result-row"> 5789 <span class="match-opponent"><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong> vs ${escapeHtml(m.adversaire)} (${escapeHtml(m.adversaire_class || '?')})</span> 5790 ${resultatMatchHtml(m)} 5791 </div> 5792 <div class="match-meta">${escapeHtml(m.tournoi || 'Match')} · ${formatDate(m.date)}</div> 5793 </div>`; 5794 }); 5795 html += `</div>`; 5796 } 5797 5798 if (totalJoueurs === 0) { 5799 html += `<div class="empty"><span class="icon">ð¾</span>Aucun joueur dans l'académie pour le moment.<br> 5800 <button class="btn btn-primary mt-2" data-ui-click="navigate('players'); setTimeout(()=>openModalPlayer(), 100)">+ Ajouter un joueur</button></div>`; 5801 } 5802 5803 return html + `</div></details>`; 5804} 5805 5806// ============================================================ 5807// SCREEN: VALIDATIONS (coach) - Joueurs en attente d'affectation 5808// ============================================================ 5809function renderValidations() { 5810 if (!isCoach()) return ''; 5811 const enAttente = cache.joueurs.filter(j => j.statut === 'en_attente_validation'); 5812 5813 if (enAttente.length === 0) { 5814 return `<div class="empty"> 5815 <span class="icon">â</span> 5816 Aucune inscription en attente.<br> 5817 <span class="text-faint">Les nouvelles inscriptions joueur apparaîtront ici.</span> 5818 </div>`; 5819 } 5820 5821 let html = `<div class="section-title">Inscriptions à valider (${enAttente.length})</div>`; 5822 html += `<div class="text-dim mb-2" style="font-size:13px">Examine chaque inscription, affecte le joueur à un groupe et définis son objectif. Tu peux aussi refuser une inscription.</div>`; 5823 5824 enAttente.forEach(j => { 5825 const age = computeAge(j); 5826 const isMineur = age !== null && age < 18; 5827 html += `<div class="card" data-ui-click="openValidationModal(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="cursor:pointer"> 5828 <div class="row-between mb-1"> 5829 <div> 5830 <strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong> 5831 ${age !== null ? `<span class="text-dim"> · ${age} ans${isMineur ? ' (mineur)' : ''}</span>` : ''} 5832 </div> 5833 <span class="badge badge-warning">ð nouveau</span> 5834 </div> 5835 <div class="text-dim" style="font-size:13px"> 5836 Classement déclaré : <strong>${escapeHtml(j.classement || 'NC')}</strong> · ${j.sexe === 'F' ? 'Féminin' : 'Masculin'} 5837 </div> 5838 ${isMineur && (!j.parent_telephone && !j.parent_email) ? `<div class="alert danger" style="margin-top:8px; padding:6px 10px; font-size:12px"><span class="icon">â </span><div>Mineur sans contact parental complet</div></div>` : ''} 5839 <div class="text-faint" style="font-size:11px; margin-top:6px">Touche pour examiner â</div> 5840 </div>`; 5841 }); 5842 5843 return html; 5844} 5845 5846function openValidationModal(joueurId) { 5847 const j = joueurById(joueurId); 5848 if (!j) return; 5849 const age = computeAge(j); 5850 const isMineur = age !== null && age < 18; 5851 5852 let html = ` 5853 <div class="modal-header"> 5854 <h3 class="modal-title">Valider l'inscription</h3> 5855 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 5856 </div> 5857 5858 <div class="flex gap-1 mb-2" style="align-items:center"> 5859 ${avatarHtml(j, 'lg')} 5860 <div class="flex-1"> 5861 <div style="font-size:17px; font-weight:700">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</div> 5862 <div class="text-dim" style="font-size:13px">${age !== null ? age + ' ans' : 'âge ?'} · ${j.sexe === 'F' ? 'Féminin' : 'Masculin'}${isMineur ? ' · <span style="color:var(--warning-text)">mineur</span>' : ''}</div> 5863 </div> 5864 </div> 5865 5866 <div class="section-title" style="margin-top:0">Informations saisies par le joueur</div> 5867 <div class="card" style="font-size:13px; line-height:1.6"> 5868 <div><strong>Date de naissance :</strong> ${j.date_naissance ? formatDate(j.date_naissance) : 'non renseignée'}</div> 5869 <div><strong>Téléphone :</strong>
5869 ${escapeHtml(j.telephone || 'non renseigné')}</div> 5870 <div><strong>Classement déclaré :</strong> ${escapeHtml(j.classement || 'NC')}</div> 5871 <div><strong>Points forts :</strong> ${(j.points_forts && j.points_forts.length) ? j.points_forts.map(escapeHtml).join(', ') : '<span class="text-faint">non renseignés</span>'}</div> 5872 <div><strong>Axes de progrès :</strong> ${(j.axes_progres && j.axes_progres.length) ? j.axes_progres.map(escapeHtml).join(', ') : '<span class="text-faint">non renseignés</span>'}</div> 5873 </div> 5874 5875 ${isMineur ? ` 5876 <div class="section-title">Contact parental</div> 5877 <div class="card" style="font-size:13px; line-height:1.6"> 5878 <div><strong>Nom :</strong> ${escapeHtml(j.parent_nom || 'non renseigné')}</div> 5879 <div><strong>Téléphone :</strong> ${escapeHtml(j.parent_telephone || 'non renseigné')}</div> 5880 <div><strong>Email :</strong> ${escapeHtml(j.parent_email || 'non renseigné')}</div> 5881 </div>` : ''} 5882 5883 <div class="section-title">Consentements</div> 5884 <div class="card" style="font-size:13px; line-height:1.6"> 5885 <div>${j.consentement_rgpd ? 'â' : 'â'} <strong>RGPD :</strong> ${j.consentement_rgpd ? 'accepté' : 'NON accepté'}</div> 5886 ${isMineur ? `<div>${j.consentement_parental ? 'â' : 'â'} <strong>Consentement parental :</strong> ${j.consentement_parental ? 'déclaré' : 'non coché'}</div>` : ''} 5887 ${j.consentement_date ? `<div class="text-faint" style="font-size:11px; margin-top:4px">Date : ${formatDate(j.consentement_date)}</div>` : ''} 5888 </div> 5889 5890 <div class="section-title">Décision</div> 5891 <div class="form-row"> 5892 <label class="form-label">Affecter au groupe</label> 5893 <div class="pill-group" id="validGroupe"> 5894 ${Object.keys(cache.config).filter(k => typeof cache.config[k] === 'object' && cache.config[k].nom).map(k => ` 5895 <span class="pill" data-value="${k}" data-ui-click="selectPill(this, 'validGroupe')">${escapeHtml(cache.config[k].nom)}</span> 5896 `).join('')} 5897 </div> 5898 </div> 5899 <div class="form-row"> 5900 <label class="form-label">Objectif de classement</label> 5901 <select id="validObjectif"> 5902 <option value="">-</option> 5903 ${CLASSEMENTS.map(c => `<option value="${c}">${c}</option>`).join('')} 5904 </select> 5905 </div> 5906 <div class="form-row"> 5907 <label class="form-label">Note du coach (optionnel)</label> 5908 <textarea id="validNotes" rows="2" placeholder="Première impression, remarques..."></textarea> 5909 </div> 5910 5911 <div class="flex gap-1 mt-2"> 5912 <button class="btn btn-danger" data-ui-click="rejectJoueur(${escapeHtml(encoderArgumentTexteUI(j.id))})">Refuser</button> 5913 <button class="btn flex-1" data-ui-click="closeModal()">Plus tard</button> 5914 <button class="btn btn-primary flex-1" data-ui-click="validateJoueur(${escapeHtml(encoderArgumentTexteUI(j.id))})">â Valider et affecter</button> 5915 </div> 5916 <div class="mt-1" style="text-align:center"> 5917 <button class="btn btn-sm" data-ui-click="validateJoueur(${escapeHtml(encoderArgumentTexteUI(j.id))}, true)" style="color:var(--text-dim)">â Valider sans groupe (accès à l'app, affectation plus tard)</button> 5918 </div> 5919 `; 5920 5921 showModal(html); 5922} 5923 5924async function validateJoueur(joueurId, sansGroupe) { 5925 let groupe = null; 5926 if (!sansGroupe) { 5927 const groupePill = document.querySelector('#validGroupe .pill.active'); 5928 if (!groupePill) { 5929 toast('Choisis un groupe d\'abord (ou utilise « Valider sans groupe »)', true); 5930 return; 5931 } 5932 groupe = groupePill.dataset.value; 5933 } 5934 const objectif = $('validObjectif') ? $('validObjectif').value : ''; 5935 const notes = $('validNotes') ? $('validNotes').value.trim() : ''; 5936 5937 const { error } = await updateConfirmee(db.from('joueurs').update({ 5938 statut: 'actif', 5939 groupe: groupe, 5940 objectif: objectif || null, 5941 notes_coach: notes || null, 5942 updated_at: new Date().toISOString() 5943 }).eq('id', joueurId)); 5944 5945 if (error) { toastErreur(error); return; } 5946 closeModal(); 5947 toast(sansGroupe ? 'Joueur validé (sans groupe)' : 'Joueur validé et affecté'); 5948 5949 // Envoi email de bienvenue au joueur (+ parent si mineur) 5950 const joueur = joueurById(joueurId); 5951 if (joueur) { 5952 if (sansGroupe) { 5953 emailInscriptionValidee({ ...joueur, statut: 'actif', groupe: null }, null); 5954 } else { 5955 const groupeNom = (cache.config[groupe] || {}).nom || groupe; 5956 emailInscriptionValidee({ ...joueur, statut: 'actif', groupe }, groupeNom); 5957 } 5958 } 5959 5960 await refresh(); 5961} 5962 5963async function rejectJoueur(joueurId) { 5964 const j = joueurById(joueurId); 5965 if (!j || j.statut !== 'en_attente_validation') { 5966 toast('Cette inscription nâest plus en attente. Recharge la page.'); 5967 return; 5968 } 5969 if (!(await demanderConfirmation(`Refuser l'inscription de ${j.prenom} ${j.nom} ?\n\nLa fiche et le compte de connexion seront supprimés définitivement. La personne pourra ensuite se réinscrire avec la même adresse email.`, { titre: 'Refuser lâinscription', libelle: 'Refuser et supprimer' }))) return; 5970 5971 const { error } = await db.functions.invoke('reject-registration', { 5972 body: { joueur_id: joueurId } 5973 }); 5974 if (error) { toastErreur(error); return; } 5975 closeModal(); 5976 toast('Inscription refusée et compte supprimé'); 5977 await refresh(); 5978} 5979 5980// ============================================================ 5981// SCREEN: ALERTES (coach) - Toutes les alertes avec filtres 5982// ============================================================ 5983const alertFilters = etatApp.filtresAlertes; 5984 5985function renderAlertes() { 5986 if (!isCoach()) return ''; 5987 const palmyre = !isPoleEnv(); 5988 const alertes = listeSure(cache.alertes); 5989 const entete = renderUxScreenHeading(palmyre ? 'Suivi de lâédition' : 'Pilotage de lâacadémie', palmyre ? 'Joueurs à surveiller' : 'Alertes utiles'); 5990 5991 if (alertes.length === 0) { 5992 return entete + `<div class="empty">
5993 <span class="icon">â</span> 5994 <strong>Aucune alerte en cours</strong><br> 5995 <span class="text-faint">${palmyre ? 'Aucun signal sportif préoccupant sur cette édition.' : "Toute l'académie est dans le vert. Continue comme ça !"}</span> 5996 </div>`; 5997 } 5998 5999 // Compteurs par type pour les filtres 6000 const countByType = {}; 6001 const countByGroupe = { tremplin: 0, accession: 0, elite: 0, sans_groupe: 0 }; 6002 alertes.forEach(a => { 6003 countByType[a.alertType] = (countByType[a.alertType] || 0) + 1; 6004 if (a.groupe) countByGroupe[a.groupe] = (countByGroupe[a.groupe] || 0) + 1; 6005 else countByGroupe.sans_groupe++; 6006 }); 6007 6008 // Filtrage 6009 let visibles = alertes; 6010 if (!palmyre && alertFilters.groupe !== 'all') { 6011 if (alertFilters.groupe === 'sans_groupe') visibles = visibles.filter(a => !a.groupe); 6012 else visibles = visibles.filter(a => a.groupe === alertFilters.groupe); 6013 } 6014 if (alertFilters.type !== 'all') visibles = visibles.filter(a => a.alertType === alertFilters.type); 6015 6016 // Tri : danger d'abord, puis warning, puis info, puis par date desc 6017 const severityOrder = { danger: 0, warning: 1, info: 2 }; 6018 visibles = visibles.slice().sort((a, b) => { 6019 const s = severityOrder[a.type] - severityOrder[b.type]; 6020 if (s !== 0) return s; 6021 return (b.date || '').localeCompare(a.date || ''); 6022 }); 6023 6024 let html = entete + `<div class="section-title">${alertes.length} alerte${alertes.length > 1 ? 's' : ''} en cours</div>`; 6025 6026 // Filtres groupe 6027 html += `<div class="card mb-2" style="padding:12px"> 6028 ${palmyre ? '' : `<div class="text-faint mb-1" style="font-size:11px; text-transform:uppercase; letter-spacing:1px">Filtrer par académie</div> 6029 <div class="pill-group"> 6030 <span class="pill ${alertFilters.groupe === 'all' ? 'active' : ''}" data-ui-click="setAlertFilter('groupe', 'all')">Tous (${alertes.length})</span> 6031 ${['tremplin', 'accession', 'elite'].map(g => `
6032 <span class="pill ${alertFilters.groupe === g ? 'active' : ''}" data-ui-click="setAlertFilter('groupe', ${escapeHtml(encoderArgumentTexteUI(g))})" style="${alertFilters.groupe === g ? 'border-color: var(--' + (g === 'tremplin' ? 'success' : g === 'accession' ? 'info' : 'purple') + ')' : ''}">${escapeHtml((cache.config[g] || {}).nom || g)} (${countByGroupe[g] || 0})</span> 6033 `).join('')} 6034 ${countByGroupe.sans_groupe > 0 ? `<span class="pill ${alertFilters.groupe === 'sans_groupe' ? 'active' : ''}" data-ui-click="setAlertFilter('groupe', 'sans_groupe')">Sans groupe (${countByGroupe.sans_groupe})</span>` : ''} 6035 </div>`} 6036 <div class="text-faint mt-2 mb-1" style="font-size:11px; text-transform:uppercase; letter-spacing:1px">Filtrer par type</div> 6037 <div class="pill-group"> 6038 <span class="pill ${alertFilters.type === 'all' ? 'active' : ''}" data-ui-click="setAlertFilter('type', 'all')">Tous</span> 6039 ${Object.keys(countByType).map(t => ` 6040 <span class="pill ${alertFilters.type === t ? 'active' : ''}" data-ui-click="setAlertFilter('type', ${escapeHtml(encoderArgumentTexteUI(t))})">${escapeHtml(ALERT_TYPE_LABELS[t] || t)} (${countByType[t]})</span> 6041 `).join('')} 6042 </div> 6043 </div>`; 6044 6045 if (visibles.length === 0) { 6046 html += `<div class="empty"><span class="icon">ð</span>Aucune alerte ne correspond à ces filtres.</div>`; 6047 return html; 6048 } 6049 6050 visibles.forEach(a => { 6051 const groupColor = a.groupe === 'tremplin' ? 'success' : a.groupe === 'accession' ? 'info' : a.groupe === 'elite' ? 'purple' : null; 6052 const groupBadge = palmyre ? '<span class="badge badge-warning">Palmyre</span>' : groupColor 6053 ? `<span class="badge badge-${a.groupe}" style="font-size:10px">${escapeHtml((cache.config[a.groupe] || {}).nom || a.groupe)}</span>` 6054 : `<span class="badge badge-warning" style="font-size:10px">sans groupe</span>`; 6055 6056 html += `<div class="alert ${a.type}" style="margin-bottom:8px; cursor:pointer" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(a.joueurId))})"> 6057 ${a.alertType === 'inactivite' ? `<span class="inactivity-art">${progressArtHtml('inactivite',{size:42})}</span>` : progressArtHtml(a.art || 'objectif_groupe',{size:42})} 6058 <div class="alert-content"> 6059 <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px"> 6060 <strong style="flex:1">${escapeHtml(a.text)}</strong> 6061 ${groupBadge} 6062 </div> 6063 <div class="text-faint" style="font-size:11px; margin-top:4px">${escapeHtml(ALERT_TYPE_LABELS[a.alertType] || a.alertType)} · cliquer pour ouvrir la fiche joueur</div> 6064 </div> 6065 </div>`; 6066 }); 6067 6068 return html; 6069} 6070 6071function setAlertFilter(key, value) { 6072 etatApp.definirFiltreAlerte(key, value); 6073 render(); 6074} 6075 6076 6077const perfFilters = etatApp.filtresPerfs; 6078 6079function setPerfFilter(key, value) { 6080 etatApp.definirFiltrePerf(key, value); 6081 render(); 6082} 6083 6084function openCoachPerformanceSection(section) { 6085 if (!isCoach()) return; 6086 navigate('perfs'); 6087 setTimeout(()=>document.getElementById(section === 'montees' ? 'perfs-montees' : 'perfs-belles')?.scrollIntoView({ behavior: 'smooth', block: 'start' }),60); 6088} 6089 6090function setMatchResultFilter(value) { 6091 etatApp.definirFiltreResultat(value); 6092 render(); 6093} 6094 6095function monteesClassementPeriode(periode) { 6096 if (!isPoleEnv()) return []; 6097 const [debut, fin] = getPeriodeDates(periode); 6098 const joueursEnvIds = new Set(joueursActifsDansEnvCourant().map(joueur => joueur.id)); 6099 return (listeSure(cache.classementHistorique)) 6100 .filter(h => TennisDomainRules.dateDansIntervalle(h.date_changement, debut, fin) 6101 && joueursEnvIds.has(h.joueur_id) 6102 && h.source !== 'import' // les montées importées ne sont pas des montées « à célébrer » 6103 && (TennisDomainRules.ecartClassements(h.ancien_classement, h.nouveau_classement) || 0) > 0) 6104 .sort((a, b) => (b.date_changement || '').localeCompare(a.date_changement || '')); 6105} 6106 6107function renderMonteesSection(periode) { 6108 const montees = monteesClassementPeriode(periode); 6109 6110 if (montees.length === 0) return ''; 6111 6112 let html = `<div class="section-title" id="perfs-montees" style="display:flex; align-items:center; gap:10px;scroll-margin-top:150px"> 6113 <img class="section-art-icon" src="assets/v4/nav/performances.webp" alt=""><span>Montées de classement</span> <span class="badge" style="background:var(--success-bg); color:var(--success-text)">${montees.length}</span> 6114 </div>`; 6115 html += `<div class="card" style="padding:8px; margin-bottom:16px">`;
6116 montees.forEach((h, i) => { 6117 const j = joueurById(h.joueur_id); 6118 const nom = j ? `${j.prenom} ${j.nom}` : 'Joueur'; 6119 const gain = TennisDomainRules.ecartClassements(h.ancien_classement, h.nouveau_classement) || 0; 6120 html += `<div class="match-row ${j ? 'clickable' : ''}" ${j ? `data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(h.joueur_id))})"` : ''} style="${i < montees.length - 1 ? 'border-bottom:1px solid var(--border)' : ''}"> 6121 <div class="match-result-row"> 6122 <span style="display:flex;align-items:center;gap:8px">${progressArtHtml('perfs', { size: 27 })}<strong>${escapeHtml(nom)}</strong></span> 6123 <span class="success" style="font-weight:600">${escapeHtml(h.ancien_classement || 'NC')} â ${escapeHtml(h.nouveau_classement)}${gain > 1 ? ` (+${gain})` : ''}</span> 6124 </div> 6125 <div class="match-meta">${formatDate(h.date_changement)}</div> 6126 </div>`; 6127 }); 6128 html += `</div>`; 6129 return html; 6130} 6131 6132function renderPerfs() { 6133 if (!isCoach()) return ''; 6134 const entete = renderUxScreenHeading('Dynamique sportive', 'Performances'); 6135 const perfs = computeBellePerfs(periodes.perfs); 6136 const labelPeriode = getPeriodeLabel(periodes.perfs); 6137 6138 const selector = renderPeriodeSelector(periodes.perfs, 'setPeriodePerfs'); 6139 const monteesBloc = renderMonteesSection(periodes.perfs); 6140 6141 if (perfs.length === 0) { 6142 return entete + selector + monteesBloc + `<div class="empty" id="perfs-belles" style="scroll-margin-top:150px"> 6143 ${progressArtHtml('perfs',{size:64})} 6144 <strong>Aucune belle perf sur cette période</strong><br> 6145 <span class="text-faint">Une perf = victoire sur un adversaire mieux classé. Les perfs de <strong>${labelPeriode}</strong> s'afficheront ici dès qu'un joueur en réalise une.</span> 6146 </div>`; 6147 } 6148 6149 const countByGroupe = { tremplin: 0, accession: 0, elite: 0, sans_groupe: 0 }; 6150 const countBySexe = { F: 0, M: 0, autre: 0 }; 6151 perfs.forEach(p => { 6152 if (p.groupe) countByGroupe[p.groupe] = (countByGroupe[p.groupe] || 0) + 1; 6153 else countByGroupe.sans_groupe++; 6154 const s = p.joueur.sexe; 6155 if (s === 'F' || s === 'M') countBySexe[s]++; 6156 else countBySexe.autre++; 6157 }); 6158 6159 let visibles = perfs; 6160 if (perfFilters.groupe !== 'all') { 6161 if (perfFilters.groupe === 'sans_groupe') visibles = visibles.filter(p => !p.groupe); 6162 else visibles = visibles.filter(p => p.groupe === perfFilters.groupe); 6163 } 6164 if (perfFilters.sexe !== 'all') { 6165 visibles = visibles.filter(p => p.joueur.sexe === perfFilters.sexe); 6166 } 6167 6168 let html = entete + selector + monteesBloc + `<div class="section-title" id="perfs-belles" style="display:flex; align-items:center; gap:10px;scroll-margin-top:150px"> 6169 <span style="display:inline-flex;align-items:center;gap:8px">${progressArtHtml('perfs',{size:36})} Belles perfs</span> 6170 <span class="text-faint" style="font-weight:400; text-transform:none; letter-spacing:0; font-size:13px">â ${labelPeriode}</span> 6171 </div>`; 6172 6173 // Filtres 6174 html += `<div class="card mb-2" style="padding:12px"> 6175 <div class="text-faint mb-1" style="font-size:11px; text-transform:uppercase; letter-spacing:1px">Filtrer par académie</div> 6176 <div class="pill-group"> 6177 <span class="pill ${perfFilters.groupe === 'all' ? 'active' : ''}" data-ui-click="setPerfFilter('groupe', 'all')">Tous (${perfs.length})</span> 6178 ${['tremplin','accession','elite'].map(g => { 6179 if (countByGroupe[g] === 0) return ''; 6180 const color = g === 'tremplin' ? 'success' : g === 'accession' ? 'info' : 'purple'; 6181 const isActive = perfFilters.groupe === g; 6182 return `<span class="pill ${isActive ? 'active' : ''}" data-ui-click="setPerfFilter('groupe', ${escapeHtml(encoderArgumentTexteUI(g))})" style="${isActive ? 'border-color: var(--' + color + '); background: var(--' + color + '-bg); color: var(--' + color + ')' : ''}">${escapeHtml((cache.config[g] || {}).nom || g)} (${countByGroupe[g]})</span>`; 6183 }).join('')} 6184 ${countByGroupe.sans_groupe > 0 ? `<span class="pill ${perfFilters.groupe === 'sans_groupe' ? 'active' : ''}" data-ui-click="setPerfFilter('groupe', 'sans_groupe')">Sans groupe (${countByGroupe.sans_groupe})</span>` : ''} 6185 </div> 6186 <div class="text-faint mt-2 mb-1" style="font-size:11px; text-transform:uppercase; letter-spacing:1px">Filtrer par sexe</div> 6187 <div class="pill-group">
6188 <span class="pill ${perfFilters.sexe === 'all' ? 'active' : ''}" data-ui-click="setPerfFilter('sexe', 'all')">Tous</span> 6189 ${countBySexe.F > 0 ? `<span class="pill ${perfFilters.sexe === 'F' ? 'active' : ''}" data-ui-click="setPerfFilter('sexe', 'F')">Féminin (${countBySexe.F})</span>` : ''} 6190 ${countBySexe.M > 0 ? `<span class="pill ${perfFilters.sexe === 'M' ? 'active' : ''}" data-ui-click="setPerfFilter('sexe', 'M')">Masculin (${countBySexe.M})</span>` : ''} 6191 </div> 6192 </div>`; 6193 6194 if (visibles.length === 0) { 6195 html += `<div class="empty"><span class="icon">ð</span>Aucune perf ne correspond à ces filtres.</div>`; 6196 return html; 6197 } 6198 6199 visibles.forEach(p => { 6200 const groupBadge = p.groupe 6201 ? `<span class="badge badge-${p.groupe}" style="font-size:10px">${escapeHtml((cache.config[p.groupe] || {}).nom || p.groupe)}</span>` 6202 : `<span class="badge badge-warning" style="font-size:10px">sans groupe</span>`; 6203 const ecartLabel = p.ecart >= 3 ? ' <span class="badge" style="background:#dfff35;color:#071d1f;font-size:10px">exceptionnelle</span>' : ''; 6204 const ecartTxt = p.ecart === 1 ? '+1 échelon' : `+${p.ecart} échelons`; 6205 6206 html += `<div class="alert success" data-ui-click="openModalMatch(${escapeHtml(encoderArgumentTexteUI(p.match.id))})" style="margin-bottom:8px; cursor:pointer"> 6207 ${progressArtHtml('perfs',{size:44})} 6208 <div class="alert-content"> 6209 <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px"> 6210 <div style="flex:1"> 6211 <strong>${escapeHtml(p.joueur.prenom)} ${escapeHtml(p.joueur.nom)}</strong> (${escapeHtml(p.classementEpoque || p.joueur.classement)}) bat <strong>${escapeHtml(p.adversaire)}</strong> (${escapeHtml(p.adversaire_class)})${p.estDouble ? ` <span class="format-double-badge">Double</span>` : ''}${ecartLabel} 6212 </div> 6213 ${groupBadge} 6214 </div> 6215 <div class="text-faint" style="font-size:11px; margin-top:4px">${formatDate(p.date)} · ${ecartTxt}${p.estDouble ? ' · double' : ''}${p.score ? ' · ' + escapeHtml(p.score) : ''} · cliquer pour voir le match</div> 6216 </div> 6217 </div>`; 6218 }); 6219 6220 return html; 6221} 6222 6223 6224// ============================================================ 6225// SCREEN: GROUPES (coach) - Tableaux de bord par groupe avec score santé 6226// ============================================================ 6227 6228function setGroupeDetail(key) { 6229 etatApp.definirGroupeDetail(key); 6230 window.scrollTo(0, 0); 6231 render(); 6232} 6233 6234// Calcule le score santé d'un groupe sur 100, sur la PÃRIODE donnée. 6235// Score périodisé : Engagement (35) + Victoires (30) + Perfs (20) 6236// + progression de classement signée (-15 à +15). En Palmyre, la progression 6237// est inapplicable et les 85 points restants sont renormalisés sur 100. 6238// Les alertes ne font pas partie du score (état temps réel, affiché à part). 6239// Config synthétique du pseudo-groupe Palmyre (objectif lu sur l'édition active). 6240function palmyreGroupConfig() { 6241 const ev = getActiveEvenement(); 6242 return { 6243 nom: (ev && ev.nom) || 'Palmyre', 6244 objectifMatches: (ev && ev.objectif_matches != null) ? ev.objectif_matches : 20, 6245 places: null 6246 }; 6247} 6248 6249function computeGroupHealth(key, periode) { 6250 periode = periode || getTrimestreCourant(); 6251 // Pseudo-groupe Palmyre : tous les participants à l'édition, quel que soit leur 6252 // groupe académie. On synthétise une config (objectif basé sur la durée de l'édition). 6253 const isPalmyreGroup = (key === '__palmyre__'); 6254 const g = isPalmyreGroup 6255 ? palmyreGroupConfig() 6256 : cache.config[key]; 6257 if (!g || typeof g !== 'object') return null; 6258 const joueurs = isPalmyreGroup 6259 ? joueursActifsDansEnvCourant() 6260 : cache.joueurs.filter(j => j.groupe === key && TennisDomainRules.estJoueurActif(j)); 6261 if (joueurs.length === 0) { 6262 return { score: null, label: 'Vide', color: 'text-faint', joueurs: [], details: {} }; 6263 } 6264 6265 // Un groupe explicitement configuré hors compétition n'a pas de score santé. 6266 if (g.horsCompet) { 6267 return { score: null, label: 'Hors compét', color: 'text-faint', joueurs, details: {}, horsCompet: true }; 6268 } 6269 6270 // En Académie, un nouveau joueur sans match sportif depuis moins de 30 jours est 6271 // en grâce : il reste visible dans le groupe mais ne pénalise aucun critère moyen. 6272 // En Palmyre, les créneaux planifiés font foi et cette grâce ne s'applique pas. 6273 const joueursEvalues = isPalmyreGroup 6274 ? joueurs 6275 : joueurs.filter(j => !TennisDomainRules.estEnGrace(j, matchesSaisonJoueur(j.id), Date.now())); 6276 6277 const now = Date.now(); 6278 const [perDebut, perFin] = getPeriodeDates(periode); 6279 const periodeFuture = !isPalmyreGroup && TennisDomainRules.etatIntervalle(perDebut, perFin) === 'avenir';
6280 // Matchs d'un joueur sur la période 6281 const matchsPeriodeJoueur = (jid) => matchsHorsWO(matchsDuJoueur(jid)) 6282 .filter(m => TennisDomainRules.dateDansIntervalle(m.date, perDebut, perFin)); 6283 6284 // ======= Critère 1 : Engagement moyen (35 pts) ======= 6285 // Matchs joués sur la période / objectif proratisé sur cette même période. 6286 // Plancher : l'objectif de référence ne descend jamais sous 1 match, sinon en tout 6287 // début de période un seul match suffirait à exploser le ratio (division par ~0). 6288 // Cas Palmyre : le prorata temporel n'a pas de sens pour un stage court et borné. 6289 // L'objectif est désormais PAR JOUEUR = nombre de créneaux (rencontres) qui lui sont 6290 // planifiés dans l'édition. C'est plus juste qu'un objectif fixe commun : un joueur 6291 // inscrit sur plus de tournois/tours a mécaniquement plus de matchs attendus. 6292 // Repli : si un joueur n'a AUCUN créneau planifié, on retombe sur l'objectif fixe de 6293 // l'édition (réglable dans les paramètres) pour ne pas fausser sa lecture. 6294 const objectifFixe = isPalmyreGroup 6295 ? g.objectifMatches 6296 : Math.max(1, getObjectifPeriode(g.objectifMatches, periode)); 6297 6298 // Nombre de créneaux planifiés d'un joueur sur l'édition Palmyre active. 6299 const evPalmyre = isPalmyreGroup ? getActiveEvenement() : null; 6300 const creneauxPlanifiesJoueur = (jid) => { 6301 if (!evPalmyre) return 0; 6302 return creneauxPalmyreDuJoueurEvenement(jid, evPalmyre.id).length; 6303 }; 6304 6305 const donneesJoueurs = joueursEvalues.map(joueur => { 6306 const matchsPeriode = matchsPeriodeJoueur(joueur.id); 6307 const compte = TennisDomainRules.statistiquesSportives( 6308 matchsPeriode, 6309 match => classementADate(joueur.id, match.date) 6310 ); 6311 let objectifJoueur = objectifFixe; 6312 if (isPalmyreGroup) { 6313 const nbCreneaux = creneauxPlanifiesJoueur(joueur.id); 6314 objectifJoueur = nbCreneaux > 0 ? nbCreneaux : objectifFixe; 6315 } 6316 return { 6317 matchs: compte.total, 6318 objectif: objectifJoueur, 6319 victoires: compte.victoires, 6320 total: compte.total, 6321 perfs: compte.perfs, 6322 progression: isPalmyreGroup ? 0 : progressionClassementPeriode(joueur.id, periode) 6323 }; 6324 }); 6325 const sante = TennisDomainRules.calculerSanteGroupe({ 6326 joueurs: donneesJoueurs, 6327 palmyre: isPalmyreGroup, 6328 periodeFuture, 6329 debutPeriode: !isPalmyreGroup && estDebutDePeriode(periode) 6330 }); 6331 const { score, label, color } = sante; 6332 6333 // Alertes : hors score, état temps réel (toujours "maintenant", pas la période) 6334 const alertesGroupe = (listeSure(cache.alertes)).filter(a => a.groupe === key).length; 6335 6336 return { 6337 score, label, color, joueurs, 6338 periode, 6339 joueursEvalues: joueursEvalues.length, 6340 alertesGroupe, 6341 details: sante.details 6342 }; 6343} 6344 6345// === Vue principale : aiguillage liste ou détail === 6346function renderGroupes() { 6347 if (!isCoach()) return ''; 6348 // En mode Palmyre : un seul "groupe" = les participants. On va directement au détail. 6349 if (!isPoleEnv()) { 6350 return renderUxScreenHeading('Collectif', 'Participants') + renderGroupeDetail('__palmyre__'); 6351 } 6352 const groupesConfig = ['tremplin', 'accession', 'elite'].filter(k => { 6353 const g = cache.config[k]; 6354 return g && typeof g === 'object' && g.nom; 6355 }); 6356 if (groupesConfig.length === 0) { 6357 return renderUxScreenHeading('Collectif', 'Mes groupes') + `<div class="empty"><span class="icon">ð</span>Aucun groupe configuré.<br> 6358 <span class="text-faint">Va dans Réglages pour configurer tes groupes (places, objectifs).</span></div>`; 6359 } 6360 if (etatApp.groupeDetail && groupesConfig.includes(etatApp.groupeDetail)) { 6361 return renderUxScreenHeading('Collectif', escapeHtml((cache.config[etatApp.groupeDetail] || {}).nom || 'Mon groupe')) + renderGroupeDetail(etatApp.groupeDetail); 6362 } 6363 return renderUxScreenHeading('Collectif', 'Mes groupes') + renderGroupesListe(groupesConfig); 6364} 6365 6366// === Vue 1 : liste des 3 groupes en cartes === 6367function renderGroupesListe(groupesConfig) { 6368 let html = `<div class="text-faint mb-2" style="font-size:12px"> 6369 <span class="sort-hint-mark"></span>Clique sur un groupe pour voir son tableau de bord détaillé. 6370 </div>`; 6371 6372 html += `<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(280px, 1fr)); gap:12px">`; 6373 6374 // Score affiché sur les étiquettes = trimestre en cours (le "moment"). 6375 // Le détail d'un groupe permet de choisir une autre temporalité (saison, autre T).
6376 const periodeListe = getTrimestreCourant(); 6377 const labelPeriode = getPeriodeLabel(periodeListe); 6378 6379 groupesConfig.forEach(key => { 6380 const g = cache.config[key]; 6381 const health = computeGroupHealth(key, periodeListe); 6382 const horsCompet = !!g.horsCompet; 6383 const color = key === 'tremplin' ? 'success' : key === 'accession' ? 'info' : 'purple'; 6384 const scoreDisplay = horsCompet 6385 ? `<div style="font-size:36px; font-weight:700; color:var(--orange-text)">${health.joueurs.length}</div> 6386 <div class="text-faint" style="font-size:12px; margin-top:2px">joueur${health.joueurs.length > 1 ? 's' : ''} suivi${health.joueurs.length > 1 ? 's' : ''}</div>` 6387 : (health.score !== null 6388 ? `<div style="font-size:36px; font-weight:700; color:var(--${health.color})">${health.score}<span style="font-size:14px; color:var(--text-dim); font-weight:400">/100</span></div> 6389 <div class="text-faint" style="font-size:12px; margin-top:2px">${escapeHtml(health.label)} · ${escapeHtml(labelPeriode)}</div>` 6390 : `<div class="text-faint" style="font-size:14px; padding:24px 0">${health.joueurs.length ? escapeHtml(health.label || 'Pas assez de recul') : 'Aucun joueur'}</div>`); 6391 6392 html += `<div class="card group-card clickable" data-ui-click="setGroupeDetail(${escapeHtml(encoderArgumentTexteUI(key))})" 6393 style="cursor:pointer; padding:20px; border-top:3px solid var(--${color}); transition:all 0.2s"> 6394 <div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:12px"> 6395 <div> 6396 <span class="badge badge-${key}" style="font-size:11px">${escapeHtml(g.nom)}</span> 6397 <div class="text-faint mt-1" style="font-size:11px">${horsCompet ? 'hors compétition · détection' : (health.joueurs.length + '/' + (g.places || '?') + ' places · objectif ' + g.objectifMatches + ' matches/an')}</div> 6398 </div> 6399 </div> 6400 <div style="text-align:center; padding:12px 0">${scoreDisplay}</div> 6401 ${(!horsCompet && health.score !== null) ? ` 6402 <div class="text-faint" style="font-size:11px; text-align:center; margin-top:8px"> 6403 Engagement ${health.details.engagement.valeur} · Victoires ${health.details.victoires.valeur} · ${health.details.perfs.valeur}${health.details.progression.applicable ? ' · Classement ' + health.details.progression.valeur : ''} 6404 </div>` : ''} 6405 </div>`; 6406 }); 6407 6408 html += `</div>`; 6409 return html; 6410} 6411 6412// === Vue 2 : détail d'un groupe === 6413function renderGroupeDetail(key) { 6414 const isPalmyreGroup = (key === '__palmyre__'); 6415 const g = isPalmyreGroup 6416 ? palmyreGroupConfig() 6417 : cache.config[key]; 6418 const horsCompet = (!isPalmyreGroup) && !!g.horsCompet; 6419 const health = computeGroupHealth(key, periodes.groupe); 6420 const color = isPalmyreGroup ? 'orange' : key === 'tremplin' ? 'success' : key === 'accession' ? 'info' : 'purple'; 6421 6422 let html = ''; 6423 // Conteneur de contexte groupe : permet de colorer tous les section-title de cet 6424 // écran selon la couleur du groupe (via CSS .groupe-ctx-xxx .section-title). 6425 const grpClasse = isPalmyreGroup ? 'palmyre' : key; 6426 html += `<div class="groupe-ctx groupe-ctx-${grpClasse}">`; 6427 if (!isPalmyreGroup) { 6428 html += `<div style="margin-bottom:16px"> 6429 <button class="btn btn-sm" data-ui-click="setGroupeDetail(null)">â Retour aux groupes</button> 6430 </div>`; 6431 } 6432 6433 // Sélecteur de période (pilote le score santé ET les sections ci-dessous) 6434 if (!horsCompet) { 6435 html += renderPeriodeSelector(periodes.groupe, 'setPeriodeGroupe'); 6436 } 6437 6438 // En-tête identité 6439 html += `<div class="card" style="padding:20px; border-top:4px solid var(--${color}); margin-bottom:12px"> 6440 <div style="display:flex; justify-content:space-between; align-items:flex-start; flex-wrap:wrap; gap:12px"> 6441 <div> 6442 <div style="display:flex; align-items:center; gap:10px; margin-bottom:6px"> 6443 <span class="badge badge-${isPalmyreGroup ? 'orange' : key}" style="font-size:13px; padding:4px 10px">${isPalmyreGroup ? 'ðï¸ ' : ''}${escapeHtml(g.nom)}</span> 6444 <h2 style="margin:0; font-size:20px">Tableau de bord</h2> 6445 </div> 6446 <div class="text-faint" style="font-size:13px"> 6447 ${isPalmyreGroup 6448 ? `${health.joueurs.length} participant${health.joueurs.length > 1 ? 's' : ''} · stage intensif` 6449 : horsCompet 6450 ? `${health.joueurs.length} joueur${health.joueurs.length > 1 ? 's' : ''} suivi${health.joueurs.length > 1 ? 's' : ''} · hors compétition (détection)` 6451 : `${health.joueurs.length}/${g.places || '?'} places · objectif ${g.objectifMatches} matches/an`} 6452 </div> 6453 </div> 6454 ${health.score !== null ? ` 6455 <div style="text-align:right"> 6456 <div style="font-size:42px; font-weight:700; color:var(--${health.color}); line-height:1">${health.score}<span style="font-size:16px; color:var(--text-dim); font-weight:400">/100</span></div> 6457 <div class="text-faint" style="font-size:12px; margin-top:2px">Score santé · <strong style="color:var(--${health.color})">${escapeHtml(health.label)}</strong></div> 6458 <div class="text-faint" style="font-size:11px">
6458${escapeHtml(getPeriodeLabel(periodes.groupe))}</div> 6459 </div>` : ''} 6460 </div> 6461 </div>`; 6462 6463 // Bandeau alertes temps réel (hors score, état actuel du groupe) 6464 if (!horsCompet && health.alertesGroupe > 0) { 6465 html += `<div class="card clickable" data-ui-click="navigate('alertes')" style="margin-bottom:12px; border-left:3px solid var(--danger); display:flex; align-items:center; gap:10px; padding:12px; cursor:pointer"> 6466 <span style="font-size:18px">ð¨</span> 6467 <div style="flex:1; font-size:13px"><strong>${health.alertesGroupe} alerte${health.alertesGroupe > 1 ? 's' : ''}</strong> en cours sur ce groupe <span class="text-faint">(état actuel, toutes périodes)</span></div> 6468 <span class="text-faint">â</span> 6469 </div>`; 6470 } 6471 6472 if (health.joueurs.length === 0) { 6473 html += `<div class="empty"><span class="icon">ð¥</span>Aucun joueur dans ce groupe pour le moment.</div>`; 6474 html += `</div>`; 6475 return html; 6476 } 6477 6478 // Décomposition du score santé (uniquement pour les groupes compét) 6479 if (!horsCompet) { 6480 html += `<div class="section-title">Décomposition du score · ${escapeHtml(getPeriodeLabel(periodes.groupe))}</div>`; 6481 html += `<div class="card" style="padding:16px">`; 6482 ['engagement', 'victoires', 'perfs', 'progression'].forEach(k => { 6483 const d = health.details[k]; 6484 // En Palmyre, le critère progression de classement n'est pas applicable â on ne l'affiche pas. 6485 if (k === 'progression' && d && d.applicable === false) return; 6486 const labelMap = { 6487 engagement: { art: 'matchs', label: 'Engagement (matches joués vs objectif de la période)' }, 6488 victoires: { art: 'victoires', label: 'Taux victoires moyen (sur la période)' }, 6489 perfs: { art: 'perfs', label: 'Perfs sur la période' }, 6490 progression: { art: 'sans_faute', label: 'Progression de classement (montées / descentes)' } 6491 }; 6492 const meta = labelMap[k]; 6493 if (k === 'progression') { 6494 // Critère signé : barre centrée, vert si bonus, rouge si malus 6495 const pts = d.points;
6495 // de -15 à +15 6496 const pctAbs = Math.round((Math.abs(pts) / d.max) * 100); 6497 const signe = pts > 0 ? '+' : ''; 6498 const barColor = pts > 0 ? 'success' : pts < 0 ? 'danger' : ''; 6499 const ptsColor = pts > 0 ? 'success' : pts < 0 ? 'danger' : 'text-faint'; 6500 html += `<div style="margin-bottom:14px"> 6501 <div style="display:flex; justify-content:space-between; align-items:baseline; gap:8px; margin-bottom:4px"> 6502 <div style="display:flex;align-items:center;gap:8px;font-size:13px">${progressArtHtml(meta.art,{size:34})}<strong>${escapeHtml(meta.label)}</strong> <span class="text-faint" style="font-size:11px">(${escapeHtml(d.valeur)})</span></div> 6503 <div style="font-size:13px; font-weight:600" class="${ptsColor}">${signe}${pts}/${d.max}</div> 6504 </div> 6505 <div class="progress-bar" style="height:6px; background:var(--bg-input)"><div class="progress-fill ${barColor}" style="width:${pctAbs}%; ${pts < 0 ? 'margin-left:auto' : ''}"></div></div> 6506 </div>`; 6507 } else { 6508 const pct = Math.round((d.points / d.max) * 100); 6509 const barColor = pct >= 75 ? 'success' : pct >= 50 ? 'warning' : 'danger'; 6510 html += `<div style="margin-bottom:14px"> 6511 <div style="display:flex; justify-content:space-between; align-items:baseline; gap:8px; margin-bottom:4px"> 6512 <div style="display:flex;align-items:center;gap:8px;font-size:13px">${progressArtHtml(meta.art,{size:34})}<strong>${escapeHtml(meta.label)}</strong> <span class="text-faint" style="font-size:11px">(${escapeHtml(d.valeur)})</span></div> 6513 <div style="font-size:13px; font-weight:600">${d.points}/${d.max}</div> 6514 </div> 6515 <div class="progress-bar" style="height:6px"><div class="progress-fill ${barColor}" style="width:${pct}%"></div></div> 6516 </div>`; 6517 } 6518 }); 6519 html += `</div>`; 6520 } 6521 6522 // Calculer stats par joueur pour locomotives / à accompagner, sur la PÃRIODE choisie 6523 const [perDeb, perFin] = getPeriodeDates(periodes.groupe); 6524 const stats = health.joueurs.map(j => { 6525 const matchesJoueur = matchsDuJoueur(j.id); 6526 const compteGlobal = comptesMatchs(matchesJoueur); 6527 const total = compteGlobal.total; 6528 const tauxV = compteGlobal.tauxVictoire; 6529 6530 // Matchs de la PÃRIODE (pour activité, taux V période, perfs). WO déjà exclus ci-dessus. 6531 const msPeriode = matchsHorsWO(matchesJoueur) 6532 .filter(m => TennisDomainRules.dateDansIntervalle(m.date, perDeb, perFin)); 6533 const comptePeriode = TennisDomainRules.statistiquesSportives( 6534 msPeriode, 6535 match => classementADate(j.id, match.date) 6536 ); 6537 const matchesPeriode = comptePeriode.total; 6538 const tauxVPeriode = comptePeriode.tauxVictoire; 6539 6540 // Belles perfs de la période (victoire sur un adversaire mieux classé) 6541 const perfsPeriode = comptePeriode.perfs; 6542 const progressionPeriode = progressionClassementPeriode(j.id, periodes.groupe); 6543 6544 // Engagement SAISON (conservé pour le ranking locomotives, suivi annuel) 6545 const matchesSaison = matchesSaisonJoueur(j.id); 6546 let scoreEngagement = 0; 6547 const enGraceSaison = TennisDomainRules.estEnGrace(j, matchesSaison, Date.now()); 6548 if (enGraceSaison) { 6549 scoreEngagement = -1; // en grâce, ignoré dans le ranking 6550 } else if (isPalmyreGroup) { 6551 // Palmyre : objectif PAR JOUEUR = ses créneaux planifiés dans l'édition (repli sur 6552 // l'objectif fixe si aucun créneau). Cohérent avec computeGroupHealth. 6553 const ev = getActiveEvenement(); 6554 const nbCreneaux = ev ? creneauxPalmyreDuJoueurEvenement(j.id, ev.id).length : 0; 6555 const objJoueur = nbCreneaux > 0 ? nbCreneaux : g.objectifMatches; 6556 scoreEngagement = Math.min(1, matchesSaison / Math.max(1, objJoueur)); 6557 } else { 6558 const objectifAttendu = g.objectifMatches * getProrataObjectif(); 6559 scoreEngagement = objectifAttendu > 0 ? matchesSaison / objectifAttendu : 0; 6560 } 6561 6562 // Engagement PÃRIODE (pour le tableau récap, suit le sélecteur) 6563 let scoreEngagementPeriode = 0; 6564 if (TennisDomainRules.estEnGrace(j, matchesSaison, Date.now())) { 6565 scoreEngagementPeriode = -1; // en grâce (badge visuel "nouveau") 6566 } else if (isPalmyreGroup) { 6567 // Palmyre : objectif PAR JOUEUR = ses créneaux planifiés dans l'édition (repli sur 6568 // l'objectif fixe si aucun créneau). Cohérent avec computeGroupHealth. 6569 const ev = getActiveEvenement(); 6570 const nbCreneaux = ev ? creneauxPalmyreDuJoueurEvenement(j.id, ev.id).length : 0; 6571 const objJoueur = nbCreneaux > 0 ? nbCreneaux : g.objectifMatches; 6572 scoreEngagementPeriode = Math.min(1, matchesPeriode / Math.max(1, objJoueur)); 6573 } else { 6574 // Plancher à 1 match pour éviter des ratios absurdes en tout début de période. 6575 const objectifPer = Math.max(1, getObjectifPeriode(g.objectifMatches, periodes.groupe)); 6576 scoreEngagementPeriode = Math.min(1, matchesPeriode / objectifPer); 6577 } 6578 6579 return { 6580 joueur: j, 6581 matchesAnnee: matchesSaison, 6582 matchesPeriode, 6583 total, tauxV, 6584 tauxVPeriode, 6585 perfsPeriode, 6586 contrePerfsPeriode: computeContrePerfs(j.id, periodes.groupe).length, 6587 progressionClass: Math.max(0, progressionPeriode), // montées seulement (0 si descente) 6588 descenteClass: Math.max(0, -progressionPeriode), // échelons perdus (0 si montée/stable) 6589 scoreEngagement, 6590 scoreEngagementPeriode 6591 }; 6592 }); 6593 6594 // ===== à accompagner : difficulté sportive sur la période =====
6595 // Deux signaux : contre-perfs (⥠2) ET/OU descente de classement. 6596 // Score de difficulté = contre-perfs + 2 points par échelon perdu (la descente 6597 // pèse plus lourd car c'est un signal fort). Trié du plus en difficulté au moins. 6598 const elligibles = stats.filter(s => s.scoreEngagement >= 0); 6599 // Taille des listes pondérée par l'effectif éligible : environ 1/3 du groupe, 6600 // avec un minimum de 1 et un maximum de 3. Ãvite de classer la moitié d'un petit 6601 // groupe en "locomotive" tout en restant utile sur les petits effectifs. 6602 const tailleListe = Math.max(1, Math.min(3, Math.round(elligibles.length / 3))); 6603 stats.forEach(s => { 6604 s.scoreDifficulte = s.contrePerfsPeriode + s.descenteClass * 2; 6605 }); 6606 const aAccompagner = stats 6607 .filter(s => s.contrePerfsPeriode >= 2 || s.descenteClass >= 1) 6608 .sort((a, b) => b.scoreDifficulte - a.scoreDifficulte) 6609 .slice(0, tailleListe); 6610 6611 // ===== Locomotives : meilleurs DU GROUPE sur un score de dynamique combiné ===== 6612 // Score = moyenne équilibrée de 4 dimensions normalisées sur le max du groupe : 6613 // activité (matchs période), taux de victoire période, belles perfs période, 6614 // progression de classement sur la période (montées). 6615 // Relatif au groupe â il y a toujours une locomotive, même sans atteindre l'objectif. 6616 // Candidats : joueurs avec au moins 3 matchs sur la période (sinon non significatif). 6617 // NB : les deux listes mesurent des choses différentes (dynamique vs contre-perfs), 6618 // donc un joueur PEUT être dans les deux â ce n'est pas contradictoire. 6619 const candidatsLoco = elligibles.filter(s => s.matchesPeriode >= 3); 6620 const maxActivite = Math.max(1, ...candidatsLoco.map(s => s.matchesPeriode)); 6621 const maxTauxV = Math.max(1, ...candidatsLoco.map(s => s.tauxVPeriode)); 6622 const maxPerfs = Math.max(1, ...candidatsLoco.map(s => s.perfsPeriode)); 6623 const maxProg = Math.max(1, ...candidatsLoco.map(s => s.progressionClass)); 6624 candidatsLoco.forEach(s => { 6625 const nA = s.matchesPeriode / maxActivite; 6626 const nT = s.tauxVPeriode / maxTauxV; 6627 const nP = s.perfsPeriode / maxPerfs; 6628 const nProg = s.progressionClass / maxProg; 6629 s.scoreDynamique = (nA + nT + nP + nProg) / 4; 6630 }); 6631 const locomotives = candidatsLoco 6632 .slice() 6633 .sort((a, b) => b.scoreDynamique - a.scoreDynamique) 6634 .slice(0, tailleListe); 6635 6636 // N'afficher la section que s'il y a au moins 3 joueurs éligibles et au moins une 6637 // des deux listes non vide. 6638 if (elligibles.length >= 3 && (locomotives.length > 0 || aAccompagner.length > 0)) { 6639 // Légende explicative 6640 html += `<div class="text-faint" style="font-size:11px; margin-top:12px; line-height:1.5"> 6641 <strong>Locomotives</strong> : les joueurs les plus dynamiques du groupe (activité, victoires, belles perfs et progression de classement combinées). <strong>à accompagner</strong> : ceux en difficulté sportive â contre-perfs (défaites contre un adversaire moins bien classé) et/ou descente de classement. 6642 </div>`; 6643 html += `<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(280px, 1fr)); gap:12px; margin-top:8px">`; 6644 6645 // Locomotives (dynamique du groupe) 6646 html += `<div> 6647 <div class="section-title" style="margin-top:0">ð Locomotives</div> 6648 <div class="card" style="padding:8px">`; 6649 if (locomotives.length === 0) { 6650 html += `<div class="text-faint" style="font-size:12px; text-align:center; padding:10px">Pas encore assez de matchs pour désigner une locomotive.</div>`; 6651 } else { 6652 locomotives.forEach((s, idx) => { 6653 const medal = idx === 0 ? 'ð¥' : idx === 1 ? 'ð¥' : 'ð¥'; 6654 // Détail : progression · perfs · % V · matchs (sur la période) 6655 const detail = []; 6656 if (s.progressionClass > 0) detail.push(`ð +${s.progressionClass}`); 6657 if (s.perfsPeriode > 0) detail.push(`${s.perfsPeriode} perf${s.perfsPeriode > 1 ? 's' : ''}`); 6658 detail.push(`${s.tauxVPeriode}% V`); 6659 detail.push(`${s.matchesPeriode} match${s.matchesPeriode > 1 ? 's' : ''}`); 6660 html += `<div class="match-row clickable" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(s.joueur.id))})" style="border-bottom:1px solid var(--border)"> 6661 <div class="match-result-row">
6662 <span><strong>${medal} ${escapeHtml(s.joueur.prenom)} ${escapeHtml(s.joueur.nom)}</strong> <span class="text-faint" style="font-size:11px">(${escapeHtml(s.joueur.classement)})</span></span> 6663 </div> 6664 <div class="match-meta">${detail.join(' · ')}</div> 6665 </div>`; 6666 }); 6667 } 6668 html += `</div></div>`; 6669 6670 // à accompagner (contre-perfs et/ou descente de classement) 6671 html += `<div> 6672 <div class="section-title" style="margin-top:0">â ï¸ Ã accompagner</div> 6673 <div class="card" style="padding:8px">`; 6674 if (aAccompagner.length === 0) { 6675 html += `<div class="text-faint" style="font-size:12px; text-align:center; padding:10px">Rien à signaler ð</div>`; 6676 } else { 6677 aAccompagner.forEach(s => { 6678 // Badge : on affiche les signaux présents (descente + contre-perfs) 6679 const badges = []; 6680 if (s.descenteClass > 0) badges.push(`ð -${s.descenteClass}`); 6681 if (s.contrePerfsPeriode > 0) badges.push(`${s.contrePerfsPeriode} contre-perf${s.contrePerfsPeriode > 1 ? 's' : ''}`); 6682 const motifs = []; 6683 if (s.descenteClass > 0) motifs.push('descente de classement'); 6684 if (s.contrePerfsPeriode >= 2) motifs.push('défaites contre moins bien classé'); 6685 html += `<div class="match-row clickable" data-ui-click="openContrePerfsModal(${escapeHtml(encoderArgumentTexteUI(s.joueur.id))}, ${escapeHtml(encoderArgumentTexteUI(periodes.groupe))})" style="border-bottom:1px solid var(--border)" title="Voir le détail des contre-perfs"> 6686 <div class="match-result-row"> 6687 <span><strong>${escapeHtml(s.joueur.prenom)} ${escapeHtml(s.joueur.nom)}</strong> <span class="text-faint" style="font-size:11px">(${escapeHtml(s.joueur.classement)})</span></span> 6688 <span class="danger" style="font-weight:600">${badges.join(' · ')}</span> 6689 </div> 6690 <div class="match-meta">${motifs.join(' · ')} · cliquer pour le détail</div> 6691 </div>`; 6692 }); 6693 } 6694 html += `</div></div>`; 6695 6696 html += `</div>`; 6697 } 6698 6699 // Liste complète des joueurs du groupe (triée par classement) 6700 html += `<div class="section-title">Tous les joueurs du groupe (${health.joueurs.length}) · ${escapeHtml(getPeriodeLabel(periodes.groupe))}</div>`; 6701 html += `<div class="card" style="padding:0; overflow-x:auto"> 6702 <table class="overview-table" style="width:100%; border-collapse:collapse; font-size:12px"> 6703 <thead style="background:var(--bg-section)"> 6704 <tr> 6705 <th style="text-align:left; padding:8px; color:var(--text-dim)">Joueur</th> 6706 <th style="text-align:center; padding:8px; color:var(--text-dim); width:70px">Classt</th> 6707 <th style="text-align:center; padding:8px; color:var(--text-dim); width:90px">Matches${horsCompet ? '' : `<div style="font-weight:400; font-size:10px; text-transform:none">/ objectif</div>`}</th> 6708 <th style="text-align:center; padding:8px; color:var(--text-dim); width:60px">% V</th> 6709 ${horsCompet ? '' : `<th style="text-align:center; padding:8px; color:var(--text-dim); width:80px">Engagement</th>`} 6710 </tr> 6711 </thead><tbody>`; 6712 6713 // Objectif de matchs attendu sur la période (pour la colonne Matches, hors hors-compét). 6714 // En Palmyre, l'objectif est PAR JOUEUR (ses créneaux planifiés), calculé dans la boucle. 6715 const objMatchesPeriode = horsCompet ? 0 : (isPalmyreGroup ? null : Math.max(1, getObjectifBasePeriodeEntier(g.objectifMatches, periodes.groupe))); 6716 const evPalmyreAff = isPalmyreGroup ? getActiveEvenement() : null; 6717 const objMatchesJoueur = (jid) => { 6718 if (!isPalmyreGroup) return objMatchesPeriode; 6719 const nb = evPalmyreAff ? creneauxPalmyreDuJoueurEvenement(jid, evPalmyreAff.id).length : 0; 6720 return nb > 0 ? nb : g.objectifMatches; 6721 }; 6722 6723 stats.slice().sort((a, b) => getRankIndex(b.joueur.classement) - getRankIndex(a.joueur.classement)).forEach(s => { 6724 const j = s.joueur; 6725 const objAffiche = isPalmyreGroup ? objMatchesJoueur(j.id) : objMatchesPeriode; 6726 const ratio = s.scoreEngagementPeriode >= 0 ? Math.round(s.scoreEngagementPeriode * 100) + '%' : 'â'; 6727 const cls = s.scoreEngagementPeriode < 0 ? 'text-faint' : s.scoreEngagementPeriode >= 0.8 ? 'success' : s.scoreEngagementPeriode < 0.5 ? 'danger' : ''; 6728 const tauxCls = s.matchesPeriode > 0 && s.tauxVPeriode >= 55 ? 'success' : ''; 6729 html += `<tr class="overview-row" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(j.id
6729))})" style="cursor:pointer; border-top:1px solid var(--border)"> 6730 <td style="padding:8px"><strong>${escapeHtml(j.prenom)}</strong> ${escapeHtml(j.nom)}</td> 6731 <td style="text-align:center; padding:8px"><strong class="orange">${escapeHtml(j.classement || 'NC')}</strong></td> 6732 <td style="text-align:center; padding:8px">${horsCompet ? s.matchesPeriode : s.matchesPeriode + '/' + objAffiche}</td> 6733 <td style="text-align:center; padding:8px" class="${tauxCls}"><strong>${s.matchesPeriode > 0 ? s.tauxVPeriode + '%' : 'â'}</strong></td> 6734 ${horsCompet ? '' : `<td style="text-align:center; padding:8px" class="${cls}"><strong>${ratio}</strong></td>`} 6735 </tr>`; 6736 }); 6737 html += `</tbody></table></div>`; 6738 6739 if (!horsCompet) { 6740 // La colonne « objectif » affiche l'objectif PLEIN du trimestre (objectif annuel 6741 // réparti selon la saisonnalité), NON proratisé dans le temps â les 4 trimestres 6742 // somment exactement à l'objectif annuel. L'Engagement, lui, reste proratisé dans 6743 // le temps (avancement vs objectif attendu à ce jour). 6744 const labelObj = periodes.groupe === 'saison' 6745 ? 'objectif de la saison' 6746 : 'objectif du trimestre (réparti selon la saisonnalité)'; 6747 html += `<div class="text-faint mt-2" style="font-size:11px; text-align:center"> 6748 Matches : nombre réalisé / ${labelObj} · Engagement : avancement vs objectif attendu à ce jour · % V sur la période · « â » = aucun match 6749 </div>`; 6750 } 6751 6752 html += `</div>`; // fermeture .groupe-ctx 6753 return html; 6754} 6755 6756 6757// ============================================================ 6758// SCREEN: VUE D'ENSEMBLE (coach) 6759// Tableau exhaustif unique, triable en cliquant sur les en-têtes 6760// Tri par défaut : par classement (du meilleur au moins bien classé) 6761// ============================================================ 6762 6763const overviewSort = etatApp.triOverview; 6764 6765function setOverviewSort(col) { 6766 etatApp.basculerTriOverview(col); 6767 render(); 6768} 6769 6770function renderOverview() { 6771 if (!isCoach()) return ''; 6772 const entete = renderUxScreenHeading('Analyse de lâacadémie', 'Vue dâensemble'); 6773 const joueursActifs = joueursActifsDansEnvCourant(); 6774 6775 // En mode Palmyre : ne montrer que les participants à l'édition. 6776 if (!isPoleEnv()) { 6777 if (joueursActifs.length === 0) { 6778 const ev = getActiveEvenement(); 6779 return entete + `<div class="empty"><span class="icon">ðï¸</span>Aucun participant à ${ev ? escapeHtml(ev.nom) : 'la Palmyre'}.<br> 6780 <span class="text-faint">Coche « Participe à la Palmyre » sur la fiche des joueurs concernés.</span></div>`; 6781 } 6782 } 6783 6784 if (joueursActifs.length === 0) { 6785 return entete + `<div class="empty"><span class="icon">ð</span>Aucun joueur actif à afficher.<br> 6786 <span class="text-faint">Crée ou valide des joueurs pour voir la vue d'ensemble de l'académie.</span></div>`; 6787 } 6788 6789 const now = Date.now(); 6790 const trimSaison = getSaisonCourante(); 6791 const trimNum = getTrimestreCourant(); 6792 // En Palmyre, la fenêtre de calcul des perfs = toute l'édition (pas le trimestre). 6793 const [debut, fin] = getPeriodeDates(isPoleEnv() ? trimNum : 'saison'); 6794 6795 // Calculer les données enrichies pour chaque joueur 6796 const rows = joueursActifs.map(j => { 6797 const stats = statsJoueur(j.id); 6798 const idxJoueur = getRankIndex(j.classement); 6799 const matchesJoueur = matchsDuJoueur(j.id); 6800 const dernierMatch = matchesJoueur.length > 0 ? matchesJoueur[0] : null; 6801 6802 // Perfs du trimestre courant 6803 const matchesPeriode = matchesJoueur.filter(m => TennisDomainRules.dateDansIntervalle(m.date, debut, fin)); 6804 const perfsT = TennisDomainRules.statistiquesSportives( 6805 matchesPeriode, 6806 match => classementADate(j.id, match.date) 6807 ).perfs; 6808 6809 return { 6810 joueur: j, 6811 stats, 6812 classementIdx: idxJoueur, 6813 age: computeAge(j), 6814 sexe: j.sexe || '', 6815 groupe: j.groupe || '', 6816 groupeOrder: { tremplin: 1, accession: 2, elite: 3 }[j.groupe] || 99, 6817 perfsT, 6818 dernierMatch, 6819 dernierMatchMs: dernierMatch ? new Date(dernierMatch.date).getTime() : 0 6820 }; 6821 }); 6822 6823 // Tri dynamique selon overviewSort 6824 rows.sort((a, b) => { 6825 let va, vb; 6826 switch (overviewSort.col) { 6827 case 'nom':
6828 va = (a.joueur.nom + ' ' + a.joueur.prenom).toLowerCase(); 6829 vb = (b.joueur.nom + ' ' + b.joueur.prenom).toLowerCase(); 6830 break; 6831 case 'sexe': 6832 va = a.sexe; vb = b.sexe; 6833 break; 6834 case 'age': 6835 va = a.age !== null ? a.age : 999; 6836 vb = b.age !== null ? b.age : 999; 6837 break; 6838 case 'classement': 6839 va = a.classementIdx; vb = b.classementIdx; 6840 break; 6841 case 'groupe': 6842 va = a.groupeOrder; vb = b.groupeOrder; 6843 break; 6844 case 'matches': 6845 va = a.stats.total; vb = b.stats.total; 6846 break; 6847 case 'taux': 6848 va = a.stats.tauxVictoire; vb = b.stats.tauxVictoire; 6849 break; 6850 case 'perfs': 6851 va = a.perfsT; vb = b.perfsT; 6852 break; 6853 case 'dernier': 6854 va = a.dernierMatchMs; vb = b.dernierMatchMs; 6855 break; 6856 default: 6857 va = a.classementIdx; vb = b.classementIdx; 6858 } 6859 if (typeof va === 'string') { 6860 const cmp = va.localeCompare(vb); 6861 return overviewSort.dir === 'asc' ? cmp : -cmp; 6862 } 6863 return overviewSort.dir === 'asc' ? va - vb : vb - va; 6864 }); 6865 6866 // Helper pour rendre un en-tête de colonne triable 6867 const sortHeader = (col, label, width) => { 6868 const isActive = overviewSort.col === col; 6869 const arrow = isActive ? (overviewSort.dir === 'asc' ? ' â' : ' â') : ''; 6870 const style = `text-align:center; padding:8px; font-weight:600; color:${isActive ? 'var(--orange)' : 'var(--text-dim)'}; ${width ? 'width:' + width + ';' : ''} cursor:pointer; user-select:none`; 6871 return `<th style="${style}" data-ui-click="setOverviewSort(${escapeHtml(encoderArgumentTexteUI(col))})" title="Cliquer pour trier">${escapeHtml(label)}${arrow}</th>`; 6872 }; 6873 6874 let html = entete + `<div class="text-faint mb-2" style="font-size:12px"> 6875 <span class="sort-hint-mark"></span>Clique sur un en-tête de colonne pour trier le tableau. Re-cliquer inverse l'ordre. 6876 </div>`; 6877 6878 html += `<div class="card" style="padding:0; overflow-x:auto"> 6879 <table class="overview-table" style="width:100%; border-collapse:collapse; font-size:12px; min-width:900px"> 6880 <thead style="background:var(--bg-section); position:sticky; top:0"> 6881 <tr> 6882 ${sortHeader('nom', 'Nom').replace('text-align:center', 'text-align:left')} 6883 ${sortHeader('sexe', 'Sexe', '50px')} 6884 ${sortHeader('age', 'Ãge', '50px')} 6885 ${sortHeader('classement', 'Classt', '70px')} 6886 ${sortHeader('groupe', 'Groupe', '110px')} 6887 ${sortHeader('matches', 'Matches', '90px')} 6888 ${sortHeader('taux', '% V', '60px')} 6889 ${sortHeader('perfs', isPoleEnv() ? 'Perfs T' : 'Perfs', '70px')} 6890 ${sortHeader('dernier', 'Dernier match', '110px')} 6891 </tr> 6892 </thead> 6893 <tbody>`; 6894 6895 rows.forEach(r => { 6896 const j = r.joueur; 6897 const groupKey = (j.groupe && cache.config[j.groupe] && typeof cache.config[j.groupe] === 'object') ? j.groupe : null; 6898 const g = groupKey ? cache.config[groupKey] : null; 6899 const groupBadge = groupKey 6900 ? `<span class="badge badge-${groupKey}" style="font-size:10px">${escapeHtml(g.nom)}</span>` 6901 : `<span class="text-faint">â</span>`; 6902 const sexeIcon = r.sexe === 'F' 6903 ? '<span class="gender-art"><img src="assets/v4/gender/female.webp" alt="Femme"><b>F</b></span>' 6904 : r.sexe === 'M' ? '<span class="gender-art"><img src="assets/v4/gender/male.webp" alt="Homme"><b>M</b></span>' : 'â'; 6905 const dernierMatchStr = r.dernierMatch ? formatDate(r.dernierMatch.date) : 'â'; 6906 const dernierMatchCls = r.dernierMatch 6907 ? ((now - r.dernierMatchMs) > 45*24*3600*1000 ? 'text-faint' : '') 6908 : 'text-faint'; 6909 // Objectif cible : en Palmyre, c'est l'objectif de l'édition active (pas celui du groupe). 6910 const objCible = !isPoleEnv() ? palmyreGroupConfig().objectifMatches : (g ? g.objectifMatches : 0); 6911 const objMatches = objCible > 0 ? `/${objCible}` : ''; 6912 const matchesCls = objCible > 0 6913 ? (r.stats.total >= objCible * 0.8 ? 'success' : r.stats.total >= objCible * 0.5 ? '' : 'danger') 6914 : ''; 6915 const tauxCls = r.stats.tauxVictoire >= 55 ? 'success' : ''; 6916 6917 html += `<tr class="overview-row" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(j.id
6917))})" style="cursor:pointer; border-top:1px solid var(--border)"> 6918 <td style="padding:8px"><strong>${escapeHtml(j.prenom)}</strong> ${escapeHtml(j.nom)}</td> 6919 <td style="text-align:center; padding:8px">${sexeIcon}</td> 6920 <td style="text-align:center; padding:8px">${r.age !== null ? r.age : 'â'}</td> 6921 <td style="text-align:center; padding:8px"><strong class="orange">${escapeHtml(j.classement || 'NC')}</strong></td> 6922 <td style="text-align:center; padding:8px">${groupBadge}</td> 6923 <td style="text-align:center; padding:8px" class="${matchesCls}">${r.stats.total}${objMatches}</td> 6924 <td style="text-align:center; padding:8px" class="${tauxCls}"><strong>${r.stats.tauxVictoire}%</strong></td> 6925 <td style="text-align:center; padding:8px">${r.perfsT > 0 ? `<strong style="display:inline-flex;align-items:center;justify-content:center;gap:3px;color:var(--success-text)">${progressArtHtml('perfs',{size:22})}${r.perfsT}</strong>` : '<span class="text-faint">0</span>'}</td> 6926 <td style="text-align:center; padding:8px" class="${dernierMatchCls}">${dernierMatchStr}</td> 6927 </tr>`; 6928 }); 6929 6930 html += `</tbody></table></div>`; 6931 6932 html += `<div class="text-faint mt-2" style="font-size:11px; text-align:center"> 6933 ${rows.length} joueur${rows.length > 1 ? 's' : ''} actif${rows.length > 1 ? 's' : ''} · Perfs ${isPoleEnv() ? 'T' : ''} = victoires sur mieux classés ${isPoleEnv() ? `ce trimestre (${trimNum} ${trimSaison})` : 'pendant le stage'} 6934 </div>`; 6935 6936 return html; 6937} 6938 6939// ============================================================ 6940// BILANS PALMYRE (bilan de fin de semaine d'une édition) 6941// ============================================================ 6942 6943// Stats d'un joueur sur l'édition Palmyre active (matchs, V, D, perfs). 6944// ============================================================ 6945// PLANNING & LOGISTIQUE PALMYRE 6946// ============================================================ 6947 6948// Liste ordonnée des jours de l'édition (du date_debut au date_fin). 6949function joursEdition(ev) { 6950 if (!ev || !ev.date_debut || !ev.date_fin) return []; 6951 const jours = []; 6952 const d = new Date(ev.date_debut + 'T00:00:00'); 6953 const fin = new Date(ev.date_fin + 'T00:00:00'); 6954 let guard = 0; 6955 while (d <= fin && guard < 60) { 6956 // dateLocaleISO : formatage en heure locale (évite le décalage d'un jour 6957 // que provoque toISOString(), qui convertit en UTC). 6958 jours.push(dateLocaleISO(d)); 6959 d.setDate(d.getDate() + 1); 6960 guard++; 6961 } 6962 return jours; 6963} 6964const JOURS_SEMAINE = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']; 6965function libelleJourCourt(dateStr) { 6966 const d = new Date(dateStr + 'T00:00:00'); 6967 return JOURS_SEMAINE[d.getDay()] + ' ' + String(d.getDate()).padStart(2, '0') + '/' + String(d.getMonth() + 1).padStart(2, '0'); 6968} 6969function creneauxDuJoueurJour(joueurId, jour) { 6970 const ev = getActiveEvenement(); 6971 if (!ev) return []; 6972 return (listeSure(cache.creneauxPalmyre)) 6973 .filter(c => c.joueur_id === joueurId && c.jour === jour && c.evenement_id === ev.id) 6974 .sort((a, b) => (a.heure || '').localeCompare(b.heure || '')); 6975} 6976 6977// ============================================================ 6978// PRÃSENCE AUX COURS (académie) â étape 2a : gestion du planning type 6979// ============================================================ 6980const JOURS_SEMAINE_COURS = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']; 6981 6982// Formate une Date en YYYY-MM-DD en heure LOCALE (évite le décalage de fuseau 6983// horaire que provoque toISOString(), qui convertit en UTC et peut reculer d'un jour). 6984function dateLocaleISO(d) { 6985 return TennisFormatters.dateLocaleISO(d); 6986} 6987 6988// Lundi de la semaine en cours (format YYYY-MM-DD). Semaine lundiâdimanche. 6989function getLundiSemaine(ref) { 6990 const d = ref ? new Date(ref) : new Date(); 6991 d.setHours(0, 0, 0, 0); 6992 const jour = d.getDay(); // 0=dim..6=sam 6993 const decalage = (jour === 0) ? -6 : (1 - jour); // ramener au lundi 6994 d.setDate(d.getDate() + decalage); 6995 return dateLocaleISO(d); 6996} 6997 6998// Dimanche de la semaine en cours (borne fin de semaine calendaire). 6999function getVendrediSemaine(ref) { 7000 const lundi = new Date(getLundiSemaine(ref)); 7001 lundi.setDate(lundi.getDate() + 4); 7002 return dateLocaleISO(lundi); 7003} 7004 7005function finLocaleSeanceCours(seance, creneau) { 7006 if (!seance?.date) return null; 7007 const heureFin = /^\d{2}:\d{2}/.test(creneau?.heure_fin || '') 7008 ? String(creneau.heure_fin).slice(0, 5) 7009 : '23:59'; 7010 const fin = new Date(`${seance.date}T${heureFin}:00`); 7011 return Number.isNaN(fin.getTime()) ? null : fin; 7012} 7013 7014function seanceCoursEstTerminee(seance, creneau, reference = new Date()) { 7015 const fin = finLocaleSeanceCours(seance, creneau); 7016 return fin ? fin.getTime() <= reference.getTime() : seance?.date < dateLocaleISO(reference); 7017} 7018 7019function joueurPeutRegulariserPresence(seance, reference = new Date()) { 7020 if (!seance?.date) return false;
7021 const lundiSuivant = new Date(`${getLundiSemaine(`${seance.date}T12:00:00`)}T00:00:00`); 7022 lundiSuivant.setDate(lundiSuivant.getDate() + 7); 7023 return reference.getTime() < lundiSuivant.getTime(); 7024} 7025 7026// Séances de la semaine en cours (lundiâvendredi, les cours n'ont pas lieu le week-end). 7027function seancesDeLaSemaine() { 7028 const lundi = getLundiSemaine(); 7029 const vendredi = getVendrediSemaine(); 7030 return (listeSure(cache.seancesCours)) 7031 .filter(s => TennisDomainRules.dateDansIntervalle(s.date, lundi, vendredi)) 7032 .map(s => { 7033 const cr = creneauCoursById(s.creneau_id); 7034 return { ...s, creneau: cr }; 7035 }) 7036 .filter(s => s.creneau) // ignore les séances dont le créneau a été supprimé 7037 .sort((a, b) => (a.date.localeCompare(b.date)) || ((a.creneau.heure_debut || '').localeCompare(b.creneau.heure_debut || ''))); 7038} 7039 7040function presencesDeSeance(seanceId) { 7041 return presencesBrutesDeSeance(seanceId) 7042 .map(p => ({ ...p, joueur: joueurById(p.joueur_id) })) 7043 .filter(p => p.joueur) 7044 .sort((a, b) => getRankIndex(b.joueur.classement) - getRankIndex(a.joueur.classement)); 7045} 7046 7047const PRESENCE_STATUTS = { 7048 present: { label: 'Présent', court: 'P', icone: 'â ', cls: 'success' }, 7049 absent: { label: 'Absent', court: 'A', icone: 'â', cls: 'danger' }, 7050 retard: { label: 'Retard', court: 'R', icone: 'â±ï¸', cls: 'warning' }, 7051 non_renseigne: { label: 'Non renseigné', court: 'â', icone: 'â¬', cls: 'text-faint' } 7052}; 7053 7054function joueursDuCreneau(creneauId) { 7055 return idsJoueursDuCreneau(creneauId) 7056 .map(joueurById) 7057 .filter(Boolean) 7058 .sort((a, b) => getRankIndex(b.classement) - getRankIndex(a.classement)); 7059} 7060 7061function renderPresenceCours() { 7062 if (!isCoach()) return ''; 7063 const creneaux = (listeSure(cache.creneauxCours)); 7064 const seancesActivesCount = creneaux.filter(c => c.actif).length; 7065 7066 let html = renderUxScreenHeading('Suivi des cours', 'Présences') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)"> 7067 <div style="font-weight:700; font-size:15px">â Présence aux cours</div> 7068 <div class="text-faint" style="font-size:12px; margin-top:2px">Fais l'appel sur les séances de la semaine. Les créneaux se gèrent dans l'onglet « Planning ».</div> 7069 </div>`; 7070 html += `<div class="mb-2"><button class="btn btn-full" data-ui-click="openModalExportPresences()"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">Exporter les présences</button></div>`; 7071 7072 const lundi = getLundiSemaine(); 7073 const vendredi = getVendrediSemaine(); 7074 const lundiSuivant = getLundiSemaine(Date.now() + 6048e5); 7075 const vendrediSuivant = getVendrediSemaine(Date.now() + 6048e5); 7076 const toutesLesSeances = (listeSure(cache.seancesCours)) 7077 .filter(s => TennisDomainRules.dateDansIntervalle(s.date, lundi, vendrediSuivant)) 7078 .map(s => ({ ...s, creneau: creneauCoursById(s.creneau_id) })) 7079 .filter(s => s.creneau) 7080 .sort((a, b) => (a.date.localeCompare(b.date)) || ((a.creneau.heure_debut || '').localeCompare(b.creneau.heure_debut || ''))); 7081 const periodes = [ 7082 { titre: 'Cette semaine', debut: lundi, fin: vendredi }, 7083 { titre: 'Semaine à venir', debut: lundiSuivant, fin: vendrediSuivant } 7084 ]; 7085 if (new Date().getDay() === 0) periodes.reverse(); 7086 const todayStr = dateLocaleISO(new Date()); 7087 periodes.forEach(periode => { 7088 const seances = toutesLesSeances.filter(s => TennisDomainRules.dateDansIntervalle(s.date, periode.debut, periode.fin)); 7089 html += `<div class="section-title">${periode.titre} <span class="text-faint" style="font-weight:400; text-transform:none; letter-spacing:0; font-size:12px">â du ${formatDate(periode.debut)} au ${formatDate(periode.fin)}</span></div>`; 7090 if (seances.length === 0) { 7091 html += `<div class="card text-faint" style="text-align:center; padding:18px; font-size:13px">Aucune séance sur cette période. ${seancesActivesCount === 0 ? 'Crée un créneau actif dans l\'onglet « Planning ».' : 'Les séances se génèrent automatiquement.'}</div>`; 7092 return; 7093 }
7094 seances.forEach(s => { 7095 const presences = presencesDeSeance(s.id); 7096 const statsPresence = TennisDomainRules.statistiquesPresences(presences); 7097 const complet = statsPresence.renseignes === statsPresence.total && statsPresence.total > 0; 7098 let etatBadge; 7099 if (statsPresence.total === 0) etatBadge = `<span class="text-faint" style="font-size:11px">aucun joueur</span>`; 7100 else if (complet) etatBadge = `<span class="badge badge-success">Appel complet · ${statsPresence.presents}/${statsPresence.total} présents</span>`; 7101 else if (statsPresence.renseignes > 0) etatBadge = `<span class="badge badge-warning">${statsPresence.renseignes}/${statsPresence.total} renseignés</span>`; 7102 else etatBadge = `<span class="badge badge-info">Appel à faire</span>`; 7103 7104 html += `<div class="card" style="padding:12px 14px; margin-bottom:8px; cursor:pointer" data-ui-click="openModalAppel(${escapeHtml(encoderArgumentTexteUI(s.id))})"> 7105 <div style="display:flex; justify-content:space-between; align-items:center; gap:10px"> 7106 <div style="min-width:0"> 7107 <div style="font-weight:600; font-size:14px">${escapeHtml(s.creneau.nom)} ${s.date === todayStr ? '<span class="badge badge-info" style="font-size:10px">ce jour</span>' : ''}</div> 7108 <div class="text-faint" style="font-size:12px; margin-top:2px">${libelleJourCourt(s.date)} · ð ${escapeHtml(s.creneau.heure_debut)}â${escapeHtml(s.creneau.heure_fin)}</div> 7109 <div style="margin-top:6px">${etatBadge}</div> 7110 </div> 7111 <span class="text-faint" style="font-size:18px; flex-shrink:0">âº</span> 7112 </div> 7113 </div>`; 7114 }); 7115 }); 7116 return html; 7117} 7118 7119function renderPlanningCours() { 7120 if (!isCoach()) return ''; 7121 const creneaux = (listeSure(cache.creneauxCours)).filter(c => c.actif).slice() 7122 .sort((a, b) => (a.jour_semaine - b.jour_semaine) || (a.heure_debut || '').localeCompare(b.heure_debut || '')); 7123 7124 let html = renderUxScreenHeading('Organisation', 'Planning de la semaine') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)"> 7125 <div style="font-weight:700; font-size:15px">ð Planning de la semaine</div> 7126 <div class="text-faint" style="font-size:12px; margin-top:2px">La grille type des cours de l'académie, du lundi au vendredi. Touche un créneau pour le modifier ou gérer ses joueurs.</div> 7127 </div>`; 7128 html += `<div class="mb-2" style="display:flex; gap:8px"><button class="btn btn-primary" style="flex:1" data-ui-click="openModalCreneauCours()">+ Ajouter un créneau</button><button class="btn" data-ui-click="exportPlanningPDF()"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">PDF</button></div>`; 7129 7130 if (creneaux.length === 0) { 7131 html += `<div class="card text-faint" style="text-align:center; padding:24px; font-size:13px">Aucun créneau pour le moment. Crée ton premier créneau de cours (jour, horaire, joueurs).</div>`; 7132 return html; 7133 } 7134 7135 const joursGrille = [1, 2, 3, 4, 5]; 7136 html += renderGrilleCreneaux(creneaux, joursGrille); 7137 7138 return html; 7139} 7140 7141function heureEnMinutes(hhmm) { 7142 const [h, m] = (hhmm || '0:0').split(':').map(n => parseInt(n) || 0); 7143 return h * 60 + m; 7144} 7145 7146function renderGrilleCreneaux(creneaux, joursGrille) { 7147 const parJour = {}; 7148 joursGrille.forEach(js => { parJour[js] = []; }); 7149 creneaux.forEach(c => { if (parJour[c.jour_semaine]) parJour[c.jour_semaine].push(c); }); 7150 7151 let minMin = Infinity, maxMin = -Infinity; 7152 creneaux.forEach(c => { 7153 minMin = Math.min(minMin, heureEnMinutes(c.heure_debut)); 7154 maxMin = Math.max(maxMin, heureEnMinutes(c.heure_fin)); 7155 }); 7156 if (!isFinite(minMin)) { minMin = 9 * 60; maxMin = 22 * 60; } 7157 const debutGrille = Math.floor(minMin / 60) * 60; 7158 const finGrille = Math.ceil(maxMin / 60) * 60; 7159 const PX_PAR_MIN = 1.1; 7160 const hauteurTotale = (finGrille - debutGrille) * PX_PAR_MIN; 7161 7162 let axe = `<div class="planning-axe" style="height:${hauteurTotale}px">`; 7163 for (let m = debutGrille; m <= finGrille; m += 60) { 7164 const top = (m - debutGrille) * PX_PAR_MIN; 7165 const h = Math.floor(m / 60); 7166 axe += `<div class="planning-heure" style="top:${top}px">${String(h).padStart(2, '0')}h</div>`; 7167 } 7168 axe += `</div>`; 7169 7170 let cols = ''; 7171 joursGrille.forEach(js => {
7172 let creneauxHtml = ''; 7173 for (let m = debutGrille; m <= finGrille; m += 60) { 7174 const top = (m - debutGrille) * PX_PAR_MIN; 7175 creneauxHtml += `<div class="planning-ligne" style="top:${top}px"></div>`; 7176 } 7177 const duJour = parJour[js].slice().sort((a, b) => { 7178 const da = heureEnMinutes(a.heure_debut), dbb = heureEnMinutes(b.heure_debut); 7179 if (da !== dbb) return da - dbb; 7180 return heureEnMinutes(a.heure_fin) - heureEnMinutes(b.heure_fin); 7181 }); 7182 // Attribution des lanes 7183 const laneFin = []; // minute de fin du dernier créneau de chaque lane 7184 const laneDe = new Map(); // id créneau -> index de lane 7185 duJour.forEach(c => { 7186 const deb = heureEnMinutes(c.heure_debut); 7187 const fin = heureEnMinutes(c.heure_fin); 7188 let lane = 0; 7189 while (lane < laneFin.length && laneFin[lane] > deb) lane++; 7190 laneDe.set(c.id, lane); 7191 laneFin[lane] = fin; 7192 }); 7193 // Calcul du nombre de lanes de CHAQUE cluster (créneaux connectés par chevauchement) 7194 const nbLanesPour = new Map(); 7195 duJour.forEach(c => { 7196 const deb = heureEnMinutes(c.heure_debut); 7197 const fin = heureEnMinutes(c.heure_fin); 7198 // lanes occupées par les créneaux qui chevauchent c (c compris) 7199 const lanesCluster = new Set(); 7200 duJour.forEach(o => { 7201 const od = heureEnMinutes(o.heure_debut), of = heureEnMinutes(o.heure_fin); 7202 if (od < fin && of > deb) lanesCluster.add(laneDe.get(o.id)); // chevauchement strict 7203 }); 7204 nbLanesPour.set(c.id, Math.max(1, lanesCluster.size)); 7205 }); 7206 7207 duJour.forEach(c => { 7208 const debut = heureEnMinutes(c.heure_debut); 7209 const fin = heureEnMinutes(c.heure_fin); 7210 const top = (debut - debutGrille) * PX_PAR_MIN; 7211 const hauteur = Math.max(28, (fin - debut) * PX_PAR_MIN - 3); 7212 const nbJoueurs = joueursDuCreneau(c.id).length; 7213 const nbLanes = nbLanesPour.get(c.id) || 1; 7214 const lane = laneDe.get(c.id) || 0; 7215 const widthPct = 100 / nbLanes; 7216 const leftPct = widthPct * lane; 7217 const coachLigne = c.coach ? ` · ð¤ ${escapeHtml(c.coach)}` : ''; 7218 creneauxHtml += `<div class="planning-creneau ${c.actif ? '' : 'inactif'}" style="top:${top}px; height:${hauteur}px; left:calc(${leftPct}% + 2px); width:calc(${widthPct}% - 4px); right:auto" data-ui-click="openModalCreneauCours(${escapeHtml(encoderArgumentTexteUI(c.id))})" title="${escapeHtml(c.nom)}${c.coach ? ' · ' + escapeHtml(c.coach) : ''} · ${escapeHtml(c.heure_debut)}â${escapeHtml(c.heure_fin)}"> 7219 <div class="planning-creneau-nom">${escapeHtml(c.nom)}</div> 7220 <div class="planning-creneau-h">${escapeHtml(c.heure_debut)}â${escapeHtml(c.heure_fin)} · ð¥${nbJoueurs}${coachLigne}${c.actif ? '' : ' · inactif'}</div> 7221 </div>`; 7222 }); 7223 cols += `<div class="planning-col"> 7224 <div class="planning-col-head">${JOURS_SEMAINE_COURS[js]}</div> 7225 <div class="planning-col-body" style="height:${hauteurTotale}px">${creneauxHtml}</div> 7226 </div>`; 7227 }); 7228 7229 return `<div class="planning-grille-wrap"> 7230 <div class="planning-axe-col"> 7231 <div class="planning-col-head" style="visibility:hidden">.</div> 7232 ${axe} 7233 </div> 7234 <div class="planning-grille">${cols}</div> 7235 </div>`; 7236} 7237 7238// Modal d'appel : statuer la présence de chaque joueur d'une séance. 7239function openModalAppel(seanceId) { 7240 const seance = seanceCoursById(seanceId); 7241 if (!seance) return; 7242 const creneau = creneauCoursById(seance.creneau_id); 7243 const presences = presencesDeSeance(seanceId); 7244 7245 let lignes = presences.map(p => { 7246 const boutons = ['present', 'absent', 'retard'].map(st => { 7247 const meta = PRESENCE_STATUTS[st]; 7248 const actif = p.statut === st; 7249 // Clic sur le statut déjà actif â on le réinitialise (non_renseigne). 7250 const cible = actif ? 'non_renseigne' : st; 7251 return `<button class="presence-btn ${meta.cls} ${actif ? 'actif' : ''}" data-ui-click="setPresence(${escapeHtml(encoderArgumentTexteUI(seanceId))},${escapeHtml(encoderArgumentTexteUI(p.joueur_id))},${escapeHtml(encoderArgumentTexteUI(cible))})" title="${actif ? 'Annuler' : meta.label}" aria-label="${actif ? `Annuler ${meta.label.toLowerCase()}` : meta.label}" aria-pressed="${actif}">${meta.icone}</button>`; 7252 }).join(''); 7253 const declParJoueur = p.declare_par === 'joueur' && p.statut !== 'non_renseigne' 7254 ? `<span class="text-faint" style="font-size:10px"> · déclaré par le joueur</span>` : ''; 7255 const justificationRetard = p.statut === 'retard' && p.justification 7256 ? `<span class="presence-delay-reason">${escapeHtml(p.justification)}</span>` : ''; 7257 return `<div style="display:flex; justify-content:space-between; align-items:center; gap:8px; padding:8px 0; border-bottom:1px solid var(--border)">
7258 <span style="min-width:0; font-size:13px"><span style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><strong>${escapeHtml(p.joueur.prenom)}</strong> ${escapeHtml(p.joueur.nom)}${declParJoueur}</span>${justificationRetard}</span> 7259 <span style="display:flex; gap:4px; flex-shrink:0">${boutons}</span> 7260 </div>`; 7261 }).join(''); 7262 7263 if (presences.length === 0) { 7264 lignes = `<div class="text-faint" style="padding:12px; text-align:center; font-size:13px">Aucun joueur attendu sur cette séance.</div>`; 7265 } 7266 7267 const html = `<div class="modal-header"><h3 class="modal-title">Appel · ${escapeHtml(creneau ? creneau.nom : '')}</h3><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 7268 <div class="modal-body"> 7269 <div class="text-faint mb-2" style="font-size:12px">${libelleJourCourt(seance.date)}${creneau ? ` · ð ${escapeHtml(creneau.heure_debut)}â${escapeHtml(creneau.heure_fin)}` : ''}</div> 7270 <div class="presence-legend"> 7271 <span class="success">â Présent</span><span class="danger">â Absent</span><span class="warning">â±ï¸ Retard</span> 7272 </div> 7273 <div class="card" style="padding:4px 12px; max-height:55vh; overflow-y:auto">${lignes}</div> 7274 <div class="mt-2"><button class="btn btn-full" data-ui-click="closeModal()">Terminer</button></div> 7275 </div>`; 7276 showModal(html); 7277} 7278 7279// Enregistre/modifie la présence d'un joueur (côté coach = fait foi). 7280async function setPresence(seanceId, joueurId, statut) { 7281 const existante = presenceDeSeancePourJoueur(seanceId, joueurId); 7282 const obj = { statut, declare_par: 'coach' }; 7283 if (existante) { 7284 const { data, error } = await updateConfirmee(db.from('presences').update(obj).eq('id', existante.id), 'id,updated_at'); 7285 if (error) { toastErreur(error); return; } 7286 // Mise à jour locale ciblée (pas de rechargement global) 7287 existante.statut = statut; 7288 existante.declare_par = 'coach'; 7289 existante.updated_at = data[0]?.updated_at || existante.updated_at; 7290 } else { 7291 const res = await db.from('presences').insert({ seance_id: seanceId, joueur_id: joueurId, ...obj }).select(COLONNES_PRESENCE).single(); 7292 if (res.error) { toastErreur(res.error); return; } 7293 // Ajout local de la nouvelle présence 7294 if (res.data) (cache.presences = listeSure(cache.presences)).push(res.data); 7295 } 7296 // Ré-affichage : on rafraîchit l'écran sous-jacent (badges d'état des séances) 7297 // puis on ré-ouvre le modal d'appel par-dessus, le tout depuis le cache local. 7298 render(); 7299 openModalAppel(seanceId); 7300} 7301 7302// ============================================================ 7303// CÃTà JOUEUR : « Mes cours » â déclaration de présence en amont 7304// ============================================================ 7305function renderMesCours() { 7306 const joueurId = profile && profile.joueur_id; 7307 if (!joueurId) return ''; 7308 7309 const aujourdHui = dateLocaleISO(new Date()); 7310 const historiquePresence = (listeSure(cache.presences)).filter(presence => { 7311 if (presence.joueur_id !== joueurId) return false; 7312 const seance = seanceCoursById(presence.seance_id); 7313 return !!seance && seance.date <= aujourdHui; 7314 }); 7315 const bilanPresence = TennisDomainRules.statistiquesPresences(historiquePresence); 7316 7317 let html = renderUxScreenHeading('Mon académie', 'Mes présences') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)"> 7318 <div style="font-weight:700; font-size:15px">Mes présences</div> 7319 <div class="text-faint" style="font-size:12px; margin-top:2px">Ton créneau Académie reste régulier : indique simplement si tu seras présent, absent ou en retard cette semaine.</div> 7320 </div> 7321 <div class="ux-presence-summary" aria-label="Bilan de présence de la saison"> 7322 <div class="ux-presence-stat present"><strong>${bilanPresence.present}</strong><span>Présences</span></div> 7323 <div class="ux-presence-stat absent"><strong>${bilanPresence.absent}</strong><span>Absences</span></div> 7324 <div class="ux-presence-stat retard"><strong>${bilanPresence.retard}</strong><span>Retards</span></div> 7325 </div> 7326 <div class="ux-presence-note">Saison en cours · un retard reste compté comme une présence effective dans le taux dâassiduité.</div>`; 7327 7328 const lundi = getLundiSemaine(); 7329 const vendredi = getVendrediSemaine(); 7330 const finAffichage=new Date().getDay()?vendredi:getLundiSemaine(Date.now()+864e5); 7331 const maintenant = new Date(); 7332 const mesSeances = (listeSure(cache.seancesCours)) 7333 .filter(s => TennisDomainRules.dateDansIntervalle(s.date, lundi, finAffichage)) 7334 .map(s => { 7335 const cr = creneauCoursById(s.creneau_id); 7336 const presence = presenceDeSeancePourJoueur(s.id, joueurId); 7337 return { ...s, creneau: cr, presence }; 7338 }) 7339 .filter(s => s.creneau && s.presence) 7340 .filter(s => !seanceCoursEstTerminee(s, s.creneau, maintenant) 7341 || (s.presence.statut === 'non_renseigne' && joueurPeutRegulariserPresence(s, maintenant))) 7342 .sort((a, b) => (a.date.localeCompare(b.date)) || ((a.creneau.heure_debut || '').localeCompare(b.creneau.heure_debut || ''))); 7343 7344 html += `<div class="section-title">Mes cours <span class="text-faint" style="font-weight:400; text-transform:none; letter-spacing:0; font-size:12px">â du ${formatDate(lundi)} au ${formatDate(finAffichage)}</span></div>`; 7345 7346 if (mesSeances.length === 0) { 7347 html += `<div class="card text-faint" style="text-align:center; padding:18px; font-size:13px">Aucun cours prévu pour toi cette semaine.</div>`; 7348 return html; 7349 } 7350 7351 const todayStr = aujourdHui;
7352 mesSeances.forEach(s => { 7353 const st = s.presence.statut; 7354 const estTerminee = seanceCoursEstTerminee(s, s.creneau, maintenant); 7355 const regularisationOuverte = joueurPeutRegulariserPresence(s, maintenant); 7356 const boutons = ['present', 'absent', 'retard'].map(code => { 7357 const meta = PRESENCE_STATUTS[code]; 7358 const actif = st === code; 7359 const cible = actif ? 'non_renseigne' : code; 7360 return `<button class="presence-btn ${meta.cls} ${actif ? 'actif' : ''}" ${regularisationOuverte ? '' : 'disabled style="opacity:0.3; cursor:default"'} data-ui-click="setPresenceJoueur(${escapeHtml(encoderArgumentTexteUI(s.id))},${escapeHtml(encoderArgumentTexteUI(cible))})" title="${actif ? 'Annuler' : meta.label}" aria-label="${actif ? `Annuler ${meta.label.toLowerCase()}` : meta.label}" aria-pressed="${actif}">${meta.icone}</button>`; 7361 }).join(''); 7362 // Libellé du statut courant 7363 let statutLabel; 7364 if (st === 'non_renseigne') statutLabel = estTerminee 7365 ? `<span style="color:var(--orange-text); font-size:11px; font-weight:700">à régulariser avant dimanche soir</span>` 7366 : `<span class="text-faint" style="font-size:11px">à renseigner</span>`; 7367 else { 7368 const meta = PRESENCE_STATUTS[st]; 7369 const parQui = s.presence.declare_par === 'coach' ? ' (validé coach)' : ''; 7370 statutLabel = `<span class="${meta.cls}" style="font-size:11px; font-weight:600">${meta.icone} ${meta.label}${parQui}</span>`; 7371 } 7372 7373 html += `<div class="card" style="padding:12px 14px; margin-bottom:8px"> 7374 <div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap"> 7375 <div style="min-width:0"> 7376 <div style="font-weight:600; font-size:14px">${escapeHtml(s.creneau.nom)} ${s.date === todayStr ? '<span class="badge badge-info" style="font-size:10px">ce jour</span>' : ''}</div> 7377 <div class="text-faint" style="font-size:12px; margin-top:2px">${libelleJourCourt(s.date)} · ð ${escapeHtml(s.creneau.heure_debut)}â${escapeHtml(s.creneau.heure_fin)}</div> 7378 <div style="margin-top:4px">${statutLabel}</div> 7379 </div> 7380 <span style="display:flex; gap:4px; flex-shrink:0">${boutons}</span> 7381 </div> 7382 </div>`; 7383 }); 7384 7385 html += `<div class="text-faint" style="font-size:11px; text-align:center; margin-top:4px">â Présent · â Absent · â±ï¸ Retard · un oubli reste régularisable jusquâau dimanche soir</div>`; 7386 return html; 7387} 7388 7389// Le joueur déclare sa propre présence (vaut par défaut, le coach peut corriger). 7390function openRetardJustification(seanceId) { 7391 const html = `<div class="modal-header"><h3 class="modal-title">Signaler un retard</h3><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 7392 <div class="modal-body"> 7393 <div class="text-dim mb-2" style="font-size:13px">Indique brièvement la raison de ton retard pour prévenir ton coach.</div> 7394 <div class="form-row"><label class="form-label" for="retardJustification">Justification obligatoire</label><textarea id="retardJustification" maxlength="500" rows="4" placeholder="Ex. sortie tardive du collège, problème de transportâ¦"></textarea></div> 7395 <button class="btn btn-primary btn-full" data-ui-click="savePresenceRetard(${escapeHtml(encoderArgumentTexteUI(seanceId))})">Confirmer mon retard</button> 7396 </div>`; 7397 showModal(html); 7398 setTimeout(() => document.getElementById('retardJustification')?.focus(), 50); 7399} 7400 7401async function savePresenceRetard(seanceId) { 7402 const justification = (document.getElementById('retardJustification')?.value || '').trim(); 7403 if (!justification) { toast('La justification du retard est obligatoire', true); return; } 7404 await setPresenceJoueur(seanceId, 'retard', justification); 7405} 7406 7407async function setPresenceJoueur(seanceId, statut, justification = '') { 7408 const joueurId = profile && profile.joueur_id; 7409 if (!joueurId) return; 7410 if (statut === 'retard' && !String(justification || '').trim()) { openRetardJustification(seanceId); return; } 7411 const existante = presenceDeSeancePourJoueur(seanceId, joueurId); 7412 if (!existante) { toast('Séance introuvable', true); return; } 7413 const seance = seanceCoursById(seanceId); 7414 if (!seance || !joueurPeutRegulariserPresence(seance)) { 7415 toast('La période de régularisation est terminée : demande à ton coach.', true); 7416 return; 7417 } 7418 7419 const xpAvant = calculerXP(joueurId).xp; 7420 const etatAvant = { niveau: niveauDepuisXP(xpAvant).niveau, xp: xpAvant }; 7421 const { data, error } = await updateConfirmee(db.from('presences') 7422 .update({ statut, declare_par: 'joueur', justification: statut === 'retard' ? String(justification).trim() : null }) 7423 .eq('id', existante.id), 'id,declare_joueur_at,updated_at'); 7424 if (error) { toastErreur(error); return; } 7425 existante.statut = statut; existante.declare_par = 'joueur'; 7426 existante.justification = statut === 'retard' ? String(justification).trim() : null; 7427 existante.declare_joueur_at = data[0]?.declare_joueur_at || existante.declare_joueur_at; 7428 existante.updated_at = data[0]?.updated_at || existante.updated_at; 7429 if (statut === 'absent' || statut === 'retard') { 7430 await sendEmailEvent(statut === 'absent' ? 'presence_absence_coach' : 'presence_retard_coach', existante.id); 7431 } 7432 if (statut === 'retard') closeModal(); 7433 const confirmations = { 7434 present: 'Présence enregistrée', 7435 absent: 'Absence enregistrée', 7436 retard: 'Retard et justification enregistrés', 7437 non_renseigne: 'Réponse annulée' 7438 }; 7439 toast(confirmations[statut] || 'Réponse enregistrée'); 7440 await synchroniserBadgesEtCelebrer(joueurId, etatAvant); 7441 render(); 7442} 7443 7444// Génère (sans doublon) un rappel in-app pour les cours imminents non renseignés. 7445// Appelée à l'ouverture de l'app côté joueur. Pas de serveur : déclenché à l'usage. 7446async function genererRappelsPresenceJoueur() { 7447 const joueurId = profile && profile.joueur_id; 7448 if (!joueurId || !session) return; 7449 const todayStr = dateLocaleISO(new Date()); 7450 const demain = new Date(); demain.setDate(demain.getDate() + 1); 7451 const demainStr = dateLocaleISO(demain); 7452 7453 // Séances aujourd'hui ou demain où je suis attendu avec un statut non renseigné 7454 const seancesImminentes = (listeSure(cache.seancesCours)).filter(s => s.date === todayStr || s.date === demainStr); 7455 const aCreer = [];
7456 seancesImminentes.forEach(s => { 7457 const presence = presenceDeSeancePourJoueur(s.id, joueurId); 7458 if (!presence || presence.statut !== 'non_renseigne') return; // déjà positionné â pas de rappel 7459 // Doublon ? une notif rappel existe déjà pour cette séance ? 7460 const dejaNotifiee = (listeSure(cache.notifications)).some(n => n.type === 'rappel_presence_cours' && n.lien_id === s.id); 7461 if (dejaNotifiee) return; 7462 const creneau = creneauCoursById(s.creneau_id); 7463 if (!creneau) return; 7464 const quand = s.date === todayStr ? 'ce jour' : 'demain'; 7465 aCreer.push({ 7466 user_id: session.user.id, 7467 type: 'rappel_presence_cours', 7468 titre: 'ð Cours à venir', 7469 message: `${creneau.nom} ${quand} à ${creneau.heure_debut} : indique si tu seras présent.`, 7470 lien_type: 'seance', 7471 lien_id: s.id 7472 }); 7473 }); 7474 if (aCreer.length === 0) return; 7475 const { error } = await db.from('notifications').insert(aCreer); 7476 if (error) { journal.warn('[rappel présence] insert:', error); return; } 7477 // Recharger les notifs pour afficher le badge immédiatement 7478 await loadNotifications(); 7479} 7480 7481function activerRechercheJoueursCreneau() { 7482 const recherche = document.getElementById('rechercheJoueursCreneau'); 7483 if (!recherche) return; 7484 recherche.addEventListener('input', () => { 7485 const terme = TennisDomainRules.normaliserTexteRecherche(recherche.value || ''); 7486 document.querySelectorAll('.creneau-player-choice').forEach(ligne => { 7487 ligne.style.display = terme !== '' && !TennisDomainRules.normaliserTexteRecherche(ligne.dataset.recherche || '').includes(terme) ? 'none' : ''; 7488 }); 7489 }); 7490} 7491 7492function rechercheJoueursCreneauHtml() { 7493 return `<input type="search" id="rechercheJoueursCreneau" autocomplete="off" placeholder="Rechercher par nom ou prénomâ¦" style="margin-bottom:8px">`; 7494} 7495 7496async function openModalCreneauCours(creneauId) { 7497 const c = creneauId ? (listeSure(cache.creneauxCours)).find(x => x.id === creneauId) : null; 7498 const { data: profilsCoachs, error: coachsError } = await db.from('profiles').select('id,prenom,nom').eq('role', 'coach').order('prenom'); 7499 const coachs = coachsError ? [profile] : listeSure(profilsCoachs); 7500 const coachCourant = c?.coach || `${profile?.prenom || ''} ${profile?.nom || ''}`.trim(); 7501 const nomsCoachs = coachs.map(coach => `${coach.prenom || ''} ${coach.nom || ''}`.trim()).filter(Boolean); 7502 if (coachCourant && !nomsCoachs.includes(coachCourant)) nomsCoachs.push(coachCourant); 7503 const optionsCoachs = nomsCoachs.map(nom => `<option value="${escapeHtml(nom)}" ${nom === coachCourant ? 'selected' : ''}>${escapeHtml(nom)}</option>`).join(''); 7504 const dejaIds = new Set(c ? (listeSure(cache.creneauxCoursJoueurs)).filter(a => a.creneau_id === c.id).map(a => a.joueur_id) : []); 7505 const joueursDisponibles = listeSure(cache.joueurs) 7506 .filter(TennisDomainRules.estJoueurActif) 7507 .sort((a, b) => `${a.nom || ''} ${a.prenom || ''}`.localeCompare(`${b.nom || ''} ${b.prenom || ''}`, 'fr')); 7508 const choixJoueurs = joueursDisponibles.map(j => `<label class="creneau-player-choice" data-recherche="${escapeHtml(`${j.prenom || ''} ${j.nom || ''}`)}"> 7509 <input type="checkbox" class="creneau-joueur-cb" value="${escapeHtml(j.id)}" ${dejaIds.has(j.id) ? 'checked' : ''}> 7510 <span><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong><small>${escapeHtml(j.classement || 'NC')} · ${escapeHtml(estPreAcademie(j) ? 'Pré-Académie' : ((cache.config[j.groupe] || {}).nom || j.groupe || 'Académie'))}</small></span> 7511 </label>`).join(''); 7512 const jourSel = c ? c.jour_semaine : 1; 7513 const optionsJours = [1, 2, 3, 4, 5].map(js => 7514 `<option value="${js}" ${jourSel === js ? 'selected' : ''}>${JOURS_SEMAINE_COURS[js]}</option>` 7515 ).join(''); 7516 const deleteBtn = c ? `<div class="mt-1"><button class="btn btn-danger btn-full" data-ui-click="deleteCreneauCours(${escapeHtml(encoderArgumentTexteUI(c.id))})">Supprimer ce créneau</button></div>` : ''; 7517 7518 const html = `<div class="modal-header"><h3 class="modal-title">
7518${c ? 'Modifier' : 'Nouveau'} créneau</h3><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 7519 <div class="modal-body"> 7520 <div class="form-row"><label class="form-label">Nom du créneau</label><input type="text" id="creneauNom" value="${c ? escapeHtml(c.nom) : ''}" placeholder="Ex. : Ãlite jeunes, Accession lundiâ¦"></div> 7521 <div class="form-row"><label class="form-label">Coach</label><select id="creneauCoach">${optionsCoachs}</select></div> 7522 <div class="form-row"><label class="form-label">Jour</label><select id="creneauJour">${optionsJours}</select></div> 7523 <div style="display:flex; gap:10px"> 7524 <div class="form-row" style="flex:1"><label class="form-label">Début</label><input type="time" id="creneauDebut" value="${c ? c.heure_debut : '18:00'}"></div> 7525 <div class="form-row" style="flex:1"><label class="form-label">Fin</label><input type="time" id="creneauFin" value="${c ? c.heure_fin : '19:30'}"></div> 7526 </div> 7527 <div class="form-row"><label style="display:flex; align-items:center; gap:8px; font-size:13px; cursor:pointer"><input type="checkbox" id="creneauActif" ${!c || c.actif ? 'checked' : ''}> Créneau actif (génère des séances)</label></div> 7528 <div class="form-row"><div class="form-label">Joueurs affectés <span class="text-faint">(Académie et Pré-Académie · optionnel)</span></div> 7529 ${rechercheJoueursCreneauHtml()} 7530 <div class="creneau-player-picker">${choixJoueurs || '<div class="text-faint" style="padding:12px">Aucun joueur actif.</div>'}</div> 7531 </div> 7532 <div class="mt-2"><button class="btn btn-primary btn-full" data-ui-click="saveCreneauCours(${c ? escapeHtml(encoderArgumentTexteUI(c.id)) : 'null'})">${c ? 'Enregistrer' : 'Créer le créneau'}</button></div> 7533 ${deleteBtn} 7534 </div>`; 7535 showModal(html); 7536 activerRechercheJoueursCreneau(); 7537} 7538 7539async function saveCreneauCours(creneauId) { 7540 const nom = (document.getElementById('creneauNom').value || '').trim(); 7541 const jour = parseInt(document.getElementById('creneauJour').value); 7542 const debut = document.getElementById('creneauDebut').value; 7543 const fin = document.getElementById('creneauFin').value; 7544 const actif = document.getElementById('creneauActif').checked; 7545 const joueurIds = Array.from(document.querySelectorAll('.creneau-joueur-cb')).filter(cb => cb.checked).map(cb => cb.value); 7546 if (!nom) { toast('Donne un nom au créneau', true); return; } 7547 if (isNaN(jour)) { toast('Choisis un jour', true); return; } 7548 if (!debut || !fin) { toast('Indique les horaires', true); return; } 7549 if (fin <= debut) { toast('L\'heure de fin doit être après le début', true); return; } 7550 7551 const obj = { nom, jour_semaine: jour, heure_debut: debut, heure_fin: fin, actif }; 7552 const coach = (document.getElementById('creneauCoach') || {}).value; 7553 obj.coach = coach ? coach.trim() : null; 7554 let error, nouvelId = creneauId; 7555 if (creneauId) { 7556 ({ error } = await updateConfirmee(db.from('creneaux_cours').update(obj).eq('id', creneauId))); 7557 } else { 7558 const res = await db.from('creneaux_cours').insert(obj).select('id').single(); 7559 error = res.error; 7560 nouvelId = res.data ? res.data.id : null; 7561 } 7562 if (error) { toastErreur(error); return; } 7563 const { error: joueursError } = await db.rpc('coach_remplacer_joueurs_creneau', { 7564 p_creneau_id: nouvelId, 7565 p_joueur_ids: joueurIds 7566 }); 7567 if (joueursError) { 7568 // En création, ne jamais laisser un créneau vide alors que le coach avait 7569 // demandé des joueurs dans le même formulaire. 7570 if (!creneauId && nouvelId) await db.from('creneaux_cours').delete().eq('id', nouvelId); 7571 toastErreur(joueursError); 7572 return; 7573 } 7574 const { error: generationError } = await db.rpc('generer_seances_semaine', { p_lundi: getLundiSemaine() }); 7575 if (generationError) { toastErreur(generationError); return; } 7576 closeModal(); 7577 toast(creneauId ? 'Créneau et joueurs modifiés' : 'Créneau et joueurs créés ð¾'); 7578 await reloadTables(['creneaux_cours', 'creneaux_cours_joueurs', 'seances_cours', 'presences']); 7579 render(); 7580} 7581 7582async function deleteCreneauCours(creneauId) {
7583 if (!(await demanderConfirmation('Retirer ce créneau du planning ? Son historique de présences sera conservé.'))) return; 7584 // Le cache des séances est borné à la saison analysée. On interroge donc la 7585 // base afin de ne pas tenter de supprimer un créneau lié à une saison archivée. 7586 const { data: seancesExistantes, error: verificationError } = await db.from('seances_cours') 7587 .select('id') 7588 .eq('creneau_id', creneauId) 7589 .limit(1); 7590 if (verificationError) { toastErreur(verificationError); return; } 7591 const possedeHistorique = listeSure(seancesExistantes).length > 0; 7592 const resultat = possedeHistorique 7593 ? await updateConfirmee(db.from('creneaux_cours').update({ actif: false }).eq('id', creneauId)) 7594 : await db.from('creneaux_cours').delete().eq('id', creneauId); 7595 if (resultat.error) { toastErreur(resultat.error); return; } 7596 closeModal(); 7597 toast(possedeHistorique ? 'Créneau retiré du planning · historique conservé' : 'Créneau supprimé'); 7598 await reloadTables(['creneaux_cours', 'creneaux_cours_joueurs']); 7599 render(); 7600} 7601 7602function openModalJoueursCreneau(creneauId) { 7603 const c = (listeSure(cache.creneauxCours)).find(x => x.id === creneauId); 7604 if (!c) return; 7605 const dejaIds = (listeSure(cache.creneauxCoursJoueurs)) 7606 .filter(ccj => ccj.creneau_id === creneauId) 7607 .map(ccj => ccj.joueur_id); 7608 const joueursActifs = (listeSure(cache.joueurs)) 7609 .filter(TennisDomainRules.estJoueurActif) 7610 .sort((a, b) => (a.nom || '').localeCompare(b.nom || '')); 7611 7612 const lignes = joueursActifs.map(j => { 7613 const checked = dejaIds.includes(j.id) ? 'checked' : ''; 7614 return `<label class="creneau-player-choice" data-recherche="${escapeHtml(`${j.prenom || ''} ${j.nom || ''}`)}" style="display:flex; align-items:center; gap:10px; padding:8px 4px; border-bottom:1px solid var(--border); cursor:pointer; font-size:13px"> 7615 <input type="checkbox" class="creneau-joueur-cb" value="${j.id}" ${checked}> 7616 <span style="flex:1"><strong>${escapeHtml(j.prenom)}</strong> ${escapeHtml(j.nom)} <span class="text-faint">· ${escapeHtml(j.classement || 'NC')} · ${escapeHtml(estPreAcademie(j) ? 'Pré-Académie' : ((cache.config[j.groupe] && cache.config[j.groupe].nom) || j.groupe || 'Académie'))}</span></span> 7617 </label>`; 7618 }).join(''); 7619 7620 const html = `<div class="modal-header"><h3 class="modal-title">Joueurs · ${escapeHtml(c.nom)}</h3><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 7621 <div class="modal-body"> 7622 <div class="text-faint mb-2" style="font-size:12px">Coche les joueurs attendus à ce créneau, Académie et Pré-Académie confondues (${JOURS_SEMAINE_COURS[c.jour_semaine]} ${escapeHtml(c.heure_debut)}â${escapeHtml(c.heure_fin)}).</div> 7623 ${rechercheJoueursCreneauHtml()} 7624 <div class="card" style="padding:4px 12px; max-height:50vh; overflow-y:auto">${lignes || '<div class="text-faint" style="padding:12px">Aucun joueur actif.</div>'}</div> 7625 <div class="mt-2"><button class="btn btn-primary btn-full" data-ui-click="saveJoueursCreneau(${escapeHtml(encoderArgumentTexteUI(creneauId))})">Enregistrer la liste</button></div> 7626 </div>`; 7627 showModal(html); 7628 activerRechercheJoueursCreneau(); 7629} 7630 7631async function saveJoueursCreneau(creneauId) { 7632 const checkboxes = document.querySelectorAll('.creneau-joueur-cb'); 7633 const nouveauxIds = Array.from(checkboxes).filter(cb => cb.checked).map(cb => cb.value); 7634 const { error } = await db.rpc('coach_remplacer_joueurs_creneau', { 7635 p_creneau_id: creneauId, 7636 p_joueur_ids: nouveauxIds 7637 }); 7638 if (error) { toastErreur(error); return; } 7639 const { error: generationError } = await db.rpc('generer_seances_semaine', { p_lundi: getLundiSemaine() }); 7640 if (generationError) { toastErreur(generationError); return; } 7641 closeModal(); 7642 toast('Liste des joueurs enregistrée'); 7643 await reloadTables(['creneaux_cours_joueurs', 'seances_cours', 'presences']); 7644 render(); 7645} 7646 7647// Vue Planning : grille participants à jours. 7648function renderPlanningPalmyre() { 7649 if (!isCoach()) return ''; 7650 const ev = getActiveEvenement(); 7651 if (!ev) return `<div class="empty"><span class="icon">ð </span>Aucune édition active.</div>`; 7652 7653 const participants = joueursActifsDansEnvCourant() 7654 .sort((a, b) => (a.prenom || '').localeCompare(b.prenom || '')); 7655 if (participants.length === 0) { 7656 return `<div class="empty"><span class="icon">ð </span>Aucun participant à ${escapeHtml(ev.nom)}.</div>`; 7657 } 7658 const jours = joursEdition(ev); 7659 7660 let html = renderUxScreenHeading('Organisation terrain', 'Planning Palmyre') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)"> 7661 <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px"> 7662 <div> 7663 <div style="font-weight:700; font-size:15px">ð Planning · ${escapeHtml(ev.nom)}</div> 7664 <div class="text-faint" style="font-size:12px; margin-top:2px">Programme les créneaux (heure + lieu) de chaque participant. Touche une case pour ajouter.</div> 7665 </div> 7666 <button class="btn" style="white-space:nowrap" data-ui-click="exportPlanningPalmyrePDF()"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">PDF</button> 7667 </div> 7668 </div>`; 7669 7670 // Filtre par joueur : permet au coach de vérifier le planning complet d'un joueur 7671 // (repérer un oubli). '' = tous. 7672 const filtreValide = etatApp.planningJoueur && participants.some(j => j.id === etatApp.planningJoueur); 7673 const participantsAffiches = filtreValide ? participants.filter(j => j.id === etatApp.planningJoueur) : participants; 7674 html += `<div style="margin-bottom:10px; display:flex; align-items:center; gap:8px; flex-wrap:wrap"> 7675 <select data-ui-change="setPlanningFiltreJoueur(this.value)" style="flex:1; min-width:180px"> 7676 <option value="">Tous les joueurs (${participants.length})</option> 7677 ${participants.map(j => `<option value="${j.id}" ${etatApp.planningJoueur === j.id ? 'selected' : ''}>${escapeHtml(j.prenom)} ${escapeHtml(j.nom || '')}</option>`).join('')} 7678 </select>`; 7679 if (filtreValide) { 7680 const nbCr = creneauxPalmyreDuJoueurEvenement(etatApp.planningJoueur, ev.id).length; 7681 html += `<span class="text-faint" style="font-size:12px; white-space:nowrap">${nbCr} créneau${nbCr > 1 ? 'x' : ''} cette semaine</span>`;
7682 } 7683 html += `</div>`; 7684 7685 // Grille scrollable horizontalement 7686 html += `<div style="overflow-x:auto; -webkit-overflow-scrolling:touch"><table style="border-collapse:collapse; font-size:12px; min-width:100%"> 7687 <thead><tr> 7688 <th style="position:sticky; left:0; background:var(--bg-section); z-index:2; text-align:left; padding:8px; min-width:90px; border:1px solid var(--border)">Joueur</th> 7689 ${jours.map(j => `<th style="text-align:center; padding:8px; min-width:120px; border:1px solid var(--border); background:var(--bg-section)">${escapeHtml(libelleJourCourt(j))}</th>`).join('')} 7690 </tr></thead><tbody>`; 7691 7692 participantsAffiches.forEach(j => { 7693 html += `<tr> 7694 <td style="position:sticky; left:0; background:var(--bg-card); z-index:1; font-weight:600; padding:8px; border:1px solid var(--border)">${escapeHtml(j.prenom)}</td>`; 7695 jours.forEach(jour => { 7696 const crs = creneauxDuJoueurJour(j.id, jour); 7697 const contenu = crs.map(c => { 7698 const fond = fondResultatCreneaux([c]) || 'background:var(--orange-bg, rgba(243,146,0,0.12));'; 7699 return `<div data-ui-click="event.stopPropagation(); openModalCreneau(${escapeHtml(encoderArgumentTexteUI(j.id))},${escapeHtml(encoderArgumentTexteUI(jour))},${escapeHtml(encoderArgumentTexteUI(c.id))})" style="cursor:pointer; ${fond} border-radius:5px; padding:3px 5px; margin-bottom:3px"> 7700 <strong>${escapeHtml(c.heure || '')}</strong>${c.lieu ? ' · ' + escapeHtml(c.lieu) : ''}${badgeTypePublic(c.type_public)}${badgeResultatCreneau(c)} 7701 </div>`; 7702 }).join(''); 7703 html += `<td data-ui-click="openModalCreneau(${escapeHtml(encoderArgumentTexteUI(j.id))},${escapeHtml(encoderArgumentTexteUI(jour))})" style="cursor:pointer; vertical-align:top; padding:4px; border:1px solid var(--border); min-width:120px"> 7704 ${contenu} 7705 <div class="text-faint" style="font-size:14px; text-align:center; padding:2px">+</div> 7706 </td>`; 7707 }); 7708 html += `</tr>`; 7709 }); 7710 html += `</tbody></table></div>`; 7711 return html; 7712} 7713 7714// Export PDF du planning Palmyre GLOBAL (tous les participants), en tableau 7715// joueurs à jours, A4 paysage, avec pagination automatique. 7716async function exportPlanningPalmyrePDF() { return appelerExportPdf('exportPlanningPalmyrePDF'); } 7717 7718// Résultat d'un créneau : UNIQUEMENT ce que le coach a coché lui-même. 7719// Volontairement PAS de déduction depuis les matchs saisis par les joueurs : un enfant 7720// peut saisir son match le lendemain, ce qui ferait apparaître (ou changer) un résultat 7721// après coup sur le planning. Une seule source de vérité : le coach. 7722function resultatCreneau(c) { 7723 if (!c) return null; 7724 if (c.resultat === 'V' || c.resultat === 'D') return { code: c.resultat }; 7725 return null; 7726} 7727 7728// Pastille de résultat, très visible (vert = victoire, rouge = défaite). 7729function badgeResultatCreneau(c) { 7730 const r = resultatCreneau(c); 7731 if (!r) return ''; 7732 const cfg = r.code === 'V' 7733 ? { bg: '#16a34a', txt: 'â V' } 7734 : { bg: '#dc2626', txt: 'â D' }; 7735 return ` <span style="display:inline-block; font-size:10px; font-weight:800; padding:2px 7px; border-radius:999px; background:${cfg.bg}; color:#fff; white-space:nowrap">${cfg.txt}</span>`; 7736} 7737 7738// Couleur de fond d'une case de planning selon le résultat (lisible d'un coup d'Åil). 7739function fondResultatCreneaux(creneaux) { 7740 const res = (listeSure(creneaux)).map(c => resultatCreneau(c)).filter(Boolean); 7741 if (res.length === 0) return ''; 7742 const v = res.filter(r => r.code === 'V').length; 7743 const d = res.filter(r => r.code === 'D').length; 7744 if (v && !d) return 'background:rgba(22,163,74,0.18); border-left:3px solid #16a34a;'; 7745 if (d && !v) return 'background:rgba(220,38,38,0.16); border-left:3px solid #dc2626;'; 7746 return 'background:rgba(234,179,8,0.14); border-left:3px solid #eab308;'; // mixte V+D 7747} 7748 7749// Options d'heures pour le planning Palmyre : créneaux de 15 min de 07h00 à 22h00, 7750// au format "HHhMM" (compatible avec les données existantes). Une valeur hors grille 7751// (ancien créneau) est ajoutée comme option pour ne pas la perdre à l'édition. 7752function optionsHeuresPalmyre(selected) { 7753 const vals = []; 7754 for (let h = 7; h <= 22; h++) { 7755 for (let m = 0; m < 60; m += 15) { 7756 if (h === 22 && m > 0) break; 7757 vals.push(`${String(h).padStart(2, '0')}h${String(m).padStart(2, '0')}`); 7758 } 7759 } 7760 let opts = '<option value="">â choisir â</option>'; 7761 opts += vals.map(v => `<option value="${v}" ${selected === v ? 'selected' : ''}>${v}</option>`).join(''); 7762 if (selected && !vals.includes(selected)) { 7763 opts += `<option value="${escapeHtml(selected)}" selected>${escapeHtml(selected)} (hors liste)</option>`; 7764 } 7765 return opts; 7766} 7767 7768// Tuile "type de tournoi" (enfants / adultes) affichée sur un créneau du planning Palmyre. 7769// Pastilles pleines à couleurs franches et opposées pour une différenciation immédiate. 7770function badgeTypePublic(t) { 7771 if (!t) return ''; 7772 return ` <span style="display:inline-block; font-size:10px; font-weight:700; padding:2px 8px; border-radius:999px; background:#6366f1; color:#fff; letter-spacing:.3px; white-space:nowrap">${escapeHtml(t)}
7772</span>`; 7773} 7774 7775// Catégories de tournoi proposées dans le planning Palmyre (liste éditable). 7776const CATEGORIES_TOURNOI = [ 7777 'SM', 'SD', 'DM', 'DD', 'DX', 'TMC', 7778 '9-10 ans', '11-12 ans', '13-14 ans', '15-16 ans', '17-18 ans', 7779 'Seniors', '+35', '+45' 7780]; 7781function optionsCategoriesTournoi(selected) { 7782 let opts = '<option value="">â aucune â</option>'; 7783 opts += CATEGORIES_TOURNOI.map(cat => `<option value="${escapeHtml(cat)}" ${selected === cat ? 'selected' : ''}>${escapeHtml(cat)}</option>`).join(''); 7784 if (selected && !CATEGORIES_TOURNOI.includes(selected)) { 7785 opts += `<option value="${escapeHtml(selected)}" selected>${escapeHtml(selected)} (hors liste)</option>`; 7786 } 7787 return opts; 7788} 7789 7790// Modale d'ajout/édition d'un créneau. 7791function openModalCreneau(joueurId, jour, creneauId) { 7792 const j = joueurById(joueurId); 7793 const ev = getActiveEvenement(); 7794 if (!j || !ev) { toast('Joueur ou édition introuvable', true); return; } 7795 const c = creneauId ? creneauPalmyreById(creneauId) : null; 7796 const isEdit = !!c; 7797 7798 let html = `<div class="modal-header"> 7799 <h3 class="modal-title">${isEdit ? 'Modifier' : 'Ajouter'} un créneau</h3> 7800 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 7801 </div> 7802 <div class="text-faint mb-2" style="font-size:13px">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)} · ${escapeHtml(libelleJourCourt(jour))}</div> 7803 <form data-ui-submit="saveCreneau(event, ${escapeHtml(encoderArgumentTexteUI(joueurId))}, ${escapeHtml(encoderArgumentTexteUI(jour))}, ${escapeHtml(encoderArgumentTexteUI(creneauId || ''))})"> 7804 <div class="grid-2"> 7805 <div class="form-row"><label class="form-label">Heure</label> 7806 <select name="heure" style="width:100%">${optionsHeuresPalmyre(c ? (c.heure || '') : '')}</select></div> 7807 <div class="form-row"><label class="form-label">Lieu</label> 7808 <select name="lieu" style="width:100%"> 7809 <option value="">â choisir â</option> 7810 ${LIEUX_PALMYRE.map(l => `<option value="${escapeHtml(l)}" ${c && c.lieu === l ? 'selected' : ''}>${escapeHtml(l)}</option>`).join('')} 7811 ${c && c.lieu && !LIEUX_PALMYRE.includes(c.lieu) ? `<option value="${escapeHtml(c.lieu)}" selected>${escapeHtml(c.lieu)} (ancien)</option>` : ''} 7812 </select></div> 7813 </div> 7814 <div class="form-row"><label class="form-label">Type de tournoi (optionnel)</label> 7815 <select name="type_public" style="width:100%">${optionsCategoriesTournoi(c ? (c.type_public || '') : '')}</select></div> 7816 <div class="form-row"><label class="form-label">Résultat (optionnel)</label> 7817 <div class="pill-group" id="pillResultatCreneau"> 7818 <span class="pill success ${c && c.resultat === 'V' ? 'active' : ''}" data-value="V" data-ui-click="selectPillToggle(this, 'pillResultatCreneau')">â Victoire</span> 7819 <span class="pill danger ${c && c.resultat === 'D' ? 'active' : ''}" data-value="D" data-ui-click="selectPillToggle(this, 'pillResultatCreneau')">â Défaite</span> 7820 </div> 7821 </div> 7822 <div class="form-row"><label class="form-label">Note (optionnel)</label> 7823 <input type="text" name="note" value="${c ? escapeHtml(c.note || '') : ''}" placeholder="Précision..."></div> 7824 <div class="flex gap-1 mt-2"> 7825 ${isEdit ? `<button type="button" class="btn btn-danger" data-ui-click="deleteCreneau(${escapeHtml(encoderArgumentTexteUI(creneauId))},${escapeHtml(encoderArgumentTexteUI(joueurId))},${escapeHtml(encoderArgumentTexteUI(jour))})">Supprimer</button>` : ''} 7826 <button type="button" class="btn flex-1" data-ui-click="closeModal()">Annuler</button> 7827 <button type="submit" class="btn btn-primary flex-1">${isEdit ? 'Enregistrer' : 'Ajouter'}</button> 7828 </div> 7829 </form>`; 7830 showModal(html); 7831 const formulaireBilan = document.getElementById('formBilanPalmyre'); 7832 formulaireBilan?.addEventListener('input', () => { 7833 const brouillon = capturerBrouillonBilan(joueurId); 7834 if (brouillon) gestionnaireBrouillons.conserverBilan(brouillon); 7835 }); 7836} 7837 7838async function saveCreneau(e, joueurId, jour, creneauId) { 7839 e.preventDefault(); 7840 const f = e.target; 7841 const ev = getActiveEvenement(); 7842 if (!ev) { toast('Ãdition introuvable', true); return; } 7843 const heure = f.heure.value.trim(); 7844 const lieu = f.lieu.value.trim(); 7845 if (!heure && !lieu) { toast('Renseigne au moins une heure ou un lieu', true); return; } 7846 const type_public = (f.type_public && f.type_public.value.trim()) || null; 7847 const resPill = document.querySelector('#pillResultatCreneau .pill.active'); 7848 const resultat = resPill ? resPill.dataset.value : null; 7849 7850 const obj = { 7851 evenement_id: ev.id, 7852 joueur_id: joueurId, 7853 jour: jour, 7854 heure: heure || null, 7855 lieu: lieu || null, 7856 type_public: type_public, 7857 resultat: resultat, 7858 note: f.note.value.trim() || null 7859 }; 7860 7861 let error; 7862 const execution = await executerAvecVerrou(f, () => creneauId 7863 ? updateConfirmee(db.from('creneaux_palmyre').update(obj).eq('id', creneauId)) 7864 : db.from('creneaux_palmyre').insert(obj)); 7865 if (execution.ignoree) return; 7866 ({ error } = execution.valeur); 7867 if (error) { toastErreur(error); return; } 7868 closeModal(); 7869 toast(creneauId ? 'Créneau modifié' : 'Créneau ajouté'); 7870 await reloadTables(['creneaux_palmyre']); 7871 render(); 7872} 7873 7874async function deleteCreneau(creneauId, joueurId, jour) {
7875 if (!(await demanderConfirmation('Supprimer ce créneau ?'))) return; 7876 const { error } = await db.from('creneaux_palmyre').delete().eq('id', creneauId); 7877 if (error) { toastErreur(error); return; } 7878 closeModal(); 7879 toast('Créneau supprimé'); 7880 await reloadTables(['creneaux_palmyre']); 7881 render(); 7882} 7883 7884// Vue Transport : regroupement par jour â par lieu â joueurs (pour le rôle chauffeur). 7885function renderTransportPalmyre() { 7886 if (!isCoach()) return ''; 7887 const ev = getActiveEvenement(); 7888 if (!ev) return `<div class="empty"><span class="icon"><img src="assets/v4/nav/transport.webp" alt="" style="width:62px;height:62px;object-fit:contain"></span>Aucune édition active.</div>`; 7889 7890 const creneaux = creneauxPalmyreDeEvenement(ev.id).filter(c => c.lieu); 7891 if (creneaux.length === 0) { 7892 return `<div class="empty"><span class="icon"><img src="assets/v4/nav/transport.webp" alt="" style="width:62px;height:62px;object-fit:contain"></span>Aucun déplacement programmé.<br><span class="text-faint">Ajoute des créneaux avec un lieu dans le Planning.</span></div>`; 7893 } 7894 7895 let html = renderUxScreenHeading('Organisation terrain', 'Transport') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)"> 7896 <div style="font-weight:700; font-size:15px;display:flex;align-items:center;gap:8px"><img class="inline-art-icon" src="assets/v4/nav/transport.webp" alt="" style="width:30px;height:30px">Logistique transport · ${escapeHtml(ev.nom)}</div> 7897 <div class="text-faint" style="font-size:12px; margin-top:2px">Qui emmener, quand et où. Regroupé par jour puis par lieu.</div> 7898 </div>`; 7899 7900 const jours = joursEdition(ev).filter(j => creneaux.some(c => c.jour === j)); 7901 jours.forEach(jour => { 7902 const crsJour = creneaux.filter(c => c.jour === jour); 7903 // Regrouper par lieu 7904 const parLieu = {}; 7905 crsJour.forEach(c => { (parLieu[c.lieu] = parLieu[c.lieu] || []).push(c); }); 7906 7907 html += `<div class="section-title">${escapeHtml(libelleJourCourt(jour))}</div>`; 7908 Object.keys(parLieu).sort().forEach(lieu => { 7909 const liste = parLieu[lieu].sort((a, b) => (a.heure || '').localeCompare(b.heure || '')); 7910 html += `<div class="card mb-1" style="padding:10px 12px"> 7911 <div style="font-weight:600; margin-bottom:6px"><span class="venue-mark" aria-hidden="true"></span>${escapeHtml(lieu)} <span class="text-faint" style="font-size:12px">· ${liste.length} joueur${liste.length > 1 ? 's' : ''}</span></div> 7912 ${liste.map(c => { 7913 const j = joueurById(c.joueur_id); 7914 // Transport = qui va où et à quelle heure. Le résultat du match n'a rien à 7915 // faire ici : il n'apparaît que sur le planning. 7916 return `<div style="display:flex; justify-content:space-between; padding:3px 0; font-size:13px; border-top:1px solid var(--border)"> 7917 <span>${j ? escapeHtml(j.prenom + ' ' + j.nom) : '?'}</span> 7918 <span class="text-faint">${escapeHtml(c.heure || '')}${badgeTypePublic(c.type_public)}</span> 7919 </div>`; 7920 }).join('')} 7921 </div>`; 7922 }); 7923 }); 7924 return html; 7925} 7926 7927// Vue joueur : son planning personnel du stage (lecture seule), par jour. 7928function renderMonPlanningJoueur() { 7929 if (!profile || !profile.joueur_id) return `<div class="empty">Profil joueur non trouvé.</div>`; 7930 const joueur = joueurById(profile.joueur_id); 7931 const editionsVisibles = editionsPlanningPalmyreJoueur(joueur); 7932 if (editionsVisibles.length === 0) { 7933 return `<div class="empty"><span class="icon">ð </span>Aucun planning Palmyre en cours ou à venir.</div>`; 7934 } 7935 const edition = editionsVisibles.find(ev => creneauxPalmyreDuJoueurEvenement(profile.joueur_id, ev.id).length > 0) 7936 || editionsVisibles[0]; 7937 const mesCreneaux = creneauxPalmyreDuJoueurEvenement(profile.joueur_id, edition.id); 7938 7939 if (mesCreneaux.length === 0) { 7940 return `<div class="empty"><span class="icon">ð </span>Aucun créneau programmé pour le moment.</div>`; 7941 } 7942 7943 const creneaux = mesCreneaux; 7944 7945 let html = renderUxScreenHeading('Mon séjour', 'Planning Palmyre') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)"> 7946 <div style="font-weight:700; font-size:15px">ðï¸ Mon planning · ${escapeHtml(edition.nom || 'Palmyre')}</div> 7947 <div class="text-faint" style="font-size:12px; margin-top:2px">Tes créneaux programmés par le coach (heure et lieu).</div> 7948 </div>`; 7949 7950 if (creneaux.length === 0) { 7951 html += `<div class="empty"><span class="icon">ð </span>Tu n'as aucun créneau programmé.</div>`; 7952 return html; 7953 } 7954 7955 // Regrouper par jour, dans l'ordre chronologique 7956 const jours = [...new Set(creneaux.map(c => c.jour))].sort();
7957 jours.forEach(jour => { 7958 const crs = creneaux.filter(c => c.jour === jour).sort((a, b) => (a.heure || '').localeCompare(b.heure || '')); 7959 html += `<div class="section-title">${escapeHtml(libelleJourCourt(jour))}</div>`; 7960 html += `<div class="card" style="padding:0; overflow:hidden">`; 7961 crs.forEach(c => { 7962 html += `<div style="display:flex; align-items:center; gap:12px; padding:11px 14px; border-bottom:1px solid var(--border)"> 7963 <div style="font-weight:700; font-size:15px; color:var(--orange-text); min-width:60px">${escapeHtml(c.heure || 'â')}</div> 7964 <div style="flex:1"> 7965 ${c.lieu ? `<div style="font-weight:600"><span class="venue-mark"></span>${escapeHtml(c.lieu)}</div>` : ''} 7966 ${(c.type_public || (isCoach() && resultatCreneau(c))) ? `<div style="margin-top:3px">${badgeTypePublic(c.type_public)}${isCoach() ? badgeResultatCreneau(c) : ''}</div>` : ''} 7967 ${c.note ? `<div class="text-faint" style="font-size:12px; margin-top:2px">${escapeHtml(c.note)}</div>` : ''} 7968 </div> 7969 </div>`; 7970 }); 7971 html += `</div>`; 7972 }); 7973 return html; 7974} 7975 7976function setPlanningFiltreJoueur(value) { 7977 etatApp.definirPlanningJoueur(value); 7978 render(); 7979} 7980 7981// ============================================================ 7982// CARNET D'OBSERVATIONS PALMYRE (notes de travail du coach) 7983// ============================================================ 7984 7985const OBS_THEMES = [ 7986 { cle: 'tactique', label: 'Tactique', icone: 'ð¯' }, { cle: 'technique', label: 'Technique', icone: 'ð¾' }, 7987 { cle: 'mental', label: 'Mental', icone: 'ð§ ' }, 7988 { cle: 'physique', label: 'Physique', icone: 'ðª' }, 7989 { cle: 'groupe', label: 'Vie de groupe', icone: 'ð¥' } 7990]; 7991 7992// Thèmes proposés à la SAISIE sur un joueur : "Vie de groupe" en est exclu, 7993// car il n'a de sens que dans les notes collectives (pas rattaché à un joueur). 7994const OBS_THEMES_JOUEUR = OBS_THEMES.filter(t => t.cle !== 'groupe'); 7995 7996// Lieux du stage de la Palmyre (utilisés pour les observations ET le planning). 7997// Liste fixe fournie par le coach. 7998const LIEUX_PALMYRE = ['La Palmyre', 'Royan', 'Breuillet', 'Vaux-sur-Mer', 'Saint-Georges', 'Saujon', 'Médis']; 7999function obsThemeMeta(cle) { return OBS_THEMES.find(t => t.cle === cle) || { label: cle, icone: 'â¢' }; } 8000 8001// ============================================================ 8002// AXES DE TRAVAIL PALMYRE (consignes envoyées au joueur) 8003// Contrairement aux observations (privées coach), un axe est PARTAGà avec le joueur : 8004// il le voit sur son profil, reçoit une notification, et coche "fait" quand c'est fini. 8005// ============================================================ 8006// Axes d'un joueur. 8007// - Côté COACH : on filtre sur l'édition courante (il travaille dans un environnement donné). 8008// - Côté JOUEUR : on NE filtre PAS par édition. La RLS ne lui renvoie déjà que SES axes, et 8009// toute tentative de deviner "son" édition est fragile (une fois le stage terminé, la 8010// résolution retombait sur une autre édition active et ses axes disparaissaient). 8011function axesJoueur(joueurId) { 8012 const tous = axesPalmyreDuJoueur(joueurId); 8013 const evCoach = getActiveEvenement(); // null pour un joueur 8014 const liste = isCoach() 8015 ? (evCoach ? tous.filter(a => a.evenement_id === evCoach.id) : tous.filter(a => !a.evenement_id)) 8016 : tous; 8017 return liste.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')); 8018} 8019 8020// Les axes réalisés restent archivés en base pour le suivi, mais ne font plus partie 8021// de la liste de travail quotidienne du joueur ni de celle du coach. 8022function axesActifsJoueur(joueurId) { 8023 return axesJoueur(joueurId).filter(axe => !axe.fait); 8024} 8025 8026// Coach : envoie un axe de travail au joueur (+ notification). 8027async function envoyerAxeTravail(joueurId) { 8028 if (!isCoach()) return; 8029 const ev = getActiveEvenement(); 8030 const el = document.getElementById('axeTexte'); 8031 const texte = (el && el.value || '').trim(); 8032 if (!texte) { toast('Ãcris l\'axe de travail', true); return; } 8033 8034 const { error } = await db.from('axes_travail_palmyre').insert({ 8035 evenement_id: ev ? ev.id : null, 8036 joueur_id: joueurId, 8037 coach_id: session.user.id, 8038 texte: texte 8039 }); 8040 if (error) { toastErreur(error); return; } 8041 8042 // Notification au joueur. Un échec ici ne doit PAS être silencieux : sans ça, le coach 8043 // croit que le joueur a été prévenu alors qu'il n'a rien reçu. 8044 const j = joueurById(joueurId); 8045 let notifEnvoyee = false;
8046 let notifProbleme = ''; 8047 if (!j || !j.user_id) { 8048 notifProbleme = 'ce joueur n\'a pas encore de compte'; 8049 } else { 8050 const { error: errNotif } = await db.from('notifications').insert({ 8051 user_id: j.user_id, 8052 type: 'axe_travail', 8053 titre: 'ð¯ Nouvel axe de travail', 8054 message: `Ton coach te propose cet axe de travail : ${texte}`, 8055 lien_type: 'axe_travail', 8056 lien_id: null 8057 }); 8058 if (errNotif) { notifProbleme = errNotif.message; journal.warn('[axe] notif:', errNotif); } 8059 else notifEnvoyee = true; 8060 } 8061 8062 if (el) el.value = ''; 8063 if (notifEnvoyee) { 8064 toast('Axe envoyé au joueur ð¯'); 8065 } else { 8066 toast('Axe enregistré, mais le joueur n\'a pas été notifié (' + notifProbleme + ')', true); 8067 } 8068 await reloadTables(['axes_travail_palmyre']); 8069 const box = document.getElementById('axesListe'); 8070 if (box) box.innerHTML = renderAxesListe(joueurId); 8071} 8072 8073// Liste des axes d'un joueur (vue coach : suivi du "fait"). 8074function renderAxesListe(joueurId) { 8075 const axes = axesActifsJoueur(joueurId); 8076 if (axes.length === 0) return `<div class="text-faint" style="font-size:12px">Aucun axe en attente.</div>`; 8077 return axes.map(a => `<div style="display:flex; align-items:flex-start; gap:8px; padding:8px 0; border-bottom:1px solid var(--border)"> 8078 <span style="font-size:14px">${a.fait ? 'â ' : 'â³'}</span> 8079 <div style="flex:1; min-width:0"> 8080 <div style="font-size:13px; ${a.fait ? 'opacity:.65' : ''}">${escapeHtml(a.texte)}</div> 8081 <div class="text-faint" style="font-size:11px; margin-top:2px"> 8082 ${a.fait ? 'Fait' + (a.fait_at ? ' le ' + formatDate(a.fait_at) : '') : 'En attente'} 8083 </div> 8084 </div> 8085 <button class="btn" style="padding:2px 8px; font-size:11px" aria-label="Supprimer lâaxe ${escapeHtml(a.axe || a.titre || '')}" data-ui-click="supprimerAxe(${escapeHtml(encoderArgumentTexteUI(a.id))}, ${escapeHtml(encoderArgumentTexteUI(joueurId))})">Ã</button> 8086 </div>`).join(''); 8087} 8088 8089async function supprimerAxe(axeId, joueurId) { 8090 if (!(await demanderConfirmation('Supprimer cet axe de travail ?'))) return; 8091 const { error } = await db.from('axes_travail_palmyre').delete().eq('id', axeId); 8092 if (error) { toastErreur(error); return; } 8093 toast('Axe supprimé'); 8094 await reloadTables(['axes_travail_palmyre']); 8095 const box = document.getElementById('axesListe'); 8096 if (box) box.innerHTML = renderAxesListe(joueurId); 8097} 8098 8099// Joueur : coche un axe comme fait (ou décoche). 8100async function toggleAxeFait(axeId, fait) { 8101 const { error } = await updateConfirmee(db.from('axes_travail_palmyre') 8102 .update({ fait: fait, fait_at: fait ? new Date().toISOString() : null }) 8103 .eq('id', axeId)); 8104 if (error) { toastErreur(error); return; } 8105 toast(fait ? 'Bravo, axe validé ! ðª' : 'Axe remis en attente'); 8106 await reloadTables(['axes_travail_palmyre']); 8107 render(); 8108} 8109 8110// Joueur : bloc "Mes axes de travail" affiché sur son profil (env. Palmyre). 8111function observationsGroupe() { 8112 const ev = getActiveEvenement(); 8113 if (!ev) return []; 8114 return observationsPalmyreDuJoueurEvenement('', ev.id).slice() 8115 .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')); 8116} 8117 8118const selectionsPalmyre = etatApp.selectionsPalmyre; 8119const debriefThemes = etatApp.themesDebrief; 8120function selectObsGroupeTheme(el) { 8121 document.querySelectorAll('#obsGroupePills .pill').forEach(p => p.classList.remove('active')); 8122 el.classList.add('active'); 8123 etatApp.definirThemeObservation('groupe', el.dataset.theme); 8124} 8125 8126function openCarnetGroupe() { 8127 const ev = getActiveEvenement(); 8128 if (!ev) return; 8129 etatApp.definirThemeObservation('groupe', 'groupe'); 8130 const notes = observationsGroupe(); 8131 let html = `<div class="modal-header"> 8132 <h3 class="modal-title">ð¥ Notes de groupe</h3> 8133 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 8134 </div> 8135 <div class="card mb-2" style="padding:12px"> 8136 <div class="text-faint" style="font-size:12px; margin-bottom:8px">Ce qui concerne tout le monde (ambiance, météo, organisation, consigne collectiveâ¦). Ces notes alimentent aussi le débrief du soir.</div> 8137 <div class="pill-group" id="obsGroupePills" style="margin-bottom:10px"> 8138 ${OBS_THEMES.map(t => `<span class="pill ${t.cle === 'groupe' ? 'active' : ''}" data-theme="${t.cle}" data-ui-click="selectObsGroupeTheme(this)">${t.icone} ${t.label}</span>`).join('')} 8139 </div> 8140 <textarea id="obsGroupeTexte" rows="3" placeholder="Ex : grosse chaleur, groupe fatigué en fin de journée..." style="width:100%"></textarea> 8141 <button class="btn btn-primary btn-full mt-1" data-ui-click="ajouterObservationGroupe()">+ Ajouter la note</button> 8142 </div> 8143 <div class="section-title">Notes de la semaine</div> 8144 <div id="obsGroupeHisto">${renderObsGroupeHisto()}</div>`; 8145 showModal(html); 8146} 8147 8148function renderObsGroupeHisto() { 8149 const notes = observationsGroupe(); 8150 if (notes.length === 0) return `<div class="text-faint" style="font-size:12px">Aucune note de groupe.</div>`; 8151 return notes.map(o => { 8152 const m = obsThemeMeta(o.theme); 8153 const d = o.created_at ? new Date(o.created_at) : null; 8154 const dateStr = d ? `${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}
8154:${String(d.getMinutes()).padStart(2,'0')}` : ''; 8155 return `<div class="card mb-1" style="padding:10px 12px"> 8156 <div style="display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:4px"> 8157 <span class="badge" style="font-size:10px">${m.icone} ${escapeHtml(m.label)}</span> 8158 <span class="text-faint" style="font-size:11px">${dateStr} · <a href="#" data-ui-click="event.preventDefault(); supprimerObservationGroupe(${escapeHtml(encoderArgumentTexteUI(o.id))})" style="color:var(--danger)">supprimer</a></span> 8159 </div> 8160 <div style="font-size:13px">${escapeHtml(o.texte)}</div> 8161 </div>`; 8162 }).join(''); 8163} 8164 8165async function ajouterObservationGroupe() { 8166 const ev = getActiveEvenement(); 8167 if (!ev) { toast('Ãdition introuvable', true); return; } 8168 const texte = (document.getElementById('obsGroupeTexte').value || '').trim(); 8169 if (!texte) { toast('Ãcris la note', true); return; } 8170 const { error } = await db.from('observations_palmyre').insert({ 8171 evenement_id: ev.id, 8172 joueur_id: null, // note collective 8173 theme: selectionsPalmyre.groupeTheme, 8174 texte: texte, 8175 coach_id: session.user.id 8176 }); 8177 if (error) { toastErreur(error); return; } 8178 document.getElementById('obsGroupeTexte').value = ''; 8179 toast('Note de groupe ajoutée ð¥'); 8180 await reloadTables(['observations_palmyre']); 8181 const h = document.getElementById('obsGroupeHisto'); 8182 if (h) h.innerHTML = renderObsGroupeHisto(); 8183} 8184 8185async function supprimerObservationGroupe(obsId) { 8186 if (!(await demanderConfirmation('Supprimer cette note ?'))) return; 8187 const { error } = await db.from('observations_palmyre').delete().eq('id', obsId); 8188 if (error) { toastErreur(error); return; } 8189 toast('Note supprimée'); 8190 await reloadTables(['observations_palmyre']); 8191 const h = document.getElementById('obsGroupeHisto'); 8192 if (h) h.innerHTML = renderObsGroupeHisto(); 8193} 8194 8195// Rendu markdown léger pour la synthèse du débrief (titres, gras, listes). 8196// SÃCURITà : on échappe TOUT le HTML d'abord, puis on ne réintroduit que nos balises. 8197function markdownLeger(txt) { 8198 if (!txt) return ''; 8199 const lignes = escapeHtml(String(txt)).split('\n'); 8200 let html = ''; 8201 let dansListe = false; 8202 const inline = (s) => s 8203 .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') 8204 .replace(/(^|[\s(])\*(?!\s)(.+?)(?<!\s)\*(?=[\s.,;:)]|$)/g, '$1<em>$2</em>'); 8205 8206 const fermerListe = () => { if (dansListe) { html += '</ul>'; dansListe = false; } }; 8207 8208 lignes.forEach(l => { 8209 const t = l.trim(); 8210 if (!t) { fermerListe(); return; } 8211 8212 // Titres (#, ##, ###) 8213 const mTitre = t.match(/^(#{1,4})\s+(.*)$/); 8214 if (mTitre) { 8215 fermerListe(); 8216 const niv = mTitre[1].length; 8217 const taille = niv === 1 ? 15 : (niv === 2 ? 14 : 13); 8218 const marge = niv === 1 ? '0 0 8px' : '14px 0 6px'; 8219 html += `<div style="font-weight:700; font-size:${taille}px; margin:${marge}; color:var(--orange)">${inline(mTitre[2])}</div>`; 8220 return; 8221 } 8222 // Puces (- ou *) et listes numérotées (1. 2. â¦) 8223 const mPuce = t.match(/^[-*â¢]\s+(.*)$/); 8224 const mNum = t.match(/^\d+[.)]\s+(.*)$/); 8225 if (mPuce || mNum) { 8226 if (!dansListe) { html += '<ul style="margin:4px 0 4px 18px; padding:0">'; dansListe = true; } 8227 html += `<li style="margin:3px 0">${inline((mPuce || mNum)[1])}</li>`; 8228 return; 8229 } 8230 // Paragraphe 8231 fermerListe(); 8232 html += `<div style="margin:6px 0">${inline(t)}</div>`; 8233 }); 8234 fermerListe(); 8235 return html; 8236} 8237 8238// ============================================================ 8239// DÃBRIEF DU SOIR (Palmyre) â synthèse IA des observations de la journée. 8240// PRIVà COACH. Une synthèse par jour et par édition, éditable / supprimable / relançable. 8241// ============================================================ 8242// Thèmes retenus pour la synthèse. Par défaut : tactique + mental + vie de groupe 8243// (le débrief collectif du soir ne porte pas sur la technique individuelle), 8244// mais le coach peut élargir s'il le souhaite. 8245function toggleDebriefTheme(cle) { 8246 etatApp.basculerThemeDebrief(cle); 8247 render(); 8248} 8249 8250function setDebriefDate(value) { 8251 etatApp.definirDateDebrief(value); 8252 render(); 8253} 8254 8255function debriefDuJour(evId, date) { 8256 return debriefPalmyreDuJour(evId, date); 8257} 8258 8259// Observations prises un jour donné (tous joueurs), pour l'édition active. 8260function observationsDuJour(evId, date) { 8261 return observationsPalmyreDuJour(evId, date).slice() 8262 .sort((a, b) => (a.created_at || '').localeCompare(b.created_at || '')); 8263} 8264 8265function renderDebriefPalmyre() { 8266 if (!isCoach()) return ''; 8267 const ev = getActiveEvenement(); 8268 if (!ev) return `<div class="empty"><span class="icon"><img src="assets/v4/nav/debrief.webp" alt="" style="width:62px;height:62px;object-fit:contain"></span>Aucune édition active.</div>`; 8269 8270 const jours = joursEdition(ev); 8271 const auj = dateLocaleISO(new Date()); 8272 const date = etatApp.initialiserDateDebrief(jours, auj); 8273 8274 const obsTous = observationsDuJour(ev.id, date); 8275 const obs = obsTous.filter(o => debriefThemes.includes(o.theme)); 8276 const deb = debriefDuJour(ev.id, date); 8277 8278 let html = renderUxScreenHeading('Synthèse collective', 'Débrief du soir') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)"> 8279 <div style="font-weight:700; font-size:15px;
8279display:flex;align-items:center;gap:8px"><img class="inline-art-icon" src="assets/v4/nav/debrief.webp" alt="" style="width:30px;height:30px">Débrief du soir</div> 8280 <div class="text-faint" style="font-size:12px; margin-top:2px">Synthèse des observations de la journée (tous les joueurs), pour animer ton débrief collectif. Privé : les joueurs n'y ont pas accès.</div> 8281 </div>`; 8282 8283 // Sélecteur de jour 8284 html += `<div class="mb-2"> 8285 <select data-ui-change="setDebriefDate(this.value)" style="width:100%"> 8286 ${jours.map(jr => `<option value="${jr}" ${jr === date ? 'selected' : ''}>${escapeHtml(libelleJourCourt(jr))}${jr === auj ? " (aujourd'hui)" : ''}</option>`).join('')} 8287 </select> 8288 </div>`; 8289 8290 // Thèmes pris en compte dans la synthèse 8291 html += `<div class="card mb-2" style="padding:12px"> 8292 <div style="font-size:12px; font-weight:600; margin-bottom:6px">Thèmes à synthétiser</div> 8293 <div class="text-faint" style="font-size:11px; margin-bottom:8px">Le débrief collectif porte en général sur le mental et la tactique. Tu peux élargir si besoin.</div> 8294 <div class="pill-group"> 8295 ${OBS_THEMES.map(t => { 8296 const actif = debriefThemes.includes(t.cle); 8297 const n = obsTous.filter(o => o.theme === t.cle).length; 8298 return `<span class="pill ${actif ? 'active' : ''}" data-ui-click="toggleDebriefTheme(${escapeHtml(encoderArgumentTexteUI(t.cle))})">${t.icone} ${t.label}${n ? ` (${n})` : ''}</span>`; 8299 }).join('')} 8300 </div> 8301 </div>`; 8302 8303 // Matière première 8304 html += `<div class="card mb-2" style="padding:12px"> 8305 <div style="font-size:13px; font-weight:600; margin-bottom:6px;display:flex;align-items:center;gap:7px"><img class="inline-art-icon" src="assets/v4/nav/observations.webp" alt="" style="width:27px;height:27px">${obs.length} note${obs.length > 1 ? 's' : ''} retenue${obs.length > 1 ? 's' : ''} ce jour-là ${obsTous.length !== obs.length ? ` <span class="text-faint" style="font-weight:400">(sur ${obsTous.length})</span>` : ''}</div>`; 8306 if (obs.length === 0) { 8307 html += `<div class="text-faint" style="font-size:12px">${obsTous.length === 0 ? 'Aucune observation prise ce jour. Prends des notes sur les joueurs (ou une note de groupe) dans l\'onglet Observations, puis reviens ici.' : 'Aucune note dans les thèmes sélectionnés. Coche d\'autres thèmes ci-dessus.'}</div>`; 8308 } else { 8309 const parTheme = {}; 8310 obs.forEach(o => { (parTheme[o.theme] = parTheme[o.theme] || []).push(o); }); 8311 html += `<div style="display:flex; gap:6px; flex-wrap:wrap">` + 8312 Object.keys(parTheme).map(t => { 8313 const m = obsThemeMeta(t); 8314 return `<span class="badge" style="font-size:11px">${m.icone} ${escapeHtml(m.label)} (${parTheme[t].length})</span>`; 8315 }).join('') + `</div>`; 8316 } 8317 html += `</div>`; 8318 8319 // Synthèse 8320 if (deb) { 8321 html += `<div class="card mb-2" style="padding:12px"> 8322 <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; gap:8px"> 8323 <div style="font-weight:700; font-size:14px;display:flex;align-items:center;gap:7px"><img class="inline-art-icon" src="assets/v4/nav/reports.webp" alt="" style="width:28px;height:28px">Synthèse</div> 8324 <div style="display:flex; gap:6px;flex-wrap:wrap;justify-content:flex-end"> 8325 <button class="btn" style="padding:3px 8px; font-size:11px" data-ui-click="editerDebrief()"><img class="btn-action-art" src="assets/v4/nav/observations.webp" alt="">Modifier</button> 8326 <button class="btn" style="padding:3px 8px; font-size:11px" data-ui-click="genererDebriefIA(true)"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Relancer</button> 8327 <button class="btn" style="padding:3px 8px; font-size:11px;color:var(--danger-text)" data-ui-click="supprimerDebrief()">Supprimer</button> 8328 </div> 8329 </div> 8330 <div id="debriefContenu" style="font-size:13px; line-height:1.6">${markdownLeger(deb.texte || '')}</div> 8331 <div class="text-faint" style="font-size:11px; margin-top:8px">Généré le ${formatDate(deb.updated_at || deb.created_at)}${deb.edite ? ' · modifié à la main' : ''}</div> 8332 </div>`; 8333 } else if (obs.length > 0) { 8334 html += `<button class="btn btn-primary btn-full mb-2" data-ui-click="genererDebriefIA(false)"><img class="btn-action-art" src="assets/v4/nav/debrief.webp" alt="">Générer la synthèse du soir</button>`; 8335 } 8336 8337 return html; 8338} 8339 8340// Génère (ou régénère) la synthèse via l'Edge Function "debrief-ia". 8341async function genererDebriefIA(relance) { 8342 if (!isCoach()) return; 8343 const ev = getActiveEvenement(); 8344 if (!ev) { toast('Ãdition introuvable', true); return; } 8345 const date = selectionsPalmyre.debriefDate || dateLocaleISO(new Date()); 8346 const obs = observationsDuJour(ev.id, date).filter(o => debriefThemes.includes(o.theme)); 8347 if (obs.length === 0) { toast('Aucune note à synthétiser dans les thèmes sélectionnés', true); return; } 8348 if (relance && !(await demanderConfirmation('Régénérer la synthèse ? La version actuelle sera remplacée.', { titre: 'Remplacer la synthèse', libelle: 'Régénérer' }))) return; 8349 8350 toast('Synthèse en cours... â¨'); 8351 let synthese = ''; 8352 try { 8353 const { data, error } = await db.functions.invoke('debrief-ia', { 8354 body: { evenement_id: ev.id, date, themes: [...debriefThemes] } 8355 }); 8356 if (error) { toastErreur(error); return; } 8357 if (!data || !data.synthese) { toast('Synthèse vide, réessaie', true); return; } 8358 synthese = data.synthese;
8359 } catch (e) { 8360 toast('Synthèse indisponible : ' + e.message, true); 8361 return; 8362 } 8363 8364 const existant = debriefDuJour(ev.id, date); 8365 const obj = { 8366 evenement_id: ev.id, 8367 date: date, 8368 texte: synthese, 8369 edite: false, 8370 coach_id: session.user.id, 8371 updated_at: new Date().toISOString() 8372 }; 8373 const { error: errSave } = existant 8374 ? await updateConfirmee(db.from('debriefs_palmyre').update(obj).eq('id', existant.id)) 8375 : await db.from('debriefs_palmyre').insert(obj); 8376 if (errSave) { toastErreur(errSave); return; } 8377 8378 toast('Synthèse générée â¨'); 8379 await reloadTables(['debriefs_palmyre']); 8380 render(); 8381} 8382 8383// Ãdition manuelle de la synthèse. 8384function editerDebrief() { 8385 const ev = getActiveEvenement(); 8386 if (!ev) return; 8387 const date = selectionsPalmyre.debriefDate || dateLocaleISO(new Date()); 8388 const deb = debriefDuJour(ev.id, date); 8389 if (!deb) return; 8390 const brouillon = gestionnaireBrouillons.lireFormulaire('debrief', deb.id) || {}; 8391 const html = `<div class="modal-header"> 8392 <h3 class="modal-title" style="display:flex;align-items:center;gap:10px"><img class="modal-title-art" src="assets/v4/nav/observations.webp" alt="">Modifier la synthèse</h3> 8393 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 8394 </div> 8395 <textarea id="debriefEdit" rows="16" style="width:100%; font-size:13px; line-height:1.5">${escapeHtml(brouillon.texte || deb.texte || '')}</textarea> 8396 <button class="btn btn-primary btn-full mt-1" data-ui-click="sauverDebriefEdite(${escapeHtml(encoderArgumentTexteUI(deb.id))})">Enregistrer</button>`; 8397 showModal(html); 8398 $('debriefEdit')?.addEventListener('input', event => { 8399 gestionnaireBrouillons.enregistrerFormulaire('debrief', deb.id, { texte: event.target.value }); 8400 }); 8401} 8402 8403async function sauverDebriefEdite(id) { 8404 const texte = (document.getElementById('debriefEdit').value || '').trim(); 8405 if (!texte) { toast('La synthèse est vide', true); return; } 8406 const { error } = await updateConfirmee(db.from('debriefs_palmyre') 8407 .update({ texte: texte, edite: true, updated_at: new Date().toISOString() }) 8408 .eq('id', id)); 8409 if (error) { toastErreur(error); return; } 8410 gestionnaireBrouillons.lireFormulaire('debrief', id, true); 8411 closeModal(); 8412 toast('Synthèse mise à jour'); 8413 await reloadTables(['debriefs_palmyre']); 8414 render(); 8415} 8416 8417async function supprimerDebrief() { 8418 const ev = getActiveEvenement(); 8419 if (!ev) return; 8420 const date = selectionsPalmyre.debriefDate || dateLocaleISO(new Date()); 8421 const deb = debriefDuJour(ev.id, date); 8422 if (!deb) return; 8423 if (!(await demanderConfirmation('Supprimer cette synthèse ?'))) return; 8424 const { error } = await db.from('debriefs_palmyre').delete().eq('id', deb.id); 8425 if (error) { toastErreur(error); return; } 8426 toast('Synthèse supprimée'); 8427 await reloadTables(['debriefs_palmyre']); 8428 render(); 8429} 8430 8431// Observations d'un joueur sur l'édition active (les plus récentes d'abord). 8432function observationsJoueur(joueurId) { 8433 const ev = getActiveEvenement(); 8434 if (!ev) return []; 8435 return observationsPalmyreDuJoueurEvenement(joueurId, ev.id).slice() 8436 .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')); 8437} 8438 8439// Vue dédiée : liste des participants avec accès rapide à leur carnet. 8440function renderObservationsPalmyre() { 8441 if (!isCoach()) return ''; 8442 const ev = getActiveEvenement(); 8443 if (!ev) return `<div class="empty"><span class="icon">ð</span>Aucune édition active.</div>`; 8444 8445 const participants = joueursActifsDansEnvCourant(); 8446 if (participants.length === 0) { 8447 return `<div class="empty"><span class="icon">ð</span>Aucun participant à ${escapeHtml(ev.nom)}.</div>`; 8448 } 8449 8450 let html = renderUxScreenHeading('Suivi individuel', 'Observations') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)"> 8451 <div style="font-weight:700; font-size:15px">ð Carnet d'observations · ${escapeHtml(ev.nom)}</div> 8452 <div class="text-faint" style="font-size:12px; margin-top:2px">Tes notes de travail prises en direct, par joueur et par thème. Visibles par toi uniquement.</div> 8453 </div>`; 8454 8455 const nbGroupe = observationsGroupe().length; 8456 html += `<div class="card mb-2" data-ui-click="openCarnetGroupe()" style="padding:12px 14px; cursor:pointer; display:flex; align-items:center; gap:10px; border-left:3px solid var(--orange)"> 8457 <span style="font-size:20px">ð¥</span> 8458 <div style="flex:1"> 8459 <div style="font-weight:700; font-size:14px">Notes de groupe</div> 8460 <div class="text-faint" style="font-size:11px; margin-top:2px">Ce qui concerne tout le monde · ${nbGroupe > 0 ? nbGroupe + ' note' + (nbGroupe > 1 ? 's' : '') : 'aucune note'}</div> 8461 </div> 8462 <span class="text-faint" style="font-size:18px">â</span> 8463 </div>`; 8464 8465 html += `<div class="card" style="padding:0; overflow:hidden">`; 8466 participants.sort((a, b) => getRankIndex(b.classement) - getRankIndex(a.classement)).forEac
8466h(j => { 8467 const obs = observationsJoueur(j.id); 8468 const nb = obs.length; 8469 html += `<div class="match-row" data-ui-click="openCarnetObservations(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="cursor:pointer; display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid var(--border)"> 8470 <div style="flex:1"> 8471 <div><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong> <span class="text-faint" style="font-size:12px">${escapeHtml(j.classement || 'NC')}</span></div> 8472 <div class="text-faint" style="font-size:11px; margin-top:2px">${nb > 0 ? nb + ' note' + (nb > 1 ? 's' : '') : 'aucune note'}</div> 8473 </div> 8474 <span class="text-faint" style="font-size:18px">â</span> 8475 </div>`; 8476 }); 8477 html += `</div>`; 8478 return html; 8479} 8480 8481// Carnet d'un joueur : ajout rapide (thème + texte) + historique horodaté. 8482function openCarnetObservations(joueurId, retourAuBilan) { 8483 const j = joueurById(joueurId); 8484 const ev = getActiveEvenement(); 8485 if (!j || !ev) { toast('Joueur ou édition introuvable', true); return; } 8486 // Chaque nouvelle saisie repart sur Tactique afin que le thème affiché et le 8487 // thème enregistré restent identiques après avoir ouvert un autre carnet. 8488 etatApp.definirThemeObservation('ajout', 'tactique'); 8489 const obs = observationsJoueur(joueurId); 8490 8491 let html = `<div class="modal-header"> 8492 <h3 class="modal-title">ð ${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</h3> 8493 ${retourAuBilan 8494 ? `<button class="modal-close" data-ui-click="openModalBilanPalmyre(${escapeHtml(encoderArgumentTexteUI(joueurId))})">Ã</button>` 8495 : '<button class="modal-close" data-ui-click="closeModal()">Ã</button>'} 8496 </div>`; 8497 if (retourAuBilan) { 8498 html += `<button class="btn btn-sm btn-full mb-2" data-ui-click="openModalBilanPalmyre(${escapeHtml(encoderArgumentTexteUI(joueurId))})">â Retour au bilan</button>`; 8499 } 8500 8501 // Zone d'ajout rapide 8502 html += `<div class="card mb-2" style="padding:12px"> 8503 <div class="text-faint" style="font-size:12px; margin-bottom:8px">Nouvelle observation</div> 8504 <div class="pill-group" id="obsThemePills" style="margin-bottom:10px"> 8505 ${OBS_THEMES_JOUEUR.map((t, i) => `<span class="pill ${i === 0 ? 'active' : ''}" data-theme="${t.cle}" data-ui-click="selectObsTheme(this)">${t.icone} ${t.label}</span>`).join('')} 8506 </div> 8507 <div class="grid-2" style="margin-bottom:10px"> 8508 <div> 8509 <label class="form-label">Lieu</label> 8510 <select id="obsLieu" style="width:100%"> 8511 <option value="">â non précisé â</option> 8512 ${LIEUX_PALMYRE.map(l => `<option value="${escapeHtml(l)}">${escapeHtml(l)}</option>`).join('')} 8513 </select> 8514 </div> 8515 <div> 8516 <label class="form-label">Classement adversaire</label> 8517 <select id="obsAdvClass" style="width:100%"> 8518 <option value="">â non précisé â</option> 8519 ${CLASSEMENTS.map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('')} 8520 </select> 8521 </div> 8522 </div> 8523 <textarea id="obsTexte" rows="3" placeholder="Ce que tu observes en direct..." style="width:100%"></textarea> 8524 <button class="btn btn-primary btn-full mt-1" data-ui-click="ajouterObservation(${escapeHtml(encoderArgumentTexteUI(joueurId))})">+ Ajouter la note</button> 8525 </div> 8526 8527 <div class="card mb-2" style="padding:12px; border-left:3px solid var(--orange)"> 8528 <div style="font-weight:700; font-size:13px; margin-bottom:4px">ð¯ Axe de travail (envoyé au joueur)</div> 8529 <div class="text-faint" style="font-size:11px; margin-bottom:8px">à faire juste après le match, en autonomie. Le joueur reçoit une notification, le voit sur son profil et coche quand c'est fait.</div> 8530 <textarea id="axeTexte" rows="2" placeholder="Ex : 10 min de volées avec un partenaire, 20 lancers sur cible..." style="width:100%"></textarea> 8531 <button class="btn btn-primary btn-full mt-1" data-ui-click="envoyerAxeTravail(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img class="btn-action-art" src="assets/v4/nav/observations.webp" alt="">Envoyer au joueur</button> 8532 <div id="axesListe" style="margin-top:10px">${renderAxesListe(joueurId)}</div> 8533 </div>`; 8534 8535 // Historique 8536 html += `<div class="section-title">Historique de la semaine</div>`; 8537 html += `<div id="obsHistorique">${renderObsHistorique(joueurId)}</div>`; 8538 8539 showModal(html); 8540} 8541 8542// Rendu de l'historique (séparé pour rafraîchir sans rouvrir la modale). 8543function renderObsHistorique(joueurId) { 8544 const obs = observationsJoueur(joueurId); 8545 if (obs.length === 0) { 8546 return `<div class="text-faint text-center" style="padding:16px; font-size:13px">Aucune observation pour le moment.</div>`; 8547 } 8548 let html = '<div style="display:flex; flex-direction:column; gap:8px">';
8549 obs.forEach(o => { 8550 const meta = obsThemeMeta(o.theme); 8551 const d = o.created_at ? new Date(o.created_at) : null; 8552 const dateStr = d ? `${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}` : ''; 8553 // Sous-ligne lieu / classement adversaire (affichés seulement si renseignés) 8554 const infos = []; 8555 if (o.lieu) infos.push(`ð ${escapeHtml(o.lieu)}`); 8556 if (o.adversaire_class) infos.push(`ð¾ adv. ${escapeHtml(o.adversaire_class)}`); 8557 const infosLigne = infos.length ? `<div class="text-faint" style="font-size:11px; margin-bottom:4px">${infos.join(' · ')}</div>` : ''; 8558 html += `<div class="card" style="padding:10px 12px"> 8559 <div class="row-between" style="margin-bottom:4px"> 8560 <span class="badge badge-orange" style="font-size:10px">${meta.icone} ${escapeHtml(meta.label)}</span> 8561 <span class="text-faint" style="font-size:11px">${dateStr} · <span data-ui-click="openEditObservation(${escapeHtml(encoderArgumentTexteUI(o.id))},${escapeHtml(encoderArgumentTexteUI(joueurId))})" style="cursor:pointer; color:var(--orange-text)">modifier</span> · <span data-ui-click="supprimerObservation(${escapeHtml(encoderArgumentTexteUI(o.id))},${escapeHtml(encoderArgumentTexteUI(joueurId))})" style="cursor:pointer; color:var(--danger-text)">supprimer</span></span> 8562 </div> 8563 ${infosLigne} 8564 <div style="font-size:13px; white-space:pre-wrap">${escapeHtml(o.texte)}</div> 8565 </div>`; 8566 }); 8567 html += '</div>'; 8568 return html; 8569} 8570 8571function selectObsTheme(el) { 8572 document.querySelectorAll('#obsThemePills .pill').forEach(p => p.classList.remove('active')); 8573 el.classList.add('active'); 8574 etatApp.definirThemeObservation('ajout', el.dataset.theme); 8575} 8576 8577async function ajouterObservation(joueurId) { 8578 const ev = getActiveEvenement(); 8579 if (!ev) { toast('Ãdition introuvable', true); return; } 8580 const texte = (document.getElementById('obsTexte').value || '').trim(); 8581 if (!texte) { toast('Ãcris une observation', true); return; } 8582 const lieu = (document.getElementById('obsLieu') || {}).value || null; 8583 const advClass = (document.getElementById('obsAdvClass') || {}).value || null; 8584 8585 const { error } = await db.from('observations_palmyre').insert({ 8586 evenement_id: ev.id, 8587 joueur_id: joueurId, 8588 theme: selectionsPalmyre.ajoutTheme, 8589 texte: texte, 8590 lieu: lieu, 8591 adversaire_class: advClass, 8592 coach_id: session.user.id 8593 }); 8594 if (error) { toastErreur(error); return; } 8595 8596 document.getElementById('obsTexte').value = ''; 8597 toast('Observation ajoutée'); 8598 await reloadTables(['observations_palmyre']); 8599 // Rafraîchir uniquement l'historique 8600 const histo = document.getElementById('obsHistorique'); 8601 if (histo) histo.innerHTML = renderObsHistorique(joueurId); 8602} 8603 8604async function supprimerObservation(obsId, joueurId) { 8605 if (!(await demanderConfirmation('Supprimer cette observation ?'))) return; 8606 const { error } = await db.from('observations_palmyre').delete().eq('id', obsId); 8607 if (error) { toastErreur(error); return; } 8608 toast('Observation supprimée'); 8609 await reloadTables(['observations_palmyre']); 8610 const histo = document.getElementById('obsHistorique'); 8611 if (histo) histo.innerHTML = renderObsHistorique(joueurId); 8612} 8613 8614// Ouvre une modale d'édition d'une observation existante (thème, lieu, classement adv., texte). 8615function openEditObservation(obsId, joueurId) { 8616 const o = observationPalmyreById(obsId); 8617 if (!o) { toast('Observation introuvable', true); return; } 8618 etatApp.definirThemeObservation('edition', o.theme || 'tactique'); 8619 8620 let html = `<div class="modal-header"> 8621 <h3 class="modal-title">âï¸ Modifier l'observation</h3> 8622 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 8623 </div>`; 8624 html += `<div class="card" style="padding:12px"> 8625 <div class="pill-group" id="obsEditThemePills" style="margin-bottom:10px"> 8626 ${OBS_THEMES_JOUEUR.map(t => `<span class="pill ${o.theme === t.cle ? 'active' : ''}" data-theme="${t.cle}" data-ui-click="selectObsEditTheme(this)">${t.icone} ${t.label}</span>`).join('')} 8627 </div> 8628 <div class="grid-2" style="margin-bottom:10px"> 8629 <div> 8630 <label class="form-label">Lieu</label> 8631 <select id="obsEditLieu" style="width:100%"> 8632 <option value="">â non précisé â</option> 8633 ${LIEUX_PALMYRE.map(l => `<option value="${escapeHtml(l)}" ${o.lieu === l ? 'selected' : ''}>${escapeHtml(l)}</option>`).join('')} 8634 </select> 8635 </div> 8636 <div> 8637 <label class="form-label">Classement adversaire</label> 8638 <select id="obsEditAdvClass" style="width:100%"> 8639 <option value="">â non précisé â</option> 8640 ${CLASSEMENTS.map(c => `<option value="${escapeHtml(c)}" ${o.adversaire_class === c ? 'selected' : ''}>${escapeHtml(c)}</option>`).join('')} 8641 </select> 8642 </div> 8643 </div> 8644 <textarea id="obsEditTexte" rows="4" style="width:100%">${escapeHtml(o.texte || '')}</textarea> 8645 <div style="display:flex; gap:8px; margin-top:10px"> 8646 <button class="btn" style="flex:1" data-ui-click="openCarnetObservations(${escapeHtml(encoderArgumentTexteUI(joueurId))})">Annuler</button> 8647 <button class="btn btn-primary" style="flex:1" data-ui-click="enregistrerEditObservation(${escapeHtml(encoderArgumentTexteUI(obsId))},${escapeHtml(encoderArgumentTexteUI(joueurId))})">Enregistrer</button> 8648 </div> 8649 </div>`; 8650 showModal(html); 8651} 8652 8653function selectObsEditTheme(el) { 8654 document.querySelectorAll('#obsEditThemePills .pill').forEach(p => p.classList.remove('active')); 8655 el.classList.add('active'); 8656 etatApp.definirThemeObservation('edition', el.dataset.theme); 8657} 8658 8659async function enregistrerEditObservation(obsId, joueurId) { 8660 const texte = (document.getElementById('obsEditTexte').value || '').trim(); 8661 if (!texte) { toast('Le texte ne peut pas être vide', true); return; } 8662 const lieu = (document.getElementById('obsEditLieu') || {}).value || null; 8663 const advClass = (document.getElementById('obsEditAdvClass') || {}).value || null; 8664 8665 const { error } = await updateConfirmee(db.from('observations_palmyre').update({ 8666 theme: selectionsPalmyre.editTheme, 8667 texte: texte, 8668 lieu: lieu, 8669 adversaire_class: advClass 8670 }).eq('id', obsId)); 8671 if (error) { toastErreur(error); return; } 8672 8673 toast('Observation modifiée'); 8674 await reloadTables(['observations_palmyre']); 8675 // Revenir au carnet du joueur 8676 openCarnetObservations(joueurId); 8677} 8678 8679function statsPalmyreJoueur(joueurId) { 8680 const ev = getActiveEvenement(); 8681 if (!ev) return { total: 0, victoires: 0, defaites: 0, perfs: 0 }; 8682 // WO exclus : un forfait n'est pas un match joué (ni une victoire) dans les stats. 8683 const ms = matchsCompletsDuJoueur(joueurId).filter(m => m.evenement_id === ev.id); 8684 return TennisDomainRules.statistiquesSportives(ms, m => classementADate(joueurId, m.date)); 8685} 8686 8687// Récupère le bilan d'événement existant pour un joueur sur l'édition active. 8688function bilanPalmyreDuJoueur(joueurId) { 8689 const ev = getActiveEvenement(); 8690 if (!ev) return null; 8691 return bilanPalmyreDuJoueurEvenement(joueurId, ev.id); 8692} 8693 8694function renderBilansPalmyre() { 8695 if (!isCoach()) return ''; 8696 const ev = getActiveEvenement(); 8697 if (!ev) return `<div class="empty"><span class="icon">ðï¸</span>Aucune édition active.</div>`; 8698 8699 const participants = joueursActifsDansEnvCourant(); 8700 if (participants.length === 0) { 8701 return `<div class="empty"><span class="icon">ðï¸</span>Aucun participant à ${escapeHtml(ev.nom)}.<br>
8702 <span class="text-faint">Coche « Participe à la Palmyre » sur la fiche des joueurs concernés.</span></div>`; 8703 } 8704 8705 let html = renderUxScreenHeading('Ãvaluation', 'Bilans Palmyre') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)"> 8706 <div style="font-weight:700; font-size:15px">ðï¸ Bilans de fin de semaine · ${escapeHtml(ev.nom)}</div> 8707 <div class="text-faint" style="font-size:12px; margin-top:2px">Un bilan par participant : 5 axes notés + commentaire. Les stats sont calculées automatiquement.</div> 8708 </div>`; 8709 8710 // Comparaison inter-éditions disponible ? 8711 const autresEditions = (listeSure(cache.evenements)).filter(e => e.id !== ev.id); 8712 if (autresEditions.length > 0) { 8713 html += `<div class="card clickable mb-2" data-ui-click="openComparaisonEditions()" style="padding:12px 14px; display:flex; align-items:center; gap:10px; cursor:pointer"> 8714 <span style="font-size:20px">ð</span> 8715 <div style="flex:1"><strong>Comparer les éditions</strong><div class="text-faint" style="font-size:11px">Voir l'évolution des participants d'une année sur l'autre</div></div> 8716 <span class="text-faint" style="font-size:18px">â</span> 8717 </div>`; 8718 } 8719 8720 html += `<div class="card" style="padding:0; overflow:hidden">`; 8721 participants.sort((a, b) => getRankIndex(b.classement) - getRankIndex(a.classement)).forEach(j => { 8722 const bilan = bilanPalmyreDuJoueur(j.id); 8723 const stats = statsPalmyreJoueur(j.id); 8724 html += `<div class="match-row" data-ui-click="openModalBilanPalmyre(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="cursor:pointer; display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid var(--border)"> 8725 <div style="flex:1"> 8726 <div><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong> <span class="text-faint" style="font-size:12px">${escapeHtml(j.classement || 'NC')}</span></div> 8727 <div class="text-faint" style="font-size:11px; margin-top:2px">${stats.total} match${stats.total > 1 ? 's' : ''} · ${stats.victoires}V ${stats.defaites}D${stats.perfs > 0 ? ' · ' + stats.perfs + ' perf' + (stats.perfs > 1 ? 's' : '') : ''}</div> 8728 </div> 8729 ${!bilan 8730 ? `<span class="badge" style="font-size:10px; background:var(--bg-input); color:var(--text-dim)">à faire</span>` 8731 : (bilan.statut === 'publie' 8732 ? `<span class="badge badge-success" style="font-size:10px">â Publié</span>` 8733 : `<span class="badge badge-warning" style="font-size:10px">â Brouillon</span>`)} 8734 </div>`; 8735 }); 8736 html += `</div>`; 8737 return html; 8738} 8739 8740function openModalBilanPalmyre(joueurId) { 8741 const j = joueurById(joueurId); 8742 const ev = getActiveEvenement(); 8743 if (!j || !ev) { toast('Joueur ou édition introuvable', true); return; } 8744 const bilan = bilanPalmyreDuJoueur(joueurId); 8745 const stats = statsPalmyreJoueur(joueurId); 8746 8747 // Un brouillon de saisie prime sur les valeurs enregistrées (retour du carnet). 8748 const draft = gestionnaireBrouillons.prendreBilan(joueurId); 8749 const estPublie = !!(bilan && bilan.statut === 'publie'); 8750 8751 const axes = [ 8752 ['note_technique', 'Technique / tactique'], 8753 ['note_physique', 'Physique'], 8754 ['note_mental', 'Mental'], 8755 ['note_performance', 'Performance'], 8756 ['note_savoir_etre', 'Savoir-être / vivre'] 8757 ]; 8758 8759 let html = `<div class="modal-header"> 8760 <h3 class="modal-title">ðï¸ Bilan ${escapeHtml(ev.nom)} â ${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</h3> 8761 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 8762 </div> 8763 <form id="formBilanPalmyre" data-ui-submit="saveBilanPalmyre(event, ${escapeHtml(encoderArgumentTexteUI(joueurId))})"> 8764 8765 <div class="section-title">Synthèse chiffrée de la semaine</div> 8766 <div class="card" style="font-size:13px"> 8767 <div class="grid-2"> 8768 <div><strong>Matches joués :</strong> ${stats.total}</div> 8769 <div><strong>Victoires :</strong> ${stats.victoires}</div> 8770 <div><strong>Défaites :</strong> ${stats.defaites}</div> 8771 <div><strong>Perfs :</strong> ${stats.perfs}</div> 8772 </div> 8773 <div class="text-faint" style="font-size:11px; margin-top:8px">Calculé automatiquement à partir des matchs Palmyre saisis pour ce joueur.</div> 8774 </div> 8775 ${observationsJoueur(joueurId).length > 0 ? `<button type="button" class="btn btn-sm btn-full mb-2" data-ui-click="ouvrirObsDepuisBilan(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img class="btn-action-art" src="assets/v4/nav/observations.webp" alt="">Consulter mes observations (${observationsJoueur(joueurId).length}) pour m'aider à décider</button>` : ''} 8776 8777 <div class="section-title">Ãvaluation 5 axes (notation 1-10)</div> 8778 ${axes.map(([name, label]) => { 8779 const val = draft ? draft.notes[name] : (bilan && bilan[name] ? bilan[name] : 5); 8780 const valLabel = draft ? draft.notes[name] : (bilan && bilan[name] ? bilan[name] : '-'); 8781 return ` 8782 <div class="form-row"> 8783 <label class="form-label">${label} : <span id="bpVal_${name}" class="text-orange">${valLabel}</span>/10</label> 8784 <input type="range" name="${name}" min="1" max="10" step="1" value="${val}" data-ui-input="updateRangeValue(this, ${escapeHtml(encoderArgumentTexteUI(`bpVal_${name}`))})" style="width:100%"> 8785 </div>`; 8786 }).join('')} 8787 8788 <div class="section-title">Commentaire du coach</div> 8789 <button type="button" class="btn btn-sm btn-full mb-1" id="btnBilanIA" data-ui-click="genererBilanIA(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Proposer un bilan (IA)</button> 8790 <div class="text-faint" style="font-size:11px; margin-bottom:6px">Une proposition est rédigée à partir de tes observations et des résultats de la semaine, avec un paragraphe par domaine. Elle est entièrement modifiable avant enregistrement.</div> 8791 <div class="form-row"> 8792 <textarea name="commentaire_coach" id="bilanPalmyreCommentaire" rows="14" placeholder="Bilan de la semaine, progrès observés, points à travailler, état d'esprit...">${escapeHtml(draft ? draft.commentaire : (bilan ? (bilan.commentaire_coach || '') : ''))}</textarea> 8793 </div> 8794 8795 <div class="flex gap-1 mt-2"> 8796 ${bilan ? `<button type="button" class="btn btn-danger" data-ui-click="deleteBilanPalmyre(${escapeHtml(encoderArgumentTexteUI(bilan.id))})">Supprimer</button>` : ''} 8797 ${bilan ? `<button type="button" class="btn" data-ui-click="exportBilanPalmyrePDF(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">PDF</button>` : ''} 8798 <button type="button" class="btn flex-1" data-ui-click="saveBrouillonBilanPalmyre(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img class="btn-action-art" src="assets/v4/nav/reports.webp" alt="">Brouillon</button> 8799 <button type="submit" class="btn btn-primary flex-1">${estPublie ? 'Enregistrer' : '<img class="btn-action-art" src="assets/v4/nav/reports.webp" alt="">Publier'}</button> 8800 </div> 8801 <div class="text-faint" style="font-size:11px; text-align:center; margin-top:6px"> 8802 « Brouillon » enregistre sans rien envoyer : tu peux revenir plus tard. ${estPublie ? 'Ce bilan est déjà publié : le joueur y a accès.' : '« Publier » rend le bilan visible au joueur et prévient la famille (notification + email).'} 8803 </div> 8804 </form>`; 8805 showModal(html); 8806} 8807 8808// Capture l'état courant du formulaire de bilan (notes + commentaire), pour le restaurer 8809// après un aller-retour vers le carnet d'observations. 8810function capturerBrouillonBilan(joueurId) { 8811 const f = document.getElementById('formBilanPalmyre'); 8812 if (!f) return null; 8813 const notes = {}; 8814 ['note_technique', 'note_physique', 'note_mental', 'note_performance', 'note_savoir_etre'] 8815 .forEach(n => { notes[n] = f[n] ? parseInt(f[n].value, 10) : 5; }); 8816 return { joueurId, notes, commentaire: f.commentaire_coach ? f.commentaire_coach.value : '' }; 8817} 8818 8819// Ouvre le carnet d'observations DEPUIS le bilan, sans perdre la saisie en cours. 8820function ouvrirObsDepuisBilan(joueurId) { 8821 gestionnaireBrouillons.conserverBilan(capturerBrouillonBilan(joueurId)); 8822 openCarnetObservations(joueurId, true); 8823} 8824 8825// Propose un bilan individuel rédigé par l'IA (un paragraphe par domaine), à partir des 8826// observations du coach et des résultats du joueur sur l'édition. Le texte atterrit dans 8827// le champ commentaire : le coach le relit, le modifie et l'enregistre lui-même. 8828// La clé API reste côté serveur (Edge Function "bilan-ia"), jamais dans le navigateur. 8829async function genererBilanIA(joueurId) { 8830 if (!isCoach()) return; 8831 const ev = getActiveEvenement(); 8832 if (!ev) { toast('Ãdition introuvable', true); return; } 8833 const j = joueurById(joueurId); 8834 if (!j) { toast('Joueur introuvable', true); return; } 8835 8836 const zone = document.getElementById('bilanPalmyreCommentaire'); 8837 if (!zone) return; 8838 // Ne jamais écraser un texte déjà saisi sans accord explicite. 8839 if (zone.value.trim() && !(await demanderConfirmation('Remplacer le commentaire actuel par une nouvelle proposition ?', { titre: 'Remplacer le commentaire', libelle: 'Remplacer' }))) return; 8840 8841 const obs = observationsJoueur(joueurId); 8842 const stats = statsPalmyreJoueur(joueurId); 8843 const matchs = (listeSure(cache.matchesSaison)) 8844 .filter(m => m.joueur_id === joueurId && m.evenement_id === ev.id) 8845 .sort((a, b) => String(a.date || '').localeCompare(String(b.date || ''))); 8846 8847 if (obs.length === 0 && matchs.length === 0) { 8848 toast('Aucune observation ni match sur ce stage : rien à synthétiser', true); 8849 return; 8850 } 8851 8852 const btn = document.getElementById('btnBilanIA'); 8853 if (btn) { btn.disabled = true; btn.textContent = '⨠Rédaction en cours...'; } 8854 8855 try { 8856 const { data, error } = await db.functions.invoke('bilan-ia', { 8857 body: { joueur_id: joueurId, evenement_id: ev.id } 8858 }); 8859 if (error) { toastErreur(error); return; } 8860 if (!data || !data.bilan) { toast('Proposition vide, réessaie', true); return; } 8861 zone.value = data.bilan; 8862 toast('Proposition générée, à toi de l\'ajuster âï¸'); 8863 } catch (e) { 8864 toast('Proposition indisponible : ' + e.message, true); 8865 } finally { 8866 if (btn) { btn.disabled = false; btn.textContent = '⨠Proposer un bilan (IA)'; } 8867 } 8868} 8869 8870// Publication : rend le bilan visible au joueur et prévient la famille. 8871async function saveBilanPalmyre(e, joueurId) { 8872 e.preventDefault(); 8873 await enregistrerBilanPalmyre(joueurId, 'publie'); 8874} 8875 8876// Brouillon : enregistre le travail en cours SANS rien envoyer au joueur ni aux parents. 8877// Le joueur ne voit que les bilans au statut "publie" (filtrage déjà en place côté joueur). 8878async function saveBrouillonBilanPalmyre(joueurId) { 8879 await enregistrerBilanPalmyre(joueurId, 'brouillon'); 8880} 8881 8882async function enregistrerBilanPalmyre(joueurId, statut) { 8883 const f = document.getElementById('formBilanPalmyre'); 8884 if (!f) { toast('Formulaire introuvable', true); return; } 8885 const verrou = verrouillerSoumission(f, statut === 'publie' ? 'Publicationâ¦' : 'Enregistrementâ¦'); 8886 if (!verrou) return; 8887 const ev = getActiveEvenement(); 8888 if (!ev) { deverrouillerSoumission(verrou); toast('Ãdition introuvable', true); return; } 8889 const stats = statsPalmyreJoueur(joueurId); 8890 const bilan = bilanPalmyreDuJoueur(joueurId); 8891 const dejaPublie = !!(bilan && bilan.statut === 'publie'); 8892 8893 const num = (v) => v === '' || v == null ? null : parseInt(v, 10); 8894 const obj = { 8895 evenement_id: ev.id, 8896 joueur_id: joueurId,
8897 note_technique: num(f.note_technique.value), 8898 note_physique: num(f.note_physique.value), 8899 note_mental: num(f.note_mental.value), 8900 note_performance: num(f.note_performance.value), 8901 note_savoir_etre: num(f.note_savoir_etre.value), 8902 matches_total: stats.total, 8903 victoires: stats.victoires, 8904 defaites: stats.defaites, 8905 perfs: stats.perfs, 8906 commentaire_coach: f.commentaire_coach.value.trim(), 8907 statut: statut 8908 }; 8909 8910 let error; 8911 let bilanEnregistreId = bilan?.id || null; 8912 try { 8913 if (bilan) { 8914 obj.updated_at = new Date().toISOString(); 8915 ({ error } = await updateConfirmee(db.from('bilans_evenement').update(obj).eq('id', bilan.id))); 8916 } else { 8917 const creation = await db.from('bilans_evenement').insert(obj).select('id').single(); 8918 error = creation.error; 8919 bilanEnregistreId = creation.data?.id || null; 8920 } 8921 } finally { 8922 deverrouillerSoumission(verrou); 8923 } 8924 if (error) { toastErreur(error); return; } 8925 8926 // Notification + email UNIQUEMENT à la première publication : un brouillon ne prévient 8927 // personne, et corriger un bilan déjà publié ne re-sollicite pas la famille. 8928 const j = joueurById(joueurId); 8929 const doitPrevenir = (statut === 'publie') && !dejaPublie; 8930 8931 if (doitPrevenir && j && j.user_id) { 8932 try { 8933 await db.from('notifications').insert({ 8934 user_id: j.user_id, 8935 type: 'bilan_publie', 8936 titre: `ðï¸ Ton bilan ${ev.nom} est disponible`, 8937 message: `Ton coach a publié ton bilan de fin de semaine de ${ev.nom}. Va le consulter dans l'app !`, 8938 lien_type: 'bilan_evenement', 8939 lien_id: ev.id 8940 }); 8941 } catch (e) { journal.warn('[bilan palmyre] notif:', e); } 8942 } 8943 8944 // Email du bilan (stage intensif : on prévient le joueur + parents, comme en compét). 8945 if (doitPrevenir && j) { 8946 emailBilanPalmyrePublie(j, { ...ev, bilan_id: bilanEnregistreId }, stats); 8947 } 8948 8949 gestionnaireBrouillons.effacerFormulaire('bilan', joueurId); // la saisie est enregistrée, plus rien à restaurer 8950 closeModal(); 8951 toast(statut === 'brouillon' 8952 ? 'Brouillon enregistré (rien n\'a été envoyé au joueur) ð¾' 8953 : (dejaPublie ? 'Bilan mis à jour' : 'Bilan publié ð¤')); 8954 await reloadTables(['bilans_evenement']); 8955 render(); 8956} 8957 8958async function deleteBilanPalmyre(bilanId) { 8959 if (!(await demanderConfirmation('Supprimer ce bilan ?'))) return; 8960 const { error } = await db.from('bilans_evenement').delete().eq('id', bilanId); 8961 if (error) { toastErreur(error); return; } 8962 closeModal(); 8963 toast('Bilan supprimé'); 8964 await reloadTables(['bilans_evenement']); 8965 render(); 8966} 8967 8968// Export PDF d'un bilan Palmyre (calqué sur exportBilanPDF de l'académie). 8969async function exportBilanPalmyrePDF(joueurId) { return appelerExportPdf('exportBilanPalmyrePDF', joueurId); } 8970 8971// Comparaison inter-éditions : pour chaque participant présent sur plusieurs 8972// éditions, montre l'évolution de ses notes moyennes et de ses stats. 8973function openComparaisonEditions() { 8974 const editions = (listeSure(cache.evenements)).slice().sort((a, b) => (a.date_debut || '').localeCompare(b.date_debut || '')); 8975 const bilans = listeSure(cache.bilansEvenement); 8976 if (editions.length < 2) { 8977 toast('Il faut au moins deux éditions pour comparer', true); 8978 return; 8979 } 8980 8981 // Joueurs ayant au moins un bilan sur une édition 8982 const joueurIds = [...new Set(bilans.map(b => b.joueur_id))]; 8983 const moyenne = (b) => { 8984 const notes = [b.note_technique, b.note_physique, b.note_mental, b.note_performance, b.note_savoir_etre].filter(n => n != null); 8985 return notes.length ? (notes.reduce((a, c) => a + c, 0) / notes.length) : null; 8986 }; 8987 8988 let html = `<div class="modal-header"> 8989 <h3 class="modal-title">ð Comparaison des éditions</h3> 8990 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 8991 </div> 8992 <div class="text-faint mb-2" style="font-size:12px">Ãvolution de la note moyenne (5 axes) et du ratio de victoires par participant.</div>`; 8993 8994 if (joueurIds.length === 0) { 8995 html += `<div class="empty"><span class="icon">ð</span>Aucun bilan enregistré pour le moment.</div>`; 8996 showModal(html); 8997 return; 8998 } 8999 9000 html += `<div class="card" style="padding:0; overflow-x:auto"><table class="overview-table" style="width:100%; border-collapse:collapse; font-size:12px"> 9001 <thead style="background:var(--bg-section)"><tr> 9002 <th style="text-align:left; padding:8px">Joueur</th> 9003 ${editions.map(ed => `<th style="text-align:center; padding:8px">${escapeHtml(ed.nom)}</th>`).join('')} 9004 </tr></thead><tbody>`; 9005 9006 joueurIds.forEach(jid => { 9007 const j = joueurById(jid); 9008 if (!j) return; 9009 html += `<tr style="border-top:1px solid var(--border)"><td style="padding:8px"><strong>${escapeHtml(j.prenom)}</strong> ${escapeHtml(j.nom)}</td>`; 9010 editions.forEach(ed => { 9011 const b = bilans.find(x => x.joueur_id === jid && x.evenement_id === ed.id); 9012 if (!b) { 9013 html += `<td style="text-align:center; padding:8px; color:var(--text-faint)">â</td>`; 9014 } else { 9015 const moy = moyenne(b); 9016 const ratio = b.matches_total > 0 ? Math.round((b.victoires / b.matches_total) * 100) : 0; 9017 html += `<td style="text-align:center; padding:8px"> 9018 <div><strong>${moy != null ? moy.toFixed(1) : 'â'}</strong><span class="text-faint" style="font-size:10px">/10</span></div> 9019 <div class="text-faint" style="font-size:10px">${b.victoires}V ${b.defaites}D · ${ratio}%</div> 9020 </td>`; 9021 } 9022 }); 9023 html += `</tr>`; 9024 }); 9025 html += `</tbody></table></div>`; 9026 showModal(html); 9027} 9028 9029function renderBilansCoach() { 9030 const entete = renderUxScreenHeading('Ãvaluation individuelle', 'Bilans des joueurs'); 9031 // Les bilans trimestriels concernent l'académie : on ne garde QUE les joueurs 9032 // affectés à un groupe de compétition (Tremplin / Accession Ãlite / Ãlite). 9033 // Les joueurs sans groupe sont exclus. 9034 const GROUPES_COMPET = ORDRE_GROUPES; 9035 const joueursActifs = cache.joueurs.filter(j => 9036 (!j.statut || j.statut === 'actif') && GROUPES_COMPET.includes(j.groupe) 9037 ); 9038 if (joueursActifs.length === 0) { 9039 return entete + `<div class="empty"><span class="icon">ð</span>Aucun joueur en groupe compétition.<br><span class="text-faint">Affecte des joueurs à un groupe (Tremplin, Accession Ãlite ou Ãlite) pour saisir leurs bilans.</span></div>`; 9040 } 9041 9042 // Filtre saison 9043 const saisonsExistantes = [...new Set(cache.bilans.map(b => b.saison))].sort().reverse(); 9044 const saisonCourante = getSaisonCourante(); 9045 if (!saisonsExistantes.includes(saisonCourante)) saisonsExistantes.unshift(saisonCourante); 9046 const filtreSaison = $('filtreSaisonBilan')?.value || saisonCourante; 9047 9048 let html = entete + `<div class="card mb-2"><div class="form-row" style="margin-bottom:0"> 9049 <label class="form-label">Saison</label> 9050 <select id="filtreSaisonBilan" data-ui-change="render()"> 9051 ${saisonsExistantes.map(s => `<option value="${s}" ${s === filtreSaison ? 'selected' : ''}>${s}</option>`).join('')} 9052 </select> 9053 </div></div>`; 9054 9055 // Tableau : ligne par joueur, colonne par trimestre 9056 html += `<div class="section-title">Matrice des bilans · saison ${filtreSaison}</div>`; 9057 html += `<div class="text-dim mb-2" style="font-size:13px">Touche une cellule pour créer ou éditer un bilan trimestriel.</div>`; 9058 9059 html += `<div class="card" style="padding:0; overflow-x:auto"> 9060 <table style="width:100%; border-collapse:collapse; font-size:13px"> 9061 <thead> 9062 <tr style="background:var(--bg-input); border-bottom:1px solid var(--border)"> 9063 <th style="text-align:left; padding:10px 12px; min-width:140px">Joueur</th> 9064 ${['T1','T2','T3','T4'].map(t => `<th style="padding:10px 8px; text-align:center; min-width:70px">${t}</th>`).join('')} 9065 <th style="padding:10px 8px; text-align:center; min-width:80px; border-left:2px solid var(--border)">ð Saison</th> 9066 </tr> 9067 </thead> 9068 <tbody> 9069 ${joueursActifs.map(j => { 9070 const cells = ['T1','T2','T3','T4'].map(t => { 9071 const bilan = bilanDuJoueur(j.id, filtreSaison, t); 9072 if (!bilan) { 9073 return `<td style="padding:6px; text-align:center; border-bottom:1px solid var(--border)"><button class="btn btn-sm" data-ui-click="openModalBilan(null, ${escapeHtml(encoderArgumentTexteUI(j.id
9073))}, ${escapeHtml(encoderArgumentTexteUI(filtreSaison))}, ${escapeHtml(encoderArgumentTexteUI(t))})" style="padding:4px 8px; font-size:11px">+</button></td>`; 9074 } 9075 const statutColor = bilan.statut === 'accepte' ? 'success' : bilan.statut === 'commente' ? 'info' : bilan.statut === 'publie' ? 'warning' : 'danger'; 9076 const statutLabel = { brouillon: 'â brouillon', publie: 'ð¤ publié', commente: 'ð¬ commenté', accepte: 'â accepté' }[bilan.statut] || bilan.statut; 9077 return `<td style="padding:6px; text-align:center; border-bottom:1px solid var(--border); cursor:pointer" data-ui-click="openModalBilan(${escapeHtml(encoderArgumentTexteUI(bilan.id))})"><span class="badge badge-${statutColor}" style="font-size:10px">${statutLabel}</span></td>`; 9078 }).join(''); 9079 // Cellule "Saison" : bilan annuel (trimestre ANNUEL) 9080 const bilanAnnuel = bilanDuJoueur(j.id, filtreSaison, 'ANNUEL'); 9081 let cellSaison; 9082 if (!bilanAnnuel) { 9083 cellSaison = `<td style="padding:6px; text-align:center; border-bottom:1px solid var(--border); border-left:2px solid var(--border)"><button class="btn btn-sm" data-ui-click="openModalBilan(null, ${escapeHtml(encoderArgumentTexteUI(j.id))}, ${escapeHtml(encoderArgumentTexteUI(filtreSaison))}, 'ANNUEL')" style="padding:4px 8px; font-size:11px" title="Bilan de saison">+</button></td>`; 9084 } else { 9085 const sc = bilanAnnuel.statut === 'accepte' ? 'success' : bilanAnnuel.statut === 'commente' ? 'info' : bilanAnnuel.statut === 'publie' ? 'warning' : 'danger'; 9086 const sl = { brouillon: 'â brouillon', publie: 'ð¤ publié', commente: 'ð¬ commenté', accepte: 'â accepté' }[bilanAnnuel.statut] || bilanAnnuel.statut; 9087 cellSaison = `<td style="padding:6px; text-align:center; border-bottom:1px solid var(--border); border-left:2px solid var(--border); cursor:pointer" data-ui-click="openModalBilan(${escapeHtml(encoderArgumentTexteUI(bilanAnnuel.id))})"><span class="badge badge-${sc}" style="font-size:10px">${sl}</span></td>`; 9088 } 9089 return `<tr><td style="padding:10px 12px; border-bottom:1px solid var(--border)"><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong><br><span class="text-faint" style="font-size:11px">${escapeHtml(j.classement || 'NC')} · ${cache.config[j.groupe] ? cache.config[j.groupe].nom : 'sans groupe'}</span></td>${cells}${cellSaison}</tr>`; 9090 }).join('')} 9091 </tbody> 9092 </table> 9093 </div>`; 9094 9095 return html; 9096} 9097 9098// ============================================================ 9099// SYNTHÃSE ANNUELLE â agrège les bilans trimestriels publiés d'une saison 9100// ============================================================ 9101// Les 5 axes notés du bilan (clés DB note_<axe>), dans l'ordre d'affichage. 9102const BILAN_AXES = [ 9103 { cle: 'performance', label: 'Performance' }, 9104 { cle: 'volume', label: 'Volume / engagement' }, 9105 { cle: 'technique', label: 'Technique / tactique' }, 9106 { cle: 'physique', label: 'Physique' }, 9107 { cle: 'mental', label: 'Mental' } 9108]; 9109const ORDRE_TRIMESTRES = ['T1', 'T2', 'T3', 'T4']; 9110 9111// Construit la synthèse de la saison pour un joueur à partir des bilans 9112// trimestriels PUBLIÃS (statut 'publie' ou 'commente' ou 'accepte' â bref non brouillon). 9113function syntheseAnnuelle(joueurId, saison) { 9114 const trimestriels = (listeSure(cache.bilans)) 9115 .filter(b => b.joueur_id === joueurId && b.saison === saison 9116 && ORDRE_TRIMESTRES.includes(b.trimestre) 9117 && b.statut && b.statut !== 'brouillon') 9118 .sort((a, b) => ORDRE_TRIMESTRES.indexOf(a.trimestre) - ORDRE_TRIMESTRES.indexOf(b.trimestre)); 9119 9120 const evolution = TennisDomainRules.syntheseEvolutionBilans(trimestriels, BILAN_AXES); 9121 const { axes, phrase, enHausse, enBaisse, stables } = evolution; 9122 9123 // Ãvolution du classement : premier classement_debut connu â dernier classement_fin connu. 9124 let classementDebut = null, classementFin = null; 9125 trimestriels.forEach(b => { 9126 if (b.classement_debut && !classementDebut) classementDebut = b.classement_debut; 9127 if (b.classement_fin) classementFin = b.classement_fin; 9128 }); 9129 // Fallback : classement actuel du joueur si rien dans les bilans. 9130 const joueur = joueurById(joueurId); 9131 if (!classementFin && joueur) classementFin = joueur.classement; 9132 let gainClassement = null; 9133 if (classementDebut && classementFin) { 9134 gainClassement = TennisDomainRules.ecartClassements(classementDebut, classementFin); // >0 = progression 9135 } 9136 9137 // Cumul des stats de jeu sur la saison (à partir des matchs réels, pas des bilans). 9138 const [sa1, sa2] = saison.split('-'); 9139 const ms = matchesEntreDate(joueurId, `${sa1}-09-01`, `${sa2}-08-31`); 9140 const compteSaison = TennisDomainRules.statistiquesSportives( 9141 ms, 9142 joueur ? m => classementADate(joueurId, m.date) : null 9143 ); 9144 const perfs = compteSaison.perfs; 9145 const victoires = compteSaison.victoires; 9146 const tauxVictoire = compteSaison.total ? compteSaison.tauxVictoire : null; 9147 9148 return { 9149 nbTrimestriels: trimestriels.length, 9150 trimestriels, 9151 axes, 9152 classementDebut, classementFin, gainClassement, 9153 nbMatchs: compteSaison.total, victoires, perfs, tauxVictoire, 9154 phrase, enHausse, enBaisse, stables 9155 }; 9156} 9157 9158// ============================================================ 9159// MODAL: ÃDITION BILAN (coach) 9160// ============================================================ 9161function openModalBilan(bilanId, presetJoueurId, presetSaison, presetTrimestre) { 9162 if (!isCoach()) return; 9163 9164 const b = bilanId ? bilanById(bilanId) : null; 9165 const isEdit = !!b; 9166 9167 // Sélection du joueur / saison / trimestre 9168 const joueurId = b ? b.joueur_id : presetJoueurId || ''; 9169 const saison = b ? b.saison : presetSaison || getSaisonCourante(); 9170 const trimestre = b ? b.trimestre : presetTrimestre || getTrimestreC
9170ourant(); 9171 // La période est-elle IMPOSÃE ? (clic sur le + d'une colonne de la grille, ou édition) 9172 // Si oui, on n'affiche pas de sélecteur navigable. Si on vient du bouton "+" 9173 // flottant (création libre, sans presetTrimestre), on laisse choisir. 9174 const periodeImposee = isEdit || !!presetTrimestre; 9175 const notesDecision = joueurId 9176 ? listeSure(cache.notesCoach).filter(note => note.joueur_id === joueurId).slice(0, 8) 9177 : []; 9178 9179 // Calcul auto des stats de la période (si trimestre/joueur défini) 9180 let statsAuto = { matches: 0, victoires: 0, perfs: 0, ressenti: null }; 9181 if (joueurId && saison && trimestre) { 9182 // Pour le bilan ANNUEL, la période couvre toute la saison (1er sept â 31 août). 9183 let dDeb, dFin; 9184 if (trimestre === 'ANNUEL') { 9185 const [sa1, sa2] = saison.split('-'); 9186 dDeb = `${sa1}-09-01`; dFin = `${sa2}-08-31`; 9187 } else { 9188 [dDeb, dFin] = getTrimestreDates(saison, trimestre); 9189 } 9190 const ms = matchesEntreDate(joueurId, dDeb, dFin); 9191 const compte = TennisDomainRules.statistiquesSportives( 9192 ms, 9193 m => classementADate(joueurId, m.date) 9194 ); 9195 statsAuto.matches = compte.total; 9196 statsAuto.victoires = compte.victoires; 9197 // "Perf" = victoire contre adversaire mieux classé (heuristique simple) 9198 statsAuto.perfs = compte.perfs; 9199 statsAuto.ressenti = statsRessenti(ms); // info de lecture (moyenne, V/D, tendance) 9200 } 9201 9202 const joueurs = cache.joueurs.filter(TennisDomainRules.estJoueurActif); 9203 const groupes = groupesOrdonnes(); 9204 9205 const html = ` 9206 <div class="modal-header"> 9207 <h3 class="modal-title">${isEdit ? (b.trimestre === 'ANNUEL' ? 'ð Bilan annuel ' + b.saison : 'Bilan ' + b.saison + ' · ' + b.trimestre) : (trimestre === 'ANNUEL' ? 'ð Nouveau bilan annuel' : 'Nouveau bilan')}</h3> 9208 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 9209 </div> 9210 9211 ${!isEdit ? ` 9212 <div class="grid-2"> 9213 <div class="form-row"> 9214 <label class="form-label">Joueur *</label> 9215 <select id="bilanJoueurId" required data-ui-change="onBilanPeriodeChange()"> 9216 <option value="">Sélectionner...</option> 9217 ${joueurs.map(j => `<option value="${j.id}" ${joueurId === j.id ? 'selected' : ''}>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</option>`).join('')} 9218 </select> 9219 </div> 9220 <div class="form-row"> 9221 <label class="form-label">Saison</label> 9222 <input type="text" id="bilanSaison" value="${escapeHtml(saison)}" pattern="[0-9]{4}-[0-9]{4}" required data-ui-change="onBilanPeriodeChange()"> 9223 </div> 9224 </div> 9225 <div class="form-row"> 9226 <label class="form-label">${trimestre === 'ANNUEL' ? 'Période' : 'Trimestre'}</label> 9227 ${periodeImposee ? ` 9228 <div class="pill-group"><span class="pill active" style="cursor:default">${getTrimestreLabel(trimestre)}</span></div> 9229 <div id="bilanTrimestrePills" style="display:none"><span class="pill active" data-value="${trimestre}"></span></div> 9230 ` : ` 9231 <div class="pill-group" id="bilanTrimestrePills"> 9232 ${['T1','T2','T3','T4'].map(t => `<span class="pill ${t === trimestre ? 'active' : ''}" data-value="${t}" data-ui-click="selectPill(this, 'bilanTrimestrePills'); onBilanPeriodeChange()">${getTrimestreLabel(t)}</span>`).join('')} 9233 </div> 9234 `} 9235 </div> 9236 ` : ` 9237 <div class="card" style="font-size:13px"> 9238 <strong>${escapeHtml(joueurById(b.joueur_id)?.prenom)} ${escapeHtml(joueurById(b.joueur_id)?.nom)}</strong> · ${escapeHtml(b.saison)} · ${escapeHtml(b.trimestre)} 9239 </div> 9240 <input type="hidden" id="bilanJoueurId" value="${b.joueur_id}"> 9241 <input type="hidden" id="bilanSaison" value="${b.saison}"> 9242 <div id="bilanTrimestrePills" style="display:none"><span class="pill active" data-value="${b.trimestre}"></span></div> 9243 `} 9244 9245 <div class="section-title">Synthèse chiffrée de la période</div> 9246 <div id="bilanStatsZone" class="card" style="font-size:13px"> 9247 <div id="bilanStatsContent"> 9248 <div class="grid-2"> 9249 <div><strong>Matches joués :</strong> <span id="statMatches">${b ? b.matches_periode : statsAuto.matches}</span></div> 9250 <div><strong>Victoires :</strong> <span id="statVictoires">${b ? b.victoires_periode : statsAuto.victoires}</span></div> 9251 <div><strong>
9251Perfs :</strong> <span id="statPerfs">${b ? b.perfs_periode : statsAuto.perfs}</span></div> 9252 <div><strong>Classement :</strong> 9253 <input type="text" id="bilanClassDebut" value="${b ? escapeHtml(b.classement_debut || '') : ''}" placeholder="début" style="width:60px; display:inline-block; padding:4px 6px; font-size:12px"> â 9254 <input type="text" id="bilanClassFin" value="${b ? escapeHtml(b.classement_fin || '') : ''}" placeholder="fin" style="width:60px; display:inline-block; padding:4px 6px; font-size:12px"> 9255 </div> 9256 </div> 9257 <div id="bilanRessenti" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border)"> 9258 <strong style="font-size:13px">Ressenti du joueur :</strong> 9259 <div style="margin-top:4px">${renderRessentiBloc(statsAuto.ressenti)}</div> 9260 </div> 9261 </div> 9262 <div class="text-faint" style="font-size:11px; margin-top:8px">Les chiffres matches/victoires/perfs sont calculés automatiquement à partir des matches saisis. Les classements début/fin sont à renseigner manuellement.</div> 9263 </div> 9264 9265 <div id="bilanSyntheseAnnuelle" style="display:${trimestre === 'ANNUEL' ? 'block' : 'none'}"> 9266 <div class="section-title">ð Synthèse de l'année (d'après les bilans trimestriels)</div> 9267 <div id="bilanSyntheseContent" class="card" style="font-size:13px"> 9268 ${joueurId ? renderSyntheseAnnuelle(joueurId, saison) : '<div class="text-faint">Sélectionne un joueur.</div>'} 9269 </div> 9270 </div> 9271 9272 <div class="section-title">ð Notes coach utiles à la décision</div> 9273 <div class="card" style="font-size:12px"> 9274 ${notesDecision.length ? notesDecision.map(note => `<div style="padding:7px 0;border-bottom:1px solid var(--border)"><div>${escapeHtml(note.contenu)}</div><div class="text-faint" style="margin-top:3px">${formatDateTime(note.created_at)}</div></div>`).join('') : '<div class="text-faint">Aucune note enregistrée pour ce joueur.</div>'} 9275 <div class="text-faint" style="margin-top:8px">Lecture seule : ajoute ou modifie les notes depuis lâonglet Notes.</div> 9276 </div> 9277 9278 <div class="section-title">Ãvaluation 5 axes (notation 1-10)</div> 9279 ${[ 9280 ['performance','Performance sportive'], 9281 ['volume','Volume / engagement'], 9282 ['technique','Technique / Tactique'], 9283 ['physique','Physique'], 9284 ['mental','Mental / attitude'] 9285 ].map(([key, label]) => ` 9286 <div class="form-row"> 9287 <label class="form-label">${label} : <span id="noteVal_${key}" class="text-orange">${b && b['note_'+key] ? b['note_'+key] : '-'}</span>/10</label> 9288 <input type="range" min="0" max="10" step="1" id="bilanNote_${key}" value="${b && b['note_'+key] ? b['note_'+key] : 5}" data-ui-input="updateRangeValue(this, ${escapeHtml(encoderArgumentTexteUI(`noteVal_${key}`))})" style="width:100%"> 9289 </div> 9290 `).join('')} 9291 9292 <div class="section-title">Commentaire coach</div> 9293 <div class="form-row"> 9294 <textarea id="bilanCommentaire" rows="5" placeholder="Synthèse du trimestre : points forts observés, axes de travail, état d'esprit, événements marquants...">${b ? escapeHtml(b.commentaire_coach || '') : ''}</textarea> 9295 </div> 9296 9297 <div class="section-title">Objectifs trimestre suivant</div> 9298 <div class="form-row"> 9299 <textarea id="bilanObjectifs" rows="3" placeholder="3-5 objectifs SMART (Spécifiques, Mesurables, Atteignables, Réalistes, Temporels)">${b ? escapeHtml(b.objectifs_suivants || '') : ''}</textarea> 9300 </div> 9301 9302 <div class="section-title">Décision</div> 9303 <div class="form-row"> 9304 <label class="form-label">Décision pour le trimestre suivant</label> 9305 <div class="pill-group" id="bilanDecisionPills"> 9306 ${[ 9307 ['a_decider','à décider', ''], 9308 ['maintien','Maintien', 'success'], 9309 ['montee','Montée', 'success'], 9310 ['descente','Descente', 'danger'], 9311 ['sortie','Sortie de l\'académie', 'danger'] 9312 ].map(([key, label, cls]) => `<span class="pill ${cls} ${b && b.decision === key ? 'active' : ''}" data-value="${key}" data-ui-click="selectPill(this, 'bilanDecisionPills')">${label}</span>`).join('')} 9313 </div> 9314 </div> 9315 <div class="form-row"> 9316 <label class="form-label">Groupe destination (si montée/descente)</label> 9317 <div class="pill-group" id="bilanGroupePills">
9318 <span class="pill ${b && !b.groupe_suivant ? 'active' : ''}" data-value="" data-ui-click="selectPill(this, 'bilanGroupePills')">Inchangé</span> 9319 ${groupes.map(g => `<span class="pill ${b && b.groupe_suivant === g ? 'active' : ''}" data-value="${g}" data-ui-click="selectPill(this, 'bilanGroupePills')">${escapeHtml(cache.config[g].nom)}</span>`).join('')} 9320 </div> 9321 </div> 9322 9323 ${b && b.statut !== 'brouillon' ? ` 9324 <div class="section-title">Côté joueur</div> 9325 <div class="card" style="font-size:13px"> 9326 <div><strong>Statut :</strong> ${b.statut === 'accepte' ? 'â Accepté' : b.statut === 'commente' ? 'ð¬ Commenté (non encore accepté)' : 'ð¤ Publié, en attente'}</div> 9327 ${b.commentaire_joueur ? `<div style="margin-top:8px; padding:8px; background:var(--bg-input); border-radius:var(--radius-sm); border-left:3px solid var(--info)"><div class="text-faint" style="font-size:11px; margin-bottom:4px">Commentaire du joueur :</div>${escapeHtml(b.commentaire_joueur)}</div>` : ''} 9328 ${b.date_acceptation ? `<div class="text-faint" style="font-size:11px; margin-top:6px">Accepté le ${formatDate(b.date_acceptation)}</div>` : ''} 9329 </div> 9330 ` : ''} 9331 9332 <div class="flex gap-1 mt-2" style="flex-wrap:wrap"> 9333 ${isEdit ? `<button type="button" class="btn btn-danger" data-ui-click="deleteBilan(${escapeHtml(encoderArgumentTexteUI(b.id))})">Supprimer</button>` : ''} 9334 ${isEdit && b.statut !== 'brouillon' ? `<button type="button" class="btn" data-ui-click="exportBilanPDF(${escapeHtml(encoderArgumentTexteUI(b.id))})"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">PDF</button>` : ''} 9335 <button type="button" class="btn flex-1" data-ui-click="closeModal()">Annuler</button> 9336 <button type="button" class="btn flex-1" data-ui-click="saveBilan(${escapeHtml(encoderArgumentTexteUI(bilanId || ''))}, 'brouillon')"><img class="btn-action-art" src="assets/v4/nav/reports.webp" alt="">${isEdit && b.statut === 'brouillon' ? 'Enregistrer brouillon' : 'Brouillon'}</button> 9337 <button type="button" class="btn btn-primary flex-1" data-ui-click="saveBilan(${escapeHtml(encoderArgumentTexteUI(bilanId || ''))}, 'publie')"><img class="btn-action-art" src="assets/v4/nav/reports.webp" alt="">Publier au joueur</button> 9338 </div> 9339 `; 9340 9341 showModal(html); 9342 9343 // Si on ouvre/crée un bilan ANNUEL, dessiner le graphique d'évolution des axes. 9344 if (trimestre === 'ANNUEL' && joueurId) { 9345 setTimeout(() => drawSyntheseAxesChart(joueurId, saison), 60); 9346 } 9347} 9348 9349// Recalcule les stats auto quand joueur/saison/période change dans la modale 9350function refreshBilanStats() { 9351 const joueurId = $('bilanJoueurId')?.value; 9352 const saison = $('bilanSaison')?.value; 9353 const triPill = document.querySelector('#bilanTrimestrePills .pill.active'); 9354 const trimestre = triPill?.dataset.value; 9355 if (!joueurId || !saison || !trimestre) return; 9356 let dDeb, dFin; 9357 if (trimestre === 'ANNUEL') { 9358 const [sa1, sa2] = saison.split('-'); 9359 dDeb = `${sa1}-09-01`; dFin = `${sa2}-08-31`; 9360 } else { 9361 [dDeb, dFin] = getTrimestreDates(saison, trimestre); 9362 } 9363 const ms = matchesEntreDate(joueurId, dDeb, dFin); 9364 const compte = TennisDomainRules.statistiquesSportives( 9365 ms, 9366 match => classementADate(joueurId, match.date) 9367 ); 9368 const v = compte.victoires; 9369 const perfs = compte.perfs; 9370 if ($('statMatches')) $('statMatches').textContent = compte.total; 9371 if ($('statVictoires')) $('statVictoires').textContent = v; 9372 if ($('statPerfs')) $('statPerfs').textContent = perfs; 9373 // Mettre à jour le bloc ressenti 9374 const ressentiZone = $('bilanRessenti'); 9375 if (ressentiZone) { 9376 const inner = ressentiZone.querySelector('div'); 9377 if (inner) inner.innerHTML = renderRessentiBloc(statsRessenti(ms)); 9378 } 9379} 9380 9381// Construit le HTML de la synthèse annuelle (hors graphique, dessiné après).
9382function renderSyntheseAnnuelle(joueurId, saison) { 9383 const s = syntheseAnnuelle(joueurId, saison); 9384 if (s.nbTrimestriels === 0) { 9385 return `<div class="text-faint" style="font-style:italic">Aucun bilan trimestriel publié pour ${escapeHtml(saison)}. La synthèse annuelle s'enrichira au fur et à mesure que tu publies les bilans T1 à T4.</div>`; 9386 } 9387 9388 // Ãvolution du classement 9389 let classementBloc = ''; 9390 if (s.classementDebut || s.classementFin) { 9391 let tendanceCl = ''; 9392 if (s.gainClassement != null) { 9393 if (s.gainClassement > 0) tendanceCl = `<span style="color:var(--success-text,#1d8a5a)">â +${s.gainClassement} échelon${s.gainClassement > 1 ? 's' : ''}</span>`; 9394 else if (s.gainClassement < 0) tendanceCl = `<span style="color:var(--danger-text,#c0392b)">â ${s.gainClassement} échelon${s.gainClassement < -1 ? 's' : ''}</span>`; 9395 else tendanceCl = `<span class="text-faint">â stable</span>`; 9396 } 9397 classementBloc = `<div style="margin-bottom:10px"><strong>Classement sur l'année :</strong> ${escapeHtml(s.classementDebut || '?')} â ${escapeHtml(s.classementFin || '?')} ${tendanceCl}</div>`; 9398 } 9399 9400 // Tendance par axe (flèches + delta) sous le graphique 9401 const axesLignes = s.axes.filter(a => a.points.length > 0).map(a => { 9402 const fl = a.tendance === 'hausse' ? 'â' : a.tendance === 'baisse' ? 'â' : 'â'; 9403 const col = a.tendance === 'hausse' ? 'var(--success-text,#1d8a5a)' : a.tendance === 'baisse' ? 'var(--danger-text,#c0392b)' : 'var(--text-faint,#8a9794)'; 9404 const suite = a.points.map(p => `${p.trimestre}:${p.note}`).join(' â '); 9405 return `<div style="display:flex; justify-content:space-between; padding:3px 0; font-size:12px"> 9406 <span>${escapeHtml(a.label)} <span class="text-faint">(${suite})</span></span> 9407 <span style="color:${col}; font-weight:600">${fl} ${a.delta > 0 ? '+' : ''}${a.delta}</span> 9408 </div>`; 9409 }).join(''); 9410 9411 return ` 9412 ${classementBloc} 9413 <div style="margin-bottom:6px"><strong>Stats de la saison :</strong> ${s.nbMatchs} match${s.nbMatchs > 1 ? 'es' : ''} · ${s.victoires} victoire${s.victoires > 1 ? 's' : ''} · ${s.perfs} perf${s.perfs > 1 ? 's' : ''}${s.tauxVictoire != null ? ` · ${s.tauxVictoire}% de victoires` : ''}</div> 9414 <div style="font-weight:600; font-size:12px; margin:10px 0 4px">Ãvolution des 5 axes (T1 â T4)</div> 9415 <div style="height:200px; position:relative"><canvas id="bilanAxesChart"></canvas></div> 9416 <div style="margin-top:10px; padding-top:8px; border-top:1px solid var(--border)">${axesLignes}</div> 9417 <div style="margin-top:10px; padding:8px 10px; background:var(--accent-bg,#e8eef7); border-radius:6px; font-size:12px"> 9418 <strong>Tendance générale :</strong> ${escapeHtml(s.phrase)} 9419 </div> 9420 <div class="text-faint" style="font-size:11px; margin-top:6px">Synthèse basée sur ${s.nbTrimestriels} bilan${s.nbTrimestriels > 1 ? 's' : ''} trimestriel${s.nbTrimestriels > 1 ? 's' : ''} publié${s.nbTrimestriels > 1 ? 's' : ''}.</div> 9421 `; 9422} 9423 9424// Dessine le graphique d'évolution des axes (appelé après insertion du HTML). 9425function drawSyntheseAxesChart(joueurId, saison) { 9426 const canvas = document.getElementById('bilanAxesChart'); 9427 if (!canvas) return; 9428 if (!avecChartJs('bilanAxesChart', () => drawSyntheseAxesChart(joueurId, saison))) return; 9429 destroyChart('bilanAxesChart'); 9430 const s = syntheseAnnuelle(joueurId, saison); 9431 if (s.nbTrimestriels === 0) return; 9432 9433 // Axe X = trimestres présents (union de tous les points) 9434 const labels = ORDRE_TRIMESTRES.filter(t => s.trimestriels.some(b => b.trimestre === t)); 9435 const couleursGraphique = chartColors(); 9436 const palette = [ 9437 couleursGraphique.lineOrange, 9438 couleursGraphique.blueColor, 9439 couleursGraphique.tealColor, 9440 couleursGraphique.purpleColor, 9441 couleursGraphique.lossColor 9442 ]; 9443 const datasets = s.axes.map((a, i) => ({ 9444 label: a.label, 9445 data: labels.map(t => { 9446 const p = a.points.find(pt => pt.trimestre === t); 9447 return p ? p.note : null; 9448 }), 9449 borderColor: palette[i % palette.length], 9450 backgroundColor: 'transparent', 9451 tension: 0.3, 9452 spanGaps: true, 9453 borderWidth: 2, 9454 pointRadius: 3 9455 })); 9456 9457 chartInstances.bilanAxesChart = new window.Chart(canvas.getContext('2d'), { 9458 type: 'line', 9459 data: { labels, datasets }, 9460 options: { 9461 responsive: true, maintainAspectRatio: false, 9462 scales: { 9463 x: { 9464 grid: { color: couleursGraphique.gridColor }, 9465 ticks: { color: couleursGraphique.textColor } 9466 }, 9467 y: { 9468 min: 0, 9469 max: 10, 9470 grid: { color: couleursGraphique.gridColor }, 9471 ticks: { stepSize: 2, color: couleursGraphique.textColor } 9472 } 9473 }, 9474 plugins: { 9475 legend: { 9476 position: 'bottom', 9477 labels: { color: couleursGraphique.textColor, boxWidth: 12, font: { size: 10 } } 9478 }, 9479 tooltip: { 9480 backgroundColor: couleursGraphique.tooltipBg, 9481 borderColor: couleursGraphique.lineOrange, 9482 borderWidth: 1 9483 } 9484 } 9485 } 9486 }); 9487} 9488 9489// Appelée quand la période du bilan change (remplace l'appel direct à refreshBilanStats). 9490// Affiche/masque la zone de synthèse annuelle et la (re)calcule si besoin. 9491function onBilanPeriodeChange() { 9492 refreshBilanStats(); 9493 const triPill = document.querySelector('#bilanTrimestrePills .pill.active'); 9494 const periode = triPill ? triPill.dataset.value : null; 9495 const zone = $('bilanSyntheseAnnuelle'); 9496 const joueurId = $('bilanJoueurId')?.value; 9497 const saison = $('bilanSaison')?.value; 9498 if (!zone) return; 9499 if (periode === 'ANNUEL') { 9500 zone.style.display = 'block'; 9501 const contenu = $('bilanSyntheseContent'); 9502 if (contenu && joueurId && saison) { 9503 contenu.innerHTML = renderSyntheseAnnuelle(joueurId, saison); 9504 setTimeout(() => drawSyntheseAxesChart(joueurId, saison), 50); 9505 } 9506 } else { 9507 zone.style.display = 'none'; 9508 } 9509} 9510 9511async function saveBilan(id, statut) { 9512 const joueurId = $('bilanJoueurId').value; 9513 const saison = $('bilanSaison').value.trim(); 9514 const triPill = document.querySelector('#bilanTrimestrePills .pill.active'); 9515 if (!joueurId) { toast('Sélectionne un joueur', true); return; } 9516 if (!saison || !/^\d{4}-\d{4}
9516$/.test(saison)) { toast('Saison invalide (format AAAA-AAAA)', true); return; } 9517 if (!triPill) { toast('Sélectionne un trimestre', true); return; } 9518 const trimestre = triPill.dataset.value; 9519 9520 let dDeb, dFin; 9521 if (trimestre === 'ANNUEL') { 9522 const [sa1, sa2] = saison.split('-'); 9523 dDeb = `${sa1}-09-01`; dFin = `${sa2}-08-31`; 9524 } else { 9525 [dDeb, dFin] = getTrimestreDates(saison, trimestre); 9526 } 9527 const ms = matchesEntreDate(joueurId, dDeb, dFin); 9528 const compte = TennisDomainRules.statistiquesSportives( 9529 ms, 9530 match => classementADate(joueurId, match.date) 9531 ); 9532 const v = compte.victoires; 9533 const perfs = compte.perfs; 9534 9535 const decisionPill = document.querySelector('#bilanDecisionPills .pill.active'); 9536 const groupePill = document.querySelector('#bilanGroupePills .pill.active'); 9537 9538 const obj = { 9539 joueur_id: joueurId, 9540 saison, 9541 trimestre, 9542 date_debut: dDeb, 9543 date_fin: dFin, 9544 note_performance: parseInt($('bilanNote_performance').value) || null, 9545 note_volume: parseInt($('bilanNote_volume').value) || null, 9546 note_technique: parseInt($('bilanNote_technique').value) || null, 9547 note_physique: parseInt($('bilanNote_physique').value) || null, 9548 note_mental: parseInt($('bilanNote_mental').value) || null, 9549 classement_debut: $('bilanClassDebut').value.trim() || null, 9550 classement_fin: $('bilanClassFin').value.trim() || null, 9551 matches_periode: compte.total, 9552 victoires_periode: v, 9553 perfs_periode: perfs, 9554 commentaire_coach: $('bilanCommentaire').value.trim() || null, 9555 objectifs_suivants: $('bilanObjectifs').value.trim() || null, 9556 decision: decisionPill ? decisionPill.dataset.value : 'a_decider', 9557 groupe_actuel: joueur ? joueur.groupe : null, 9558 groupe_suivant: groupePill && groupePill.dataset.value ? groupePill.dataset.value : null, 9559 statut: statut, 9560 updated_at: new Date().toISOString() 9561 }; 9562 9563 let error; 9564 if (!id) obj.created_by = session.user.id; 9565 const execution = await executerAvecVerrou($('modalBody'), () => id 9566 ? updateConfirmee(db.from('bilans').update(obj).eq('id', id)) 9567 : db.from('bilans').insert(obj).select('id').single(), statut === 'publie' ? 'Publicationâ¦' : 'Enregistrementâ¦'); 9568 if (execution.ignoree) return; 9569 ({ error } = execution.valeur); 9570 9571 if (error) { 9572 if (error.code === '23505') toast('Un bilan existe déjà pour ce joueur/saison/trimestre', true); 9573 else toastErreur(error); 9574 return; 9575 } 9576 closeModal(); 9577 toast(statut === 'publie' ? 'Bilan publié au joueur' : 'Brouillon enregistré'); 9578 9579 // Si c'est une publication (pas un brouillon), envoyer l'email au joueur (+ parent si mineur) 9580 if (statut === 'publie') { 9581 const joueur = joueurById(obj.joueur_id); 9582 if (joueur) { 9583 emailBilanPublie({ ...obj, id: id || execution.valeur?.data?.id || null }, joueur); 9584 } 9585 } 9586 9587 await refresh(); 9588} 9589 9590async function deleteBilan(id) { 9591 if (!(await demanderConfirmation('Supprimer définitivement ce bilan ?'))) return; 9592 const { error } = await db.from('bilans').delete().eq('id', id); 9593 if (error) { toastErreur(error); return; } 9594 closeModal(); 9595 toast('Bilan supprimé'); 9596 await refresh(); 9597} 9598 9599// ============================================================ 9600// EXPORT PDF d'un bilan 9601// ============================================================ 9602// ============================================================ 9603// HELPER PDF â Couleurs + bandeau d'en-tête commun (portrait A4) 9604// ============================================================ 9605// Palette partagée par tous les exports PDF 9606let exportsPdf = null; 9607async function obtenirExportsPdf() { 9608 if (exportsPdf) return exportsPdf; 9609 if (!await chargerVendor('pdfexports') || !window.TennisPdfExports) return null; 9610 const outilsPdf = TennisPdfExports.creerOutils({ window, document }); 9611 exportsPdf = TennisPdfExports.creerExportsApplication({ 9612 cache, 9613 chargerJsPdf, 9614 toast, 9615 getActiveEvenement, 9616 isCoach, 9617 joueursActifsDansEnvCourant, 9618 joursEdition, 9619 libelleJourCourt, 9620 creneauxDuJoueurJour, 9621 resultatCreneau, 9622 joueurById, 9623 bilanPalmyreDuJoueur, 9624 formatDate, 9625 heureEnMinutes, 9626 joueursDuCreneau, 9627 dateLocaleISO, 9628 getPeriodeDates, 9629 getPeriodeLabel, 9630 getSaisonCourante, 9631 getSaisonAnalyse, 9632 getSaisonDates, 9633 creneauCoursById, 9634 presenceDeSeancePourJoueur, 9635 closeModal, 9636 bilanById, 9637 computeAge, 9638 normaliserTelephone, 9639 TennisDomainRules, 9640 TennisFormatters, 9641 document, 9642 outilsPdf, 9643 PDF_COLORS: outilsPdf.couleurs 9644 }); 9645 return exportsPdf; 9646} 9647 9648async function appelerExportPdf(methode, ...args) { 9649 const api = await obtenirExportsPdf(); 9650 if (!api || typeof api[methode] !== 'function') return false;
9651 return api[methode](...args); 9652} 9653 9654// Export PDF de la grille calendaire du planning (paysage A4). 9655async function exportPlanningPDF() { return appelerExportPdf('exportPlanningPDF'); } 9656 9657// Modal de sélection pour l'export des présences (un, plusieurs ou tous les joueurs). 9658function openModalExportPresences() { 9659 if (!isCoach()) return; 9660 const joueurs = joueursActifsDansEnvCourant() 9661 .sort((a, b) => (a.nom || '').localeCompare(b.nom || '')); 9662 if (joueurs.length === 0) { toast('Aucun joueur actif à exporter', true); return; } 9663 9664 const choixJoueurs = joueurs.map(j => `<label class="creneau-player-choice"><input type="checkbox" class="exp-pres-joueur" value="${escapeHtml(j.id)}" checked><span><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong><small>${escapeHtml(j.classement || 'NC')}</small></span></label>`).join(''); 9665 const html = `<div class="modal-header"><h3 class="modal-title">Export présences (PDF)</h3><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 9666 <div class="modal-body"> 9667 <div class="form-row"><div class="form-label">Joueurs</div> 9668 <label class="creneau-player-choice"><input type="checkbox" id="expPresTous" checked data-ui-change="toggleTousJoueursExportPresences(this.checked)"><span><strong>Tous les joueurs actifs</strong><small>Décoche pour choisir individuellement</small></span></label> 9669 <div class="creneau-player-picker">${choixJoueurs}</div> 9670 </div> 9671 <div class="form-row"><label class="form-label">Période</label> 9672 <select id="expPresPeriode" data-ui-change="document.getElementById('expPresPlage').style.display = this.value === 'plage' ? 'flex' : 'none'"> 9673 <option value="saison">Saison complète</option> 9674 <option value="T1">Trimestre 1</option> 9675 <option value="T2">Trimestre 2</option> 9676 <option value="T3">Trimestre 3</option> 9677 <option value="T4">Trimestre 4</option> 9678 <option value="plage">Plage personnaliséeâ¦</option> 9679 </select> 9680 </div> 9681 <div class="form-row" id="expPresPlage" style="display:none; gap:10px"> 9682 <div style="flex:1"><label class="form-label">Du</label><input type="date" id="expPresDu"></div> 9683 <div style="flex:1"><label class="form-label">Au</label><input type="date" id="expPresAu"></div> 9684 </div> 9685 <div class="mt-2"><button class="btn btn-primary btn-full" data-ui-click="exportPresencesPDF()"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">Générer le PDF</button></div> 9686 </div>`; 9687 showModal(html); 9688} 9689 9690function toggleTousJoueursExportPresences(checked) { 9691 document.querySelectorAll('.exp-pres-joueur').forEach(caseJoueur => { caseJoueur.checked = Boolean(checked); }); 9692} 9693 9694// Export PDF des présences d'un joueur sur une période (coach only). 9695async function exportPresencesPDF() { return appelerExportPdf('exportPresencesPDF'); } 9696 9697async function exportBilanPDF(bilanId) { return appelerExportPdf('exportBilanPDF', bilanId); } 9698 9699 9700// ============================================================ 9701// EXPORT PDF â Charte de consentement individuelle (1 joueur) 9702// ============================================================ 9703async function exportChartConsentementPDF(joueurId) { return appelerExportPdf('exportChartConsentementPDF', joueurId); } 9704 9705 9706// ============================================================ 9707// EXPORT PDF â Registre global des consentements (tous les joueurs) 9708// ============================================================ 9709async function exportRegistreConsentementsPDF() { return appelerExportPdf('exportRegistreConsentementsPDF'); } 9710 9711 9712// ============================================================ 9713// EXPORT PDF â Programme de tournois d'un joueur 9714// ============================================================ 9715// Génère un PDF de la prog d'un joueur (tournois groupés par mois), partageable 9716// et imprimable. Accessible côté coach (joueur sélectionné) et côté joueur (sa prog). 9717async function exportProgrammePDF(joueurId) { return appelerExportPdf('exportProgrammePDF', joueurId); } 9718 9719 9720// ============================================================ 9721// SCREEN: MES BADGES (joueur) â gamification 9722// ============================================================ 9723// Rend une carte pour un badge (à palier ou exploit), avec barre de progression. 9724function renderBadgeCard(badge) { 9725 if (badge.type === 'exploit') { 9726 const ok = badge.debloque; 9727 return `<div class="card" style="padding:12px; opacity:${ok ? '1' : '0.55'}; ${ok ? 'border:1px solid var(--success-text,#1d8a5a)' : ''}"> 9728 <div style="display:flex; align-items:center; gap:10px"> 9729 <div>${badgeArtHtml(badge.id, { size: 70, locked: !ok, label: badge.label })}</div> 9730 <div style="flex:1; min-width:0"> 9731 <div style="font-weight:600; font-size:13px">${escapeHtml(badge.label)} ${ok ? '<span style="color:var(--success-text,#1d8a5a)">â</span>' : ''}</div> 9732 <div class="text-faint" style="font-size:11px">${escapeHtml(badge.desc || '')}</div> 9733 </div> 9734 ${badge.detail ? `<div style="font-weight:700; font-size:14px; color:var(--orange-text,#e67e22); white-space:nowrap">${escapeHtml(badge.detail)}</div>` : ''} 9735 </div> 9736 </div>`; 9737 } 9738 // Badge à paliers â barre unique avec les 4 médailles équiréparties. 9739 const ok = badge.niveau > 0; 9740 const nbPaliers = badge.paliers.length; 9741 // Largeur remplie : chaque palier occupe une part égale de la barre. On remplit 9742 // les segments atteints + la fraction de progression dans le segment courant. 9743 const partSegment = 100 / nbPaliers; 9744 let largeur; 9745 if (badge.prochainSeuil == null) { 9746 largeur = 100; // palier max atteint 9747 } else { 9748 largeur = badge.niveau * partSegment + (badge.progression / 100) * partSegment; 9749 } 9750 largeur = Math.max(0, Math.min(100, largeur)); 9751 9752 // Médailles positionnées à la fin de chaque segment (équiréparties). 9753 const medailles = badge.paliers.map((seuil, i) => { 9754 const atteint = badge.niveau >= (i + 1); 9755 const posX = ((i + 1) / nbPaliers) * 100; 9756 return `<div style="position:absolute; left:${posX}%; top:50%; transform:translate(-50%,-50%); text-align:center; z-index:2"> 9757 <div>${rankArtHtml(i, { size: 46, locked: !atteint })}</div> 9758 <div style="font-size:10px; font-weight:600; margin-top:1px; ${atteint ? 'color:var(--orange-text,#e67e22)' : 'color:var(--text-faint,#8a9794)'}">${seuil}${badge.unite}</div> 9759 </div>`; 9760 }).join(''); 9761 9762 const prochainTxt = badge.prochainSeuil != null 9763 ? `Plus que ${Math.max(0, badge.prochainSeuil - badge.valeur)}${badge.unite} avant le palier ${BADGE_PALIERS_NOMS[badge.niveau]}` 9764 : `Palier maximal atteint`; 9765 9766 return `<div class="card" style="padding:14px 14px 10px"> 9767 <div style="display:flex; align-items:baseline; justify-content:space-between; gap:10px; margin-bottom:6px"> 9768 <div style="display:flex;align-items:center;gap:9px;font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:15px">${progressArtHtml(badge.id,{size:46})} ${escapeHtml(badge.label)}</div> 9769 <div style="font-size:24px; font-weight:800; color:var(--orange-text,#e67e22); line-height:1">${badge.valeur}${badge.unite}</div> 9770 </div> 9771 <div class="text-faint" style="font-size:11px; margin-bottom:25px">${ok ? 'Palier actuel : ' + badge.nom_palier : 'Pas encore débloqué'}</div> 9772 <div style="position:relative;height:58px;margin:0 18px"> 9773 <!-- rail --> 9774 <div class="badge-progress-track"></div> 9775 <!-- remplissage --> 9776 <div class="badge-progress-track"><div class="badge-progress-fill" style="width:${largeur}%"></div></div> 9777 ${largeur >
9777 1 ? `<span class="progress-fireball" style="left:${largeur}%">${progressArtHtml('serie',{size:34})}</span>` : ''} 9778 <!-- médailles --> 9779 ${medailles} 9780 </div> 9781 <div class="text-faint" style="font-size:11px; margin-top:2px; text-align:center">${prochainTxt}</div> 9782 </div>`; 9783} 9784 9785// Version légère : un badge vitrine est saisonnier. Un choix conservé sur la fiche 9786// ne doit jamais faire réapparaître dans le groupe un badge gagné l'an dernier. 9787function badgeVitrineSimple(joueurId) { 9788 const j = joueurById(joueurId); 9789 if (!j || !j.badge_vitrine) return ''; 9790 const def = BADGES_CATALOGUE.find(b => b.id === j.badge_vitrine && b.piste === 'Insolites'); 9791 if (!def) return ''; 9792 const obtenuCetteSaison = badgesDuJoueurPourSaison(joueurId, getSaisonCourante()) 9793 .some(badge => badge.badge_id === j.badge_vitrine); 9794 if (!obtenuCetteSaison) return ''; 9795 return `<span title="${escapeHtml(def.label)}" style="display:inline-flex">${badgeArtHtml(def.id, { size: 28, label: def.label })}</span>`; 9796} 9797 9798// Renvoie le petit badge HTML "vitrine" d'un joueur (insolite choisi), ou '' si aucun 9799// ou s'il n'est plus débloqué. Utilisé sur la fiche perso et la fiche vue par le coach. 9800function badgeVitrineHtml(j) { 9801 if (!j || !j.badge_vitrine) return ''; 9802 const def = BADGES_CATALOGUE.find(b => b.id === j.badge_vitrine && b.piste === 'Insolites'); 9803 if (!def) return ''; 9804 // Vérifier que le badge est toujours débloqué (sinon on ne l'affiche pas) 9805 const badges = calculerBadges(j.id, getSaisonCourante()); 9806 const b = badges.find(x => x.id === j.badge_vitrine && x.debloque); 9807 if (!b) return ''; 9808 return `<span class="profile-showcase-badge" title="${escapeHtml(def.desc || '')}">${badgeArtHtml(def.id, { size: 42 })}<span>${escapeHtml(def.label)}</span></span>`; 9809} 9810 9811// Fil d'actualité : derniers badges débloqués par les AUTRES joueurs de l'académie. 9812// Une ligne par joueur, avec ses médailles en icônes (compact). Saison en cours. 9813function toggleFilBadgesGroupe(groupe) { 9814 const ouvert = etatApp.basculerGroupeBadges(groupe); 9815 const corps = document.getElementById('filGrp_' + groupe); 9816 const chevron = document.getElementById('filGrpChevron_' + groupe); 9817 if (corps) corps.style.display = ouvert ? 'grid' : 'none'; 9818 if (chevron) chevron.textContent = ouvert ? 'â²' : 'â¼'; 9819} 9820 9821function renderFilBadges() { 9822 const saison = getSaisonCourante(); 9823 const monJoueurId = profile && profile.joueur_id; 9824 // Badges de la saison, hors joueur connecté (on ne se voit pas soi-même) 9825 const entries = (listeSure(cache.badgesJoueurs)).filter(x => x.saison === saison && x.joueur_id !== monJoueurId); 9826 if (entries.length === 0) return ''; 9827 9828 // Regrouper par joueur 9829 const parJoueur = {}; 9830 entries.forEach(e => { 9831 if (!parJoueur[e.joueur_id]) parJoueur[e.joueur_id] = { joueur_id: e.joueur_id, items: [], dernier: e.obtenu_le || '' }; 9832 parJoueur[e.joueur_id].items.push(e); 9833 if ((e.obtenu_le || '') > parJoueur[e.joueur_id].dernier) parJoueur[e.joueur_id].dernier = e.obtenu_le || ''; 9834 }); 9835 9836 const annu = cache.annuaire || {}; 9837 const annuGrp = cache.annuaireGroupe || {}; 9838 9839 // Construire la ligne (nom + icônes) pour un joueur donné 9840 const construireLigneJoueur = (ligne) => { 9841 let nom = annu[ligne.joueur_id]; 9842 if (!nom) { 9843 const jou = joueurById(ligne.joueur_id); 9844 nom = jou ? `${jou.prenom}${jou.nom ? ' ' + jou.nom.charAt(0).toUpperCase() + '.' : ''}` : 'Un joueur'; 9845 } 9846 const meilleurPalier = {}; 9847 const exploits = []; 9848 ligne.items.forEach(it => { 9849 const def = BADGES_CATALOGUE.find(b => b.id === it.badge_id); 9850 if (!def) return; 9851 if (it.palier) { 9852 if (!meilleurPalier[it.badge_id] || it.palier > meilleurPalier[it.badge_id].palier) meilleurPalier[it.badge_id] = { palier: it.palier, def }; 9853 } else exploits.push(def); 9854 }); 9855 const iconesHtml = []; 9856 exploits.forEach(def => iconesHtml.push(`<span title="${escapeHtml(def.label + (def.desc ? ' â ' + def.desc : ''))}" style="cursor:help;display:inline-flex">${badgeArtHtml(def.id, { size: 36 })}</span>`)); 9857 Object.values(meilleurPalier).forEach(({ palier, def }) => { 9858 const nomP = BADGE_PALIERS_NOMS[palier - 1] || ''; 9859 iconesHtml.push(`<span title="${escapeHtml(def.label + ' â ' + nomP)}" style="cursor:help;display:inline-flex">${rankArtHtml(palier - 1, { size: 34 })}</span>`); 9860 }); 9861 const icones = iconesHtml.slice(0, 8); 9862 if (icones.length === 0) return ''; 9863 return `<div class="card" style="padding:8px 12px; display:flex; align-items:center; gap:10px"> 9864 <div style="flex:1; min-width:0; font-size:13px"><strong>${escapeHtml(nom)}</strong></div> 9865 <div style="font-size:20px; letter-spacing:2px; white-space:nowrap">${icones.join('')}</div> 9866 </div>`; 9867 }; 9868 9869 // Regrouper les joueurs par groupe (via l'annuaire des groupes) 9870 const joueursParGroupe = {}; 9871 Object.values(parJoueur).forEach(ligne => { 9872 const grp = annuGrp[ligne.joueur_id] || (joueurById(ligne.joueur_id) || {}).groupe || 'autres'; 9873 (joueursParGroupe[grp] = joueursParGroupe[grp] || []).push(ligne); 9874 }); 9875 9876 // Compter les médailles par groupe (nombre d'entrées badges) 9877 const medaillesParGroupe = {};
9878 entries.forEach(e => { 9879 const grp = annuGrp[e.joueur_id] || (joueurById(e.joueur_id) || {}).groupe || 'autres'; 9880 medaillesParGroupe[grp] = (medaillesParGroupe[grp] || 0) + 1; 9881 }); 9882 9883 // Ordre des groupes (compét d'abord, puis autres) 9884 const groupesPresents = Object.keys(joueursParGroupe); 9885 const ordonnes = [...ORDRE_GROUPES.filter(g => groupesPresents.includes(g)), 9886 ...groupesPresents.filter(g => !ORDRE_GROUPES.includes(g))]; 9887 9888 let html = `<div class="section-title" style="display:flex;align-items:center;gap:9px">${rankArtHtml(2,{size:36})}Dernières médailles de l'académie</div>`; 9889 html += `<div style="display:grid; grid-template-columns:1fr; gap:6px; margin-bottom:14px">`; 9890 9891 ordonnes.forEach(grp => { 9892 const lignes = joueursParGroupe[grp].sort((a, b) => String(b.dernier).localeCompare(String(a.dernier))); 9893 const nomGroupe = (cache.config[grp] && cache.config[grp].nom) || (grp === 'autres' ? 'Autres' : grp); 9894 const nbMed = medaillesParGroupe[grp] || 0; 9895 const ouvert = etatApp.groupeBadgesDeplie(grp); 9896 9897 // En-tête de groupe repliable 9898 html += `<div class="card" style="padding:10px 12px; cursor:pointer; background:var(--bg-section)" data-ui-click="toggleFilBadgesGroupe(${escapeHtml(encoderArgumentTexteUI(grp))})"> 9899 <div style="display:flex; align-items:center; justify-content:space-between; gap:10px"> 9900 <div style="font-weight:700; font-size:13px">${escapeHtml(nomGroupe)}</div> 9901 <div style="display:flex; align-items:center; gap:8px"> 9902 <span class="badge" style="background:var(--orange-text,#e67e22); color:#fff; font-size:11px">${nbMed} médaille${nbMed > 1 ? 's' : ''}</span> 9903 <span id="filGrpChevron_${grp}" style="font-size:11px; color:var(--text-faint)">${ouvert ? 'â²' : 'â¼'}</span> 9904 </div> 9905 </div> 9906 </div>`; 9907 // Corps du groupe (joueurs) 9908 html += `<div id="filGrp_${grp}" style="display:${ouvert ? 'grid' : 'none'}; grid-template-columns:1fr; gap:6px; margin-left:8px">`; 9909 lignes.forEach(ligne => { html += construireLigneJoueur(ligne); }); 9910 html += `</div>`; 9911 }); 9912 9913 html += `</div>`; 9914 return html; 9915} 9916 9917function renderMesBadges() { 9918 if (!profile || !profile.joueur_id) { 9919 return `<div class="empty"><span class="icon"><img src="assets/v4/nav/badges.webp" alt="" style="width:62px;height:62px;object-fit:contain"></span>Les badges sont réservés aux joueurs.</div>`; 9920 } 9921 const j = joueurById(profile.joueur_id); 9922 const saison = getSaisonCourante(); 9923 const badges = calculerBadges(profile.joueur_id, saison); 9924 9925 // Compteur de badges débloqués (paliers atteints + exploits) 9926 const nbDebloques = badges.filter(b => b.debloque).length; 9927 const nbTotal = badges.length; 9928 9929 // Grouper par piste 9930 const pistes = ['Assiduité', 'Compétiteur', 'Performance', 'Objectif', 'Insolites']; 9931 9932 let html = renderUxScreenHeading('Mes défis', 'Mes badges') + `<div class="screen-header"><h2 class="screen-title" style="display:flex;align-items:center;gap:10px"><img class="modal-title-art" src="assets/v4/nav/badges.webp" alt="">Mes badges</h2> 9933 <div class="text-faint" style="font-size:13px">Saison ${escapeHtml(saison)} · ${nbDebloques}/${nbTotal} débloqués</div></div>`; 9934 9935 // Badge vitrine : insolites débloqués, choix mis en avant sur le profil 9936 const insolitesDebloques = badges.filter(b => b.piste === 'Insolites' && b.debloque); 9937 const vitrineActuelle = j && j.badge_vitrine ? badges.find(b => b.id === j.badge_vitrine && b.debloque) : null; 9938 html += `<div class="card" style="padding:12px; margin-bottom:14px; background:var(--accent-bg); border-color:color-mix(in srgb,var(--accent-text) 28%,transparent)"> 9939 <div style="font-weight:600; font-size:13px; margin-bottom:6px"><img class="inline-art-icon" src="assets/v4/nav/badges.webp" alt=""> Mon badge mis en avant sur le profil</div>`; 9940 if (insolitesDebloques.length === 0) { 9941 html += `<div class="text-faint" style="font-size:12px">Débloque un badge insolite pour pouvoir l'afficher fièrement sur ton profil !</div>`; 9942 } else { 9943 html += `<div class="text-faint" style="font-size:12px; margin-bottom:8px">${vitrineActuelle ? `Actuellement : <strong style="display:inline-flex;align-items:center;gap:4px">${badgeArtHtml(vitrineActuelle.id, { size: 30 })}${escapeHtml(vitrineActuelle.label)}</strong>` : 'Aucun badge sélectionné'}</div>`; 9944 html += `<div style="display:flex; flex-wrap:wrap; gap:6px">`;
9945 insolitesDebloques.forEach(b => { 9946 const actif = j && j.badge_vitrine === b.id; 9947 html += `<button class="btn btn-sm" data-ui-click="setBadgeVitrine(${escapeHtml(encoderArgumentTexteUI(b.id))})" style="font-size:12px; ${actif ? 'background:var(--orange-text,#e67e22); color:white' : ''}">${badgeArtHtml(b.id, { size: 34 })}${escapeHtml(b.label)}${actif ? ' â' : ''}</button>`; 9948 }); 9949 if (vitrineActuelle) { 9950 html += `<button class="btn btn-sm" data-ui-click="setBadgeVitrine('')" style="font-size:12px">Retirer</button>`; 9951 } 9952 html += `</div>`; 9953 } 9954 html += `</div>`; 9955 9956 // Fil d'actualité de l'académie : derniers badges débloqués par tous les joueurs 9957 html += renderFilBadges(); 9958 9959 // Couleur de bandeau par piste 9960 const pisteCouleur = { 9961 'Assiduité': '#2e86de', 'Compétiteur': '#e67e22', 'Performance': '#8e44ad', 9962 'Objectif': '#16a085', 'Insolites': '#c0392b' 9963 }; 9964 9965 pistes.forEach(piste => { 9966 const lot = badges.filter(b => b.piste === piste); 9967 if (lot.length === 0) return; 9968 const coul = pisteCouleur[piste] || '#555'; 9969 // Bandeau coloré de rubrique 9970 html += `<div class="badge-piste-title" style="--piste-color:${coul}"> 9971 <img src="assets/v4/nav/${PISTE_ART_ASSETS[piste] || 'badges'}.webp" alt=""> 9972 <span style="font-weight:700; font-size:15px; letter-spacing:0.3px">${piste}</span> 9973 </div>`; 9974 9975 if (piste === 'Insolites') { 9976 // Tuiles 3 par ligne, grosse icône 9977 html += `<div class="insolite-grid">`; 9978 lot.forEach(b => { html += renderInsoliteTuile(b); }); 9979 html += `</div>`; 9980 } else { 9981 html += `<div style="display:grid; grid-template-columns:1fr; gap:8px; margin-bottom:6px">`; 9982 lot.forEach(b => { html += renderBadgeCard(b); }); 9983 html += `</div>`; 9984 } 9985 }); 9986 9987 html += `<div class="text-faint" style="font-size:11px; margin-top:8px; text-align:center">Les compteurs portent sur la saison en cours et se mettent à jour automatiquement.</div>`; 9988 return html; 9989} 9990 9991// Rareté d'un badge insolite parmi les joueurs visibles, sur la saison active. 9992function raretebadge(badgeId) { 9993 const totalJoueurs = Object.keys(cache.annuaire || {}).length; 9994 if (totalJoueurs < 5) return null; // pas significatif sur un trop petit effectif 9995 const saison = getSaisonCourante(); 9996 const possesseurs = new Set( 9997 (listeSure(cache.badgesJoueurs)) 9998 .filter(x => x.badge_id === badgeId && x.saison === saison) 9999 .map(x => x.joueur_id) 10000 ); 10001 const pct = Math.round((possesseurs.size / totalJoueurs) * 100); 10002 let mention, couleur; 10003 if (possesseurs.size === 0) { mention = 'Inédit'; couleur = '#b8860b'; } 10004 else if (pct <= 5) { mention = 'Légendaire'; couleur = '#b8860b'; } 10005 else if (pct <= 15) { mention = 'Très rare'; couleur = '#7F77DD'; } 10006 else if (pct <= 35) { mention = 'Rare'; couleur = '#378ADD'; } 10007 else if (pct <= 60) { mention = 'Peu commun'; couleur = '#1D9E75'; } 10008 else { mention = 'Commun'; couleur = '#888'; } 10009 return { pct, mention, couleur, possesseurs: possesseurs.size, total: totalJoueurs }; 10010} 10011 10012function renderInsoliteTuile(b) { 10013 const ok = b.debloque; 10014 const r = raretebadge(b.id); 10015 const rareteHtml = r 10016 ? `<div style="font-size:10px; font-weight:700; margin-top:4px; color:${r.couleur}">${r.mention}${r.possesseurs > 0 ? ' · ' + r.pct + '%' : ''}</div>` 10017 : ''; 10018 return `<div class="card insolite-card ${ok ? 'unlocked' : ''}" style="text-align:center"> 10019 ${badgeArtHtml(b.id, { size: 88, locked: !ok, label: b.label })} 10020 <div style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:15px;margin-top:3px;line-height:1.1">${escapeHtml(b.label)}</div> 10021 <div class="text-faint" style="font-size:10px; margin-top:5px; line-height:1.25">${escapeHtml(b.desc || '')}</div> 10022 ${rareteHtml} 10023 ${ok ? '<div style="color:var(--success-text,#1d8a5a);font-size:11px;font-weight:800;margin-top:7px;text-transform:uppercase;letter-spacing:.7px">â débloqué</div>' : ''} 10024 </div>`; 10025} 10026 10027// Enregistre le badge insolite que le joueur met en avant sur son profil. 10028// Sécurité : on vérifie que le badge est bien un insolite RÃELLEMENT débloqué. 10029async function setBadgeVitrine(badgeId) { 10030 if (!profile || !profile.joueur_id) return; 10031 if (badgeId) { 10032 const badges = calculerBadges(profile.joueur_id, getSaisonCourante()); 10033 const b = badges.find(x => x.id === badgeId && x.piste === 'Insolites' && x.debloque); 10034 if (!b) { toast('Ce badge n\'est pas (encore) débloqué', true); return; } 10035 } 10036 const { error } = await updateConfirmee(db.from('joueurs').update({ badge_vitrine: badgeId || null }).eq('id', profile.joueur_id)); 10037 if (error) { toastErreur(error); return; } 10038 // Mettre à jour le cache local pour un retour immédiat 10039 const jl = joueurById(profile.joueur_id); 10040 if (jl) jl.badge_vitrine = badgeId || null; 10041 toast(badgeId ? 'Badge mis en avant sur ton profil â¨' : 'Badge retiré du profil'); 10042 render(); 10043} 10044 10045// ============================================================ 10046// SCREEN: MES BILANS (joueur) 10047// ============================================================ 10048function libelleArretBilan(bilan) { 10049 const date = String(bilan && (bilan.updated_at || bilan.created_at) || '').slice(0, 10); 10050 return date ? `Chiffres arrêtés au ${formatDate(date)}` : 'Instantané établi lors de la publication'; 10051} 10052 10053function renderMyBilans() { 10054 if (!profile || !profile.joueur_id) { 10055 return `<div class="empty">Profil joueur non trouvé.</div>`; 10056 } 10057 const mesBilans = bilansDuJoueur(profile.joueur_id); 10058 10059 // Bilans Palmyre (publiés) â RLS ne renvoie que ceux du joueur connecté. 10060 const mesBilansPalmyre = bilansPalmyreDuJoueur(profile.joueur_id).filter(b => b.statut === 'publie'); 10061 10062 if (mesBilans.length === 0 && mesBilansPalmyre.length === 0) { 10063 return `<div class="empty"><span class="icon">ð</span>Aucun bilan disponible.<br><span class="text-faint">Ton coach n'a pas encore publié de bilan pour toi.</span></div>`; 10064 } 10065 10066 let html = renderUxScreenHeading('Retour du coach', 'Mes bilans'); 10067 10068 // --- Bilans Palmyre (en premier, mis en avant) --- 10069 if (mesBilansPalmyre.length > 0) { 10070 html += `<div class="section-title">ðï¸ Bilans Palmyre</div>`; 10071 mesBilansPalmyre.forEach(b => { 10072 const ev = (listeSure(cache.evenements)).find(e => e.id === b.evenement_id) 10073 || (listeSure(cache.evenementsTous)).find(e => e.id === b.evenement_id); 10074 const nomEd = ev ? ev.nom : 'Palmyre'; 10075 html += `<div class="card" data-ui-click="openMyBilanPalmyreDetail(${escapeHtml(encoderArgumentTexteUI(b.id))})" style="cursor:pointer; border-left:3px solid var(--orange)"> 10076 <div class="row-between mb-1"> 10077 <strong>ðï¸ ${escapeHtml(nomEd)}</strong>
10078 <span class="badge badge-orange" style="background:var(--orange); color:#fff">Stage</span> 10079 </div> 10080 <div class="text-dim" style="font-size:13px"> 10081 ${b.matches_total || 0} matchs · ${b.victoires || 0} V · ${b.defaites || 0} D${(b.perfs || 0) > 0 ? ` · ${b.perfs} perf${b.perfs > 1 ? 's' : ''}` : ''} 10082 </div> 10083 <div class="text-faint" style="font-size:11px; margin-top:4px">${libelleArretBilan(b)}</div> 10084 <div class="text-faint" style="font-size:11px; margin-top:6px">Touche pour consulter â</div> 10085 </div>`; 10086 }); 10087 } 10088 10089 // --- Bilans trimestriels (académie) --- 10090 if (mesBilans.length > 0) { 10091 const parSaison = {}; 10092 mesBilans.forEach(b => { 10093 if (!parSaison[b.saison]) parSaison[b.saison] = []; 10094 parSaison[b.saison].push(b); 10095 }); 10096 Object.keys(parSaison).sort().reverse().forEach(saison => { 10097 html += `<div class="section-title">Saison ${saison}</div>`; 10098 parSaison[saison].sort((a, b) => a.trimestre.localeCompare(b.trimestre)).forEach(b => { 10099 const statutLabel = { publie: 'ð¤ à consulter', commente: 'ð¬ Commenté', accepte: 'â Accepté' }[b.statut] || b.statut; 10100 const statutColor = b.statut === 'accepte' ? 'success' : b.statut === 'commente' ? 'info' : 'warning'; 10101 html += `<div class="card" data-ui-click="openMyBilanDetail(${escapeHtml(encoderArgumentTexteUI(b.id))})" style="cursor:pointer"> 10102 <div class="row-between mb-1"> 10103 <strong>${getTrimestreLabel(b.trimestre)}</strong> 10104 <span class="badge badge-${statutColor}">${statutLabel}</span> 10105 </div> 10106 <div class="text-dim" style="font-size:13px"> 10107 ${b.matches_periode} matches · ${b.victoires_periode} V · ${b.perfs_periode} perfs 10108 ${b.classement_debut && b.classement_fin ? ` · ${escapeHtml(b.classement_debut)} â ${escapeHtml(b.classement_fin)}` : ''} 10109 </div> 10110 <div class="text-faint" style="font-size:11px; margin-top:4px">${libelleArretBilan(b)}</div> 10111 <div class="text-faint" style="font-size:11px; margin-top:6px">Touche pour consulter â</div> 10112 </div>`; 10113 }); 10114 }); 10115 } 10116 10117 return html; 10118} 10119 10120// Détail d'un bilan Palmyre côté joueur (lecture seule). 10121function openMyBilanPalmyreDetail(bilanId) { 10122 const b = (listeSure(cache.bilansEvenement)).find(x => x.id === bilanId); 10123 if (!b) { toast('Bilan introuvable', true); return; } 10124 const ev = (listeSure(cache.evenements)).find(e => e.id === b.evenement_id) 10125 || (listeSure(cache.evenementsTous)).find(e => e.id === b.evenement_id); 10126 const nomEd = ev ? ev.nom : 'Palmyre'; 10127 10128 const axes = [ 10129 ['note_technique', 'Technique / tactique'], 10130 ['note_physique', 'Physique'], 10131 ['note_mental', 'Mental'], 10132 ['note_performance', 'Performance'], 10133 ['note_savoir_etre', 'Savoir-être / vivre'] 10134 ]; 10135 10136 let html = `<div class="modal-header"> 10137 <h3 class="modal-title">ðï¸ Mon bilan ${escapeHtml(nomEd)}</h3> 10138 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 10139 </div> 10140 <div class="card mb-2" style="font-size:13px"> 10141 <div class="grid-2"> 10142 <div><strong>Matches :</strong> ${b.matches_total || 0}</div> 10143 <div><strong>Victoires :</strong> ${b.victoires || 0}</div> 10144 <div><strong>Défaites :</strong> ${b.defaites || 0}</div> 10145 <div><strong>Perfs :</strong> ${b.perfs || 0}</div> 10146 </div> 10147 <div class="text-faint" style="font-size:11px; margin-top:8px">${libelleArretBilan(b)}</div> 10148 </div> 10149 <div class="section-title">Ãvaluation</div>`; 10150 10151 axes.forEach(([name, label]) => { 10152 const val = b[name]; 10153 const pct = val ? (val / 10) * 100 : 0; 10154 const color = val >= 7 ? 'var(--success)' : val >= 5 ? 'var(--orange)' : 'var(--danger)'; 10155 html += `<div style="margin-bottom:10px"> 10156 <div class="row-between" style="font-size:13px"><span>${label}</span><strong>${val != null ? val : '-'}/10</strong></div> 10157 <div class="progress-bar" style="margin-top:4px"><div class="progress-fill" style="width:${pct}%; background:${color}"></div></div> 10158 </div>`; 10159 }); 10160 10161 if (b.commentaire_coach) { 10162 html += `<div class="section-title">Commentaire du coach</div> 10163 <div class="card" style="font-size:13px; white-space:pre-wrap">${escapeHtml(b.commentaire_coach)}</div>`; 10164 } 10165 10166 html += `<div class="flex gap-1 mt-2"><button type="button" class="btn btn-full" data-ui-click="closeModal()">Fermer</button></div>`; 10167 showModal(html); 10168} 10169 10170// Vue détaillée d'un bilan côté joueur
10171function openMyBilanDetail(bilanId) { 10172 const b = bilanById(bilanId); 10173 if (!b) return; 10174 10175 const notes = [ 10176 ['Performance sportive', b.note_performance], 10177 ['Volume / engagement', b.note_volume], 10178 ['Technique / Tactique', b.note_technique], 10179 ['Physique', b.note_physique], 10180 ['Mental / attitude', b.note_mental] 10181 ]; 10182 10183 const decisionLabels = { 10184 a_decider: { txt: 'à décider', cls: '' }, 10185 maintien: { txt: 'Maintien dans le groupe', cls: 'success' }, 10186 montee: { txt: 'Montée de groupe ð', cls: 'success' }, 10187 descente: { txt: 'Descente de groupe', cls: 'danger' }, 10188 sortie: { txt: 'Sortie de l\'académie', cls: 'danger' } 10189 }; 10190 const dec = decisionLabels[b.decision] || decisionLabels.a_decider; 10191 10192 let html = ` 10193 <div class="modal-header"> 10194 <h3 class="modal-title">Bilan ${escapeHtml(b.saison)} · ${getTrimestreLabel(b.trimestre)}</h3> 10195 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 10196 </div> 10197 10198 <div class="section-title" style="margin-top:0">Synthèse de la période</div> 10199 <div class="stat-grid"> 10200 <div class="stat-card"><div class="stat-label">Matches</div><div class="stat-value">${b.matches_periode}</div></div> 10201 <div class="stat-card"><div class="stat-label">Victoires</div><div class="stat-value success">${b.victoires_periode}</div></div> 10202 <div class="stat-card"><div class="stat-label">Perfs</div><div class="stat-value orange">${b.perfs_periode}</div></div> 10203 ${b.classement_debut || b.classement_fin ? `<div class="stat-card"><div class="stat-label">Classement</div><div class="stat-value" style="font-size:18px">${escapeHtml(b.classement_debut || '?')}<br><span style="font-size:12px; color:var(--text-faint)">â</span><br>${escapeHtml(b.classement_fin || '?')}</div></div>` : ''} 10204 </div> 10205 10206 <div class="section-title">Ãvaluation du coach</div> 10207 ${notes.map(([label, val]) => ` 10208 <div class="eval-row" style="margin-bottom:12px"> 10209 <div style="display:flex; justify-content:space-between; font-size:13px; margin-bottom:4px"> 10210 <span>${label}</span> 10211 <span style="font-weight:700; color:var(--orange-text)">${val !== null && val !== undefined ? val + '/10' : '-'}</span> 10212 </div> 10213 <div class="progress-bar"><div class="progress-fill ${val < 5 ? 'danger' : val < 7 ? 'warning' : ''}" style="width:${(val || 0) * 10}%"></div></div> 10214 </div> 10215 `).join('')} 10216 10217 ${b.commentaire_coach ? ` 10218 <div class="section-title">Commentaire du coach</div> 10219 <div class="card" style="font-size:13px; line-height:1.6; white-space:pre-wrap">${escapeHtml(b.commentaire_coach)}</div> 10220 ` : ''} 10221 10222 ${b.objectifs_suivants ? ` 10223 <div class="section-title">Objectifs trimestre suivant</div> 10224 <div class="card" style="font-size:13px; line-height:1.6; white-space:pre-wrap; border-left:3px solid var(--orange)">${escapeHtml(b.objectifs_suivants)}</div> 10225 ` : ''} 10226 10227 <div class="section-title">Décision</div> 10228 <div class="alert ${dec.cls || 'info'}" style="margin-bottom:0"> 10229 <span class="icon">${b.decision === 'montee' ? 'â' : b.decision === 'descente' ? 'â' : b.decision === 'sortie' ? 'â' : 'â'}</span> 10230 <div class="alert-content"><strong>${dec.txt}</strong></div> 10231 </div> 10232 10233 <div class="section-title">Mon retour</div> 10234 <div class="form-row"> 10235 <label class="form-label">Commentaire (optionnel)</label> 10236 <textarea id="commentaireJoueur" rows="3" placeholder="Tu peux noter tes propres ressentis, questions, ou réactions sur ce bilan...">${escapeHtml(b.commentaire_joueur || '')}</textarea> 10237 </div> 10238 10239 ${b.statut === 'accepte' ? ` 10240 <div class="alert success" style="margin-bottom:16px"> 10241 <span class="icon">â</span> 10242 <div class="alert-content">Tu as accepté ce bilan le ${formatDate(b.date_acceptation)}.<br><span style="font-size:11px">Ce bilan est désormais archivé en lecture seule.</span></div> 10243 </div> 10244 ` : ''} 10245 10246 <div class="flex gap-1 mt-2"> 10247 <button class="btn flex-1" data-ui-click="closeModal()">Fermer</button> 10248 <button class="btn" data-ui-click="exportBilanPDF(${escapeHtml(encoderArgumentTexteUI(b.id))})"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">PDF</button> 10249 ${b.statut !== 'accepte' ? `<button class="btn" data-ui-click="saveMyBilanComment(${escapeHtml(encoderArgumentTexteUI(b.id))}, false)"><img class="btn-action-art" src="assets/v4/nav/observations.webp" alt="">Enregistrer commentaire</button>` : ''} 10250 ${b.statut !== 'accepte' ? `<button class="btn btn-primary" data-ui-click="saveMyBilanComment(${escapeHtml(encoderArgumentTexteUI(b.id))}, true)">â J'ai pris connaissance et j'accepte</button>` : ''} 10251 </div> 10252 `; 10253 10254 showModal(html); 10255} 10256 10257async function saveMyBilanComment(bilanId, accepter) { 10258 const commentaire = $('commentaireJoueur').value.trim(); 10259 const bilan = bilanById(bilanId); 10260 if (!bilan) return; 10261 10262 const update = { 10263 commentaire_joueur: commentaire || null, 10264 updated_at: new Date().toISOString() 10265 }; 10266 10267 if (accepter) { 10268 update.statut = 'accepte'; 10269 update.date_acceptation = new Date().toISOString(); 10270 } else if (commentaire && bilan.statut === 'publie') { 10271 update.statut = 'commente'; 10272 } 10273 10274 const { error } = await updateConfirmee(db.from('bilans').update(update).eq('id', bilanId)); 10275 if (error) { toastErreur(error); return; } 10276 closeModal(); 10277 toast(accepter ? 'Bilan accepté' : 'Commentaire enregistré'); 10278 await refresh(); 10279} 10280 10281// ============================================================ 10282// SCREEN: MY (vue joueur) 10283// ============================================================ 10284// Bloc visuel niveau + barre d'XP pour la fiche du joueur. 10285function renderNiveauXP(joueurId) { 10286 const { xp } = calculerXP(joueurId); 10287 const n = niveauDepuisXP(xp); 10288 const pct = n.progression; 10289 const sousTexte = n.max 10290 ? 'Niveau maximum atteint ð' 10291 : `${n.restant} XP avant <strong>${escapeHtml(n.suivant.titre)}</strong>`; 10292 return ` 10293 <div class="xp-card" data-ui-click="openNiveauxModal()" style="padding:16px; margin-bottom:12px; cursor:pointer; border-radius:0; position:relative; overflow:visible; 10294 background:linear-gradient(135deg, #2a1d3a 0%, #1a1424 55%, #241a12 100%); border:1px solid rgba(255,210,63,0.25)"> 10295 <div class="xp-card-glow"></div> 10296 <div class="xp-card-main" style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; position:relative; z-index:2"> 10297 <div style="display:flex; align-items:center; gap:12px"> 10298 <div class="xp-niveau-pastille" style="flex-shrink:0">${levelArtHtml(n.niveau,{size:72})}</div> 10299 <div> 10300 <div style="font-size:10px; font-weight:800; letter-spacing:1.5px; text-transform:uppercase; color:#FFD23F">Niveau ${n.niveau}</div> 10301 <div style="font-size:16px; font-weight:900; color:#fff">${escapeHtml(n.titre)}</div> 10302 </div> 10303 </div> 10304 <div style="text-align:right"> 10305 <div style="font-size:18px; font-weight:900; color:#FFD23F">
10305${xp.toLocaleString('fr-FR')}</div> 10306 <div style="font-size:10px; color:#b0a8c8">XP saison</div> 10307 </div> 10308 </div> 10309 <div class="xp-track" style="height:10px; background:rgba(255,255,255,0.08); border-radius:0; overflow:visible; position:relative; z-index:2"> 10310 <div class="xp-fill" style="height:100%; width:${pct}%; border-radius:0; position:relative; overflow:hidden; 10311 background:linear-gradient(90deg, #F39200, #FFD23F); box-shadow:0 0 10px rgba(255,210,63,0.5); transition:width 0.9s cubic-bezier(0.22,1,0.36,1)"> 10312 <div class="xp-shimmer"></div> 10313 </div> 10314 ${pct > 1 ? `<span class="progress-fireball" style="left:${pct}%">${progressArtHtml('serie',{size:38})}</span>` : ''} 10315 </div> 10316 <div style="font-size:11px; margin-top:8px; text-align:center; color:#b0a8c8; position:relative; z-index:2">${sousTexte} · <span style="color:#FFD23F; text-decoration:underline">voir les niveaux</span></div> 10317 </div>`; 10318} 10319 10320// Modale : grille complète des niveaux + barème d'XP. 10321function openNiveauxModal() { 10322 const joueurId = profile && profile.joueur_id; 10323 if (!joueurId) return; 10324 const xp = calculerXP(joueurId).xp; 10325 const n = niveauDepuisXP(xp); 10326 10327 let lignes = ''; 10328 NIVEAUX.forEach(niv => { 10329 const atteint = niv.niveau <= n.niveau; 10330 const actuel = niv.niveau === n.niveau; 10331 lignes += `<div class="level-row ${actuel ? 'current' : ''} ${atteint ? '' : 'locked'}"> 10332 <div>${levelArtHtml(niv.niveau,{size:50})}</div> 10333 <div style="flex:1; min-width:0"> 10334 <div style="font-family:'Bricolage Grotesque',sans-serif;font-size:14px;font-weight:${actuel ? '900' : '700'};color:${atteint ? 'var(--text)' : 'var(--text-dim)'}">${escapeHtml(niv.titre)}${actuel ? ' · toi' : ''}</div> 10335 <div class="text-faint" style="font-size:10px">${niv.seuil.toLocaleString('fr-FR')} XP</div> 10336 </div> 10337 ${atteint ? sportUiIcon('check') : sportUiIcon('lock')} 10338 </div>`; 10339 }); 10340 10341 const bareme = [ 10342 ['seances', 'Déclarer une séance libre', '+10 XP'], 10343 ['check', 'Répondre pour une présence', '+5 XP par séance'], 10344 ['matchs', 'Jouer un match', '+20 XP'], 10345 ['victoires', 'Gagner un match', '+15 XP'], 10346 ['perfs', 'Belle perf (par échelon d\'écart)', '+20 XP · plafond 12 échelons'], 10347 ['objectif_groupe', 'Débloquer un badge insolite', '+50 XP'], 10348 ['serie', 'Palier bronze / argent / or / diamant', '+40 â +300 XP'] 10349 ]; 10350 let baremeHtml = ''; 10351 bareme.forEach(([ic, label, val]) => { 10352 baremeHtml += `<div style="display:flex; align-items:center; gap:8px; padding:6px 0; font-size:13px; border-bottom:1px solid var(--border)"> 10353 ${progressArtHtml(ic,{size:38})} 10354 <span style="flex:1">${escapeHtml(label)}</span> 10355 <span style="font-weight:700; color:var(--orange-text); white-space:nowrap">${escapeHtml(val)}</span> 10356 </div>`; 10357 }); 10358 10359 const html = `<div class="modal-header"> 10360 <h3 class="modal-title">${sportUiIcon('trend')} Niveaux & XP</h3> 10361 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 10362 </div> 10363 <div class="modal-body"> 10364 <div class="text-faint" style="font-size:12px; margin-bottom:12px; text-align:center"> 10365 Tu es <strong style="color:var(--orange-text)">Niveau ${n.niveau} · ${escapeHtml(n.titre)}</strong> avec <strong>${xp.toLocaleString('fr-FR')} XP</strong> cette saison. 10366 ${n.max ? 'Tu es au sommet ð' : `Plus que ${n.restant} XP pour ${escapeHtml(n.suivant.titre)}.`} 10367 </div> 10368 <div class="section-title">${sportUiIcon('trend')} Les niveaux</div> 10369 ${lignes} 10370 <div class="section-title" style="margin-top:18px">${progressArtHtml('serie',{size:34})} Comment gagner de l'XP</div> 10371 ${baremeHtml} 10372 <div class="text-faint" style="font-size:11px; margin-top:12px; text-align:center; line-height:1.4"> 10373 L'XP récompense ton activité sur le court. Elle est remise à zéro à chaque nouvelle saison. 10374 </div> 10375 </div>`; 10376 showModal(html); 10377} 10378 10379function prochainesSeancesJoueur(joueurId) { 10380 const maintenant = new Date(); 10381 return (listeSure(cache.seancesCours)) 10382 .map(seance => ({ 10383 seance, 10384 creneau: creneauCoursById(seance.creneau_id), 10385 presence: presenceDeSeancePourJoueur(seance.id, joueurId) 10386 })) 10387 .filter(item => item.creneau && item.presence && !seanceCoursEstTerminee(item.seance, item.creneau, maintenant)) 10388 .sort((a, b) => (a.seance.date || '').localeCompare(b.seance.date || '') || (a.creneau.heure_debut || '').localeCompare(b.creneau.heure_debut || '')); 10389} 10390 10391// Lecture sportive commune de la forme récente. La forme n'est ni une note 10392// subjective ni la statistique de toute la saison : c'est le taux de victoires 10393// sur les 10 derniers matchs réellement joués (WO exclus). 10394function diagnosticFormeRecente(matches) { 10395 const fenetre = matchsHorsWO(listeSure(matches)).slice(0, 10); 10396 const compte = comptesMatchs(fenetre); 10397 let libelle = 'En attente de matchs'; 10398 if (fenetre.length >= 3) { 10399 if (compte.tauxVictoire >= 70) libelle = 'Excellente dynamique';
10400 else if (compte.tauxVictoire >= 55) libelle = 'Dynamique positive'; 10401 else if (compte.tauxVictoire >= 45) libelle = 'Résultats équilibrés'; 10402 else if (compte.tauxVictoire >= 30) libelle = 'Résultats à consolider'; 10403 else libelle = 'Série difficile'; 10404 } 10405 return { matches: fenetre, compte, disponible: fenetre.length >= 3, libelle }; 10406} 10407 10408function renderPlayerHome() { 10409 if (!profile || !profile.joueur_id) return `<div class="empty">Profil joueur non trouvé. Contacte ton coach.</div>`; 10410 const j = joueurById(profile.joueur_id); 10411 if (!j) return `<div class="empty">Ta fiche joueur n'a pas pu être chargée.</div>`; 10412 if (j.statut === 'en_attente_validation') return renderEcranAttenteValidation(j); 10413 const estPreAca = estPreAcademie(j); 10414 const stats = statsJoueur(j.id); 10415 const prochainsCours = estPreAca ? [] : prochainesSeancesJoueur(j.id); 10416 const forme = diagnosticFormeRecente(matchsDuJoueur(j.id)); 10417 const prochainsTournois = (listeSure(cache.tournois)) 10418 .filter(t => (listeSure(t.inscrits)).includes(j.id) && (t.date_fin || t.date_debut) >= dateLocaleISO(new Date())) 10419 .sort((a, b) => (a.date_debut || '').localeCompare(b.date_debut || '')) 10420 .slice(0, 4); 10421 const dateAccueil = new Date().toLocaleDateString('fr-FR', { weekday: 'short', day: '2-digit', month: 'short' }).replace('.', ''); 10422 10423 let html = `<section class="ux-player-home" aria-labelledby="player-home-title"> 10424 <div class="ux-home-heading"><div><div class="ux-home-kicker">Bonjour ${escapeHtml(j.prenom)}</div><h2 class="ux-home-title" id="player-home-title">${estPreAca ? 'Mes tournois et mes matchs' : "Ma saison en un coup d'Åil"}</h2></div><div class="ux-home-date">${escapeHtml(dateAccueil)}</div></div> 10425 ${estPreAca ? '' : renderNiveauXP(j.id)} 10426 <div class="sub-title">Mes statistiques</div> 10427 <div class="ux-summary-grid player"> 10428 ${estPreAca ? '' : `<button class="ux-summary-card" data-ui-click="openDemanderClassementJoueur()"><div class="ux-summary-label">Classement</div><div class="ux-summary-value" style="color:var(--sport-orange)">${escapeHtml(j.classement || 'NC')}</div><div class="ux-summary-detail">Objectif ${escapeHtml(j.objectif || 'â')}</div></button>`} 10429 <button class="ux-summary-card" data-ui-click="navigate('matches')"><div class="ux-summary-label">Matches</div><div class="ux-summary-value">${stats.total}</div><div class="ux-summary-detail">Cette saison</div></button> 10430 <button class="ux-summary-card" data-ui-click="navigate('matches')"><div class="ux-summary-label">Victoires</div><div class="ux-summary-value" style="color:var(--success-text)">${stats.tauxVictoire}%</div><div class="ux-summary-detail">WO exclus</div></button> 10431 </div> 10432 ${estPreAca ? '' : `<button class="ux-form-card" data-ui-click="navigate('myprofile')"><img src="assets/v4/progress/serie.webp" alt=""><span><strong>Dynamique récente : ${escapeHtml(forme.libelle.toLowerCase())}</strong><small>${forme.matches.length ? `${forme.compte.victoires} V · ${forme.compte.defaites} D sur les ${forme.matches.length} derniers matchs joués · WO exclus` : 'Joue ton premier match pour lancer le suivi'}</small></span><em>â</em></button>`}`; 10433 10434 if (prochainsTournois.length) { 10435 html += `<div class="sub-title">${prochainsTournois.length > 1 ? 'Mes prochains tournois' : 'Mon prochain tournoi'}</div>`; 10436 prochainsTournois.forEach(tournoi => { 10437 const participants = autresInscritsNomsPole(tournoi, j.id); 10438 html += `<button class="ux-event-card" data-ui-click="openModalTournoi(${escapeHtml(encoderArgumentTexteUI(tournoi.id))})"><img src="assets/v4/nav/tournaments.webp" alt=""><span><strong>${escapeHtml(tournoi.nom || tournoi.lieu || 'Tournoi')}</strong><small>Inscrit · ${formatDate(tournoi.date_debut)}${tournoi.lieu ? ` · ${escapeHtml(tournoi.lieu)}` : ''}</small>${participants.length ? `<small class="tournament-participants">ð¾ Autres inscrits de lâAcadémie : ${escapeHtml(participants.join(', '))}</small>` : ''}</span><em>â</em></button>`; 10439 }); 10440 } else { 10441 html += `<div class="sub-title">Mes prochains tournois</div><button class="ux-event-card ux-empty-card" data-ui-click="navigate('calendar')">
10441<img src="assets/v4/nav/tournaments.webp" alt=""><span><strong>Aucun tournoi inscrit à venir</strong><small>Consulte les propositions du coach et inscris-toi lorsqu'une date te convient.</small></span><em>â</em></button>`; 10442 } 10443 10444 if (!estPreAca) { 10445 html += `<div class="sub-title">Mes prochains cours Académie</div>`; 10446 if (prochainsCours.length) { 10447 prochainsCours.forEach(({ seance, creneau, presence }) => { 10448 const d = new Date(`${seance.date}T12:00:00`); 10449 const statut = PRESENCE_STATUTS[presence.statut] || PRESENCE_STATUTS.non_renseigne; 10450 const presentActif = presence.statut === 'present'; 10451 const absentActif = presence.statut === 'absent'; 10452 const retardActif = presence.statut === 'retard'; 10453 html += `<article class="ux-course-card"><div class="ux-course-date"><strong>${d.getDate()}</strong>${d.toLocaleDateString('fr-FR', { month: 'short' }).replace('.', '')}</div><div><div class="ux-course-title">${escapeHtml(creneau.nom || 'Cours Académie')}</div><div class="ux-course-meta">${escapeHtml(creneau.heure_debut || '')}â${escapeHtml(creneau.heure_fin || '')} · ${escapeHtml(statut.label || 'à renseigner')}</div></div><button class="ux-home-link" data-ui-click="navigate('mescours')" aria-label="Voir les détails du cours">Voir le cours</button><div class="ux-presence-actions"><button class="present ${presentActif ? 'active' : ''}" aria-pressed="${presentActif}" data-ui-click="setPresenceJoueur(${escapeHtml(encoderArgumentTexteUI(seance.id))},${escapeHtml(encoderArgumentTexteUI(presentActif ? 'non_renseigne' : 'present'))})">${presentActif ? 'â ' : ''}Présent</button><button class="absent ${absentActif ? 'active' : ''}" aria-pressed="${absentActif}" data-ui-click="setPresenceJoueur(${escapeHtml(encoderArgumentTexteUI(seance.id))},${escapeHtml(encoderArgumentTexteUI(absentActif ? 'non_renseigne' : 'absent'))})">${absentActif ? 'â ' : ''}Absent</button><button class="retard ${retardActif ? 'active' : ''}" aria-pressed="${retardActif}" data-ui-click="setPresenceJoueur(${escapeHtml(encoderArgumentTexteUI(seance.id))},${escapeHtml(encoderArgumentTexteUI(retardActif ? 'non_renseigne' : 'retard'))})">${retardActif ? 'â ' : ''}Retard</button></div></article>`; 10454 }); 10455 } else { 10456 html += `<button class="ux-course-card ux-course-button ux-empty-card" data-ui-click="navigate('mescours')"><div class="ux-course-date">â</div><div><div class="ux-course-title">Aucun cours programmé prochainement</div><div class="ux-course-meta">Le prochain créneau apparaîtra ici dès que le planning sera publié.</div></div><span>â</span></button>`; 10457 } 10458 } 10459 html += `<div class="sub-title">${estPreAca ? 'Ajouter un match' : 'Déclarer mon activité'}</div><div class="ux-actions"> 10460 <button class="ux-action primary" data-ui-click="openModalMatch()"><img src="assets/v4/nav/matches.webp" alt=""><span>Déclarer un match<small>Résultat, surface et ressenti</small></span></button> 10461 ${estPreAca ? '' : `<button class="ux-action secondary" data-ui-click="openModalEntrainement()"><img src="assets/v4/nav/training.webp" alt=""><span>Déclarer une séance libre<small>Hors cours Académie</small></span></button>`} 10462 </div>`; 10463 html += `</section>`; 10464 return html; 10465} 10466 10467function renderPlayerProgression() { 10468 if (!profile || !profile.joueur_id) return `<div class="empty">Profil joueur non trouvé. Contacte ton coach.</div>`; 10469 const j = joueurById(profile.joueur_id); 10470 if (!j) return `<div class="empty">Ta fiche joueur n'a pas pu être chargée.</div>`; 10471 if (j.statut === 'en_attente_validation') return renderEcranAttenteValidation(j); 10472 10473 const stats = statsJoueur(j.id); 10474 const perfs = TennisDomainRules.listerPerformancesJoueur( 10475 matchsDuJoueur(j.id), 10476 match => classementADate(j.id, match.date), 10477 'ecart' 10478 ); 10479 const matchsJoues = matchsHorsWO(matchsDuJoueur(j.id)); 10480 const forme = diagnosticFormeRecente(matchsJoues); 10481 const formeRecente = forme.matches; 10482 const compteForme = forme.compte; 10483 const formeDisponible = forme.disponible; 10484 const progressionFft = progressionClassementPeriode(j.id, 'saison'); 10485 const progressionFftLabel = progressionFft > 0 10486 ? `+${progressionFft}` 10487 : progressionFft < 0 ? String(progressionFft) : 'Stable'; 10488 const surfaces = [ 10489 ['dur', 'Dur'], ['terre', 'Terre battue'], ['indoor', 'Indoor'] 10490 ].map(([code, label]) => { 10491 const liste = matchsJoues.filter(match => match.surface === code); 10492 const compte = comptesMatchs(liste); 10493 return { code, label, total: compte.total, taux: compte.total ? compte.tauxVictoire : 0 }; 10494 }); 10495 const axes = axesActifsJoueur(j.id); 10496 const objectif = (listeSure(j.axes_progres))[0] || 'Ton prochain axe prioritaire sera défini avec ton coach.'; 10497 const objectifsHtml = axes.length 10498 ? axes.map(axe => `<div class="ux-objective-card"><img src="assets/v4/progress/objectif_groupe-v2.webp" alt=""><div class="ux-objective-copy"><strong>${escapeHtml(axe.texte)}</strong><small>Axe partagé par ton coach</small></div><label class="ux-objective-check"><input type="checkbox" data-ui-change="toggleAxeFait(${escapeHtml(encoderArgumentTexteUI(axe.id))}, this.checked)" aria-label="Marquer cet axe comme réalisé"><span>Terminé</span></label></div>`).join('') 10499 : `<div class="ux-objective-card"><img src="assets/v4/progress/objectif_groupe-v2.webp" alt=""><div class="ux-objective-copy"><strong>${escapeHtml(objectif)}</strong><small>à travailler lors de tes prochaines séances</small></div></div>`; 10500 10501 let html = `<section class="ux-player-progression" aria-labelledby="player-progress-title"> 10502 <div class="ux-home-heading"><div><div class="ux-home-kicker">Ma progression</div><h2 class="ux-home-title" id="player-progress-title">Objectifs et dynamique</h2></div></div> 10503 <div class="ux-progress-metrics"> 10504 <button data-ui-click="navigate('matches')"><strong>${perfs.length}</strong><span>Belles perfs</span></button> 10505 <button data-ui-click="navigate('matches')"><strong>${formeDisponible ? compteForme.tauxVictoire + '%' : 'â'}</strong><span>${formeDisponible ? 'Forme récente' : 'Pas assez de matchs'}</span><small>${formeDisponible ? `${compteForme.victoires} V · ${compteForme.defaites} D · ${formeRecente.length} derniers matchs` : `${formeRecente.length}/3 matchs requis`} · WO exclus</small></button> 10506 <div><strong>${escapeHtml(progressionFftLabel)}</strong><span>Progression FFT${progressionFft ? ` · ${Math.abs(progressionFft)} échelon${Math.abs(progressionFft) > 1 ? 's' : ''}` : ''}</span></div> 10507 </div> 10508 <p class="ux-form-explainer"><strong>${escapeHtml(forme.libelle)}</strong> · La forme récente correspond au pourcentage de victoires sur tes 10 derniers matchs joués. Elle démarre après 3 matchs et exclut les WO.</p> 10509 <div class="ux-chart-title"><strong>Ãvolution du classement</strong><span>Saison complète</span></div> 10510 <div class="ux-chart-card">
10510<canvas id="myChartClassement"></canvas></div>`; 10511 10512 if (stats.total >= 3) { 10513 html += `<div class="ux-chart-title"><strong>Forme sur les 10 derniers matchs</strong><span>${stats.tauxVictoire}% saison</span></div><div class="ux-chart-card compact"><canvas id="myChartForme"></canvas></div>`; 10514 } else { 10515 html += `<div class="ux-form-card ux-empty-card"><img src="assets/v4/progress/serie.webp" alt=""><span><strong>Forme récente</strong><small>Trois matchs joués sont nécessaires pour afficher la courbe.</small></span></div>`; 10516 } 10517 10518 html += `<div class="ux-chart-title"><strong>Victoires et défaites cumulées</strong><span>Saison complète · WO exclus</span></div><div class="ux-chart-card compact"><canvas id="myChartCumul"></canvas></div>`; 10519 10520 html += `<div class="ux-chart-title"><strong>Résultats selon la surface</strong><span>${matchsJoues.length} matchs</span></div><div class="ux-surface-card">${surfaces.map(surface => `<div class="ux-surface-row"><span>${escapeHtml(surface.label)}</span><div><i style="width:${surface.total ? surface.taux : 0}%"></i></div><strong>${surface.total ? surface.taux + '%' : 'â'}</strong></div>`).join('')}</div> 10521 <div class="ux-chart-title"><strong>Objectif prioritaire</strong><span>${axes.length ? `${axes.length} en cours` : 'à définir'}</span></div> 10522 <div class="ux-objective-list">${objectifsHtml}</div> 10523 </section>`; 10524 return html; 10525} 10526 10527// ============================================================ 10528// PRÃFÃRENCES DE NOTIFICATIONS EMAIL â UI partagée joueur / coach 10529// ============================================================ 10530// mode : 'joueur' (affiche les 5 prefs joueur) ou 'coach' (affiche la pref coach) 10531// opts : { noTitle: true } pour ne pas afficher le titre (cas modale qui a déjà son header) 10532function renderPreferencesNotifs(mode, opts = {}) { 10533 const userId = session && session.user && session.user.id; 10534 if (!userId) return ''; 10535 10536 const prefs = (cache.preferencesNotifs && cache.preferencesNotifs[userId]) || {}; 10537 // Helper : pour le joueur, défaut activé (true sauf si explicitement false). 10538 // Pour le coach (opt-in), défaut désactivé (true seulement si explicitement true). 10539 const isOn = (key) => mode === 'coach' ? (prefs[key] === true) : (prefs[key] !== false); 10540 10541 // Liste des préférences à afficher selon le mode 10542 const items = mode === 'joueur' ? [ 10543 { key: 'email_bilan_publie', icon: 'ð', label: 'Bilan trimestriel publié', help: 'Quand le coach publie ton bilan trimestriel' }, 10544 { key: 'email_inscription_validee', icon: 'â', label: 'Inscription validée', help: 'Quand le coach valide ton inscription à l\'académie (1 seule fois)' }, 10545 { key: 'email_rappel_tournoi', icon: 'â°', label: 'Rappel tournoi (J-28)', help: 'Rappel automatique 28 jours avant un tournoi auquel tu es inscrit' }, 10546 { key: 'email_match_ajoute_par_coach', icon: 'ð¾', label: 'Match ajouté par le coach', help: 'Quand le coach saisit un match à ta place' }, 10547 { key: 'email_belle_perf', icon: 'ð¥', label: 'Belle perf', help: 'Quand tu signes une victoire sur un joueur nettement mieux classé (écart ⥠2 échelons)' } 10548 ] : [ 10549 { key: 'email_coach_match', icon: 'ð¾', label: 'Match saisi par un joueur', help: 'à chaque nouveau match joué déclaré par un joueur de l\'académie' }, 10550 { key: 'email_coach_presence', icon: 'â±ï¸', label: 'Absences et retards', help: 'Quand un joueur annonce une absence ou un retard à un cours' }, 10551 { key: 'email_coach_perf', icon: 'ð¥', label: 'Belle perf d\'un joueur', help: 'Quand un joueur de l\'académie signe une victoire sur un adversaire mieux classé' }, 10552 { key: 'email_coach_entrainement', icon: 'ð¾', label: 'Entraînement déclaré', help: 'Quand un joueur déclare une séance d\'entraînement' }, 10553 { key: 'email_coach_demande_classement', icon: 'ð', label: 'Demande de classement', help: 'Quand un joueur déclare un changement de classement à valider' }, 10554 { key: 'email_rappel_bilan_trimestre', icon: 'ð', label: 'Rappels clôture trimestre', help: 'Rappels J-30, J-15 et J-7 avant la fin du trimestre pour ne pas oublier les bilans' } 10555 ]; 10556 10557 let html = ''; 10558 if (!opts.noTitle) { 10559 html += `<div class="section-title">ð Mes préférences d'emails</div>`; 10560 } 10561 html += `<div class="card" style="padding:14px"> 10562 <div class="text-faint mb-2" style="font-size:12px"> 10563 ${mode === 'joueur' 10564 ? 'Décoche les emails que tu ne veux plus recevoir. Les notifications dans l\'app continueront d\'apparaître dans tous les cas.' 10565 : 'Coche les emails que tu souhaites recevoir. Par défaut, aucun email n\'est envoyé. Les notifications dans l\'app apparaissent dans tous les cas.'} 10566 </div>`; 10567
10568 items.forEach(item => { 10569 const on = isOn(item.key); 10570 html += `<label style="display:flex; align-items:flex-start; gap:10px; padding:10px 0; border-top:1px solid var(--border); cursor:pointer"> 10571 <input type="checkbox" ${on ? 'checked' : ''} data-ui-change="toggleNotifPref(${escapeHtml(encoderArgumentTexteUI(item.key))}, this.checked)" style="margin-top:3px; width:auto; flex-shrink:0"> 10572 <div style="flex:1"> 10573 <div style="font-size:13px; font-weight:600">${item.icon} ${escapeHtml(item.label)}</div> 10574 <div class="text-faint" style="font-size:11px; margin-top:2px">${escapeHtml(item.help)}</div> 10575 </div> 10576 </label>`; 10577 }); 10578 10579 html += `</div>`; 10580 10581 return html; 10582} 10583 10584// Sauvegarde de la préférence : upsert dans la table 10585async function toggleNotifPref(key, value) { 10586 if (!session) return; 10587 const userId = session.user.id; 10588 10589 // Construire l'objet à upsert : on inclut user_id + le champ modifié 10590 const update = { user_id: userId, [key]: value }; 10591 10592 // Upsert : insère si pas de ligne, sinon update 10593 const { data, error } = await db.from('preferences_notifications') 10594 .upsert(update, { onConflict: 'user_id' }) 10595 .select() 10596 .single(); 10597 10598 if (error) { 10599 toastErreur(error); 10600 return; 10601 } 10602 10603 // Mise à jour du cache local 10604 cache.preferencesNotifs[userId] = data; 10605 toast(value ? 'Email activé' : 'Email désactivé'); 10606} 10607 10608 10609// ============================================================ 10610// SECTION JOUEUR : ð¯ Mes objectifs du trimestre 10611// ============================================================ 10612// Affiche les objectifs fixés par le coach dans le dernier bilan publié/accepté/commenté, 10613// avec barres de progression automatiques sur les critères mesurables. 10614function renderMyGroupeScreen() { 10615 const j = profile.joueur_id ? joueurById(profile.joueur_id) : null; 10616 if (!j) { 10617 return `<div class="empty"><span class="icon">ð¤</span>Profil introuvable.</div>`; 10618 } 10619 if (!j.groupe || !cache.config[j.groupe]) { 10620 return `<div class="empty"><span class="icon">ð¤</span>Tu n'es pas encore affecté à un groupe.<br> 10621 <span class="text-faint">Ton coach t'assignera à un groupe prochainement.</span></div>`; 10622 } 10623 10624 const g = cache.config[j.groupe]; 10625 const dyn = cache.dynamiqueGroupe; 10626 const nbMembres = dyn ? (Number(dyn.nb_membres) || 0) : 0; 10627 const nbMatches = dyn ? (Number(dyn.nb_matches) || 0) : 0; 10628 const nbVictoires = dyn ? (Number(dyn.nb_victoires) || 0) : 0; 10629 const perfs = listeSure(cache.perfsGroupe); 10630 const monteesGroupe = listeSure(cache.monteesGroupe); 10631 const objectif = cache.objectifGroupe; 10632 const prochainsTournois = listeSure(cache.prochainsTournoisGroupe); 10633 const membres = listeSure(cache.membresGroupe); 10634 10635 // En-tête du groupe 10636 let html = renderUxScreenHeading('Mon collectif', g.nom) + `<div class="card" style="padding:18px; margin-bottom:12px; text-align:center; background:linear-gradient(135deg, var(--bg-card), var(--bg-input))"> 10637 <div style="font-size:32px; line-height:1">ð¤</div> 10638 <div style="font-size:20px; font-weight:700; margin-top:6px">${escapeHtml(g.nom)}</div> 10639 <div class="text-faint" style="font-size:13px; margin-top:2px">${nbMembres} joueur${nbMembres > 1 ? 's' : ''} dans ton groupe</div> 10640 </div>`; 10641 10642 // ===== Les joueurs du groupe (nom + classement + photo) ===== 10643 if (membres.length > 0) { 10644 html += `<div class="section-title">ð¥ Les joueurs du groupe</div>`; 10645 html += `<div class="card" style="padding:8px 14px">`; 10646 membres.forEach((m, i) => { 10647 const estMoi = m.est_moi; 10648 const nbM = Number(m.nb_matches_saison) || 0; 10649 html += `<div style="display:flex; align-items:center; gap:12px; padding:10px 0;${i < membres.length - 1 ? ' border-bottom:1px solid var(--border)' : ''}"> 10650 ${avatarHtml(m, 'md')} 10651 <div style="flex:1; min-width:0"> 10652 <div style="font-size:14px; font-weight:${estMoi ? '700' : '500'}">${escapeHtml(m.prenom)} ${escapeHtml(m.nom)}${estMoi ? ' <span style="font-size:11px; color:var(--orange-text); font-weight:600">(toi)</span>' : ''} ${badgeVitrineSimple(m.id)}</div> 10653 <div class="text-faint" style="font-size:11px; margin-top:2px">ð¾ ${nbM} match${nbM > 1 ? 's' : ''} cette saison</div> 10654 </div> 10655 <div style="font-size:13px; font-weight:600; color:var(--orange-text); flex-shrink:0">${escapeHtml(m.classement || 'NC')}</div> 10656 </div>`; 10657 }); 10658 html += `</div>`; 10659 } 10660 10661 // ===== Objectif collectif de matches (saison) ===== 10662 if (g.objectifMatches && objectif) { 10663 const matchesSaison = Number(objectif.nb_matches_saison) || 0; 10664 const membresObj = Number(objectif.nb_membres) || nbMembres || 1; 10665 const objectifTotal = g.objectifMatches * membresObj; // objectif annuel par joueur à nb de joueurs 10666 const pct = objectifTotal > 0 ? Math.min(100, Math.round((matchesSaison / objectifTotal) * 100)) : 0; 10667 html += `<div class="section-title">ð¯ Objectif collectif de la saison</div>`; 10668 html += `<div class="card" style="padding:14px; margin-bottom:12px"> 10669 <div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:8px">
10670 <span style="font-size:14px; font-weight:600">${matchesSaison} / ${objectifTotal} matches</span> 10671 <span style="font-size:13px; font-weight:700; color:var(--orange-text)">${pct}%</span> 10672 </div> 10673 <div style="background:var(--bg-input); border-radius:10px; height:12px; overflow:hidden"> 10674 <div style="width:${pct}%; height:100%; background:linear-gradient(90deg, var(--orange), var(--success)); border-radius:10px; transition:width 0.3s"></div> 10675 </div> 10676 <div class="text-faint" style="font-size:11px; margin-top:8px; text-align:center"> 10677 ${pct >= 100 ? 'Objectif atteint, bravo à tout le groupe ! ð' : `Chaque match que tu joues fait avancer le groupe vers son objectif ðª`} 10678 </div> 10679 </div>`; 10680 } 10681 10682 // ===== Prochain rendez-vous du groupe (tournois avec membres inscrits) ===== 10683 if (prochainsTournois.length > 0) { 10684 html += `<div class="section-title">ð Vos prochains rendez-vous</div>`; 10685 prochainsTournois.forEach(t => { 10686 const nbG = Number(t.nb_inscrits_groupe) || 0; 10687 const noms = listeSure(t.participants); 10688 const tournoiId = t.tournoi_id; 10689 const tournoiLocal = cache.tournois.find(tournoi => tournoi.id === tournoiId); 10690 const dejaInscrit = !!(profile.joueur_id && tournoiLocal && listeSure(tournoiLocal.inscrits).includes(profile.joueur_id)); 10691 html += `<div class="card clickable" data-ui-click="openModalTournoi(${escapeHtml(encoderArgumentTexteUI(tournoiId))})" style="cursor:pointer; padding:12px; margin-bottom:8px"> 10692 <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:10px"> 10693 <div style="flex:1; min-width:0"> 10694 <div style="font-size:14px; font-weight:600">${escapeHtml(t.nom)}</div> 10695 <div class="text-faint" style="font-size:12px; margin-top:2px">ð ${formatDate(t.date_debut)}${t.lieu ? ' · ð ' + escapeHtml(t.lieu) : ''}</div> 10696 </div> 10697 <div style="flex-shrink:0; text-align:center; background:var(--bg-input); border-radius:var(--radius-sm); padding:6px 10px"> 10698 <div style="font-size:18px; font-weight:700; color:var(--orange-text); line-height:1">${nbG}</div> 10699 <div class="text-faint" style="font-size:10px">du groupe</div> 10700 </div> 10701 </div> 10702 ${noms.length > 0 ? `<div style="font-size:12px; margin-top:8px; padding-top:8px; border-top:1px solid var(--border)"><span class="text-faint">Participants de l'académie :</span> ${noms.map(n => escapeHtml(n)).join(', ')}</div>` : ''} 10703 <div style="font-size:12px; color:var(--success-text); margin-top:8px">ð¾ Vous serez ${nbG} du groupe à y participer${nbG > 1 ? ' â encouragez-vous !' : ''}</div> 10704 <div style="font-size:12px; color:var(--orange-text); font-weight:700; margin-top:8px">${dejaInscrit ? 'Voir le tournoi et gérer mon inscription' : 'Voir le tournoi et mâinscrire'} â</div> 10705 </div>`; 10706 }); 10707 } 10708 10709 // Bloc dynamique collective 10710 html += `<div class="section-title">â¡ La dynamique du groupe ce trimestre</div>`; 10711 html += `<div class="card" style="padding:14px; margin-bottom:12px"> 10712 <div style="display:flex; gap:10px"> 10713 <div style="flex:1; text-align:center; background:var(--bg-input); border-radius:var(--radius-sm); padding:14px"> 10714 <div style="font-size:28px; font-weight:700; color:var(--orange-text); line-height:1.1">${nbMatches}</div> 10715 <div class="text-faint" style="font-size:11px; margin-top:3px">matches joués ensemble</div> 10716 </div> 10717 <div style="flex:1; text-align:center; background:var(--bg-input); border-radius:var(--radius-sm); padding:14px"> 10718 <div style="font-size:28px; font-weight:700; color:var(--success-text); line-height:1.1">${nbVictoires}</div> 10719 <div class="text-faint" style="font-size:11px; margin-top:3px">victoires du groupe</div> 10720 </div> 10721 </div>`; 10722 if (nbMatches === 0) { 10723 html += `<div class="text-faint" style="font-size:12px; text-align:center; padding:8px 6px 2px">Le groupe n'a pas encore joué ce trimestre. à vous de lancer la dynamique ! ð¥</div>`; 10724 } 10725 html += `</div>`; 10726 10727 // ===== Faits marquants du groupe ===== 10728 const faits = []; 10729 if (nbMatches > 0) { 10730 const tauxV = Math.round((nbVictoires / nbMatches) * 100); 10731 if (tauxV >= 60) faits.push(`ð¥ Le groupe gagne ${tauxV}% de ses matches ce trimestre !`); 10732 if (nbMatches >= 10) faits.push(`ðª Déjà ${nbMatches} matches joués ce trimestre, quelle énergie !`); 10733 } 10734 if (perfs.length >= 3) faits.push(`ð ${perfs.length} belles perfs réalisées ce trimestre !`); 10735 if (monteesGroupe.length > 0) faits.push(`ð ${monteesGroupe.length} montée${monteesGroupe.length > 1 ? 's' : ''} de classement dans le groupe ce trimestre !`); 10736 if (faits.length > 0) { 10737 html += `<div class="section-title">⨠Faits marquants</div>`; 10738 html += `<div class="card" style="padding:14px; margin-bottom:12px">`;
10739 faits.forEach((f, i) => { 10740 html += `<div style="font-size:13px; padding:6px 0;${i < faits.length - 1 ? ' border-bottom:1px solid var(--border)' : ''}">${f}</div>`; 10741 }); 10742 html += `</div>`; 10743 } 10744 10745 // Mur des belles perfs du groupe : victoires marquantes + montées validées. 10746 html += `<div class="section-title">ð Le mur des très belles perfs</div>`; 10747 if (perfs.length === 0 && monteesGroupe.length === 0) { 10748 html += `<div class="card" style="padding:16px; text-align:center"> 10749 <div class="text-faint" style="font-size:13px">Pas encore de belle perf ce trimestre.</div> 10750 <div class="text-faint" style="font-size:12px; margin-top:4px">Une belle perf, c'est une victoire contre un joueur mieux classé. à vous de jouer ! ðª</div> 10751 </div>`; 10752 } else { 10753 monteesGroupe.forEach(montee => { 10754 html += `<div class="card" style="padding:12px; margin-bottom:8px; display:flex; gap:12px; align-items:center; border-left:3px solid var(--success)"> 10755 <div style="font-size:24px; flex-shrink:0">ð</div> 10756 <div style="flex:1; min-width:0"> 10757 <div style="font-size:13px"><strong>${escapeHtml(montee.prenom)}</strong> monte de <strong>${escapeHtml(montee.ancien_classement || 'NC')}</strong> à <strong style="color:var(--success-text)">${escapeHtml(montee.nouveau_classement)}</strong> ð</div> 10758 <div class="text-faint" style="font-size:11px; margin-top:2px">Très belle perf · montée de classement${montee.date_montee ? ` · ${formatDate(montee.date_montee)}` : ''}</div> 10759 </div> 10760 </div>`; 10761 }); 10762 perfs.forEach(p => { 10763 const dateStr = p.match_date ? formatDate(p.match_date) : ''; 10764 html += `<div class="card" style="padding:12px; margin-bottom:8px; display:flex; gap:12px; align-items:center"> 10765 <div style="font-size:24px; flex-shrink:0">ð¾</div> 10766 <div style="flex:1; min-width:0"> 10767 <div style="font-size:13px"><strong>${escapeHtml(p.prenom)}</strong> a battu un <strong style="color:var(--orange-text)">${escapeHtml(p.adversaire_class)}</strong> ð${p.est_double ? ` <span class="format-double-badge">Double</span>` : ''}</div> 10768 ${dateStr ? `<div class="text-faint" style="font-size:11px; margin-top:2px">${dateStr}${p.est_double ? ' · double' : ''}</div>` : (p.est_double ? `<div class="text-faint" style="font-size:11px; margin-top:2px">double</div>` : '')} 10769 </div> 10770 </div>`; 10771 }); 10772 } 10773 10774 html += `<div class="text-faint" style="font-size:12px; text-align:center; margin-top:14px; font-style:italic">Vous progressez ensemble ðª</div>`; 10775 10776 return html; 10777} 10778 10779 10780// ============================================================ 10781// SCREEN: PLAYERS (coach) 10782// ============================================================ 10783const playerFilters = etatApp.filtresJoueurs; 10784 10785function setPlayerFilter(value) { 10786 etatApp.definirFiltreGroupeJoueur(value); 10787 render(); 10788} 10789 10790function setPlayerFilterField(field, value) { 10791 playerFilters[field] = value; 10792 render(); 10793} 10794 10795function resetAllPlayerFilters() { 10796 etatApp.reinitialiserVueJoueurs(); 10797 render(); 10798} 10799 10800function toggleAdvancedFilters() { 10801 etatApp.basculerFiltresAvances(); 10802 render(); 10803} 10804 10805// Vrai si au moins un filtre est actif (autre que "Tous") 10806function hasActiveFilters() { 10807 return etatApp.filtreGroupeJoueur !== 'all' 10808 || playerFilters.search.trim() !== '' 10809 || playerFilters.sexe !== 'all' 10810 || playerFilters.ageMin !== '' 10811 || playerFilters.ageMax !== '' 10812 || playerFilters.classMin !== '' 10813 || playerFilters.classMax !== ''; 10814} 10815 10816// Applique tous les filtres sur un tableau de joueurs 10817function filterPlayers(joueurs) { 10818 let result = joueurs; 10819 10820 // Filtre groupe â ignoré en mode Palmyre (pas de groupes au stage, seuls les 10821 // participants sont listés ; la recherche par nom reste active). 10822 if (isPoleEnv()) { 10823 if (etatApp.filtreGroupeJoueur === 'sans_groupe') { 10824 result = result.filter(j => !j.groupe || !cache.config[j.groupe] || typeof cache.config[j.groupe] !== 'object'); 10825 } else if (etatApp.filtreGroupeJoueur !== 'all') { 10826 result = result.filter(j => j.groupe === etatApp.filtreGroupeJoueur); 10827 } 10828 } 10829 10830 return TennisDomainRules.filtrerJoueurs(result, playerFilters, new Date()); 10831} 10832 10833function renderPlayers() { 10834 if (!isCoach()) return '';
10835 const entete = renderUxScreenHeading('Suivi permanent', 'Mes joueurs') 10836 + (isPreAcademieEnv() ? `<div class="flex mb-2" style="justify-content:flex-end"><button class="btn btn-primary" data-ui-click="openModalPlayer()">+ Ajouter un joueur</button></div>` : ''); 10837 // Exclure les joueurs en attente de validation + restreindre à la catégorie courante 10838 // (Académie : hors Pré-Académie ; Pré-Académie : uniquement Pré-Académie). 10839 let joueursVisibles = joueursActifsDansEnvCourant(); 10840 const joueursArchives = cache.joueurs.filter(j => j.statut === 'archive' && joueurDansEnvCourant(j)); 10841 10842 // En mode Palmyre : ne montrer que les participants à l'édition. 10843 if (!isPoleEnv()) { 10844 const ev = getActiveEvenement(); 10845 if (joueursVisibles.length === 0) { 10846 return entete + `<div class="empty"><span class="icon">ðï¸</span>Aucun participant à ${ev ? escapeHtml(ev.nom) : 'la Palmyre'}.<br> 10847 <span class="text-faint">Coche « Participe à la Palmyre » sur la fiche des joueurs concernés (modifiable depuis n'importe quel environnement).</span></div>`; 10848 } 10849 } 10850 10851 if (joueursVisibles.length === 0 && cache.joueurs.length === 0) { 10852 return entete + `<div class="empty"><span class="icon">ð¥</span>Aucun joueur enregistré.<br> 10853 <span class="text-faint">Communique le code d'accès de l'académie aux joueurs pour qu'ils s'inscrivent, puis valide leur fiche.</span></div>`; 10854 } 10855 10856 if (joueursVisibles.length === 0 && isPreAcademieEnv()) { 10857 return entete + `<div class="empty"><span class="icon">ð±</span>Aucun joueur actuellement en Pré-Académie.<br> 10858 <span class="text-faint">Cet espace reste disponible toute lâannée. Un joueur actif, sans groupe Académie et âgé de 11 ans ou moins en âge tennistique, y apparaîtra automatiquement.</span></div>`; 10859 } 10860 10861 if (joueursVisibles.length === 0) { 10862 return entete + `<div class="empty"><span class="icon">â³</span>Tous les joueurs sont en attente de validation.<br> 10863 <span class="text-faint">Va dans l'onglet <strong>à valider</strong> pour les traiter.</span></div>`; 10864 } 10865 10866 // Compteurs par groupe 10867 const countByGroup = { tremplin: 0, accession: 0, elite: 0, sans_groupe: 0 }; 10868 joueursVisibles.forEach(j => { 10869 if (j.groupe && cache.config[j.groupe] && typeof cache.config[j.groupe] === 'object') { 10870 countByGroup[j.groupe] = (countByGroup[j.groupe] || 0) + 1; 10871 } else { 10872 countByGroup.sans_groupe++; 10873 } 10874 }); 10875 const joueursActifsFiltres = filterPlayers(joueursVisibles); 10876 10877 let html = entete; 10878 10879 // ====================== BARRE DE FILTRES ====================== 10880 // Bloc 1 : recherche par nom (toujours visible) 10881 html += `<div class="card mb-2" style="padding:10px 12px"> 10882 <div style="display:flex; gap:8px; align-items:center; margin-bottom:10px"> 10883 <input type="text" id="playerSearch" placeholder="Rechercher par nom ou prénom..." 10884 value="${escapeHtml(playerFilters.search)}" 10885 data-ui-input="playerFilters.search = this.value; debouncedSearchRender()" 10886 style="flex:1; padding:8px 12px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px"> 10887 <button class="btn btn-sm" data-ui-click="toggleAdvancedFilters()" title="Filtres avancés"> 10888 ð§ ${etatApp.filtresAvancesOuverts ? 'â' : '+'} 10889 </button> 10890 </div> 10891 10892 ${isPoleEnv() ? `<div class="pill-group" style="margin:0"> 10893 <span class="pill ${etatApp.filtreGroupeJoueur === 'all' ? 'active' : ''}" data-ui-click="setPlayerFilter('all')">Tous (${joueursVisibles.length})</span> 10894 ${['tremplin','accession','elite'].map(g => { 10895 if (countByGroup[g] === 0) return ''; 10896 const color = g === 'tremplin' ? 'success' : g === 'accession' ? 'info' : 'purple'; 10897 const isActive = etatApp.filtreGroupeJoueur === g; 10898 return `<span class="pill ${isActive ? 'active' : ''}" data-ui-click="setPlayerFilter(${escapeHtml(encoderArgumentTexteUI(g))})" style="${isActive ? 'border-color: var(--' + color + '); background: var(--' + color + '-bg); color: var(--' + color + ')' : ''}">${escapeHtml((cache.config[g] || {}).nom || g)} (${countByGroup[g]})</span>`;
10899 }).join('')} 10900 ${countByGroup.sans_groupe > 0 ? `<span class="pill ${etatApp.filtreGroupeJoueur === 'sans_groupe' ? 'active' : ''}" data-ui-click="setPlayerFilter('sans_groupe')">Sans groupe (${countByGroup.sans_groupe})</span>` : ''} 10901 ${joueursArchives.length > 0 ? `<span class="pill ${etatApp.filtreGroupeJoueur === 'archive' ? 'active' : ''}" data-ui-click="setPlayerFilter('archive')" style="${etatApp.filtreGroupeJoueur === 'archive' ? 'border-color: var(--text-dim); background: var(--bg-input); color: var(--text-dim)' : ''}">ð¦ Archivés (${joueursArchives.length})</span>` : ''} 10902 </div>` : ''} 10903 10904 ${etatApp.filtresAvancesOuverts ? ` 10905 <div style="margin-top:12px; padding-top:12px; border-top:1px solid var(--border)"> 10906 <div class="text-faint" style="font-size:11px; text-transform:uppercase; letter-spacing:1px; margin-bottom:6px">Sexe</div> 10907 <div class="pill-group" style="margin:0 0 10px 0"> 10908 <span class="pill ${playerFilters.sexe === 'all' ? 'active' : ''}" data-ui-click="setPlayerFilterField('sexe', 'all')">Tous</span> 10909 <span class="pill ${playerFilters.sexe === 'F' ? 'active' : ''}" data-ui-click="setPlayerFilterField('sexe', 'F')">Féminin</span> 10910 <span class="pill ${playerFilters.sexe === 'M' ? 'active' : ''}" data-ui-click="setPlayerFilterField('sexe', 'M')">Masculin</span> 10911 </div> 10912 10913 <div class="text-faint" style="font-size:11px; text-transform:uppercase; letter-spacing:1px; margin-bottom:6px">Ãge</div> 10914 <div style="display:flex; gap:8px; align-items:center; margin-bottom:10px"> 10915 <input type="number" min="7" max="25" placeholder="Min" value="${escapeHtml(playerFilters.ageMin)}" 10916 data-ui-change="setPlayerFilterField('ageMin', this.value)" 10917 style="width:70px; padding:6px 10px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px"> 10918 <span class="text-faint" style="font-size:12px">à </span> 10919 <input type="number" min="7" max="25" placeholder="Max" value="${escapeHtml(playerFilters.ageMax)}" 10920 data-ui-change="setPlayerFilterField('ageMax', this.value)" 10921 style="width:70px; padding:6px 10px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px"> 10922 <span class="text-faint" style="font-size:12px">ans</span> 10923 </div> 10924 10925 <div class="text-faint" style="font-size:11px; text-transform:uppercase; letter-spacing:1px; margin-bottom:6px">Classement</div> 10926 <div style="display:flex; gap:8px; align-items:center"> 10927 <select data-ui-change="setPlayerFilterField('classMin', this.value)" 10928 style="padding:6px 10px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px"> 10929 <option value="">Min</option> 10930 ${CLASSEMENTS.map(c => `<option value="${c}" ${playerFilters.classMin === c ? 'selected' : ''}>${c}</option>`).join('')} 10931 </select> 10932 <span class="text-faint" style="font-size:12px">à </span> 10933 <select data-ui-change="setPlayerFilterField('classMax', this.value)" 10934 style="padding:6px 10px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px"> 10935 <option value="">Max</option> 10936 ${CLASSEMENTS.map(c => `<option value="${c}" ${playerFilters.classMax === c ? 'selected' : ''}>${c}</option>`).join('')} 10937 </select> 10938 </div> 10939 </div> 10940 ` : ''} 10941 10942 ${hasActiveFilters() ? ` 10943 <div style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border); display:flex; justify-content:space-between; align-items:center; gap:8px"> 10944 <span class="text-faint" style="font-size:12px">${joueursActifsFiltres.length} joueur${joueursActifsFiltres.length > 1 ? 's' : ''} affiché${joueursActifsFiltres.length > 1 ? 's' : ''} sur ${joueursVisibles.length} au total</span> 10945 <button class="btn btn-sm" data-ui-click="resetAllPlayerFilters()" style="font-size:11px">â» Réinitialiser</button> 10946 </div> 10947 ` : ''} 10948 </div>`; 10949 10950 // ====================== AFFICHAGE DES JOUEURS ====================== 10951 // Cas spécial : filtre "archivés" â on affiche la liste des joueurs archivés 10952 if (etatApp.filtreGroupeJoueur === 'archive') { 10953 html += `<div class="text-faint" style="font-size:12px; margin-bottom:8px; padding:0 4px">ð¦ Joueurs archivés : sortis de l'académie actif, historique conservé. Clique pour consulter ou réactiver.</div>`; 10954 const archivesAffiches = filterPlayers(joueursArchives); 10955 if (archivesAffiches.length === 0) { 10956 html += `<div class="empty"><span class="icon">ð¦</span>Aucun joueur archivé ne correspond à ces filtres.</div>`; 10957 return html; 10958 } 10959 archivesAffiches 10960 .sort((a, b) => (a.nom + a.prenom).localeCompare(b.nom + b.prenom)) 10961 .forEach(j => { 10962 const age = computeAge(j); 10963 const stats = statsJoueur(j.id); 10964 html += `<div class="player-row" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(j.id
10964))})" style="opacity:0.7"> 10965 ${avatarHtml(j, 'md')} 10966 <div class="player-info"> 10967 <div class="player-name">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)} <span class="badge" style="background:var(--bg-input); color:var(--text-dim)">${escapeHtml(j.classement || 'NC')}</span></div> 10968 <div class="player-meta">${age !== null ? age + ' ans · ' : ''}${stats.total} matches joués · historique conservé</div> 10969 </div> 10970 </div>`; 10971 }); 10972 return html; 10973 } 10974 10975 const joueursAffiches = joueursActifsFiltres; 10976 10977 if (joueursAffiches.length === 0) { 10978 html += `<div class="empty"> 10979 <span class="icon">ð</span> 10980 <strong>Aucun joueur ne correspond à ces filtres</strong><br> 10981 <span class="text-faint">Essaie d'élargir tes critères ou réinitialise les filtres.</span> 10982 </div>`; 10983 return html; 10984 } 10985 10986 // En Palmyre, l'objectif de matchs affiché est celui de l'édition (pas du groupe). 10987 const objCibleAff = !isPoleEnv() ? palmyreGroupConfig().objectifMatches : null; 10988 10989 // Affichage par groupe (ordre tremplin/accession/elite) 10990 ['tremplin','accession','elite'].forEach(key => { 10991 const g = cache.config[key]; 10992 if (!g || typeof g !== 'object' || !g.nom) return; 10993 const joueurs = joueursAffiches.filter(j => j.groupe === key); 10994 if (joueurs.length === 0) return; 10995 10996 html += `<div class="section-title grp-${key}">${escapeHtml(g.nom)} <span style="font-weight:400; opacity:0.85;">(${joueurs.length})</span></div>`; 10997 joueurs.forEach(j => { 10998 const stats = statsJoueur(j.id); 10999 const objMatches = objCibleAff != null ? objCibleAff : g.objectifMatches; 11000 const pct = objMatches > 0 ? Math.min(100, Math.round((stats.total / objMatches) * 100)) : 0; 11001 const cls = pct >= 80 ? '' : pct >= 50 ? 'warning' : 'danger'; 11002 const linkBadge = j.user_id ? '<span class="badge badge-success" title="Compte lié">â</span>' : '<span class="badge badge-warning" title="Pas de compte">â</span>'; 11003 const age = computeAge(j); 11004 11005 html += `<div class="player-row" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(j.id))})"> 11006 ${avatarHtml(j, 'md')} 11007 <div class="player-info"> 11008 <div class="player-name">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)} <span class="badge badge-${key}">${escapeHtml(j.classement)}</span> ${linkBadge}</div> 11009 <div class="player-meta">${age !== null ? age + ' ans · ' : ''}${stats.total}/${objMatches} matches · ${stats.tauxVictoire}% V</div> 11010 <div class="progress-bar"><div class="progress-fill ${cls}" style="width:${pct}%"></div></div> 11011 </div> 11012 </div>`; 11013 }); 11014 }); 11015 11016 const sansGroupe = joueursAffiches.filter(j => !j.groupe || !cache.config[j.groupe] || typeof cache.config[j.groupe] !== 'object'); 11017 if (sansGroupe.length > 0) { 11018 html += `<div class="section-title">Sans groupe</div>`; 11019 sansGroupe.forEach(j => { 11020 const age = computeAge(j); 11021 const stats = statsJoueur(j.id); 11022 const progressionPalmyre = objCibleAff != null; 11023 const pct = progressionPalmyre && objCibleAff > 0 11024 ? Math.min(100, Math.round((stats.total / objCibleAff) * 100)) 11025 : 0; 11026 const cls = pct >= 80 ? '' : pct >= 50 ? 'warning' : 'danger'; 11027 const linkBadge = j.user_id ? '<span class="badge badge-success" title="Compte lié">â</span>' : '<span class="badge badge-warning" title="Pas de compte">â</span>'; 11028 html += `<div class="player-row" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(j.id))})"> 11029 ${avatarHtml(j, 'md')} 11030 <div class="player-info"> 11031 <div class="player-name">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)} ${progressionPalmyre ? `<span class="badge">${escapeHtml(j.classement || 'NC')}</span> ${linkBadge}` : ''}</div> 11032 <div class="player-meta">${age !== null ? age + ' ans · ' : ''}${progressionPalmyre ? `${stats.total}/${objCibleAff} matches · ${stats.tauxVictoire}% V` : escapeHtml(j.classement || 'NC')}</div> 11033 ${progressionPalmyre ? `<div class="progress-bar"><div class="progress-fill ${cls}" style="width:${pct}%"></div></div>` : ''} 11034 </div> 11035 </div>`; 11036 }); 11037 } 11038 return html; 11039} 11040 11041// Debounce pour la recherche : on attend 200ms avant de re-render (évite de redessiner à chaque touche) 11042let searchDebounceTimer = null; 11043function debouncedSearchRender() { 11044 clearTimeout(searchDebounceTimer); 11045 searchDebounceTimer = setTimeout(() => { 11046 render(); 11047 // Restaurer le focus dans le champ de recherche après re-render 11048 setTimeout(() => { 11049 const inp = document.getElementById('playerSearch'); 11050 if (inp) { 11051 inp.focus(); 11052 // Curseur en fin de texte 11053 const v = inp.value; 11054 inp.value = ''; 11055 inp.value = v; 11056 } 11057 }, 20); 11058 }, 200); 11059} 11060 11061// Calcule l'âge depuis date_naissance, fallback sur j.age si pas de date 11062function computeAge(j) { 11063 if (j.date_naissance) { 11064 const calcule = TennisDomainRules.ageCivil(j.date_naissance, new Date()); 11065 if (calcule != null) return calcule; 11066 } 11067 return j.age || null; 11068} 11069 11070// Ãge TENNISTIQUE (catégorie d'âge FFT). Contrairement à l'âge civil, il ne se 11071// calcule PAS sur la date de naissance exacte mais sur l'ANNÃE de naissance, 11072// soustraite de l'année de FIN de la saison tennistique en cours (le « millésime »). 11073// Ex. saison 2025-2026 â millésime 2026 ;
11073 un joueur né en 2012 a 14 ans « au sens 11074// tennis » pendant toute la saison, quel que soit son jour d'anniversaire. C'est 11075// cette valeur (et non l'âge civil) qui détermine les catégories d'éligibilité 11076// aux tournois (11/12 ans, 13/14 ans, U16â¦). Repli sur j.age si l'année de 11077// naissance est inconnue. 11078function computeAgeTennistique(j) { 11079 return TennisDomainRules.ageTennistique(j, getSaisonCourante()); 11080} 11081 11082// Libellé d'âge pour les fiches joueur : âge civil, complété de l'âge tennistique 11083// UNIQUEMENT quand il diffère (cas des enfants nés en fin d'année). Renvoie '' si 11084// l'âge civil est inconnu (le rendu affiche alors « âge non renseigné »). 11085function ageLabel(j) { 11086 const civil = computeAge(j); 11087 if (civil == null) return ''; 11088 const tennis = computeAgeTennistique(j); 11089 if (tennis != null && tennis !== civil) { 11090 return `${civil} ans · ${tennis} ans tennistiques`; 11091 } 11092 return `${civil} ans`; 11093} 11094 11095// Groupes considérés comme « Académie » (affectation manuelle par le coach). 11096// Toute affectation manuelle PRIME sur la règle d'âge : un joueur rangé ici par 11097// le coach reste en Académie quel que soit son âge (on ne casse pas les profils 11098// déjà constitués par Martin). 11099const GROUPES_ACADEMIE = ['tremplin', 'accession', 'elite']; 11100 11101function categorieJoueur(j) { 11102 return TennisDomainRules.categorieJoueur(j, computeAgeTennistique(j), GROUPES_ACADEMIE); 11103} 11104 11105function estPreAcademie(j) { 11106 return categorieJoueur(j) === 'pre_academie'; 11107} 11108 11109function joueurDansEnvCourant(j) { 11110 return TennisDomainRules.joueurDansCategorie(j, etatApp.environnement, computeAgeTennistique(j), GROUPES_ACADEMIE); 11111} 11112 11113function joueursActifsDansEnvCourant() { 11114 return TennisDomainRules.joueursDansPerimetre(cache.joueurs, { 11115 environnement: etatApp.environnement, 11116 ageTennistique: computeAgeTennistique, 11117 groupesAcademie: GROUPES_ACADEMIE 11118 }); 11119} 11120 11121// ============================================================ 11122// SCREEN: MATCHES 11123// ============================================================ 11124function renderMatches() { 11125 let matchesList = cache.matches.slice(); 11126 const selectionSaison = `${renderUxScreenHeading(isCoach() ? 'Suivi sportif' : 'Historique sportif', isCoach() ? 'Matchs des joueurs' : 'Mes matchs')}<div class="card mb-2" style="padding:6px">${renderSaisonSelector() || `<div class="text-faint" style="text-align:center;font-size:12px">Saison ${getSaisonAnalyse()}</div>`}</div>`; 11127 11128 if (isCoach() && cache.joueurs.length > 0) { 11129 // Coach voit tout, peut filtrer 11130 const filterVal = $('filterJoueur')?.value || ''; 11131 if (filterVal) matchesList = matchesList.filter(m => m.joueur_id === filterVal); 11132 11133 // Liste du sélecteur : en environnement Palmyre, ne proposer que les participants 11134 // à l'édition (le bug affichait l'ensemble des joueurs de l'app). En académie : tous. 11135 const joueursListe = joueursActifsDansEnvCourant() 11136 .slice() 11137 .sort((a, b) => (a.prenom || '').localeCompare(b.prenom || '')); 11138 11139 let html = `${selectionSaison}<div class="card mb-2"><div class="form-row" style="margin-bottom:0"> 11140 <label class="form-label">Filtrer par joueur</label> 11141 <select id="filterJoueur" data-ui-change="render()"> 11142 <option value="">Tous les joueurs</option> 11143 ${joueursListe.map(j => `<option value="${j.id}" ${filterVal === j.id ? 'selected' : ''}>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</option>`).join('')} 11144 </select></div></div>`; 11145 11146 // Filtre victoire/défaite : seulement quand un joueur est sélectionné 11147 if (filterVal) { 11148 // Les WO restent AFFICHÃS dans la liste mais ne sont pas comptés (pas un match joué). 11149 const compte = comptesMatchs(matchesList); 11150 const nbTotal = compte.total; 11151 const nbWin = compte.victoires; 11152 const nbLoss = compte.defaites; 11153 const nbWO = matchesList.length - nbTotal; 11154 if (etatApp.filtreResultat === 'win') matchesList = matchesList.filter(m => m.victoire && !estWalkover(m)); 11155 else if (etatApp.filtreResultat === 'loss') matchesList = matchesList.filter(m => !m.victoire && !estWalkover(m)); 11156 html += `<div class="pill-group" style="margin-bottom:12px">
11157 <span class="pill ${etatApp.filtreResultat === 'all' ? 'active' : ''}" data-ui-click="setMatchResultFilter('all')">Tous (${nbTotal} joué${nbTotal > 1 ? 's' : ''}${nbWO ? ` · ${nbWO} WO` : ''})</span> 11158 <span class="pill ${etatApp.filtreResultat === 'win' ? 'active' : ''}" data-ui-click="setMatchResultFilter('win')">â Victoires (${nbWin})</span> 11159 <span class="pill ${etatApp.filtreResultat === 'loss' ? 'active' : ''}" data-ui-click="setMatchResultFilter('loss')">â Défaites (${nbLoss})</span> 11160 </div>`; 11161 } 11162 11163 if (matchesList.length === 0) { 11164 html += `<div class="empty">${progressArtHtml('matchs',{size:64})}<strong>Aucun match</strong></div>`; 11165 return html; 11166 } 11167 11168 html += `<div class="section-title">${matchesList.length} match${matchesList.length > 1 ? 'es' : ''}</div><div class="card">`; 11169 matchesList.forEach(m => { 11170 const j = joueurById(m.joueur_id); 11171 if (!j) return; 11172 const classementJoueurEpoque = classementADate(m.joueur_id, m.date) || j.classement || '?'; 11173 html += `<div class="match-row" data-ui-click="openMatchDetail(${escapeHtml(encoderArgumentTexteUI(m.id))})" style="cursor:pointer"> 11174 <div class="match-result-row"> 11175 <span class="match-opponent"><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong> (${escapeHtml(classementJoueurEpoque)}) vs ${escapeHtml(m.adversaire)} (${escapeHtml(m.adversaire_class || '?')})</span> 11176 ${resultatMatchHtml(m)} 11177 </div> 11178 <div class="match-meta">${escapeHtml(m.tournoi || 'Match')} · ${formatDate(m.date)}${m.format === 'double' ? ` · <span class="format-double-badge">Double</span>` : ''}${m.ressenti ? ' · ' + m.ressenti + '/5' : ''}${matchEvenementNom(m) ? ` · <span class="badge badge-orange" style="font-size:10px"><img class="palmyre-stamp-icon" src="assets/v4/nav/palmyre.webp" alt="">${escapeHtml(matchEvenementNom(m))}</span>` : ''}</div> 11179 </div>`; 11180 }); 11181 html += `</div>`; 11182 return html; 11183 } else { 11184 // Vue joueur : ses matches (déjà filtrés par RLS) 11185 const joueurId = profile && profile.joueur_id; 11186 const importTenup = joueurId ? `<button class="ux-import-tenup" data-ui-click="openModalImportMatchsTenup(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img src="assets/v4/menu/update.webp" alt=""><span><strong>Importer mes matchs Ten'Up</strong><small>Ajouter mon historique depuis Ten'Up</small></span></button>` : ''; 11187 if (matchesList.length === 0) { 11188 const aide = getSaisonAnalyse() === getSaisonCourante() 11189 ? '<br><span class="text-faint">Déclare ton premier match depuis lâaccueil.</span>' 11190 : `<br><span class="text-faint">Aucun match enregistré pour ${getSaisonAnalyse()}.</span>`; 11191 return `${selectionSaison}${importTenup}<div class="empty">${progressArtHtml('matchs',{size:64})}<strong>Aucun match enregistré</strong>${aide}</div>`; 11192 } 11193 11194 // Compteurs sportifs : les WO restent visibles dans « Tous », mais ne sont ni 11195 // des matchs joués, ni des victoires/défaites (même règle que côté coach). 11196 const comptes = comptesMatchs(matchesList); 11197 const nbTotal = comptes.total; 11198 const nbWin = comptes.victoires; 11199 const nbLoss = nbTotal - nbWin; 11200 const matchsPalmyre = matchesList.filter(m => m.evenement_id); 11201 const nbPalmyre = matchsPalmyre.length; 11202 const nbPalmyreJoues = matchsHorsWO(matchsPalmyre).length; 11203 const nbWO = matchesList.length - nbTotal; 11204 11205 // Filtre par résultat (+ filtre Palmyre) 11206 let visibles = matchesList; 11207 if (etatApp.filtreResultat === 'win') visibles = matchesList.filter(m => m.victoire && !estWalkover(m)); 11208 else if (etatApp.filtreResultat === 'loss') visibles = matchesList.filter(m => !m.victoire && !estWalkover(m)); 11209 else if (etatApp.filtreResultat === 'palmyre') visibles = matchesList.filter(m => m.evenement_id); 11210 11211 // Barre de filtres (pills) â la pill Palmyre n'apparaît que si le joueur a des matchs Palmyre 11212 let html = `${selectionSaison}${importTenup}<div class="pill-group" style="margin-bottom:12px"> 11213 <span class="pill ${etatApp.filtreResultat === 'all' ? 'active' : ''}" data-ui-click="setMatchResultFilter('all')">Tous (${nbTotal} joué${nbTotal > 1 ? 's' : ''}${nbWO ? ` · ${nbWO} WO` : ''})</span>
11214 <span class="pill ${etatApp.filtreResultat === 'win' ? 'active' : ''}" data-ui-click="setMatchResultFilter('win')">â Victoires (${nbWin})</span> 11215 <span class="pill ${etatApp.filtreResultat === 'loss' ? 'active' : ''}" data-ui-click="setMatchResultFilter('loss')">â Défaites (${nbLoss})</span> 11216 ${nbPalmyre > 0 ? `<span class="pill ${etatApp.filtreResultat === 'palmyre' ? 'active' : ''}" data-ui-click="setMatchResultFilter('palmyre')" style="${etatApp.filtreResultat === 'palmyre' ? 'border-color: var(--orange-text); background: var(--orange-bg, rgba(243,146,0,0.12)); color: var(--orange-text)' : ''}">ðï¸ Palmyre (${nbPalmyreJoues} joué${nbPalmyreJoues > 1 ? 's' : ''}${nbPalmyre > nbPalmyreJoues ? ` · ${nbPalmyre - nbPalmyreJoues} WO` : ''})</span>` : ''} 11217 </div>`; 11218 11219 if (visibles.length === 0) { 11220 html += `<div class="empty">${progressArtHtml('matchs',{size:64})}<strong>Aucun match dans cette catégorie</strong></div>`; 11221 return html; 11222 } 11223 11224 html += `<div class="section-title">${visibles.length} match${visibles.length > 1 ? 'es' : ''}</div><div class="card">`; 11225 visibles.forEach(m => { 11226 const monClassementEpoque = classementADate(m.joueur_id, m.date) || (joueurById(m.joueur_id) || {}).classement || '?'; 11227 html += `<div class="match-row" data-ui-click="openMatchDetail(${escapeHtml(encoderArgumentTexteUI(m.id))})" style="cursor:pointer"> 11228 <div class="match-result-row"> 11229 <span class="match-opponent"><strong>Moi</strong> ${escapeHtml(monClassementEpoque)} · vs ${escapeHtml(m.adversaire)} (${escapeHtml(m.adversaire_class || '?')})</span> 11230 ${resultatMatchHtml(m)} 11231 </div> 11232 <div class="match-meta">${escapeHtml(m.tournoi || 'Match')} · ${formatDate(m.date)}${m.format === 'double' ? ` · <span class="format-double-badge">Double</span>` : ''}${m.ressenti ? ' · ' + m.ressenti + '/5' : ''}${matchEvenementNom(m) ? ` · <span class="badge badge-orange" style="font-size:10px"><img class="palmyre-stamp-icon" src="assets/v4/nav/palmyre.webp" alt="">${escapeHtml(matchEvenementNom(m))}</span>` : ''}</div> 11233 </div>`; 11234 }); 11235 html += `</div>`; 11236 return html; 11237 } 11238} 11239 11240// ============================================================ 11241// SCREEN: MES ENTRAÃNEMENTS (joueur) â liste filtrée par période 11242// ============================================================ 11243function renderMesEntrainements() { 11244 const joueurId = profile && profile.joueur_id; 11245 if (!joueurId) return `<div class="empty"><span class="icon">ðï¸</span>Réservé aux joueurs.</div>`; 11246 11247 const tous = entrainementsDuJoueur(joueurId); 11248 11249 // Sélecteur de période (Saison / T1..T4), comme sur la fiche 11250 let html = renderUxScreenHeading('Mon activité', 'Mes séances libres') + renderPeriodeSelector(periodes.mesEntrainements, 'setPeriodeMesEntrainements'); 11251 const [deb, fin] = getPeriodeDates(periodes.mesEntrainements); 11252 const label = getPeriodeLabel(periodes.mesEntrainements); 11253 11254 const liste = tous 11255 .filter(e => TennisDomainRules.dateDansIntervalle(e.date, deb, fin)) 11256 .sort((a, b) => (b.date || '').localeCompare(a.date || '')); 11257 11258 if (tous.length === 0) { 11259 html += `<div class="empty"><span class="icon">ðï¸</span>Aucune séance libre déclarée.<br><span class="text-faint">Déclare ta première séance depuis lâaccueil.</span></div>`; 11260 return html; 11261 } 11262 11263 // Compteurs sur la période 11264 const statsEntrainements = TennisDomainRules.statistiquesEntrainements(liste); 11265 const nb = statsEntrainements.total; 11266 const heuresTxt = statsEntrainements.heuresTexte; 11267 11268 html += `<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:12px"> 11269 <div class="card" style="text-align:center; padding:12px"><div style="font-size:22px; font-weight:800; color:var(--orange-text)">${nb}</div><div class="text-faint" style="font-size:11px">séance${nb > 1 ? 's' : ''}</div></div> 11270 <div class="card" style="text-align:center; padding:12px"><div style="font-size:22px; font-weight:800; color:var(--orange-text)">${heuresTxt}</div><div class="text-faint" style="font-size:11px">au total</div></div> 11271 </div>`; 11272 11273 if (nb === 0) { 11274 html += `<div class="empty"><span class="icon">ðï¸</span>Aucun entraînement sur ${escapeHtml(label.toLowerCase())}.</div>`; 11275 return html; 11276 } 11277 11278 html += `<div class="section-title">${nb} entraînement${nb > 1 ? 's' : ''} <span style="font-weight:400; opacity:0.85; font-size:12px">â ${escapeHtml(label.toLowerCase())}</span></div><div class="card">`; 11279 liste.forEach(en => { 11280 const meta = ENTRAINEMENT_TYPES[en.type] || ENTRAINEMENT_TYPES.seul; 11281 const dureeTxt = en.duree_min ? `${Math.floor(en.duree_min / 60)}h${en.duree_min % 60 ? String(en.duree_min % 60).padStart(2, '0') : ''}` : ''; 11282 const heureTxt = en.heure_debut ? ` · ${escapeHtml(en.heure_debut)}` : ''; 11283 const extrait = en.contenu ? ` <span class="text-faint">· ${escapeHtml(en.contenu.length > 32 ? en.contenu.slice(0, 32) + 'â¦' : en.contenu)}</span>` : ''; 11284 html += `<div data-ui-click="openModalEntrainement(${escapeHtml(encoderArgumentTexteUI(en.id))})" style="display:flex; justify-content:space-between; align-items:center; gap:8px; padding:9px 0; cursor:pointer; font-size:13px; border-bottom:1px solid var(--border)">
11285 <span style="min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">${meta.icone} ${meta.label}${dureeTxt ? ` <span class="text-faint">(${dureeTxt})</span>` : ''}${extrait}</span> 11286 <span style="flex-shrink:0; display:flex; align-items:center; gap:8px"> 11287 <span class="text-faint" style="font-size:11px; white-space:nowrap">${formatDate(en.date)}${heureTxt}</span> 11288 <span class="text-faint" style="font-size:15px">âº</span> 11289 </span> 11290 </div>`; 11291 }); 11292 html += `</div>`; 11293 return html; 11294} 11295 11296// ============================================================ 11297// SCREEN: CALENDAR 11298// ============================================================ 11299function renderCalendar() { 11300 let html = renderUxScreenHeading(isCoach() ? 'Programmation sportive' : 'Compétition', isCoach() ? 'Tournois proposés' : 'Mes tournois'); 11301 11302 if (isCoach()) { 11303 const derniereTentative = cache.importsTournois[0] || null; 11304 const dernierImport = cache.importsTournois.find(importTournoi => 11305 ['success', 'partial'].includes(importTournoi?.statut) && importTournoi.finished_at 11306 ) || null; 11307 const aujourdHui = dateLocaleISO(new Date()); 11308 const incomplets = cache.tournois.filter(t => t?.tenup_id && !t?.categorie && (t.date_fin || t.date_debut || '') >= aujourdHui).length; 11309 html += `<div class="flex gap-1 mb-2" style="justify-content:flex-end;flex-wrap:wrap"> 11310 <button class="btn btn-sm" data-ui-click="openTenupSyncHistory()">Diagnostic TenâUp${cache.rapprochementsTournois.length ? ` · ${cache.rapprochementsTournois.length} rapprochement(s)` : ''}</button> 11311 <button class="btn btn-primary btn-sm" data-ui-click="openModalTournoi()"><img class="btn-action-art" src="assets/v4/nav/tournaments.webp" alt="">${isPreAcademieEnv() ? '+ Nouveau plateau rouge' : '+ Nouveau tournoi'}</button> 11312 ${isPreAcademieEnv() ? '' : `<button id="btn-tenup-sync" class="btn btn-sm" data-ui-click="runTenupSync()"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Synchroniser avec TenâUp</button> 11313 <button class="btn btn-sm" data-ui-click="openModalImportTenup()"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Importer des tournois</button>`} 11314 </div>${derniereTentative?.statut === 'failed' ? `<div class="danger mb-1" style="font-size:11px;text-align:right">Dernière tentative échouée : ${formatDateTime(derniereTentative.finished_at || derniereTentative.started_at)} · ${escapeHtml(TennisDomainRules.libellerErreurTenup(derniereTentative.error_summary))} · nouvelle tentative automatique différée pour protéger le quota</div>` : ''}${dernierImport ? `<div class="text-faint mb-2" style="font-size:11px;text-align:right">Dernière synchronisation terminée : ${formatDateTime(dernierImport.finished_at)}${dernierImport.statut === 'partial' ? ' · résultat partiel' : ''}${incomplets !== null ? ` · ${incomplets} fiche${incomplets > 1 ? 's' : ''} à compléter` : ''}</div>` : ''}`; 11315 } 11316 11317 const joueurConnecte = !isCoach() && profile?.joueur_id ? joueurById(profile.joueur_id) : null; 11318 const vuePreAcademie = isPreAcademieEnv() || (!!joueurConnecte && estPreAcademie(joueurConnecte)); 11319 const tournoisEnvironnement = (vuePreAcademie 11320 ? cache.tournois.filter(TennisDomainRules.tournoiPourPreAcademie) 11321 : (isAcademieEnv() ? cache.tournois.filter(t =>
11321 t?.pedagogie_json?.environnement !== 'pre_academie') : cache.tournois)) 11322 .filter(t => (t.date_fin || t.date_debut || '') >= dateLocaleISO(new Date())); 11323 if (tournoisEnvironnement.length === 0) { 11324 html += `<div class="empty"><span class="icon">ð </span>Aucun tournoi planifié.<br><span class="text-faint">${isCoach() ? 'Propose un tournoi pour le rendre disponible aux joueurs concernés.' : 'Les prochains tournois proposés par ton coach apparaîtront ici.'}</span></div>`; 11325 return html; 11326 } 11327 11328 // ===== Déterminer le scope + afficher le sélecteur ===== 11329 let mode = 'all'; // 'all' (vue d'ensemble) | 'player' (prog d'un joueur) 11330 let perspectiveId = null; 11331 let perspectiveLabel = ''; 11332 11333 if (isCoach()) { 11334 const actifs = cache.joueurs 11335 .filter(j => (j.statut === 'actif' || !j.statut) && joueurDansEnvCourant(j)) 11336 .filter(j => isPoleEnv() || j.participe_palmyre) 11337 .slice() 11338 .sort((a, b) => (a.prenom || '').localeCompare(b.prenom || '')); 11339 const filtreAvecInscrits = etatApp.calendarPlayerId === 'with_registrations'; 11340 const selVal = filtreAvecInscrits ? 'with_registrations' : ((etatApp.calendarPlayerId && actifs.some(j => j.id === etatApp.calendarPlayerId)) ? etatApp.calendarPlayerId : 'all'); 11341 if (selVal !== 'all' && !filtreAvecInscrits) { 11342 mode = 'player'; 11343 perspectiveId = selVal; 11344 const selJoueur = actifs.find(j => j.id === selVal); 11345 perspectiveLabel = selJoueur ? selJoueur.prenom : ''; 11346 } 11347 html += `<div class="card mb-2" style="padding:10px 14px"> 11348 <div class="flex gap-1 mb-1" style="flex-wrap:wrap"> 11349 <button class="btn btn-sm ${selVal === 'all' ? 'btn-primary' : ''}" data-ui-click="setCalendarPlayer('all')">Tous les tournois</button> 11350 <button class="btn btn-sm ${filtreAvecInscrits ? 'btn-primary' : ''}" data-ui-click="setCalendarPlayer('with_registrations')">Avec des inscrits</button> 11351 </div> 11352 <label style="font-size:12px; font-weight:600; display:block; margin-bottom:6px">ð¾ Filtrer par joueur</label> 11353 <select data-ui-change="setCalendarPlayer(this.value)" style="width:100%"> 11354 <option value="" ${selVal === 'all' || filtreAvecInscrits ? 'selected' : ''}>Choisir un joueurâ¦</option> 11355 ${actifs.map(j => `<option value="${j.id}" ${j.id === selVal ? 'selected' : ''}>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}${j.classement ? ' (' + escapeHtml(j.classement) + ')' : ''}</option>`).join('')} 11356 </select> 11357 </div>`; 11358 } else { 11359 // Joueur : bascule « Mes tournois » / « Tous » 11360 if (etatApp.calendarMineOnly) { 11361 mode = 'player'; 11362 perspectiveId = profile && profile.joueur_id; 11363 const moi = perspectiveId ? joueurById(perspectiveId) : null; 11364 perspectiveLabel = moi ? moi.prenom : ''; 11365 } 11366 html += `<div class="card mb-2" style="padding:6px; display:flex; gap:6px"> 11367 <button class="btn btn-sm ${etatApp.calendarMineOnly ? 'btn-primary' : ''}" style="flex:1" data-ui-click="setCalendarMineOnly(true)"><img class="btn-action-art" src="assets/v4/nav/tournaments.webp" alt="">Mes tournois</button> 11368 <button class="btn btn-sm ${!etatApp.calendarMineOnly ? 'btn-primary' : ''}" style="flex:1" data-ui-click="setCalendarMineOnly(false)"><img class="btn-action-art" src="assets/v4/nav/planning.webp" alt="">Tous</button> 11369 </div>`; 11370 11371 // Filtres intelligents : seulement en mode « Tous » (sur sa prog perso, inutile) 11372 if (!etatApp.calendarMineOnly && profile && profile.joueur_id) { 11373 const j = joueurById(profile.joueur_id); 11374 if (j) { 11375 const f = getFiltresTournoi(); 11376 const age = computeAge(j); 11377 const labelSexe = j.sexe === 'F' ? 'â Féminins' : j.sexe === 'M' ? 'â Masculins' : '⥠Mon sexe'; 11378 const labelAge = age != null ? `ð Mon âge (${age} ans +)` : 'ð Mon âge'; 11379 const labelClass = `ð Mon niveau (${escapeHtml(j.classement || 'NC')})`; 11380 const pill = (actif, label, critere) => 11381 `<span class="pill ${actif ? 'active' : ''}" data-ui-click="toggleFiltreTournoi(${escapeHtml(encoderArgumentTexteUI(critere))})" style="${actif ? 'border-color:var(--orange-text); background:var(--orange-bg,rgba(243,146,0,0.12)); color:var(--orange-text)' : ''}">${actif ? 'â ' : ''}${label}</span>`;
11382 // Nombre de tournois masqués par les filtres actifs 11383 const criteres = getFiltresTournoi(); 11384 const anyActif = criteres.sexe || criteres.age || criteres.classement; 11385 let nbMasques = 0; 11386 if (anyActif) nbMasques = tournoisEnvironnement.filter(t => !tournoiCorrespondAuJoueur(t, j, criteres)).length; 11387 html += `<div class="card mb-2" style="padding:12px 14px"> 11388 <div style="font-size:12px; color:var(--text-dim); margin-bottom:8px">Filtrer les tournois adaptés à mon profil :</div> 11389 <div class="pill-group" style="display:flex; flex-wrap:wrap; gap:6px"> 11390 ${j.sexe ? pill(f.sexe, labelSexe, 'sexe') : ''} 11391 ${pill(f.age, labelAge, 'age')} 11392 ${pill(f.classement, labelClass, 'classement')} 11393 </div> 11394 ${anyActif && nbMasques > 0 ? `<div class="text-faint" style="font-size:11px; margin-top:8px">${nbMasques} tournoi${nbMasques > 1 ? 's' : ''} masqué${nbMasques > 1 ? 's' : ''} hors de ton profil · désactive un filtre pour en voir plus</div>` : ''} 11395 ${!anyActif ? `<div class="text-faint" style="font-size:11px; margin-top:8px">Aucun filtre actif : tous les tournois sont affichés</div>` : ''} 11396 </div>`; 11397 } 11398 } 11399 } 11400 11401 // ===== Construire l'ensemble de tournois selon le mode ===== 11402 let tournois; 11403 if (mode === 'player') { 11404 tournois = perspectiveId ? tournoisEnvironnement.filter(t => (listeSure(t.inscrits)).includes(perspectiveId)) : []; 11405 } else { 11406 tournois = isCoach() ? tournoisEnvironnement.slice() : filterTournoisParGenre(tournoisEnvironnement); 11407 } 11408 if (isCoach() && etatApp.calendarPlayerId === 'with_registrations') { 11409 tournois = tournois.filter(t => listeSure(t.inscrits).length > 0); 11410 } 11411 if (!isCoach() && mode === 'all') { 11412 const aujourdHui = dateLocaleISO(new Date()); 11413 // Le catalogue de découverte ne propose que les inscriptions encore 11414 // pertinentes. Un tournoi déjà commencé reste visible dans « Mes tournois » 11415 // lorsque le joueur y est inscrit, et dans la vue coach pour son suivi. 11416 tournois = tournois.filter(t => t.date_debut && t.date_debut >= aujourdHui); 11417 } 11418 tournois = tournois.slice().sort((a, b) => (a.date_debut || '').localeCompare(b.date_debut || '')); 11419 11420 // Bouton d'export PDF (mode prog d'un joueur, s'il a au moins un tournoi) 11421 if (mode === 'player' && perspectiveId && tournois.length > 0) { 11422 html += `<div class="mb-2" style="text-align:right"> 11423 <button class="btn btn-sm" data-ui-click="exportProgrammePDF(${escapeHtml(encoderArgumentTexteUI(perspectiveId))})"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">Exporter le programme en PDF</button> 11424 </div>`; 11425 } 11426 11427 // ===== Rendu groupé par mois ===== 11428 html += renderMoisGroupes(tournois, { mode, perspectiveId, perspectiveLabel }); 11429 11430 return html; 11431} 11432 11433function setCalendarPlayer(id) { 11434 etatApp.definirCalendarPlayer(id || 'all'); 11435 render(); 11436} 11437 11438function setCalendarMineOnly(v) { 11439 etatApp.definirCalendarMineOnly(v); 11440 render(); 11441} 11442 11443async function runTenupSync() { 11444 if (!isCoach()) return; 11445 const bouton = document.getElementById('btn-tenup-sync'); 11446 if (bouton?.disabled) return; 11447 if (bouton) { bouton.disabled = true; bouton.setAttribute('aria-busy','true'); } 11448 toast('Synchronisation TenâUp en coursâ¦'); 11449 try { 11450 const { data, error } = await db.functions.invoke('tenup-sync', { body:{ mode:'sync' } }); 11451 if (error || data?.error) { 11452 let diagnostic = data || {}; 11453 if (error?.context && typeof error.context.json === 'function') { 11454 try { diagnostic = { ...diagnostic, ...(await error.context.clone().json()) }; } catch (_) { /* réponse non JSON */ } 11455 } 11456 const code = diagnostic?.error || error?.message || 'SYNC_FAILED'; 11457 const message = `${TennisDomainRules.libellerErreurTenup(code, diagnostic)} La dernière copie reste disponible.`; 11458 toast(message, true); 11459 await refresh(); 11460 return; 11461 } 11462 if (data?.queued) { 11463 toast(data.alreadyQueued 11464 ? 'La synchronisation TenâUp est déjà en cours.' 11465 : 'Synchronisation TenâUp demandée. Le PC la traitera en arrière-plan dans moins dâune minute.'); 11466 await refresh(); 11467 return; 11468 } 11469 const completude = Number.isFinite(Number(data.detailed)) && Number.isFinite(Number(data.incomplete)) 11470 ? ` · ${data.detailed} avec épreuves · ${data.incomplete} sans épreuve publiée` 11471 : ''; 11472 const etat = data.partial ? 'Synchronisation terminée partiellement' : 'Synchronisation terminée'; 11473 toast(`${etat} · ${data.created} nouveau(x) · ${data.updated} mis à jour · ${data.duplicate} doublon(s)${completude}`, !!data.partial); 11474 await refresh(); 11475 } finally { 11476 if (bouton) { bouton.disabled = false; bouton.removeAttribute('aria-busy'); } 11477 } 11478} 11479 11480function openTenupSyncHistory() { 11481 if (!isCoach()) return; 11482 const runs = cache.importsTournois; 11483 const merges = cache.rapprochementsTournois; 11484 const tournoisById = new Map(cache.tournois.map(t => [t.id,t])); 11485 const html = `<div class="modal-header"><h3 class="modal-title">Suivi TenâUp</h3><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 11486 <div class="section-title">Rapprochements à valider (${merges.length})</div> 11487 ${merges.length ? merges.map(m =>
11487 `<div class="card mb-1"><strong>${escapeHtml(tournoisById.get(m.source_tournament_id)?.nom || 'Tournoi importé')}</strong><div class="text-dim" style="font-size:12px">Correspond probablement à ${escapeHtml(tournoisById.get(m.target_tournament_id)?.nom || 'une fiche existante')} · confiance ${Math.round(Number(m.confidence)*100)} %</div><div class="flex gap-1 mt-1"><button class="btn btn-sm" data-ui-click="resolveTournamentMerge(${escapeHtml(encoderArgumentTexteUI(m.source_tournament_id))},${escapeHtml(encoderArgumentTexteUI(m.target_tournament_id))},false)">Distincts</button><button class="btn btn-primary btn-sm" data-ui-click="resolveTournamentMerge(${escapeHtml(encoderArgumentTexteUI(m.source_tournament_id))},${escapeHtml(encoderArgumentTexteUI(m.target_tournament_id))},true)">Fusionner</button></div></div>`).join('') : '<div class="empty">Aucun doublon à vérifier.</div>'} 11488 <div class="section-title mt-2">Diagnostic des synchronisations</div> 11489 ${runs.length ? runs.map(r => `<div class="card mb-1" style="font-size:12px"><div class="flex-between"><strong>${escapeHtml(r.mode)}</strong><span class="badge">${escapeHtml(r.statut)}</span></div><div class="text-dim">${formatDateTime(r.started_at)} · ${r.created_count} créé(s) · ${r.updated_count} mis à jour · ${r.duplicate_count} doublon(s) · ${r.rejected_count} rejet(s)</div>${r.error_summary?`<div class="danger">${escapeHtml(r.error_summary)}</div>`:''}</div>`).join('') : '<div class="empty">Aucun historique.</div>'}`; 11490 showModal(html); 11491} 11492 11493async function resolveTournamentMerge(sourceId,targetId,accept) { 11494 if (!isCoach()) return; 11495 const { error } = await db.rpc('merge_tournaments',{p_source:sourceId,p_target:targetId,p_accept:!!accept}); 11496 if (error) { toastErreur(error); return; } 11497 closeModal(); toast(accept ? 'Tournois fusionnés, liens locaux conservés' : 'Fiches conservées séparément'); await refresh(); 11498} 11499 11500// Détecte les tournois qui se chevauchent (périodes qui se recoupent). 11501// Retourne un Set d'ids de tournois ayant au moins un chevauchement avec un autre. 11502function detecterChevauchements(tournois) { 11503 return TennisDomainRules.idsTournoisEnChevauchement(tournois); 11504} 11505 11506// Vue calendrier groupée par mois, adaptée au périmètre choisi. 11507function renderMoisGroupes(tournois, ctx) { 11508 const { mode, perspectiveLabel } = ctx; 11509 const isCoachView = isCoach(); 11510 11511 // Cas vide 11512 if (tournois.length === 0) { 11513 if (mode === 'player') { 11514 const msg = isCoachView 11515 ? `${escapeHtml(perspectiveLabel)} n'est inscrit à aucun tournoi pour l'instant.` 11516 : `Tu n'es inscrit à aucun tournoi pour l'instant.`; 11517 const sub = isCoachView 11518 ? `Passe sur « Tous les joueurs » pour voir les tournois à lui proposer.` 11519 : `Passe sur « Tous » pour découvrir les tournois et t'inscrire.`; 11520 return `<div class="empty"><span class="icon">ðï¸</span>${msg}<br><span class="text-faint">${sub}</span></div>`; 11521 } 11522 return `<div class="empty"><span class="icon">ðï¸</span>Aucun tournoi à afficher.${isCoachView ? '' : '<br><span class="text-faint">Les filtres en masquent peut-être : désactive-en pour tout voir.</span>'}</div>`; 11523 } 11524 11525 const MOIS_NOMS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; 11526 const now = new Date(); 11527 const cleCourante = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; 11528 11529 // Grouper par mois (par date de début réelle du tournoi). 11530 const todayStr = dateLocaleISO(new Date()); 11531 const parMois = {}; 11532 tournois.forEach(t => { 11533 const d = new Date(t.date_debut); 11534 const cle = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; 11535 if (!parMois[cle]) parMois[cle] = []; 11536 parMois[cle].push(t); 11537 }); 11538 const clesAvecTournois = Object.keys(parMois).sort(); 11539 const premiere = clesAvecTournois[0]; 11540 const derniere = clesAvecTournois[clesAvecTournois.length - 1]; 11541 11542 // Plage de mois selon le mode 11543 function ymPlus(y, m, n) { const t = y * 12 + (m - 1) + n; return [Math.floor(t / 12), (t % 12) + 1]; } 11544 let cleDebut, cleFin; 11545 if (mode === 'player') { 11546 // tournée vers l'avenir : mois courant â au moins 6 mois (révèle les trous à venir) 11547 const [hy, hm] = ymPlus(now.getFullYear(), now.getMonth() + 1, 5); 11548 const horizonFin = `${hy}-${String(hm).padStart(2, '0')}`; 11549 // Début = mois courant, MAIS on recule si un tournoi est encore EN COURS (commencé 11550 // un mois précédent et pas terminé) pour qu'il reste visible à sa place réelle. 11551 cleDebut = cleCourante; 11552 tournois.forEach(t => { 11553 const finStr = t.date_fin || t.date_debut; 11554 if (finStr >= todayStr && t.date_debut < cleCourante + '-01') { 11555 const cleDebutTournoi = t.date_debut.slice(0, 7); // 'YYYY-MM'
11556 if (cleDebutTournoi < cleDebut) cleDebut = cleDebutTournoi; 11557 } 11558 }); 11559 cleFin = derniere > horizonFin ? derniere : horizonFin; 11560 } else { 11561 // vue d'ensemble : du plus tôt (ou mois courant) au plus tard (ou mois courant) 11562 cleDebut = premiere < cleCourante ? premiere : cleCourante; 11563 cleFin = derniere > cleCourante ? derniere : cleCourante; 11564 } 11565 11566 // Générer les mois continus 11567 const moisAffiches = []; 11568 let [y, m] = cleDebut.split('-').map(Number); 11569 const [yFin, mFin] = cleFin.split('-').map(Number); 11570 let garde = 0; 11571 while ((y < yFin || (y === yFin && m <= mFin)) && garde < 120) { 11572 moisAffiches.push(`${y}-${String(m).padStart(2, '0')}`); 11573 m++; if (m > 12) { m = 1; y++; } 11574 garde++; 11575 } 11576 11577 // Chevauchements : seulement en mode joueur (sur tous les tournois ce serait du bruit) 11578 const chevauchements = mode === 'player' ? detecterChevauchements(tournois) : new Set(); 11579 11580 // Bandeau d'alerte "aucun tournoi à venir" (mode joueur) 11581 let html = ''; 11582 if (mode === 'player') { 11583 const aujourdhui = Date.now() - 24 * 3600 * 1000; 11584 const futurs = tournois.filter(t => new Date(t.date_fin || t.date_debut).getTime() >= aujourdhui); 11585 if (futurs.length === 0) { 11586 html += `<div class="card mb-2" style="padding:10px 14px; border-left:3px solid var(--warning)"> 11587 <div style="font-size:13px; color:var(--warning-text); font-weight:600">â ï¸ Aucun tournoi à venir</div> 11588 <div class="text-faint" style="font-size:12px; margin-top:2px">${isCoachView ? `${escapeHtml(perspectiveLabel)} n'a rien de programmé dans les prochains mois â l'occasion de lui suggérer un tournoi.` : `Tu n'as rien de programmé pour le moment. Pense à t'inscrire à un tournoi !`}</div> 11589 </div>`; 11590 } 11591 } 11592 11593 // Rendu des mois 11594 moisAffiches.forEach(cle => { 11595 const [annee, mois] = cle.split('-').map(Number); 11596 let tournoisDuMois = parMois[cle] || []; 11597 // En mode « Mes tournois », on n'affiche jamais un tournoi déjà TERMINà (date de fin 11598 // passée). Un mois antérieur n'est dans la plage que pour montrer un tournoi EN COURS. 11599 if (mode === 'player') { 11600 tournoisDuMois = tournoisDuMois.filter(t => (t.date_fin || t.date_debut) >= todayStr); 11601 } 11602 const estMoisCourant = cle === cleCourante; 11603 11604 html += `<div style="display:flex; justify-content:space-between; align-items:center; background:var(--bg-input); ${estMoisCourant ? 'border-left:4px solid var(--orange); ' : ''}border-radius:var(--radius-sm); padding:9px 13px; margin:16px 0 8px"> 11605 <span style="font-size:15px; font-weight:700">${MOIS_NOMS[mois - 1]} <span style="color:var(--text-dim); font-weight:500; font-size:13px">${annee}</span></span> 11606 <span style="font-size:11px; color:var(--text-dim)">${estMoisCourant ? 'ce mois-ci · ' : ''}${tournoisDuMois.length > 0 ? tournoisDuMois.length + ' tournoi' + (tournoisDuMois.length > 1 ? 's' : '') : 'â'}</span> 11607 </div>`; 11608 11609 if (tournoisDuMois.length === 0) { 11610 html += `<div class="card" style="padding:10px 14px; opacity:0.5; text-align:center"><span class="text-faint" style="font-size:12px">Aucun tournoi</span></div>`; 11611 } else { 11612 tournoisDuMois.forEach(t => { 11613 html += renderTournoiMois(t, { mode, chevauche: chevauchements.has(t.id), isCoachView }); 11614 }); 11615 } 11616 }); 11617 11618 return html; 11619} 11620 11621// Carte d'un tournoi dans la vue par mois. Le rendu s'adapte au contexte : 11622// - coach + 'all' : badge d'occupation (nb inscrits, repère les tournois déserts) 11623// - mode 'player' : nb de joueurs de l'académie + alerte chevauchement (conflit perso) 11624// - joueur + 'all' : met en avant ses inscriptions + bouton s'inscrire 11625function renderTournoiMois(t, opts) { 11626 const { mode, chevauche, isCoachView } = opts; 11627 const dateStr = t.date_fin && t.date_fin !== t.date_debut 11628 ? `${formatDate(t.date_debut)} â ${formatDate(t.date_fin)}` 11629 : formatDate(t.date_debut); 11630 const inscrits = listeSure(t.inscrits); 11631 const nbInscrits = inscrits.length; 11632 const monJoueurId = profile && profile.joueur_id; 11633 const jeSuisInscrit = monJoueurId && inscrits.includes(monJoueurId); 11634 const isPast = new Date(t.date_fin || t.date_debut).getTime() < Date.now() - 24 * 3600 * 1000; 11635 11636 let actionBtn = ''; 11637 if (!isCoachView && !isPast && monJoueurId) { 11638 actionBtn = jeSuisInscrit 11639 ? `<button class="btn btn-sm" data-ui-click="event.stopPropagation(); toggleTournoiInscription(${escapeHtml(encoderArgumentTexteUI(t.id))}, false)">â© Me désinscrire</button>` 11640 : `<button class="btn btn-sm btn-primary" data-ui-click="event.stopPropagation(); toggleTournoiInscription(${escapeHtml(encoderArgumentTexteUI(t.id))}, true)">+ Je m'inscris</button>`; 11641 } 11642 11643 // Badge / infos / couleur de bordure selon le contexte 11644 let topBadge = ''; 11645 let infoLine = ''; 11646 let borderColor; 11647 const nomsInscrits = inscritsNomsPole(t); 11648 const ligneInscrits = nomsInscrits.length > 0 11649 ? `<div class="tournament-participants">ð¾ Inscrits : ${escapeHtml(nomsInscrits.join(', '))}</div>` 11650 : (nbInscrits > 0 ? `<div class="tournament-participants">ð¾ ${nbInscrits} inscrit${nbInscrits > 1 ? 's' : ''} de l'académie</div>` : ''); 11651 if (isCoachView && mode === 'all') { 11652 topBadge = nbInscrits > 0 11653 ? `<span class="badge badge-success" style="font-size:10px">${nbInscrits} inscrit${nbInscrits > 1 ? 's' : ''}</span>` 11654 : `<span class="badge" style="font-size:10px; background:var(--bg-input); color:var(--text-dim)">0 inscrit</span>`;
11655 infoLine = ligneInscrits; 11656 borderColor = nbInscrits > 0 ? 'var(--success)' : 'var(--border-strong)'; 11657 } else if (mode === 'player') { 11658 infoLine = ligneInscrits; 11659 borderColor = isPast ? 'var(--border-strong)' : 'var(--success)'; 11660 } else { 11661 topBadge = nbInscrits > 0 ? `<span class="badge badge-success" style="font-size:10px">${nbInscrits} inscrit${nbInscrits > 1 ? 's' : ''}</span>` : ''; 11662 infoLine = ligneInscrits; 11663 borderColor = jeSuisInscrit ? 'var(--success)' : 'var(--border-strong)'; 11664 } 11665 11666 // En mode joueur, un tournoi passé affiche un badge « passé » 11667 const rightBadge = (isPast && mode === 'player') ? '<span class="badge" style="font-size:10px; background:var(--bg-input); color:var(--text-dim)">passé</span>' : topBadge; 11668 const estImportTenup = t.source_creation === 'tenup_sync' || Boolean(t.tenup_id); 11669 const statutVisible = t.statut_local && !(estImportTenup && t.statut_local === 'a_verifier') 11670 ? ` · ${escapeHtml(TennisTournamentsTenup.STATUTS_TOURNOI[t.statut_local] || t.statut_local)}` 11671 : ''; 11672 const provenance = t.nature === 'competition_interne_club' 11673 ? '<span class="text-faint" style="font-size:10px">Compétition interne du club</span>' 11674 : estImportTenup 11675 ? '' 11676 : '<span class="text-faint" style="font-size:10px">Officiel FFT</span>'; 11677 const sceauFft = estImportTenup 11678 ? '<span class="fft-import-stamp" title="Import officiel FFT depuis TenâUp" aria-label="Import officiel FFT vérifié">FFT</span>' 11679 : ''; 11680 const categorieUtile = t.categorie && TennisDomainRules.normaliserTexteRecherche(t.categorie) !== TennisDomainRules.normaliserTexteRecherche(t.nom) 11681 ? escapeHtml(t.categorie) 11682 : (estImportTenup ? 'Détail des épreuves non disponible dans lâapplication' : ''); 11683 const categoriesHtml = categorieUtile && t.categorie 11684 ? `${estImportTenup ? '<div class="text-faint" style="font-size:10px; margin-top:5px">Ãpreuve officielle publiée sur TenâUp :</div>' : ''}<div class="tournament-category-list">${String(t.categorie).split('|').map((categorie) => categorie.trim()).filter(Boolean).map((categorie) => `<span class="tournament-category-chip">${escapeHtml(categorie)}</span>`).join('')}</div>` 11685 : (categorieUtile ? `<div class="text-faint" style="font-size:11px; margin-top:3px">${categorieUtile}</div>` : ''); 11686 const distanceBouliac = Number.isFinite(Number(t.distance_km)) 11687 ? ` · ${Math.round(Number(t.distance_km))} km de Bouliac` 11688 : ''; 11689 11690 return `<div class="card clickable tournament-card-layout" data-ui-click="openModalTournoi(${escapeHtml(encoderArgumentTexteUI(t.id))})" style="cursor:pointer; padding:12px; margin-bottom:6px; ${isPast ? 'opacity:0.6; ' : ''}border-left:3px solid ${borderColor}"> 11691 <div class="tournament-card-title"> 11692 <strong style="font-size:14px">${escapeHtml(t.nom)}</strong>${provenance || statutVisible ? `<div style="margin-top:3px">${provenance}${statutVisible}</div>` : ''} 11693 <div class="tournament-card-badges">${rightBadge}${sceauFft}</div> 11694 </div> 11695 <div class="text-dim" style="font-size:12px">ð ${dateStr}${t.lieu ? ' · ð ' + escapeHtml(t.lieu) : ''}${distanceBouliac}</div> 11696 ${categoriesHtml} 11697 ${infoLine} 11698 ${chevauche ? `<div style="margin-top:6px; font-size:11px; color:var(--warning-text)">â ï¸ Même période qu'un autre tournoi</div>` : ''} 11699 ${(jeSuisInscrit && !isCoachView && mode === 'all') ? `<div style="margin-top:6px; font-size:11px; color:var(--success-text)">â Tu y es inscrit</div>` : ''} 11700 ${actionBtn ? `<div style="margin-top:10px">${actionBtn}</div>` : ''} 11701 </div>`; 11702} 11703 11704async function toggleTournoiInscription(tournoiId, inscrire) { 11705 if (!profile || !profile.joueur_id) { 11706 toast('Profil joueur introuvable', true); 11707 return; 11708 } 11709 const t = cache.tournois.find(x => x.id === tournoiId); 11710 if (!t) return; 11711 11712 const { error } = await db.rpc('set_my_tournament_registration', { 11713 p_tournament_id: tournoiId, 11714 p_status: inscrire ? 'a_confirmer' : 'forfait' 11715 }); 11716 11717 if (error) { toastErreur(error); return; } 11718 toast(inscrire ? 'Inscription à confirmer enregistrée ð¾' : 'Forfait enregistré'); 11719 await refresh(); 11720} 11721 11722// ============================================================ 11723// SCREEN: INVITATIONS (coach) 11724// ============================================================ 11725function renderInvitations() { 11726 const invitations = listeSure(cache.invitations); 11727 const joueursSansCompte = cache.joueurs.filter(j =>
11727 !j.user_id); 11728 11729 let html = ''; 11730 11731 // Cas spécial : aucun joueur du tout dans la base 11732 if (cache.joueurs.length === 0) { 11733 return `<div class="empty"> 11734 <span class="icon">ð¥</span> 11735 <strong>Aucun joueur dans la base</strong><br> 11736 <span class="text-faint">Va d'abord dans l'onglet <strong>Joueurs</strong> et crée des fiches joueurs.<br>Ensuite tu pourras générer des codes d'invitation pour qu'ils se créent un compte.</span> 11737 <br><button class="btn btn-primary mt-2" data-ui-click="navigate('players')">â Aller dans Joueurs</button> 11738 </div>`; 11739 } 11740 11741 if (joueursSansCompte.length > 0) { 11742 html += `<div class="section-title">Joueurs sans compte (${joueursSansCompte.length})</div>`; 11743 html += `<div class="auth-info" style="font-size:13px;line-height:1.5;margin-bottom:12px"><strong>Un parent avec plusieurs enfants conserve un seul compte et un seul email.</strong><br>Génère le code du nouvel enfant. Le parent se connecte ensuite à son compte existant et choisit <strong>Mon profil â Mes enfants â Ajouter un enfant</strong>. Il ne doit pas refaire une inscription.</div>`; 11744 joueursSansCompte.forEach(j => { 11745 const existingInvite = invitations.find(i => i.joueur_id === j.id && !i.utilise && new Date(i.expires_at) > new Date()); 11746 html += `<div class="player-row" style="cursor:default"> 11747 ${avatarHtml(j, 'md')} 11748 <div class="player-info"> 11749 <div class="player-name">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</div> 11750 ${existingInvite ? `<button type="button" class="invite-code" data-ui-click="copyCodeFromElement(this)" title="Cliquer pour copier">${escapeHtml(existingInvite.code)}</button> 11751 <div class="text-faint" style="font-size:11px">Expire le ${formatDate(existingInvite.expires_at)} · clic pour copier</div>` : 11752 `<button class="btn btn-sm btn-primary mt-1" data-ui-click="genererInvitation(${escapeHtml(encoderArgumentTexteUI(j.id))})"><img class="btn-action-art" src="assets/v4/menu/invitations.webp" alt="">Générer un code</button>`} 11753 </div> 11754 </div>`; 11755 }); 11756 } 11757 11758 if (invitations.length > 0) { 11759 html += `<div class="section-title">Historique des invitations</div><div class="card">`; 11760 invitations.slice(0, 20).forEach(i => { 11761 const j = joueurById(i.joueur_id); 11762 const expire = new Date(i.expires_at) < new Date(); 11763 const status = i.utilise ? 'utilisé' : (expire ? 'expiré' : 'actif'); 11764 const badge = i.utilise ? 'badge-success' : (expire ? 'badge-danger' : 'badge-warning'); 11765 html += `<div class="match-row"> 11766 <div class="match-result-row"> 11767 <span><strong>${j ? escapeHtml(j.prenom + ' ' + j.nom) : '?'}</strong> · <code style="font-size:12px">${escapeHtml(i.code)}</code></span> 11768 <span style="display:flex;align-items:center;gap:8px"><span class="badge ${badge}">${status}</span>${!i.utilise && !expire ? `<button class="btn btn-sm btn-danger" data-ui-click="revoquerInvitation(${escapeHtml(encoderArgumentTexteUI(i.id))})">Révoquer</button>` : ''}</span> 11769 </div> 11770 <div class="match-meta">créé ${formatDate(i.created_at)}${i.utilise ? '' : ' · expire ' + formatDate(i.expires_at)}</div> 11771 </div>`; 11772 }); 11773 html += `</div>`; 11774 } 11775 11776 if (joueursSansCompte.length === 0 && invitations.length === 0) { 11777 html += `<div class="empty"><span class="icon">â</span>Tous tes joueurs ont déjà un compte.<br><span class="text-faint">Crée de nouveaux joueurs dans l'onglet Joueurs pour générer d'autres invitations.</span></div>`; 11778 } 11779 11780 return html; 11781} 11782 11783function copyCode(code) { 11784 if (!code) { toast('Aucun code configuré', true); return; } 11785 navigator.clipboard.writeText(code).then(() => toast('Code copié : ' + code)); 11786} 11787 11788// La valeur est lue depuis le texte du bouton : aucune donnée dynamique n'est 11789// interpolée dans du JavaScript inline, ce qui supprime la classe d'injection liée 11790// aux quotes décodées dans les attributs HTML. 11791function copyCodeFromElement(element) { 11792 copyCode(element ? element.textContent.trim() : ''); 11793} 11794 11795async function genererInvitation(joueurId) { 11796 // Générer un code lisible (8 chars alphanum sans ambiguïté) 11797 const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; 11798 let code = ''; 11799 for (let i = 0; i < 8; i++) code += chars[Math.floor(Math.random() * chars.length)]; 11800 11801 const { error } = await db.from('invitations').insert({ 11802 code, 11803 joueur_id: joueurId 11804 }); 11805 11806 if (error) { toastErreur(error); return; } 11807 toast('Code généré : ' + code); 11808 await refresh(); 11809} 11810 11811async function revoquerInvitation(invitationId) { 11812 if (!isCoach()) return; 11813 if (!(await demanderConfirmation('Révoquer ce code dâinvitation ? Il ne pourra plus être utilisé.', { titre: 'Révoquer lâinvitation', libelle: 'Révoquer' }))) return; 11814 const { error } = await db.from('invitations').delete().eq('id', invitationId); 11815 if (error) { toastErreur(error); return; } 11816 toast('Invitation révoquée'); 11817 await refresh(); 11818} 11819 11820// ============================================================ 11821// GESTION DES ÃDITIONS PALMYRE (réglages) 11822// ============================================================ 11823 11824// Liste les éditions (actives et inactives), chacune éditable. 11825function renderEditionsSettings() { 11826 // On lit toutes les éditions afin que le coach puisse aussi réactiver une archive. 11827 const editions = (cache.evenementsTous || listeSure(cache.evenements)); 11828 const editionOuverte = editions.find(ed => ed.actif) || null; 11829 let html = `<div class="card"> 11830 <div class="text-dim mb-1" style="font-size:13px">Gère les éditions du stage Palmyre (ou d'autres événements). Les dates servent à proposer automatiquement l'estampillage des matchs joués pendant la période.</div> 11831 <div style="margin:10px 0"> 11832 <button class="btn btn-primary btn-sm" data-ui-click="openModalEdition()" ${editionOuverte ? 'disabled aria-disabled="true"' : ''}>+ Nouvelle édition</button> 11833 ${editionOuverte ? `<div class="auth-info mt-1" style="font-size:12px">Archive dâabord « ${escapeHtml(editionOuverte.nom)} » pour pouvoir créer une nouvelle édition. Son historique restera conservé.</div>` : ''} 11834 </div>`; 11835 11836 if (editions.length === 0) { 11837 html += `<div class="text-faint" style="font-size:13px">Aucune édition pour le moment.</div></div>`; 11838 return html; 11839 } 11840 11841 html += `<div style="display:flex; flex-direction:column; gap:8px">`; 11842 editions.forEach(ed => { 11843 const periode = (ed.date_debut && ed.date_fin) 11844 ? `${formatDate(ed.date_debut)} â ${formatDate(ed.date_fin)}` 11845 : 'dates non renseignées'; 11846 html += `<div style="display:flex; align-items:center; gap:10px; padding:10px; border:1px solid var(--border); border-radius:8px; ${ed.actif ? '' : 'opacity:0.55'}"> 11847 <div style="flex:1; cursor:pointer" data-ui-click="openModalEdition(${escapeHtml(encoderArgumentTexteUI(ed.id))})"> 11848 <div style="font-weight:600">ðï¸ ${escapeHtml(ed.nom)} ${ed.actif ? '' : '<span class="text-faint" style="font-size:11px">(inactive)</span>'}</div> 11849 <div class="text-faint" style="font-size:12px">${periode}</div> 11850 </div> 11851 <button class="btn btn-sm" data-ui-click="toggleEditionActive(${escapeHtml(encoderArgumentTexteUI(ed.id))}, ${ed.actif ? 'false' : 'true'})">${ed.actif ? 'Archiver' : 'Réactiver'}</button> 11852 <button class="btn btn-sm" data-ui-click="openModalEdition(${escapeHtml(encoderArgumentTexteUI(ed.id))})">Modifier</button> 11853 </div>`; 11854 }); 11855 html += `</div></div>`; 11856 return html; 11857} 11858 11859function openModalEdition(editionId) { 11860 const editions = (cache.evenementsTous || listeSure(cache.evenements)); 11861 const ed = editionId ? editions.find(e => e.id === editionId) : null; 11862 const isEdit = !!ed; 11863 11864 if (!isEdit) { 11865 const editionOuverte = editions.find(item => item.actif); 11866 if (editionOuverte) { 11867 toast(`Archive dâabord « ${editionOuverte.nom} » avant de créer une nouvelle édition Palmyre.`, true); 11868 return; 11869 } 11870 } 11871 11872 const html = `<div class="modal-header"> 11873 <h3 class="modal-title">${isEdit ? 'Modifier lâédition' : 'Nouvelle édition'}</h3> 11874 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 11875 </div> 11876 <form data-ui-submit="saveEdition(event, ${escapeHtml(encoderArgumentTexteUI(editionId || ''))})"> 11877 <div class="form-row"><label class="form-label">Nom de l'édition *</label> 11878 <input type="text" name="nom" required value="${ed ? escapeHtml(ed.nom) : ''}" placeholder="La Palmyre 2027"></div> 11879 <div class="grid-2"> 11880 <div class="form-row"><label class="form-label">Date de début *</label> 11881 <input type="date" name="date_debut" required value="${ed ? (ed.date_debut || '') : ''}"></div> 11882 <div class="form-row"><label class="form-label">Date de fin *</label> 11883 <input type="date" name="date_fin" required value="${ed ? (ed.date_fin || '') : ''}"></div> 11884 </div> 11885 <div class="form-row"><label class="form-label">Objectif de matchs (sur la semaine) *</label> 11886 <input type="number" name="objectif_matches" required min="1" max="100" value="${ed && ed.objectif_matches != null ? ed.objectif_matches : 20}"> 11887 <div class="text-faint" style="font-size:11px; margin-top:3px">Nombre de matchs visé par participant sur toute la durée du stage (sert au score d'engagement).</div></div> 11888 <div class="form-row" style="display:flex; align-items:center; gap:10px"> 11889 <input type="checkbox" name="actif" id="edActif" ${!ed || ed.actif ? 'checked' : ''} style="width:18px; height:18px"> 11890 <label for="edActif" style="margin:0; cursor:pointer; font-size:13px">Ãdition active (ouverte depuis le menu Palmyre)</label> 11891 </div> 11892 <div class="flex gap-1 mt-2"> 11893 <button type="button" class="btn flex-1" data-ui-click="closeModal()">Annuler</button> 11894 <button type="submit" class="btn btn-primary flex-1">${isEdit ? 'Enregistrer' : 'Créer'}</button> 11895 </div> 11896 </form>`; 11897 showModal(html); 11898} 11899 11900async function saveEdition(e, editionId) { 11901 e.preventDefault(); 11902 const f = e.target; 11903 const nom = f.nom.value.trim(); 11904 const dateDebut = f.date_debut.value; 11905 const dateFin = f.date_fin.value; 11906 11907 if (dateFin < dateDebut) { 11908 toast('La date de fin doit être après la date de début', true); 11909 return; 11910 } 11911 if (!editionId) { 11912 const editionOuverte = (cache.evenementsTous || listeSure(cache.evenements)).find(item => item.actif); 11913 if (editionOuverte) { 11914 toast(`Création impossible : archive dâabord « ${editionOuverte.nom} ».`, true); 11915 return; 11916 } 11917 } 11918 const verrou = verrouillerSoumission(f); 11919 if (!verrou) return; 11920 11921 // Clé technique : générée à partir du nom (slug) pour une nouvelle édition.
11922 const obj = { 11923 nom, 11924 date_debut: dateDebut, 11925 date_fin: dateFin, 11926 objectif_matches: parseInt(f.objectif_matches.value, 10) || 20, 11927 // Une nouvelle édition devient toujours lâunique environnement Palmyre ouvert. 11928 actif: editionId ? f.actif.checked : true 11929 }; 11930 11931 let error; 11932 try { 11933 if (editionId) { 11934 ({ error } = await updateConfirmee(db.from('evenements').update(obj).eq('id', editionId))); 11935 } else { 11936 // Clé unique : slug du nom + suffixe aléatoire court pour éviter les collisions. 11937 const slug = nom.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') 11938 .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 30) || 'edition'; 11939 obj.cle = `${slug}_${Math.random().toString(36).slice(2, 6)}`; 11940 obj.type = 'palmyre'; 11941 ({ error } = await db.from('evenements').insert(obj)); 11942 } 11943 } finally { 11944 deverrouillerSoumission(verrou); 11945 } 11946 if (error) { toastErreur(error); return; } 11947 closeModal(); 11948 toast(editionId ? 'Ãdition mise à jour' : 'Ãdition créée'); 11949 await reloadTables(['evenements']); 11950 render(); 11951} 11952 11953async function toggleEditionActive(editionId, actif) { 11954 if (actif) { 11955 const autreEditionOuverte = (cache.evenementsTous || listeSure(cache.evenements)) 11956 .find(item => item.actif && item.id !== editionId); 11957 if (autreEditionOuverte) { 11958 toast(`Réactivation impossible : archive dâabord « ${autreEditionOuverte.nom} ».`, true); 11959 return; 11960 } 11961 } 11962 const { error } = await updateConfirmee(db.from('evenements').update({ actif }).eq('id', editionId)); 11963 if (error) { toastErreur(error); return; } 11964 const edition = (cache.evenementsTous || listeSure(cache.evenements)).find(item => item.id === editionId); 11965 if (!actif && edition && etatApp.environnement === edition.cle) { 11966 etatApp.definirEnvironnement('pole'); 11967 } 11968 toast(actif ? 'Ãdition réactivée' : 'Ãdition archivée'); 11969 await reloadTables(['evenements']); 11970 render(); 11971} 11972 11973// ============================================================ 11974// SCREEN: SETTINGS (coach) 11975// ============================================================
11976function renderSettings() { 11977 // Le code annuel simplifie l'accueil d'une promotion nombreuse. Il n'active 11978 // jamais directement un joueur : chaque demande reste validée par un coach. 11979 let html = `<div class="section-title">Accès des joueurs</div>`; 11980 html += `<div class="card"> 11981 <div class="text-dim mb-1" style="font-size:13px">Diffuse ce code annuel aux joueurs autorisés à demander leur inscription. Chaque demande reste bloquée jusqu'à sa validation ou son refus par un coach.</div> 11982 ${cache.codeAccesPole 11983 ? `<button type="button" class="invite-code" style="margin-top:10px;cursor:pointer" data-ui-click="copyCodeFromElement(this)" title="Cliquer pour copier">${escapeHtml(cache.codeAccesPole)}</button>` 11984 : `<div class="alert danger" style="margin-top:10px"><div><strong>Code joueurs non configuré</strong><div style="font-size:11px;margin-top:3px">L'inscription joueur reste bloquée tant que le code annuel n'est pas défini.</div></div></div>`} 11985 <div class="flex gap-1 mt-1" style="flex-wrap:wrap"> 11986 <button class="btn btn-primary btn-sm" data-ui-click="changeCodeAcces()"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Changer le code annuel</button> 11987 <button class="btn btn-sm" data-ui-click="navigate('invitations')"><img class="btn-action-art" src="assets/v4/menu/invitations.webp" alt="">Invitations individuelles (optionnel)</button> 11988 </div> 11989 </div>`; 11990 11991 // Section : Code d'accès coach 11992 html += `<div class="section-title">Code d'accès coach</div>`; 11993 html += `<div class="card"> 11994 <div class="text-dim mb-1" style="font-size:13px">Ce code permet à un <strong>autre coach</strong> de créer son propre compte coach. Ne le communique qu'à des personnes de confiance : un compte coach a accès à toutes les données de l'académie.</div> 11995 ${cache.codeAccesCoach 11996 ? `<button type="button" class="invite-code" style="margin-top:10px;cursor:pointer" data-ui-click="copyCodeFromElement(this)" title="Cliquer pour copier">${escapeHtml(cache.codeAccesCoach)}</button>` 11997 : `<div class="alert danger" style="margin-top:10px"><div><strong>Code coach non configuré</strong><div style="font-size:11px;margin-top:3px">La ligne <code>code_acces_coach</code> est absente ou inaccessible. La création d'un coach doit rester bloquée côté serveur.</div></div></div>`} 11998 <button class="btn btn-sm mt-1" data-ui-click="changeCodeCoach()"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Changer le code coach</button> 11999 </div>`; 12000 12001 html += `<div class="section-title">Groupes de l'académie</div>`; 12002 // Iterer dans l'ordre logique : tremplin â accession â élite 12003 const groupOrder = ['tremplin', 'accession', 'elite']; 12004 groupOrder.forEach(key => { 12005 const g = cache.config[key]; 12006 if (!g || typeof g !== 'object' || !g.nom) return; 12007 html += `<div class="card"> 12008 <div class="row-between mb-1"> 12009 <strong>${escapeHtml(g.nom)}</strong> 12010 <span class="badge badge-${key}">${escapeHtml(key)}</span> 12011 </div> 12012 <div class="grid-2"> 12013 <div> 12014 <label class="form-label">Places max</label> 12015 <input type="number" min="1" max="50" value="${g.places}" data-config-key="${key}" data-field="places" data-ui-change="updateGroupeConfig(this)"> 12016 </div> 12017 <div> 12018 <label class="form-label">Matches/an objectif</label> 12019 <input type="number" min="0" max="200" value="${g.objectifMatches}" data-config-key="${key}" data-field="objectifMatches" data-ui-change="updateGroupeConfig(this)"> 12020 </div> 12021 </div> 12022 </div>`; 12023 }); 12024 12025 html += `<div class="section-title">ðï¸ Ãditions Palmyre</div>`; 12026 html += renderEditionsSettings(); 12027 12028 html += `<div class="section-title">ð Registre RGPD</div>`; 12029 html += `<div class="card"> 12030 <div class="text-dim mb-1" style="font-size:13px">Génère le registre des consentements de tous les joueurs actifs de l'académie, exportable en PDF. Utile en cas de contrôle CNIL ou pour archivage administratif.</div> 12031 <div style="margin-top:10px"> 12032 <button class="btn btn-primary btn-sm" data-ui-click="exportRegistreConsentementsPDF()"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">Télécharger le registre global (PDF)</button> 12033 </div> 12034 </div>`; 12035 12036 html += `<div class="section-title">ð Badges</div>`; 12037 html += `<div class="card"> 12038 <div class="text-dim mb-1" style="font-size:13px">Recalcule et enregistre les badges déjà mérités par <strong>tous</strong> les joueurs (utile pour les joueurs qui n'ont rien déclaré depuis l'arrivée des badges). Le fil d'actualité et les badges se rempliront pour tout le monde.</div> 12039 <div style="margin-top:10px">
12040 <button class="btn btn-primary btn-sm" id="btnRattrapageBadges" data-ui-click="rattrapageBadgesTousJoueurs()"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Recalculer les badges de tous les joueurs</button> 12041 </div> 12042 </div>`; 12043 12044 html += `<div class="section-title">Informations</div><div class="card text-dim" style="font-size:13px"> 12045 <p><strong style="color:var(--orange-text)">Académie de Tennis Bouliac</strong></p> 12046 <p>Application centralisée et synchronisée via Supabase. Les données sont stockées en toute sécurité, accessibles depuis n'importe quel appareil après connexion.</p> 12047 <p>Connecté en tant que : <strong>${escapeHtml(profile.email)}</strong></p> 12048 <p class="text-faint" style="margin-top:12px" id="appVersionLine">Version : chargement...</p> 12049 </div>`; 12050 12051 // Affichage depuis la source de version partagée. 12052 setTimeout(loadAppVersion, 50); 12053 12054 return html; 12055} 12056 12057// Affiche la version partagée dans tous les emplacements concernés. 12058async function loadAppVersion() { 12059 const settingsLine = document.getElementById('appVersionLine'); 12060 const authFooter = document.getElementById('authVersionFooter'); 12061 const navFooter = document.getElementById('navVersionFooter'); 12062 // Si aucun des éléments n'est présent, rien à faire 12063 if (!settingsLine && !authFooter && !navFooter) return; 12064 const versionLabel = APP_VERSION_CURRENT || 'inconnue'; 12065 if (settingsLine) settingsLine.textContent = `Version ${versionLabel}`; 12066 if (authFooter) authFooter.textContent = `Version ${versionLabel}`; 12067 if (navFooter) navFooter.textContent = versionLabel; 12068} 12069 12070function changeCodeAcces() { 12071 showModalCodeAcces('joueur'); 12072} 12073 12074function changeCodeCoach() { 12075 showModalCodeAcces('coach'); 12076} 12077 12078function showModalCodeAcces(type) { 12079 const coach = type === 'coach'; 12080 const valeur = coach ? (cache.codeAccesCoach || '') : (cache.codeAccesPole || ''); 12081 const boutonEnregistrer = coach 12082 ? '<button class="btn btn-primary flex-1" data-ui-click="saveCodeCoach()">Enregistrer</button>' 12083 : '<button class="btn btn-primary flex-1" data-ui-click="saveCodeAcces()">Enregistrer</button>'; 12084 showModal(`<div class="modal-header"> 12085 <h3 class="modal-title">${coach ? 'Code dâaccès coach' : 'Code dâaccès joueurs'}</h3> 12086 <button class="modal-close" data-ui-click="closeModal()" aria-label="Fermer">Ã</button> 12087 </div> 12088 <div class="alert warning"><div>Ce code donne accès à l'inscription ${coach ? 'avec le rôle coach. Réserve-le strictement aux coachs autorisés.' : 'des joueurs de lâacadémie.'}</div></div> 12089 <div class="form-row"> 12090 <label class="form-label" for="nouveauCodeAcces">Nouveau code</label> 12091 <input type="text" id="nouveauCodeAcces" value="${escapeHtml(valeur)}" minlength="${coach ? 6 : 4}" autocomplete="off" autocapitalize="characters" spellcheck="false"> 12092 </div> 12093 <div class="flex gap-1 mt-2"> 12094 <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button> 12095 ${boutonEnregistrer} 12096 </div>`); 12097} 12098 12099async function saveCodeAcces() { 12100 const nouveau = $('nouveauCodeAcces')?.value || ''; 12101 if (!nouveau || nouveau.trim().toUpperCase() === cache.codeAccesPole) { closeModal(); return; } 12102 const codeUp = nouveau.trim().toUpperCase(); 12103 if (codeUp.length < 4) { toast('Code trop court (min 4 caractères)', true); return; } 12104 12105 const { error } = await updateConfirmee(db.from('config').update({ 12106 valeur: codeUp, 12107 updated_at: new Date().toISOString() 12108 }).eq('cle', 'code_acces_pole'), 'cle'); 12109 12110 if (error) { toastErreur(error); return; } 12111 cache.codeAccesPole = codeUp; 12112 closeModal(); 12113 toast('Code dâaccès joueurs mis à jour'); 12114 render(); 12115} 12116 12117async function saveCodeCoach() { 12118 const nouveau = $('nouveauCodeAcces')?.value || ''; 12119 if (!nouveau || nouveau.trim().toUpperCase() === cache.codeAccesCoach) { closeModal(); return; } 12120 const codeUp = nouveau.trim().toUpperCase(); 12121 if (codeUp.length < 6) { toast('Code trop court (min 6 caractères)', true); return; } 12122 12123 const { error } = await updateConfirmee(db.from('config').update({ 12124 valeur: codeUp, 12125 updated_at: new Date().toISOString() 12126 }).eq('cle', 'code_acces_coach'), 'cle'); 12127 12128 if (error) { toastErreur(error); return; } 12129 cache.codeAccesCoach = codeUp; 12130 closeModal(); 12131 toast('Code coach mis à jour'); 12132 render(); 12133} 12134 12135async function updateGroupeConfig(input) { 12136 const key = input.dataset.configKey;
12137 const field = input.dataset.field; 12138 const value = parseInt(input.value) || 0; 12139 const { data, error } = await db.rpc('coach_update_groupe_config', { 12140 p_groupe: key, p_champ: field, p_valeur: value 12141 }); 12142 if (error) { toastErreur(error); return; } 12143 // La RPC renvoie le document verrouillé et fusionné en base : on ne conserve 12144 // jamais la copie potentiellement périmée qui était ouverte dans le navigateur. 12145 cache.config = data || cache.config; 12146 toast('Réglage enregistré'); 12147} 12148 12149// ============================================================ 12150// MODAL: PLAYER (coach) 12151// ============================================================ 12152function openModalPlayer(joueurId) { 12153 if (!isCoach()) return; 12154 const j = joueurId ? joueurById(joueurId) : null; 12155 const isEdit = !!j; 12156 const creationPreAcademie = !isEdit && isPreAcademieEnv(); 12157 12158 const html = ` 12159 <div class="modal-header"> 12160 <h3 class="modal-title">${isEdit ? 'Modifier joueur' : 'Nouveau joueur'}</h3> 12161 <button class="modal-close" data-ui-click="closeModalDirect()">Ã</button> 12162 </div> 12163 <form data-ui-submit="savePlayer(event, ${escapeHtml(encoderArgumentTexteUI(joueurId || ''))})"> 12164 <div class="grid-2"> 12165 <div class="form-row"><label class="form-label">Prénom *</label><input type="text" name="prenom" required value="${j ? escapeHtml(j.prenom) : ''}"></div> 12166 <div class="form-row"><label class="form-label">Nom *</label><input type="text" name="nom" required value="${j ? escapeHtml(j.nom) : ''}"></div> 12167 </div> 12168 <div class="grid-2"> 12169 <div class="form-row"><label class="form-label">Ãge${creationPreAcademie ? ' *' : ''}</label><input type="number" name="age" min="7" max="${creationPreAcademie ? '11' : '25'}" ${creationPreAcademie ? 'required' : ''} value="${j ? (computeAge(j) || '') : ''}"></div> 12170 <div class="form-row"><label class="form-label">Sexe</label><select name="sexe"> 12171 <option value="M" ${j && j.sexe==='M'?'selected':''}>Masculin</option> 12172 <option value="F" ${j && j.sexe==='F'?'selected':''}>Féminin</option> 12173 </select></div> 12174 </div> 12175 <div class="grid-2"> 12176 ${j ? ` 12177 <div class="form-row"> 12178 <label class="form-label">Classement</label> 12179 <div style="display:flex; align-items:center; gap:8px; padding:8px 0"> 12180 <strong class="orange" style="font-size:15px">${escapeHtml(j.classement || 'NC')}</strong> 12181 <button type="button" class="btn btn-sm" style="background:var(--bg-input)" data-ui-click="openChangerClassementCoach(${escapeHtml(encoderArgumentTexteUI(j.id))})">Changer</button> 12182 </div> 12183 </div>` : ` 12184 <div class="form-row"><label class="form-label">Classement</label><select name="classement"> 12185 ${CLASSEMENTS.map(c => `<option value="${c}">${c}</option>`).join('')} 12186 </select></div>`} 12187 <div class="form-row"><label class="form-label">Objectif</label><select name="objectif"> 12188 ${CLASSEMENTS.map(c => `<option value="${c}" ${j && j.objectif===c?'selected':''}>${c}</option>`).join('')} 12189 </select></div> 12190 </div> 12191 <div class="form-row"> 12192 <label class="form-label">Groupe</label> 12193 <div class="pill-group" id="pillGroupe"> 12194 <span class="pill ${!j || !j.groupe ? 'active' : ''}" data-value="" data-ui-click="selectPill(this, 'pillGroupe')">â Sans groupe</span> 12195 ${creationPreAcademie ? '' : groupesOrdonnes().map(k => `<span class="pill ${j && j.groupe===k?'active':''}" data-value="${k}" data-ui-click="selectPill(this, 'pillGroupe')">${escapeHtml(cache.config[k].nom)}</span>`).join('')} 12196 </div> 12197 <div class="text-faint" style="font-size:11px; margin-top:4px">${creationPreAcademie ? 'Le joueur sera créé directement en Pré-Académie.' : '« Sans groupe » + 11 ans ou moins (âge tennistique) â le joueur passe en Pré-Académie.'}</div> 12198 </div> 12199 <div class="form-row"> 12200 <label style="display:flex; align-items:center; gap:10px; cursor:pointer; font-size:14px"> 12201 <input type="checkbox" name="participe_palmyre" ${j && j.participe_palmyre ? 'checked' : ''} style="width:auto; margin:0">
12202 <span>ðï¸ Participe à la Palmyre</span> 12203 </label> 12204 <div class="text-faint" style="font-size:11px; margin-top:4px">à cocher chaque année pour les joueurs qui partent au stage.</div> 12205 </div> 12206 <div class="form-row"><label class="form-label">Téléphone/Email parent</label><input type="text" name="contact" value="${j ? escapeHtml(j.contact || '') : ''}"></div> 12207 <div class="form-row"><label class="form-label">Points forts (virgules)</label><input type="text" name="points_forts" value="${j ? escapeHtml((listeSure(j.points_forts)).join(', ')) : ''}"></div> 12208 <div class="form-row"><label class="form-label">Axes progrès (virgules)</label><input type="text" name="axes_progres" value="${j ? escapeHtml((listeSure(j.axes_progres)).join(', ')) : ''}"></div> 12209 <div class="flex gap-1 mt-2"> 12210 ${isEdit ? `<button type="button" class="btn btn-danger" data-ui-click="deletePlayer(${escapeHtml(encoderArgumentTexteUI(j.id))})">Supprimer</button>` : ''} 12211 <button type="button" class="btn flex-1" data-ui-click="closeModalDirect()">Annuler</button> 12212 <button type="submit" class="btn btn-primary flex-1">${isEdit ? 'Enregistrer' : 'Créer'}</button> 12213 </div> 12214 </form>`; 12215 showModal(html); 12216} 12217 12218async function savePlayer(e, id) { 12219 e.preventDefault(); 12220 const f = e.target; 12221 const verrou = verrouillerSoumission(f); 12222 if (!verrou) return; 12223 const groupePill = document.querySelector('#pillGroupe .pill.active'); 12224 12225 // Ãtat Palmyre AVANT modification (pour détecter une nouvelle sélection). 12226 const ancienJoueur = id ? joueurById(id) : null; 12227 const participaitAvant = ancienJoueur ? !!ancienJoueur.participe_palmyre : false; 12228 12229 const obj = { 12230 prenom: f.prenom.value.trim(), 12231 nom: f.nom.value.trim(), 12232 age: parseInt(f.age.value) || null, 12233 sexe: f.sexe.value, 12234 objectif: f.objectif.value, 12235 groupe: groupePill && groupePill.dataset.value ? groupePill.dataset.value : null, 12236 contact: f.contact.value.trim(), 12237 points_forts: f.points_forts.value.split(',').map(s => s.trim()).filter(Boolean), 12238 axes_progres: f.axes_progres.value.split(',').map(s => s.trim()).filter(Boolean), 12239 participe_palmyre: f.participe_palmyre ? !!f.participe_palmyre.checked : false 12240 }; 12241 // Le classement n'est saisi qu'à la création (champ select présent uniquement alors). 12242 // En édition, il se modifie exclusivement via le workflow (openChangerClassementCoach). 12243 if (!id && f.classement) { 12244 obj.classement = f.classement.value; 12245 } 12246 12247 let error; 12248 try { 12249 if (id) { 12250 obj.updated_at = new Date().toISOString(); 12251 ({ error } = await updateConfirmee(db.from('joueurs').update(obj).eq('id', id))); 12252 } else { 12253 ({ error } = await db.from('joueurs').insert(obj)); 12254 } 12255 } finally { 12256 deverrouillerSoumission(verrou); 12257 } 12258 12259 if (error) { toastErreur(error); return; } 12260 12261 // Nouvelle sélection Palmyre (décoché â coché) : notif in-app + email enthousiaste. 12262 const participeMaintenant = obj.participe_palmyre; 12263 if (participeMaintenant && !participaitAvant) { 12264 const edition = (listeSure(cache.evenements))[0] || null; 12265 notifierSelectionPalmyre(id, edition); 12266 } 12267 12268 closeModalDirect(); 12269 toast(id ? 'Joueur modifié' : 'Joueur créé'); 12270 await refresh(); 12271} 12272 12273// Notifie un joueur de sa sélection à la Palmyre (in-app + email). 12274async function notifierSelectionPalmyre(joueurId, edition) { 12275 const j = joueurById(joueurId); 12276 if (!j || !j.user_id) return; // pas de compte â pas de notif 12277 const nomEd = edition ? edition.nom : 'la Palmyre'; 12278 const periode = (edition && edition.date_debut && edition.date_fin) 12279 ? ` (${formatDate(edition.date_debut)} â ${formatDate(edition.date_fin)})` 12280 : ''; 12281 // Notif in-app 12282 try { 12283 await db.from('notifications').insert({ 12284 user_id: j.user_id, 12285 type: 'bilan_publie', // type générique réutilisé (pas de type dédié en base) 12286 titre: `ðï¸ Félicitations, tu es sélectionné pour ${nomEd} !`, 12287 message: `Ton coach t'a sélectionné pour ${nomEd}${periode}. Prépare-toi pour une superbe semaine de tennis !`, 12288 lien_type: 'palmyre', 12289 lien_id: edition ? edition.id : null 12290 }); 12291 } catch (err) { journal.warn('[palmyre] notif:', err); } 12292 // Email enthousiaste 12293 await sendEmailEvent('palmyre_selection', joueurId); 12294} 12295 12296async function deletePlayer(id) { 12297 if (!(await demanderConfirmation('Supprimer ce joueur et tous ses matches ?', { titre: 'Suppression définitive', libelle: 'Supprimer le joueur' }))) return; 12298 const { error } = await db.from('joueurs').delete().eq('id', id); 12299 if (error) { toastErreur(error); return; } 12300 closeModal(); 12301 toast('Joueur supprimé'); 12302 await refresh(); 12303} 12304 12305// Archive un joueur : il quitte l'académie mais son historique (matches, bilans) est conservé. 12306// Réversible via reactivatePlayer(). 12307async function archivePlayer(id) { 12308 const j = joueurById(id); 12309 if (!j) return; 12310 if (!(await demanderConfirmation(`Archiver ${j.prenom} ${j.nom} ?\n\nLe joueur sortira de l'académie actif (listes, groupes, alertes) mais tout son historique sera conservé. Tu pourras le réactiver à tout moment.`, { titre: 'Archiver le joueur', libelle: 'Archiver', danger: false }))) return; 12311 const { error } = await updateConfirmee(db.from('joueurs').update({ statut: 'archive' }).eq('id', id)); 12312 if (error) { toastErreur(error); return; } 12313 closeModal(); 12314 toast(`${j.prenom} ${j.nom} archivé`); 12315 await refresh(); 12316} 12317 12318// Retire un joueur de son groupe : il reste ACTIF dans l'académie mais sans groupe, 12319// en attente d'une réaffectation. Son historique (matchs, bilans) est conservé. 12320async function retirerDuGroupe(id) { 12321 const j = joueurById(id); 12322 if (!j) return; 12323 if (!(await demanderConfirmation(`Retirer ${j.prenom} ${j.nom} de son groupe ?\n\nIl reste un joueur actif de l'académie mais sans groupe, en attente d'une nouvelle affectation. Son historique est conservé.`, { titre: 'Retirer du groupe', libelle: 'Retirer', danger: false }))) return; 12324 const { error } = await updateConfirmee(db.from('joueurs').update({ groupe: null }).eq('id', id)); 12325 if (error) { toastErreur(error); return; } 12326 closeModal(); 12327 toast(`${j.prenom} retiré de son groupe`); 12328 await refresh(); 12329} 12330
12331// Réactive un joueur archivé : il revient dans l'académie actif. 12332async function reactivatePlayer(id) { 12333 const j = joueurById(id); 12334 if (!j) return; 12335 const { error } = await updateConfirmee(db.from('joueurs').update({ statut: 'actif' }).eq('id', id)); 12336 if (error) { toastErreur(error); return; } 12337 closeModal(); 12338 toast(`${j.prenom} ${j.nom} réactivé`); 12339 await refresh(); 12340} 12341 12342// Active/désactive la participation Palmyre d'un joueur directement depuis sa fiche. 12343// Inscription : confirmation légère (déclenche notif + email). Désinscription : directe. 12344async function togglePalmyreParticipation(id) { 12345 const j = joueurById(id); 12346 if (!j) return; 12347 const edition = (listeSure(cache.evenements))[0] || null; 12348 const nomEd = edition ? edition.nom : 'la Palmyre'; 12349 const nouveauStatut = !j.participe_palmyre; 12350 12351 if (nouveauStatut) { 12352 // Inscription : on confirme car ça notifie le joueur (notif + email). 12353 if (!(await demanderConfirmation(`Inscrire ${j.prenom} à ${nomEd} ?\n\nIl recevra une notification et un email de confirmation.`, { titre: 'Confirmer lâinscription', libelle: 'Inscrire', danger: false }))) return; 12354 } 12355 12356 const { error } = await updateConfirmee(db.from('joueurs').update({ participe_palmyre: nouveauStatut }).eq('id', id)); 12357 if (error) { toastErreur(error); return; } 12358 12359 // Notif + email enthousiaste uniquement à l'inscription (passage à true). 12360 if (nouveauStatut) { 12361 notifierSelectionPalmyre(id, edition); 12362 toast(`${j.prenom} inscrit à ${nomEd} ðï¸`); 12363 } else { 12364 toast(`${j.prenom} retiré de ${nomEd}`); 12365 } 12366 12367 await reloadTables(['joueurs']); 12368 // Rafraîchir la fiche ouverte 12369 openPlayerDetail(id); 12370} 12371 12372function openPlayerDetail(id) { 12373 const j = joueurById(id); 12374 if (!j) return; 12375 enregistrerDetailNavigation('player', id); 12376 etatApp.definirJoueurDetail(id); 12377 const stats = statsJoueur(id); 12378 const g = cache.config[j.groupe]; 12379 const agePD = computeAge(j); 12380 12381 let html = `<div class="modal-header">${boutonRetourDetail('Retour')}<h3 class="modal-title">Fiche joueur</h3><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 12382 <div class="flex gap-1 mb-2" style="align-items:center"> 12383 <div data-ui-click="openAvatarUpload(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="cursor:pointer; position:relative" title="Modifier la photo"> 12384 ${avatarHtml(j, 'lg')} 12385 <div style="position:absolute;bottom:-7px;right:-8px;width:30px;height:30px;display:flex;align-items:center;justify-content:center;filter:drop-shadow(0 4px 6px rgba(0,0,0,.42))"><img src="assets/v4/nav/camera.webp" alt="Modifier la photo" style="width:32px;height:32px;object-fit:contain"></div> 12386 </div> 12387 <div class="flex-1"> 12388 <div style="font-size:17px; font-weight:700">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}${j.statut === 'archive' ? ' <span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:500;color:var(--text-dim);background:var(--bg-input);padding:2px 8px;vertical-align:middle"><img class="inline-art-icon" src="assets/v4/menu/player_archive.webp" alt="" style="width:20px;height:20px">Archivé</span>' : ''}</div> 12389 <div class="text-dim" style="font-size:13px">${agePD !== null ? ageLabel(j) : 'âge non renseigné'} · ${j.sexe === 'F' ? 'Féminin' : 'Masculin'}</div> 12390 <div style="margin-top:4px; display:flex; gap:6px; flex-wrap:wrap; align-items:center"> 12391 ${isPoleEnv() && g ? `<span class="badge badge-${j.groupe}">${escapeHtml(g.nom)}</span>` : ''} 12392 ${badgeVitrineHtml(j)} 12393 ${(listeSure(cache.evenements)).length > 0 ? `<span data-ui-click="togglePalmyreParticipation(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="cursor:pointer; font-size:11px; font-weight:600; padding:3px 9px; border-radius:10px; ${j.participe_palmyre ? 'background:var(--orange); color:#fff' : 'background:transparent; color:var(--text-dim); border:1px dashed var(--border)'}" title="${j.participe_palmyre ? 'Retirer de la Palmyre' : 'Inscrire à la Palmyre'}">ðï¸ Palmyre${j.participe_palmyre ? ' â' : ''}</span>` : ''} 12394 </div> 12395 </div> 12396 <div style="text-align:center; flex-shrink:0; padding:6px 12px; background:var(--bg-section); border-radius:8px"> 12397 <div class="text-faint" style="font-size:10px; font-weight:600; letter-spacing:0.5px">CLASST</div> 12398 <div style="font-size:20px; font-weight:800; color:var(--orange-text)">${escapeHtml(j.classement || 'NC')}</div> 12399 ${isPoleEnv() && j.objectif ? `<div class="text-faint" style="font-size:10px">objectif ${escapeHtml(j.objectif)}</div>` : ''} 12400 </div> 12401 ${isCoach() ? `<button class="btn btn-sm" data-ui-click="openModalPlayer(${escapeHtml(encoderArgumentTexteUI(id))})" title="Modifier le joueur" aria-label="Modifier le joueur"><img class="btn-action-art" src="assets/v4/menu/edit_player.webp" alt=""></button>` : ''} 12402 </div> 12403 ${isCoach() ? `<div class="player-context-actions" aria-label="Actions rapides pour ${escapeHtml(j.prenom)}"> 12404 <button type="button" class="player-context-action" data-player-shortcut="match"><img src="assets/v4/nav/matches.webp" alt=""><span>Match</span></button> 12405 ${isPoleEnv() ? `<button type="button" class="player-context-action" data-player-shortcut="note"><img src="assets/v4/menu/coach_notes.webp" alt=""><span>Note</span></button>` : ''} 12406 <button type="button" class="player-context-action" data-player-shortcut="report"><img src="assets/v4/nav/reports.webp" alt=""><span>Bilan</span></button> 12407 <button type="button" class="player-context-action" data-player-shortcut="edit"><img src="assets/v4/menu/edit_player.webp" alt=""><span>Modifier</span></button> 12408 </div>` : ''}`; 12409 12410 // Import de matchs Ten'Up (coach) : action de gestion, placée juste sous les tuiles 12411 if (isCoach() && j.statut !== 'archive' && getSaisonAnalyse() === getSaisonCourante()) { 12412 html += `<div class="mt-2"><button class="btn btn-sm btn-full" data-ui-click="closeModalDirect(); openModalImportMatchsTenup(${escapeHtml(encoderArgumentTexteUI(j.id
12412))})"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Importer des matchs depuis Ten'Up</button> 12413 <div class="text-faint" style="font-size:11px; margin-top:4px; text-align:center">Copie-colle l'historique des matchs depuis la page Ten'Up du joueur</div></div>`; 12414 } 12415 12416 // Graphiques & stats détaillées (vue coach enrichie) 12417 html += `<div class="category-title"><span class="ct-icon">ð</span><span class="ct-label">Statistiques</span></div>`; 12418 html += renderPeriodeSelector(periodes.playerDetail, 'setPeriodePlayerDetail'); 12419 12420 // Stats de la période sélectionnée pour ce joueur 12421 const [ppDebut, ppFin] = getPeriodeDates(periodes.playerDetail); 12422 const matchsPeriodeJTous = matchsDuJoueur(j.id).filter(m => TennisDomainRules.dateDansIntervalle(m.date, ppDebut, ppFin)); 12423 // Stats hors WO (un forfait n'est pas un match joué). La liste affichée plus bas garde 12424 // les WO ; seuls les compteurs les excluent. 12425 const matchsPeriodeJ = matchsHorsWO(matchsPeriodeJTous); 12426 const comptePeriodeJ = TennisDomainRules.statistiquesSportives( 12427 matchsPeriodeJTous, 12428 match => classementADate(j.id, match.date) 12429 ); 12430 const vP = comptePeriodeJ.victoires; 12431 const dP = comptePeriodeJ.defaites; 12432 const tauxP = comptePeriodeJ.tauxVictoire; 12433 // Belles perfs de ce joueur sur la période 12434 const perfsJoueur = TennisDomainRules.listerPerformancesJoueur( 12435 matchsPeriodeJTous, 12436 match => classementADate(j.id, match.date), 12437 'date' 12438 ); 12439 12440 html += `<div class="stat-grid"> 12441 <div class="stat-card"><div class="stat-label">Matches</div><div class="stat-value">${matchsPeriodeJ.length}</div></div> 12442 <div class="stat-card"><div class="stat-label">Victoires</div><div class="stat-value success">${vP}</div></div> 12443 <div class="stat-card"><div class="stat-label">Défaites</div><div class="stat-value">${dP}</div></div> 12444 <div class="stat-card"><div class="stat-label">Taux V</div><div class="stat-value ${tauxP >= 55 ? 'success' : ''}">${tauxP}%</div></div> 12445 </div>`; 12446 12447 if (perfsJoueur.length > 0) { 12448 html += `<div class="card" style="padding:10px 14px; margin-top:8px; border-left:3px solid var(--success)"> 12449 <div style="font-size:13px;display:flex;align-items:center;gap:7px">${progressArtHtml('perfs',{size:28})}<strong>${perfsJoueur.length} belle${perfsJoueur.length > 1 ? 's' : ''} perf${perfsJoueur.length > 1 ? 's' : ''}</strong> sur ${getPeriodeLabel(periodes.playerDetail).toLowerCase()}</div> 12450 <div class="text-faint" style="font-size:11px; margin-top:4px">${perfsJoueur.slice(0, 3).map(p => `vs ${escapeHtml(p.match.adversaire)} (${escapeHtml(p.classementAdversaire)})`).join(' · ')}${perfsJoueur.length > 3 ? 'â¦' : ''}</div> 12451 </div>`; 12452 } 12453 12454 // Ressenti (note déclarative du joueur sur ses matchs) â sur la période 12455 const ressentiJ = statsRessenti(matchsPeriodeJ); 12456 if (ressentiJ.renseigne) { 12457 html += `<div class="sub-title">Ressenti</div>`; 12458 html += `<div class="card" style="padding:10px 14px">${renderRessentiBloc(ressentiJ)}</div>`; 12459 } 12460 12461 // Taux de victoire par surface (sur la période) â affiché seulement si au moins 12462 // un match de la période a une surface renseignée. 12463 const SURFACES_META = { terre: { label: 'Terre battue', icone: 'ð ' }, dur: { label: 'Dur', icone: 'ðµ' }, indoor: { label: 'Indoor', icone: 'ð ' } }; 12464 const statsSurfaces = TennisDomainRules.statistiquesSportivesParCle( 12465 matchsPeriodeJTous, 12466 match => match && match.surface, 12467 Object.keys(SURFACES_META) 12468 ); 12469 if (statsSurfaces.length > 0) { 12470 html += `<div class="sub-title">Par surface</div>`; 12471 html += `<div class="card" style="padding:10px 14px">`; 12472 statsSurfaces.forEach(compteSurface => { 12473 const surf = compteSurface.cle; 12474 const v = compteSurface.victoires; 12475 const taux = compteSurface.tauxVictoire; 12476 html += `<div style="display:flex; justify-content:space-between; align-items:center; padding:4px 0; font-size:13px"> 12477 <span>${SURFACES_META[surf].icone} ${SURFACES_META[surf].label}</span> 12478 <span><strong class="${taux >= 55 ? 'success' : ''}">${taux}%</strong> <span class="text-faint" style="font-size:11px">(${v}V / ${compteSurface.total})</span></span> 12479 </div>`; 12480 }); 12481 html += `</div>`; 12482 } 12483 12484 if (isPoleEnv()) { 12485 // Assiduité (entraînements déclarés sur la période) 12486 html += renderAssiduiteSection(j.id, [ppDebut, ppFin], getPeriodeLabel(periodes.playerDetail)); 12487 12488 // Activité matches (volume V/D sur la période, comme la fiche joueur) 12489 html += `<div class="sub-title">Activité matches</div>`; 12490 if (matchsPeriodeJ.length > 0) { 12491 html += `<div class="card" style="height:220px; padding:12px">
12491<canvas id="pdChartActivite"></canvas></div>`; 12492 } else { 12493 html += `<div class="card text-faint" style="font-size:13px; text-align:center; padding:16px">Aucun match sur cette période.</div>`; 12494 } 12495 12496 // Victoires cumulées (suit le sélecteur de période, comme l'activité) 12497 html += `<div class="sub-title" style="display:flex;align-items:center;gap:7px">${progressArtHtml('victoires',{size:27})}Victoires cumulées</div>`; 12498 if (matchsPeriodeJ.length > 0) { 12499 html += `<div class="card" style="height:220px; padding:12px"><canvas id="pdChartCumul"></canvas></div>`; 12500 } else { 12501 html += `<div class="card text-faint" style="font-size:13px; text-align:center; padding:16px">Aucun match sur cette période.</div>`; 12502 } 12503 12504 // ===== Suivi global (toutes périodes, hors sélecteur) ===== 12505 html += `<div class="category-title"><span class="ct-icon">ð</span><span class="ct-label">Suivi global</span><span style="font-size:11px; font-weight:400; color:var(--text-faint); letter-spacing:0; text-transform:none">toutes périodes</span></div>`; 12506 // Ãvolution du classement (toute l'histoire) 12507 html += `<div class="sub-title">Ãvolution du classement</div>`; 12508 if (isCoach() && isPoleEnv() && j.statut !== 'archive') { 12509 html += `<div class="mb-1"><button class="btn btn-sm btn-full" data-ui-click="closeModalDirect(); openModalImportClassement(${escapeHtml(encoderArgumentTexteUI(j.id))})"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Importer l'historique de classement</button></div>`; 12510 } 12511 html += `<div class="card" style="height:220px; padding:12px"><canvas id="pdChartClassement"></canvas></div>`; 12512 12513 // Forme récente (5 derniers matches, indépendant de la période) 12514 if (stats.total > 0) { 12515 html += `<div class="sub-title">Forme récente</div>`; 12516 html += `<div class="card" style="height:160px; padding:12px"><canvas id="pdChartForme"></canvas></div>`; 12517 } 12518 12519 // Profil 5 axes (derniers bilans) â masqué pour la Pré-Académie (pas de bilans â pas d'araignée). 12520 if (!estPreAcademie(j)) { 12521 html += `<div class="sub-title">Profil 5 axes</div>`; 12522 html += `<div id="pdChartRadar_selector"></div>`; 12523 html += `<div class="card" style="height:300px; padding:12px"><canvas id="pdChartRadar"></canvas></div>`; 12524 } 12525 12526 // Tournois auxquels ce joueur est inscrit 12527 const tournoisJoueur = cache.tournois.filter(t => (listeSure(t.inscrits)).includes(j.id)) 12528 .sort((a, b) => (a.date_debut || '').localeCompare(b.date_debut || '')); 12529 const now = Date.now(); 12530 const tournoisAvenir = tournoisJoueur.filter(t => new Date(t.date_fin || t.date_debut).getTime() >= now - 24*3600*1000); 12531 const tournoisPasses = tournoisJoueur.filter(t => new Date(t.date_fin || t.date_debut).getTime() < now - 24*3600*1000); 12532 12533 html += `<div class="category-title"><span class="ct-icon">ð</span><span class="ct-label">Tournois â ${tournoisJoueur.length} inscription${tournoisJoueur.length > 1 ? 's' : ''}</span></div>`; 12534 if (tournoisJoueur.length === 0) { 12535 html += `<div class="card text-faint" style="font-size:13px; text-align:center; padding:16px">Aucun tournoi déclaré pour ce joueur.</div>`; 12536 } else { 12537 if (tournoisAvenir.length > 0) { 12538 html += `<div class="text-faint" style="font-size:11px; text-transform:uppercase; letter-spacing:1px; margin:6px 0">à venir (${tournoisAvenir.length})</div>`; 12539 tournoisAvenir.forEach(t => { 12540 const dateStr = t.date_fin && t.date_fin !== t.date_debut ? `${formatDate(t.date_debut)} â ${formatDate(t.date_fin)}` : formatDate(t.date_debut); 12541 html += `<div class="card" data-ui-click="closeModalDirect(); navigate('calendar'); setTimeout(()=>openModalTournoi(${escapeHtml(encoderArgumentTexteUI(t.id))}), 200)" style="cursor:pointer; padding:10px 12px; margin-bottom:6px"> 12542 <div style="font-weight:600; font-size:13px">${escapeHtml(t.nom)}</div> 12543 <div class="text-dim" style="font-size:12px">${dateStr}${t.lieu ? ' · ' + escapeHtml(t.lieu) : ''}</div> 12544 </div>`; 12545 }); 12546 } 12547 if (tournoisPasses.length > 0) { 12548 html += `<div class="text-faint" style="font-size:11px; text-transform:uppercase; letter-spacing:1px; margin:10px 0 6px">Passés (${tournoisPasses.length})</div>`;
12549 tournoisPasses.slice(0, 5).forEach(t => { 12550 const dateStr = t.date_fin && t.date_fin !== t.date_debut ? `${formatDate(t.date_debut)} â ${formatDate(t.date_fin)}` : formatDate(t.date_debut); 12551 html += `<div class="card" style="padding:8px 12px; margin-bottom:4px; opacity:0.7"> 12552 <div style="font-size:12px">${escapeHtml(t.nom)} <span class="text-faint">· ${dateStr}</span></div> 12553 </div>`; 12554 }); 12555 if (tournoisPasses.length > 5) { 12556 html += `<div class="text-faint" style="font-size:11px; text-align:center">+ ${tournoisPasses.length - 5} autres tournois passés</div>`; 12557 } 12558 } 12559 } 12560 } 12561 12562 // Catégorie Matchs (les derniers matches ; l'import est remonté sous les tuiles) 12563 if (stats.derniers5.length > 0) { 12564 html += `<div class="category-title"><span class="ct-icon">ð¾</span><span class="ct-label">Matchs</span></div>`; 12565 html += `<div class="sub-title">Derniers matches</div><div class="card">`; 12566 stats.derniers5.forEach(m => { 12567 html += `<div class="match-row clickable" data-ui-click="openModalMatch(${escapeHtml(encoderArgumentTexteUI(m.id))})" title="Cliquer pour modifier"> 12568 <div class="match-result-row"><span class="match-opponent">vs ${escapeHtml(m.adversaire)} (${escapeHtml(m.adversaire_class || '?')})</span>${resultatMatchHtml(m)}</div> 12569 <div class="match-meta">${escapeHtml(m.tournoi || 'Match')} · ${formatDate(m.date)}</div> 12570 </div>`; 12571 }); 12572 html += `</div>`; 12573 } 12574 12575 // Les notes privées appartiennent au suivi Académie. En Palmyre, les 12576 // observations du stage sont l'unique carnet de suivi afin d'éviter les doublons. 12577 if (isPoleEnv()) { 12578 html += `<div class="category-title" id="playerCoachNotes"><span class="ct-icon ct-icon-art"><img src="assets/v4/menu/coach_notes.webp" alt=""></span><span class="ct-label">Notes du coach</span></div>`; 12579 html += renderNotesCoachSection(j.id); 12580 } 12581 if (isAcademieEnv()) { 12582 html += `<div class="category-title"><span class="ct-icon">ð¯</span><span class="ct-label">Axes de travail partagés</span></div> 12583 <div class="card" style="padding:14px;margin-bottom:8px"> 12584 <div class="text-faint" style="font-size:12px;margin-bottom:8px">Visible par le joueur dans sa progression.</div> 12585 <textarea id="axeTexte" rows="2" placeholder="Ex. : travailler la deuxième balle et le retour croisé" style="width:100%"></textarea> 12586 <button class="btn btn-primary btn-full mt-1" data-ui-click="envoyerAxeTravail(${escapeHtml(encoderArgumentTexteUI(j.id))})">Envoyer au joueur</button> 12587 <div id="axesListe" style="margin-top:10px">${renderAxesListe(j.id)}</div> 12588 </div>`; 12589 } 12590 12591 // La gestion administrative appartient à l'Académie, jamais au suivi Palmyre. 12592 if (isPoleEnv()) { 12593 // Consentements RGPD + bouton génération charte PDF 12594 const isMineurDetail = computeAge(j) !== null && computeAge(j) < 18; 12595 html += `<div class="category-title"><span class="ct-icon ct-icon-art"><img src="assets/v4/menu/account.webp" alt=""></span><span class="ct-label">Administratif</span></div> 12596 <div class="sub-title">Consentements RGPD</div> 12597 <div class="card" style="padding:14px"> 12598 <div style="font-size:13px; line-height:1.7"> 12599 <div>${j.consentement_rgpd ? '<span class="success">â</span>' : '<span class="danger">â</span>'} <strong>RGPD :</strong> ${j.consentement_rgpd ? 'accepté' : 'NON accepté'}</div> 12600 ${isMineurDetail ? `<div>${j.consentement_parental ? '<span class="success">â</span>' : '<span class="warning">â</span>'} <strong>Consentement parental :</strong> ${j.consentement_parental ? 'déclaré' : 'non coché'}</div>` : ''} 12601 ${j.consentement_date ? `<div class="text-faint" style="font-size:11px; margin-top:6px">Date du consentement : ${formatDate(j.consentement_date)}</div>` : ''} 12602 </div> 12603 <div style="margin-top:12px; text-align:center"> 12604 <button class="btn btn-sm" data-ui-click="exportChartConsentementPDF(${escapeHtml(encoderArgumentTexteUI(j.id))})"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">Télécharger la charte de consentement (PDF)</button> 12605 </div> 12606 </div>`; 12607 12608 // Action archivage / réactivation (coach uniquement) 12609 if (isCoach()) { 12610 if (j.statut === 'archive') { 12611 html += `<div class="card" style="padding:14px; margin-top:8px; border-left:3px solid var(--text-dim)"> 12612 <div class="text-dim" style="font-size:13px; margin-bottom:10px;display:flex;align-items:flex-start;gap:8px"><img class="inline-art-icon" src="assets/v4/menu/player_archive.webp" alt="" style="width:30px;height:30px">Ce joueur est <strong>archivé</strong> : il n'apparaît plus dans les listes actives, les groupes ni les alertes. Son historique est conservé.</div> 12613 <button class="btn btn-primary btn-sm btn-full" data-ui-click="reactivatePlayer(${escapeHtml(encoderArgumentTexteUI(j.id
12613))})"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Réactiver ce joueur</button> 12614 </div>`; 12615 } else { 12616 // Actions sur un joueur actif 12617 const aUnGroupe = j.groupe && cache.config[j.groupe]; 12618 html += `<div class="mt-2" style="display:flex; flex-direction:column; gap:8px; align-items:center">`; 12619 if (aUnGroupe) { 12620 html += `<button class="btn btn-sm" data-ui-click="retirerDuGroupe(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="color:var(--text-dim)"><img class="btn-action-art" src="assets/v4/nav/group.webp" alt="">Retirer du groupe (le mettre en attente d'affectation)</button>`; 12621 } 12622 html += `<button class="btn btn-sm" data-ui-click="archivePlayer(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="color:var(--text-dim)"><img class="btn-action-art" src="assets/v4/menu/player_archive.webp" alt="">Archiver (fin de saison / départ de l'académie)</button> 12623 </div>`; 12624 } 12625 } 12626 } 12627 12628 showModal(html); 12629 const modalBody = $('modalBody'); 12630 modalBody?.querySelectorAll('[data-player-shortcut]').forEach(button => { 12631 button.addEventListener('click', () => { 12632 const action = button.dataset.playerShortcut; 12633 if (action === 'match') { closeModalDirect(); openModalMatch(); } 12634 else if (action === 'report') { 12635 closeModalDirect(); 12636 if (isPoleEnv()) openModalBilan(null, j.id); 12637 else openModalBilanPalmyre(j.id); 12638 } 12639 else if (action === 'edit') openModalPlayer(j.id); 12640 else if (action === 'note') modalBody.querySelector('#playerCoachNotes')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); 12641 }); 12642 }); 12643 // Rendu post-affichage 12644 setTimeout(() => { 12645 if (document.getElementById('pdChartClassement')) renderClassementChart('pdChartClassement', j); 12646 if (document.getElementById('pdChartActivite')) renderActiviteChart('pdChartActivite', j, [ppDebut, ppFin]); 12647 if (document.getElementById('pdChartCumul')) renderCumulChart('pdChartCumul', j, [ppDebut, ppFin]); 12648 if (document.getElementById('pdChartForme')) renderFormeChart('pdChartForme', j); 12649 if (document.getElementById('pdChartRadar')) renderRadarBilans('pdChartRadar', j); 12650 }, 50); 12651} 12652 12653 12654// ============================================================ 12655// NOTES COACH â Observations libres sur un joueur (coachs uniquement) 12656// ============================================================ 12657 12658function renderNotesCoach() { 12659 if (!isCoach()) return ''; 12660 const joueurs = joueursActifsDansEnvCourant().slice() 12661 .sort((a, b) => `${a.nom || ''} ${a.prenom || ''}`.localeCompare(`${b.nom || ''} ${b.prenom || ''}`)); 12662 const compteur = joueurId => listeSure(cache.notesCoach).filter(note => note.joueur_id === joueurId).length; 12663 return `${renderUxScreenHeading('Suivi individuel', 'Notes coach')} 12664 <div class="card mb-2" style="padding:12px 14px"> 12665 <div style="font-weight:700">Choisir un joueur</div> 12666 <div class="text-faint" style="font-size:12px;margin-top:3px">Les notes restent privées entre coachs et sont reprises en lecture dans les bilans.</div> 12667 </div> 12668 <div class="players-list">${joueurs.map(joueur => { 12669 const nb = compteur(joueur.id); 12670 return `<button type="button" class="player-row card notes-player-row" data-ui-click="openNotePlayer(${escapeHtml(encoderArgumentTexteUI(joueur.id))})"> 12671 <div class="player-info"><div class="player-name">${escapeHtml(joueur.prenom)} ${escapeHtml(joueur.nom)}</div> 12672 <div class="player-meta">${escapeHtml(joueur.classement || 'NC')} · ${nb} note${nb > 1 ? 's' : ''}</div></div> 12673 <span class="notes-player-arrow" aria-hidden="true">â</span> 12674 </button>`; 12675 }).join('') || '<div class="empty">Aucun joueur actif.</div>'}</div>`; 12676} 12677 12678function openNotePlayer(joueurId) { 12679 openPlayerDetail(joueurId); 12680 setTimeout(() => document.getElementById('playerCoachNotes')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80); 12681} 12682 12683function renderNotesCoachSection(joueurId) { 12684 // Visible uniquement aux coachs. 12685 if (!isCoach()) return ''; 12686 12687 const notes = (listeSure(cache.notesCoach)).filter(n => n.joueur_id === joueurId); 12688 12689 let html = `<div class="text-faint" style="font-size:12px; margin:-4px 0 10px"><img class="private-note-mark" src="assets/v4/menu/account.webp" alt="">Ces notes sont visibles uniquement par les coachs, jamais par le joueur ni les parents.</div>`; 12690 12691 // Zone de saisie nouvelle note 12692 html += `<div class="card" style="padding:14px; margin-bottom:8px"> 12693 <textarea id="newNoteContent_${joueurId}" placeholder="Ajouter une observation (ex. vu blessé à l'épaule mardi, parents inquiets sur le rythme scolaire, très motivé en ce moment...)" 12694 style="width:100%; min-height:60px; padding:8px 10px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px; resize:vertical; font-family:inherit"></textarea> 12695 <div style="text-align:right; margin-top:8px"> 12696 <button class="btn btn-primary btn-sm" data-ui-click="addNoteCoach(${escapeHtml(encoderArgumentTexteUI(joueurId))})">+ Ajouter la note</button> 12697 </div> 12698 </div>`; 12699 12700 if (notes.length === 0) { 12701 html += `<div class="card text-faint" style="font-size:12px; text-align:center; padding:14px"> 12702 Aucune note pour ce joueur pour le moment. 12703 </div>`; 12704 return html; 12705 } 12706 12707 // Liste des notes existantes (chronologique inverse â plus récentes en haut)
12708 notes.forEach(n => { 12709 const isMe = n.auteur_id === session.user.id; 12710 const auteurLabel = isMe ? (profile.prenom || 'Toi') : 'Autre coach'; 12711 const dateStr = formatDateTime(n.created_at); 12712 html += `<div class="card" style="padding:12px; margin-bottom:6px; border-left:3px solid var(--orange)"> 12713 <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px; margin-bottom:6px"> 12714 <div class="text-faint" style="font-size:11px"> 12715 <strong style="color:var(--text)">${escapeHtml(auteurLabel)}</strong> · ${dateStr} 12716 </div> 12717 ${isMe ? `<button class="btn btn-sm" data-ui-click="deleteNoteCoach(${escapeHtml(encoderArgumentTexteUI(n.id))}, ${escapeHtml(encoderArgumentTexteUI(joueurId))})" title="Supprimer cette note" style="padding:2px 8px; font-size:11px">Ã</button>` : ''} 12718 </div> 12719 <div style="font-size:13px; line-height:1.5; white-space:pre-wrap">${escapeHtml(n.contenu)}</div> 12720 </div>`; 12721 }); 12722 12723 return html; 12724} 12725 12726// Format date+heure pour les notes 12727function formatDateTime(iso) { 12728 return TennisFormatters.formatDateTime(iso); 12729} 12730 12731async function addNoteCoach(joueurId) { 12732 const textarea = document.getElementById('newNoteContent_' + joueurId); 12733 if (!textarea) return; 12734 const contenu = textarea.value.trim(); 12735 if (!contenu) { 12736 toast('La note ne peut pas être vide', true); 12737 return; 12738 } 12739 if (contenu.length > 5000) { 12740 toast('Note trop longue (max 5000 caractères)', true); 12741 return; 12742 } 12743 12744 const { error } = await db.from('notes_joueur').insert({ 12745 joueur_id: joueurId, 12746 auteur_id: session.user.id, 12747 contenu 12748 }); 12749 12750 if (error) { 12751 toastErreur(error); 12752 return; 12753 } 12754 12755 // Recharger les notes + rafraîchir l'affichage de la fiche 12756 const { data: notesData } = await db.from('notes_joueur').select(COLONNES_NOTE_COACH).order('created_at', { ascending: false }); 12757 cache.notesCoach = listeSure(notesData); 12758 toast('Note ajoutée'); 12759 closeModal(); 12760 openPlayerDetail(joueurId); 12761} 12762 12763async function deleteNoteCoach(noteId, joueurId) { 12764 if (!(await demanderConfirmation('Supprimer cette note ?'))) return; 12765 12766 const { error } = await db.from('notes_joueur').delete().eq('id', noteId); 12767 if (error) { 12768 toastErreur(error); 12769 return; 12770 } 12771 12772 const { data: notesData } = await db.from('notes_joueur').select(COLONNES_NOTE_COACH).order('created_at', { ascending: false }); 12773 cache.notesCoach = listeSure(notesData); 12774 toast('Note supprimée'); 12775 closeModal(); 12776 openPlayerDetail(joueurId); 12777} 12778 12779 12780// ============================================================ 12781// MODAL: ENTRAÃNEMENT (déclaration par le joueur) 12782// ============================================================ 12783const ENTRAINEMENT_TYPES = { 12784 seul: { label: 'Seul', icone: 'ð¯' }, 12785 partenaire: { label: 'Partenaire', icone: 'ð¤' } 12786}; 12787 12788// Section "Assiduité" réutilisable (coach + joueur). Suit la période via bornes [debut, fin]. 12789// Ãtape 1 : ligne "Entraînements" (nb déclaré). La ligne "Présence aux cours" viendra en étape 2. 12790function renderAssiduiteSection(joueurId, bornes, periodeLabel) { 12791 const [debut, fin] = listeSure(bornes); 12792 const entrainements = entrainementsDuJoueur(joueurId).filter(e => { 12793 if (debut && fin) return TennisDomainRules.dateDansIntervalle(e.date, debut, fin); 12794 return true; 12795 }); 12796 const statsEntrainements = TennisDomainRules.statistiquesEntrainements(entrainements); 12797 const nbEntrainements = statsEntrainements.total; 12798 // Détail par type (pour info, en petit) 12799 const detailType = Object.keys(statsEntrainements.parType) 12800 .map(t => `${(ENTRAINEMENT_TYPES[t] || ENTRAINEMENT_TYPES.seul).icone} ${statsEntrainements.parType[t]}`) 12801 .join(' '); 12802 12803 // Présence aux cours sur la période : on regarde les séances passées/du jour 12804 // où le joueur était attendu (présence existante) et dont le statut est tranché. 12805 const afficherPresenceCours = isPoleEnv(); 12806 const todayStr = dateLocaleISO(new Date()); 12807 const idSeancesPeriode = new Set( 12808 (afficherPresenceCours ? listeSure(cache.seancesCours) : []) 12809 .filter(s => { 12810 if (debut && fin) { 12811 const finEffective = fin < todayStr ? fin : todayStr; 12812 return TennisDomainRules.dateDansIntervalle(s.date, debut, finEffective); 12813 } 12814 return TennisDomainRules.isoDate(s.date) <= todayStr; // on ne compte pas les cours futurs 12815 }) 12816 .map(s => s.id) 12817 ); 12818 const mesPresences = presencesDuJoueur(joueurId) 12819 .filter(p => idSeancesPeriode.has(p.seance_id)); 12820 const statsPresence = TennisDomainRules.statistiquesPresences(mesPresences); 12821 const nbCoursAttendus = statsPresence.total; 12822 const nbPresents = statsPresence.presents; 12823 const nbRenseignes = statsPresence.renseignes; 12824 const tauxPresence = statsPresence.tauxPresence; 12825 12826 // La section n'apparaît que s'il y a au moins un entraînement OU un cours attendu sur la période. 12827 if (nbEntrainements === 0 && nbCoursAttendus === 0) return ''; 12828 12829 let html = `<div class="section-title">Assiduité <span class="text-faint" style="font-weight:400; text-transform:none; letter-spacing:0; font-size:12px">â ${periodeLabel}</span></div>`; 12830 html += `<div class="card" style="padding:10px 14px">`; 12831 // Ligne entraînements 12832 if (nbEntrainements > 0) { 12833 html += `<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; padding:4px 0; font-size:13px">
12834 <span>ð¾ Entraînements déclarés <strong>${nbEntrainements}</strong></span> 12835 ${detailType ? `<span class="text-faint" style="font-size:11px; white-space:nowrap">${detailType}</span>` : ''} 12836 </div>`; 12837 } 12838 // Ligne présence aux cours 12839 if (nbCoursAttendus > 0) { 12840 const detail = tauxPresence !== null 12841 ? `<span class="text-faint" style="font-size:11px; white-space:nowrap"><strong class="${tauxPresence >= 80 ? 'success' : tauxPresence < 50 ? 'danger' : ''}">${tauxPresence}%</strong> · ${nbPresents}/${nbRenseignes} présent${nbPresents > 1 ? 's' : ''}</span>` 12842 : `<span class="text-faint" style="font-size:11px">non renseigné</span>`; 12843 html += `<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; padding:4px 0; font-size:13px"> 12844 <span>â Présence aux cours <strong>${nbCoursAttendus}</strong></span> 12845 ${detail} 12846 </div>`; 12847 } 12848 html += `</div>`; 12849 return html; 12850} 12851 12852function openModalEntrainement(entrainementId) { 12853 const e = entrainementId ? entrainementById(entrainementId) : null; 12854 const today = dateLocaleISO(new Date()); 12855 const pills = Object.keys(ENTRAINEMENT_TYPES).map(k => { 12856 const meta = ENTRAINEMENT_TYPES[k]; 12857 const active = e && e.type === k ? 'active' : (!e && k === 'seul' ? 'active' : ''); 12858 return `<span class="pill ${active}" data-value="${k}" data-ui-click="selectPill(this, 'pillEntrainementType')">${meta.icone} ${meta.label}</span>`; 12859 }).join(''); 12860 12861 const btnLabel = e ? 'Enregistrer' : 'Déclarer la séance'; 12862 const deleteBtn = e ? `<div class="mt-1"><button class="btn btn-danger btn-full" data-ui-click="supprimerEntrainement(${escapeHtml(encoderArgumentTexteUI(e.id))})">Supprimer</button></div>` : ''; 12863 // Heure pré-remplie : celle enregistrée (édition) sinon l'heure courante (nouvelle séance) 12864 const now = new Date(); 12865 const heureDefaut = (e && e.heure_debut) ? e.heure_debut 12866 : `${String(now.getHours()).padStart(2, '0')}
12866:${String(now.getMinutes()).padStart(2, '0')}`; 12867 12868 const html = `<div class="modal-header"><h3 class="modal-title">${e ? 'Modifier la séance libre' : 'Déclarer une séance libre'}</h3><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 12869 <div class="modal-body"> 12870 <div class="form-row"><label class="form-label">Date</label><input type="date" id="entrainementDate" value="${e ? e.date : today}" max="${today}"></div> 12871 <div class="form-row"><label class="form-label">Type de séance</label> 12872 <div class="pill-group" id="pillEntrainementType">${pills}</div> 12873 </div> 12874 <div class="form-row"><label class="form-label">Durée</label> 12875 <select id="entrainementDuree"> 12876 <option value="30" ${e && e.duree_min === 30 ? 'selected' : ''}>30 min</option> 12877 <option value="60" ${e && e.duree_min === 60 ? 'selected' : (!e ? 'selected' : '')}>1h</option> 12878 <option value="90" ${e && e.duree_min === 90 ? 'selected' : ''}>1h30</option> 12879 <option value="120" ${e && e.duree_min === 120 ? 'selected' : ''}>2h</option> 12880 </select> 12881 </div> 12882 <div class="form-row"><label class="form-label">Heure de début</label><input type="time" id="entrainementHeure" value="${heureDefaut}"></div> 12883 <div class="form-row"><label class="form-label">Contenu de la séance <span class="text-faint" style="font-weight:400; text-transform:none">(optionnel)</span></label> 12884 <textarea id="entrainementContenu" rows="3" placeholder="Ex. : 30 min de service, panier coup droit, jeu au filetâ¦">${e ? escapeHtml(e.contenu || '') : ''}</textarea> 12885 </div> 12886 <div class="mt-2"><button class="btn btn-primary btn-full" data-ui-click="saveEntrainement(${e ? escapeHtml(encoderArgumentTexteUI(e.id)) : 'null'})">${btnLabel}</button></div> 12887 ${deleteBtn} 12888 </div>`; 12889 showModal(html); 12890} 12891 12892async function saveEntrainement(entrainementId) { 12893 const dateEl = document.getElementById('entrainementDate'); 12894 const typePill = document.querySelector('#pillEntrainementType .pill.active'); 12895 const contenuEl = document.getElementById('entrainementContenu'); 12896 if (!dateEl || !dateEl.value) { toast('Indique une date', true); return; } 12897 if (!typePill) { toast('Choisis un type de séance', true); return; } 12898 12899 // Le joueur déclare pour lui-même ; le coach (s'il utilise ce modal) n'est pas concerné. 12900 const joueurId = profile && profile.joueur_id ? profile.joueur_id : null; 12901 if (!joueurId) { toast('Action réservée aux joueurs', true); return; } 12902 if (joueurEnAttente()) { toast('Ton inscription est en attente de validation par le coach.', true); return; } 12903 12904 const obj = { 12905 joueur_id: joueurId, 12906 date: dateEl.value, 12907 type: typePill.dataset.value, 12908 contenu: contenuEl ? contenuEl.value.trim() : '', 12909 duree_min: (() => { const d = document.getElementById('entrainementDuree'); return d ? parseInt(d.value) || null : null; })(), 12910 heure_debut: (() => { const h = document.getElementById('entrainementHeure'); return h && h.value ? h.value : null; })() 12911 }; 12912 12913 let error, nouvelId = entrainementId; 12914 const execution = await executerAvecVerrou($('modalBody'), async () => { 12915 if (entrainementId) return updateConfirmee(db.from('entrainements').update(obj).eq('id', entrainementId)); 12916 return db.from('entrainements').insert(obj).select('id').single(); 12917 }); 12918 if (execution.ignoree) return; 12919 error = execution.valeur.error; 12920 if (!entrainementId) nouvelId = execution.valeur.data ? execution.valeur.data.id : null; 12921 if (error) { toastErreur(error); return; } 12922 12923 // Notification au coach uniquement à la création (pas en modification) 12924 if (!entrainementId && nouvelId) { 12925 const joueur = joueurById(joueurId); 12926 if (joueur) await notifierEntrainementCoach(obj, joueur, nouvelId); 12927 } 12928 12929 closeModal(); 12930 toast(entrainementId ? 'Entraînement modifié' : 'Entraînement déclaré ð¾'); 12931 // Niveau AVANT prise en compte (le cache reflète encore l'état précédent) 12932 const etatAvantEnt = (!isCoach() && joueurId === profile.joueur_id) 12933 ? (() => { const x = calculerXP(joueurId).xp; return { niveau: niveauDepuisXP(x).niveau, xp: x }; })() 12934 : null; 12935 await reloadTables(['entrainements']); 12936 // Détecter et enregistrer d'éventuels nouveaux badges (assiduité, etc.) + level-up + gain XP 12937 await synchroniserBadgesEtCelebrer(joueurId, etatAvantEnt); 12938 render(); 12939} 12940
12941async function supprimerEntrainement(entrainementId) { 12942 if (!(await demanderConfirmation('Supprimer cet entraînement ?'))) return; 12943 const { error } = await db.from('entrainements').delete().eq('id', entrainementId); 12944 if (error) { toastErreur(error); return; } 12945 closeModal(); 12946 toast('Entraînement supprimé'); 12947 await reloadTables(['entrainements']); 12948 render(); 12949} 12950 12951// Notifie les coachs (in-app + email) qu'un entraînement a été déclaré. 12952async function notifierEntrainementCoach(entrainement, joueur, entrainementId) { 12953 if (!joueur || joueur.groupe === 'tremplin') return; 12954 try { 12955 const { data, error } = await db.rpc('notifier_entrainement_coach', { 12956 p_joueur_id: joueur.id, 12957 p_type: entrainement.type || 'seul', 12958 p_date: entrainement.date, 12959 p_entrainement_id: entrainementId 12960 }); 12961 if (error) { journal.warn('[entrainement coach] notif error:', error); return; } 12962 await sendEmailEvent('entrainement_coach', entrainementId); 12963 } catch (e) { 12964 journal.warn('[entrainement coach] exception:', e); 12965 } 12966} 12967 12968 12969// ============================================================ 12970// MODAL: MATCH 12971// ============================================================ 12972function effacerBrouillonMatch() { 12973 gestionnaireBrouillons.effacerMatch(session?.user?.id || ''); 12974} 12975 12976function contenuBrouillonMatch(form) { 12977 if (!form || form.dataset.matchId || form.dataset.restoring === '1') return; 12978 const fields = {}; 12979 Array.from(form.elements).forEach(element => { 12980 if (!element.name || element.type === 'submit' || element.type === 'button') return; 12981 fields[element.name] = element.type === 'checkbox' ? element.checked : element.value; 12982 }); 12983 const actif = id => document.querySelector(`#${id} .pill.active`)?.dataset.value ?? null; 12984 return { 12985 fields, 12986 pills: { 12987 surface: actif('pillSurface'), 12988 format: actif('pillFormat'), 12989 resultat: actif('pillVictoire'), 12990 ressenti: actif('pillRessenti') 12991 } 12992 }; 12993} 12994 12995function sauvegarderBrouillonMatch(form) { 12996 const contenu = contenuBrouillonMatch(form); 12997 if (contenu) gestionnaireBrouillons.enregistrerMatch(session?.user?.id || '', contenu); 12998} 12999 13000function planifierBrouillonMatch(form) { 13001 gestionnaireBrouillons.planifierMatch(session?.user?.id || '', () => contenuBrouillonMatch(form)); 13002} 13003 13004function restaurerBrouillonMatch(form) { 13005 if (!form || form.dataset.matchId) return false; 13006 const brouillon = gestionnaireBrouillons.lireMatch(session?.user?.id || ''); 13007 if (!brouillon) return false; 13008 form.dataset.restoring = '1'; 13009 Object.entries(brouillon.fields || {}).forEach(([name, value]) => { 13010 const element = form.elements.namedItem(name); 13011 if (!element || element instanceof RadioNodeList) return; 13012 if (element.type === 'checkbox') element.checked = !!value; 13013 else if (element.tagName !== 'SELECT' || Array.from(element.options).some(option => option.value === String(value))) { 13014 element.value = value ?? ''; 13015 } 13016 }); 13017 const activer = (id, value, action) => { 13018 if (value == null) return; 13019 const bouton = document.querySelector(`#${id} .pill[data-value="${CSS.escape(String(value))}"]`); 13020 if (bouton) action(bouton); 13021 }; 13022 activer('pillSurface', brouillon.pills?.surface, bouton => selectPillToggle(bouton, 'pillSurface')); 13023 activer('pillFormat', brouillon.pills?.format, bouton => setFormatMatch(bouton.dataset.value)); 13024 activer('pillVictoire', brouillon.pills?.resultat, bouton => selectPill(bouton, 'pillVictoire')); 13025 activer('pillRessenti', brouillon.pills?.ressenti, bouton => selectPill(bouton, 'pillRessenti')); 13026 delete form.dataset.restoring; 13027 majCasePalmyre(form.date?.value || ''); 13028 syncScore(); 13029 majNotesPlaceholder(); 13030 return true; 13031} 13032 13033function openModalMatch(matchId) { 13034 const m = matchId ? (cache.matchesSaison || cache.matches).find(x => x.id === matchId) : null; 13035 const isEdit = !!m; 13036 if (isEdit) enregistrerDetailNavigation('match', matchId); 13037 13038 // Joueur peut seulement saisir pour lui-même 13039 // Coach peut choisir le joueur (en Palmyre : uniquement les participants à l'édition) 13040 let joueursSelectables; 13041 if (isCoach()) { 13042 joueursSelectables = TennisDomainRules.joueursSelectablesPourMatch({ 13043 joueursTous: cache.joueurs, 13044 joueursEnvironnement: joueursActifsDansEnvCourant(), joueurIdExistant: m?.joueur_id || null 13045 }); 13046 } else { 13047 joueursSelectables = cache.joueurs.filter(j => j.id === profile.joueur_id); 13048 } 13049 if (joueursSelectables.length === 0) { 13050 toast('Aucun joueur disponible', true); 13051 return; 13052 } 13053 13054 const preselect = m ? m.joueur_id : (joueursSelectables.length === 1 ? joueursSelectables[0].id : ''); 13055 const choixTournoiInitial = TennisTournamentsTenup.choixTournoisMatch({ 13056 tournaments: cache.tournois, 13057 registrations: cache.inscriptionsTournois, 13058 player: joueurById(preselect), 13059 matchDate: m ? m.date : dateLocaleISO(new Date()), 13060 compatible: (tournoi, joueur) => tournoiCorrespondAuJoueur(tournoi, joueur, { sexe:true, age:true, classement:true }) 13061 }); 13062 const tournoiLie = m?.tournament_id ? cache.tournois.find(t => t.id === m.tournament_id) : null; 13063 const selectionTournoiInitiale = m?.tournament_id || choixTournoiInitial.preselectionId || ''; 13064 13065 // --- Case "Match de la Palmyre" --- 13066 // Affichée si une édition existe ET que le joueur présélectionné y participe. 13067 // En édition de match, on respecte l'état existant (evenement_id déjà posé). 13068 // Côté coach saisissant DEPUIS l'environnement Palmyre, l'estampillage reste 13069 // automatique (géré dans saveMatch) : la case sert surtout au joueur et au 13070 // coach quand il saisit depuis l'académie. 13071 const editionPalmyre = (listeSure(cache.evenements))[0] || null; // la plus récente (tri date_debut desc) 13072 const joueurPreselect = preselect ? joueurById(preselect) : null; 13073 const joueurParticipe = joueurPreselect ? !!joueurPreselect.participe_palmyre : false;
13074 const dateMatch = m ? m.date : dateLocaleISO(new Date()); 13075 const dateDansFenetre = (ed, d) => !!ed && TennisDomainRules.dateDansIntervalle(d, ed.date_debut, ed.date_fin); 13076 // Affichage de la case "Match de la Palmyre" : 13077 // - Coach : on l'affiche dès qu'une édition existe et que le joueur participe (il peut 13078 // rattacher/corriger a posteriori), sauf en environnement Palmyre où c'est automatique. 13079 // - Joueur : on ne l'affiche QUE si la date du match tombe dans la période du stage 13080 // (en création : la date du jour ; en édition : la date du match). Hors de cette 13081 // fenêtre, la case n'a pas de sens et est masquée. 13082 const baseVisible = !!editionPalmyre && joueurParticipe && isPoleEnv(); 13083 const montrerCasePalmyre = baseVisible && ( 13084 isCoach() ? true : (dateDansFenetre(editionPalmyre, dateMatch) || (isEdit && !!(m && m.evenement_id))) 13085 ); 13086 const palmyreCoche = isEdit 13087 ? !!(m && m.evenement_id) 13088 : dateDansFenetre(editionPalmyre, dateMatch); 13089 13090 const html = `<div class="modal-header">${isEdit ? boutonRetourDetail('Retour') : ''}<h3 class="modal-title">${isEdit ? 'Modifier match' : 'Nouveau match'}</h3><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 13091 <form id="matchForm" data-match-id="${escapeHtml(matchId || '')}"> 13092 <div id="matchFormErrors" class="alert danger" role="alert" tabindex="-1" hidden></div> 13093 ${joueursSelectables.length > 1 ? `<div class="form-row"><label class="form-label">Joueur *</label><select name="joueur_id" required> 13094 <option value="">Sélectionner...</option> 13095 ${joueursSelectables.map(j => `<option value="${j.id}" ${preselect === j.id ? 'selected' : ''}>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</option>`).join('')} 13096 </select></div>` : `<input type="hidden" name="joueur_id" value="${preselect}">`} 13097 <div class="grid-2"> 13098 <div class="form-row"><label class="form-label">Date *</label><input type="date" name="date" required max="${dateLocaleISO(new Date())}" value="${m ? m.date : dateLocaleISO(new Date())}" data-match-change="date"></div> 13099 <div class="form-row"><label class="form-label">Type</label><select name="type"> 13100 <option value="Tournoi" ${m && m.type==='Tournoi'?'selected':''}>Tournoi</option> 13101 <option value="Championnat" ${m && m.type==='Championnat'?'selected':''}>Championnat</option> 13102 <option value="Match libre" ${m && m.type==='Match libre'?'selected':''}>Match libre â homologué FFT</option> 13103 </select></div> 13104 </div> 13105 <div class="form-row"><label class="form-label">Tournoi</label><div id="matchTournamentSelector">${TennisTournamentsTenup.selecteurMatchHtml(choixTournoiInitial, selectionTournoiInitiale)}</div> 13106 <input type="text" id="matchTournamentFallback" name="tournoi" value="${m ? escapeHtml(m.tournoi || tournoiLie?.nom || '') : ''}" placeholder="Nom du tournoi ou lieu" style="display:${m && !m.tournament_id && m.tournoi ? '' : 'none'};margin-top:8px"> 13107 <div class="text-faint" style="font-size:11px;margin-top:5px">Les inscriptions sont proposées en premier. Un tournoi récent peut être saisi jusquâà 45 jours après sa fin.</div> 13108 </div> 13109 <div class="form-row"><label class="form-label">Surface <span class="text-faint" style="font-weight:400; text-transform:none">(optionnel)</span></label> 13110 <div class="pill-group" id="pillSurface"> 13111 <button type="button" class="pill match-form-pill ${m && m.surface==='terre' ? 'active' : ''}" data-value="terre" data-match-action="surface">${matchChoiceIcon('clay')} Terre battue</button> 13112 <button type="button" class="pill match-form-pill ${m && m.surface==='dur' ? 'active' : ''}" data-value="dur" data-match-action="surface">${matchChoiceIcon('hard')} Dur</button> 13113 <button type="button" class="pill match-form-pill ${m && m.surface==='indoor' ? 'active' : ''}" data-value="indoor" data-match-action="surface">${matchChoiceIcon('indoor')} Indoor</button> 13114 </div> 13115 </div> 13116 <div class="form-row"><label class="form-label">Type de match</label> 13117 <div class="pill-group" id="pillFormat"> 13118 <button type="button" class="pill match-form-pill ${!m || m.format !== 'double' ? 'active' : ''}" data-value="simple" data-match-action="format">${matchChoiceIcon('single')} Simple</button> 13119 <button type="button" class="pill match-form-pill ${m && m.format === 'double' ? 'active' : ''}" data-value="double" data-match-action="format">${matchChoiceIcon('double')} Double</button> 13120 </div> 13121 </div> 13122 <div class="grid-2"> 13123 <div class="form-row"><label class="form-label" id="lblAdversaire1">Adversaire *</label><input type="text" name="adversaire" required value="${m ? escapeHtml((m.adversaire || '').split(' / ')[0]) : ''}"></div> 13124 <div class="form-row"><label class="form-label">Classt adv.</label><select name="adversaire_class"> 13125 ${CLASSEMENTS.map(c => `<option value="${c}" ${m && m.adversaire_class===c?'selected':''}>${c}</option>`).join('')} 13126 </select></div> 13127 </div> 13128 <div class="grid-2" id="blocAdversaire2" style="display:${m && m.format === 'double' ? 'grid' : 'none'}"> 13129 <div class="form-row"><label class="form-label">Partenaire adverse</label><input type="text" name="adversaire2_nom" value="${m && m.adversaire2_nom ? escapeHtml(m.adversaire2_nom) : ''}" placeholder="Nom Prénom"></div> 13130 <div class="form-row"><label class="form-label">Classt partenaire</label><select name="adversaire2_class"> 13131 <option value="">â</option> 13132 ${CLASSEMENTS.map(c => `<option value="${c}" ${m && m.adversaire2_class===c?'selected':''}>${c}</option>`).join('')} 13133 </select></div> 13134 </div> 13135 <div class="form-row"><label class="form-label">Résultat *</label> 13136 <div class="pill-group" id="pillVictoire"> 13137 <button type="button" class="pill match-form-pill success ${!m || m.victoire ? 'active' : ''}" data-value="true" data-match-action="resultat">${matchChoiceIcon('win')} Victoire</button> 13138 <button type="button" class="pill match-form-pill danger ${m && !m.victoire ? 'active' : ''}" data-value="false" data-match-action="resultat">${matchChoiceIcon('loss')} Défaite</button> 13139 </div> 13140 </div> 13141 <div class="form-row"><label class="form-label">Score</label>${scoreWidgetHtml(m)}</div> 13142 <div class="form-row" style="display:flex; align-items:center; gap:10px; background:var(--bg-input); padding:10px 12px; border-radius:8px"> 13143 <input type="checkbox" name="walkover" id="matchWalkover" ${m && m.walkover ? 'checked' : ''} style="width:18px; height:18px; flex-shrink:0"> 13144 ${matchChoiceIcon('wo')}<label for="matchWalkover" style="margin:0; font-size:13px; cursor:pointer">Forfait (WO) : l'adversaire ne s'est pas présenté<div class="text-faint" style="font-size:11px">Le match n'est pas compté dans les statistiques (ni victoire, ni match joué), mais reste dans l'historique.</div></label> 13145 </div> 13146 <div class="form-row"><label class="form-label">Ressenti</label> 13147 <div class="pill-group" id="pillRessenti"> 13148 ${[ 13149 [1,'Très dur','#ef6464'],[2,'Difficile','#ff9b54'],[3,'Moyen','#f1d84a'],[4,'Bon','#79dc78'],[5,'Excellent','#35d49a'] 13150 ].map(([n,label,color]) => `<button type="button" class="pill ${m && m.ressenti===n?'active':''}" style="--mood-color:${color}" data-value="${n}" data-match-action="ressenti" title="${label} â ${n} sur 5" aria-label="Ressenti ${label}, ${n} sur 5"><span class="feeling-number">${n}</span><span class="feeling-label">${label}</span></button>`).join('')} 13151 </div> 13152 </div> 13153 <div class="form-row"><label class="form-label match-field-label">${matchChoiceIcon('positive')} Point positif</label><textarea name="point_positif" id="matchPositif" rows="2" placeholder="Ce qui a bien marché, ce dont tu es fier...">${m ? escapeHtml(m.point_positif || '') : ''}</textarea></div> 13154 <div class="form-row"><label class="form-label match-field-label">${matchChoiceIcon('improve')} Point à améliorer</label><textarea name="point_negatif" id="matchNegatif" rows="2" placeholder="Ce que tu veux travailler pour la prochaine fois...">${m ? escapeHtml(m.point_negatif || '') : ''}</textarea></div> 13155 ${baseVisible ? `<div class="form-row" id="casePalmyreWrap" data-debut="${escapeHtml(editionPalmyre.date_debut || '')}" data-fin="${escapeHtml(editionPalmyre.date_fin || '')}" data-coach="${isCoach() ? '1' : '0'}" data-edit-rattache="${isEdit && m && m.evenement_id ? '1' : '0'}" style="display:${montrerCasePalmyre ? 'flex' : 'none'}; align-items:center; gap:10px; background:var(--orange-bg, rgba(243,146,0,0.10)); padding:10px 12px; border-radius:8px"> 13156 <input type="checkbox" name="est_palmyre" id="estPalmyre" ${palmyreCoche ? 'checked' : ''} style="width:18px; height:18px; flex-shrink:0"> 13157 <label for="estPalmyre" style="margin:0; cursor:pointer; font-size:13px">ðï¸ Match joué à la <strong>${escapeHtml(editionPalmyre.nom)}</strong></label> 13158 </div>` : ''} 13159 <div class="flex gap-1 mt-2"> 13160 ${isEdit ? `<button type="button" class="btn btn-danger" data-match-action="delete" data-match-delete-id="${escapeHtml(m.id)}">Supprimer</button>` : ''} 13161 <button type="button" class="btn flex-1" data-match-action="cancel">Annuler</button> 13162 <button type="submit" class="btn btn-primary flex-1">${isEdit ? 'Enregistrer' : 'Ajouter'}</button> 13163 </div> 13164 </form>`; 13165 showModal(html); 13166 initialiserInteractionsFormulaireMatch(); 13167 synchroniserTournoiMatch(document.getElementById('matchForm')); 13168} 13169 13170function initialiserInteractionsFormulaireMatch() { 13171 const form = document.getElementById('matchForm'); 13172 if (!form) return; 13173 13174 form.querySelectorAll('.pill').forEach(pill => { 13175 pill.setAttribute('aria-pressed', pill.classList.contains('active') ? 'true' : 'false'); 13176 }); 13177 13178 form.addEventListener('submit', event =>
13178 saveMatch(event, form.dataset.matchId || '')); 13179 form.addEventListener('click', event => { 13180 const cible = event.target.closest('[data-match-action]'); 13181 if (!cible || !form.contains(cible)) return; 13182 const action = cible.dataset.matchAction; 13183 if (action === 'surface') selectPillToggle(cible, 'pillSurface'); 13184 else if (action === 'format') setFormatMatch(cible.dataset.value); 13185 else if (action === 'resultat') { 13186 selectPill(cible, 'pillVictoire'); 13187 majNotesPlaceholder(); 13188 } else if (action === 'ressenti') selectPill(cible, 'pillRessenti'); 13189 else if (action === 'cancel') { effacerBrouillonMatch(); closeModal(); } 13190 else if (action === 'delete') deleteMatch(cible.dataset.matchDeleteId); 13191 if (!form.dataset.matchId) planifierBrouillonMatch(form); 13192 }); 13193 form.addEventListener('change', event => { 13194 if (event.target.matches('[data-match-change="date"]')) { majCasePalmyre(event.target.value); actualiserSelecteurTournoiMatch(form); } 13195 if (event.target.matches('select[name="joueur_id"]')) actualiserSelecteurTournoiMatch(form); 13196 if (event.target.matches('[data-match-change="tournament"]')) synchroniserTournoiMatch(form); 13197 if (event.target.matches('[data-match-score]')) syncScore(); 13198 if (!form.dataset.matchId) sauvegarderBrouillonMatch(form); 13199 }); 13200 form.addEventListener('input', event => { 13201 if (event.target.matches('[data-match-score]')) syncScore(); 13202 const erreurs = document.getElementById('matchFormErrors'); 13203 if (erreurs && !erreurs.hidden) erreurs.hidden = true; 13204 if (!form.dataset.matchId) planifierBrouillonMatch(form); 13205 }); 13206 if (restaurerBrouillonMatch(form)) toast('Brouillon de match restauré'); 13207} 13208 13209function actualiserSelecteurTournoiMatch(form) { 13210 const joueur = joueurById(form?.joueur_id?.value); 13211 const choix = TennisTournamentsTenup.choixTournoisMatch({ tournaments:cache.tournois, registrations:cache.inscriptionsTournois, player:joueur, matchDate:form?.date?.value, compatible:(tournoi,j)=>tournoiCorrespondAuJoueur(tournoi,j,{sexe:true,age:true,classement:true}) }); 13212 const slot = document.getElementById('matchTournamentSelector'); 13213 const ancienne = form?.tournament_id?.value || ''; 13214 if (slot) slot.innerHTML = TennisTournamentsTenup.selecteurMatchHtml(choix, ancienne || choix.preselectionId); 13215 synchroniserTournoiMatch(form); 13216} 13217 13218function synchroniserTournoiMatch(form) { 13219 const id = form?.tournament_id?.value || ''; 13220 const fallback = document.getElementById('matchTournamentFallback'); 13221 const hidden = form?.tenup_tournament_id; 13222 const tournoi = cache.tournois.find(t => t.id === id); 13223 if (fallback) { 13224 fallback.style.display = id === '__missing__' ? '' : 'none'; 13225 if (tournoi) fallback.value = tournoi.nom || ''; 13226 else if (!id && fallback.style.display === 'none') fallback.value = ''; 13227 } 13228 if (hidden) hidden.value = tournoi?.tenup_id || tournoi?.tenup_code || ''; 13229} 13230 13231// Recalcule la visibilité de la case "Match de la Palmyre" quand la date du match change. 13232// - Coach : la case reste toujours visible (il peut rattacher/corriger a posteriori). 13233// - Joueur : visible seulement si la date tombe dans la fenêtre du stage, OU si le match 13234// était déjà rattaché à la Palmyre (pour pouvoir le décocher). Si on masque la case, 13235// on décoche pour ne pas enregistrer un rattachement involontaire. 13236function majCasePalmyre(dateVal) { 13237 const wrap = document.getElementById('casePalmyreWrap'); 13238 if (!wrap) return; 13239 const debut = wrap.dataset.debut || '';
13240 const fin = wrap.dataset.fin || ''; 13241 const estCoach = wrap.dataset.coach === '1'; 13242 const editRattache = wrap.dataset.editRattache === '1'; 13243 const d = (dateVal || '').slice(0, 10); 13244 const dansFenetre = debut && fin && d >= debut && d <= fin; 13245 const visible = estCoach ? true : (dansFenetre || editRattache); 13246 wrap.style.display = visible ? 'flex' : 'none'; 13247 const chk = document.getElementById('estPalmyre'); 13248 if (chk) { 13249 if (!visible) { 13250 chk.checked = false; // masqué â pas de rattachement 13251 } else if (!estCoach && !editRattache) { 13252 // Joueur, création dans la fenêtre : on coche par défaut (cohérent avec l'ouverture). 13253 chk.checked = dansFenetre; 13254 } 13255 } 13256} 13257 13258// Bascule Simple / Double dans le formulaire de match : affiche ou masque le bloc 13259// du 2e adversaire, et adapte le libellé du premier. 13260function setFormatMatch(format) { 13261 document.querySelectorAll('#pillFormat .pill').forEach(p => { 13262 const actif = p.dataset.value === format; 13263 p.classList.toggle('active', actif); 13264 p.setAttribute('aria-pressed', actif ? 'true' : 'false'); 13265 }); 13266 const bloc2 = document.getElementById('blocAdversaire2'); 13267 if (bloc2) bloc2.style.display = format === 'double' ? 'grid' : 'none'; 13268 const lbl1 = document.getElementById('lblAdversaire1'); 13269 if (lbl1) lbl1.textContent = format === 'double' ? 'Adversaire 1 *' : 'Adversaire *'; 13270} 13271 13272// Widget de saisie du score à la Ten'Up : une case par jeu, tie-break et super 13273// tie-break gérés. Assemble en coulisses la chaîne canonique "6/4 3/6 7/6(5)" dans 13274// le champ caché name="score" (rétrocompatible : aucun changement de stockage). 13275function scoreWidgetHtml(m) { 13276 const sets = parseScoreSets(m ? (m.score || '') : ''); 13277 const rawTokens = (m && m.score) ? String(m.score).trim().split(/\s+/) : []; 13278 const tbOf = (i) => { const t = rawTokens[i] || ''; const mt = t.match(/\((\d+)\)/); return mt ? mt[1] : ''; }; 13279 const superTB = !!(sets[2] && Math.max(sets[2].moi, sets[2].adv) >= 10); 13280 const vA = (i) => sets[i] ? sets[i].moi : ''; 13281 const vB = (i) => sets[i] ? sets[i].adv : ''; 13282 const estTie = (i) => sets[i] && Math.abs(sets[i].moi - sets[i].adv) === 1 13283 && [3, 4, 5, 7].includes(Math.max(sets[i].moi, sets[i].adv)); 13284 const box = (id, val) => `<input type="number" id="${id}" min="0" max="20" inputmode="numeric" value="${val}" data-match-score style="width:46px;text-align:center;padding:8px 4px">`; 13285 const row = (i) => { 13286 const n = i + 1; 13287 const tbVisible = estTie(i) && !(i === 2 && superTB); 13288 return `<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px"> 13289 <span id="set${n}lbl" style="width:70px;font-size:12px;color:var(--text-dim)">${(i === 2 && superTB) ? 'Super TB' : 'Set ' + n}</span> 13290 ${box('set' + n + 'a', vA(i))}<span style="opacity:.6">/</span>${box('set' + n + 'b', vB(i))} 13291 <input type="number" id="tb${n}" placeholder="TB" min="0" max="30" inputmode="numeric" value="${tbOf(i)}" data-match-score title="Points du perdant au jeu décisif" style="width:52px;text-align:center;padding:8px 4px;display:${tbVisible ? '' : 'none'}"> 13292 </div>`; 13293 }; 13294 return `<div id="scoreWidget"> 13295 ${row(0)}${row(1)}${row(2)} 13296 <label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-dim);margin-top:2px;cursor:pointer"> 13297 <input type="checkbox" id="superTB" ${superTB ? 'checked' : ''} data-match-score style="width:16px;height:16px"> 3áµ set = super tie-break (10 pts) 13298 </label> 13299 <input type="hidden" name="score" id="scoreHidden" value="${m ? escapeHtml(m.score || '') : ''}"> 13300 <div id="scoreApercu" class="text-faint" style="font-size:11px;margin-top:4px">${m && m.score ? 'Score : ' + escapeHtml(m.score) : ''}</div> 13301 <div id="scoreErreur" class="danger" role="alert" style="font-size:11px;margin-top:4px" hidden></div> 13302 </div>`; 13303} 13304 13305// Recompose la chaîne de score depuis les cases, met à jour le champ caché, 13306// gère l'affichage des tie-breaks, et déduit automatiquement Victoire/Défaite. 13307function syncScore() { 13308 const superTB = !!(document.getElementById('superTB') && document.getElementById('superTB').checked); 13309 const tokens = []; 13310 for (let i = 1; i <= 3; i++) { 13311 const a = document.getElementById('set' + i + 'a'); 13312 const b = document.getElementById('set' + i + 'b'); 13313 const tb = document.getElementById('tb' + i); 13314 const lbl = document.getElementById('set' + i + 'lbl'); 13315 if (!a || !b) continue; 13316 const na = parseInt(a.value, 10), nb = parseInt(b.value, 10); 13317 const superRow = (i === 3 && superTB); 13318 if (lbl) lbl.textContent = superRow ? 'Super TB' : 'Set ' + i; 13319 const isTie = !superRow && Math.abs(na - nb) === 1 && [3, 4, 5, 7].includes(Math.max(na, nb)); 13320 if (tb) tb.style.display = isTie ? '' : 'none'; 13321 if (a.value.trim() === '' || b.value.trim() === '') continue; 13322 if (isNaN(na) || isNaN(nb)) continue; 13323 let token = na + '/' + nb; 13324 if (isTie && tb && tb.value.trim() !== '' && !isNaN(parseInt(tb.value, 10))) { 13325 token += '(' + parseInt(tb.value, 10) + ')'; 13326 } 13327 tokens.push(token); 13328 } 13329 const scoreStr = tokens.join(' '); 13330 const hidden = document.getElementById('scoreHidden'); 13331 if (hidden) hidden.value = scoreStr; 13332 const apercu = document.getElementById('scoreApercu'); 13333 if (apercu) apercu.textContent = scoreStr ? 'Score : ' + scoreStr : ''; 13334 const analyse = TennisDomainRules.analyserScoreSaisi(scoreStr); 13335 const erreurScore = document.getElementById('scoreErreur'); 13336 if (erreurScore) { 13337 erreurScore.textContent = scoreStr && !analyse.valide ? analyse.erreurs[0] : ''; 13338 erreurScore.hidden = !erreurScore.textContent; 13339 } 13340 // Déduction automatique du résultat uniquement depuis un score valide. 13341 if (analyse.valide && analyse.victoireDeduite != null) { 13342 const gagne = analyse.victoireDeduite; 13343 const grp = document.getElementById('pillVictoire'); 13344 if (grp) grp.querySelectorAll('.pill').forEach(p => { 13345 const actif = (p.dataset.value === 'true') === gagne; 13346 p.classList.toggle('active', actif); 13347 p.setAttribute('aria-pressed', actif ? 'true' : 'false'); 13348 }); 13349 } 13350 majNotesPlaceholder(); 13351} 13352 13353function afficherErreursMatch(erreurs) { 13354 const zone = document.getElementById('matchFormErrors');
13355 const liste = Array.isArray(erreurs) ? erreurs.filter(Boolean) : []; 13356 if (!zone || !liste.length) return; 13357 zone.innerHTML = `<strong>Vérifie la fiche :</strong><ul style="margin:6px 0 0;padding-left:18px">${liste.map(erreur => `<li>${escapeHtml(erreur)}</li>`).join('')}</ul>`; 13358 zone.hidden = false; 13359 zone.focus(); 13360} 13361 13362// Nudge growth mindset (non-bloquant) : quand le résultat est une défaite, on invite 13363// le joueur à noter un point positif / un apprentissage dans le champ Notes. 13364function majNotesPlaceholder() { 13365 const el = document.getElementById('matchNegatif'); 13366 if (!el) return; 13367 const perdu = !!document.querySelector('#pillVictoire .pill.active[data-value="false"]'); 13368 el.placeholder = perdu 13369 ? 'Qu\'as-tu appris de ce match ? Que veux-tu travailler ?' 13370 : 'Ce que tu veux travailler pour la prochaine fois...'; 13371} 13372 13373async function saveMatch(e, id) { 13374 e.preventDefault(); 13375 if (joueurEnAttente()) { toast('Ton inscription est en attente de validation par le coach.', true); return; } 13376 const f = e.target; 13377 const victoirePill = document.querySelector('#pillVictoire .pill.active'); 13378 const ressentiPill = document.querySelector('#pillRessenti .pill.active'); 13379 const surfacePill = document.querySelector('#pillSurface .pill.active'); 13380 const formatPill = document.querySelector('#pillFormat .pill.active'); 13381 const format = formatPill ? formatPill.dataset.value : 'simple'; 13382 const ancienMatch = id ? (cache.matchesSaison || listeSure(cache.matches)).find(match => match.id === id) : null; 13383 13384 // Adversaire : en double, on combine "Nom1 / Nom2" et on stocke le 2e séparément. 13385 const adv1 = f.adversaire.value.trim(); 13386 const adv2 = (f.adversaire2_nom && f.adversaire2_nom.value.trim()) || ''; 13387 const adv2Class = (f.adversaire2_class && f.adversaire2_class.value) || null; 13388 const saisie = {
13389 joueur_id: f.joueur_id.value, 13390 date: f.date.value, 13391 type: f.type.value, 13392 tournoi: f.tournoi.value.trim(), 13393 adversaire: adv1, 13394 adversaire_class: f.adversaire_class.value, 13395 adversaire2_nom: adv2 || null, 13396 adversaire2_class: adv2Class, 13397 format, 13398 victoire: victoirePill ? victoirePill.dataset.value === 'true' : null, 13399 score: f.score.value.trim(), 13400 surface: surfacePill ? surfacePill.dataset.value : null, 13401 ressenti: ressentiPill ? parseInt(ressentiPill.dataset.value) : null, 13402 point_positif: f.point_positif ? f.point_positif.value.trim() : null, 13403 point_negatif: f.point_negatif ? f.point_negatif.value.trim() : null, 13404 walkover: !!(f.walkover && f.walkover.checked), 13405 // "notes" n'est plus saisi par le joueur, mais on le PRÃSERVE : l'import Ten'Up y 13406 // écrit "WO", et estWalkover() s'en sert pour détecter les forfaits (impacte les 13407 // belles perfs, l'XP et les badges). Le vider casserait ces calculs. 13408 notes: ancienMatch && ancienMatch.notes ? ancienMatch.notes : '' 13409 }; 13410 13411 // Certains imports historiques ont conservé le score dans l'ordre du vainqueur, 13412 // même lorsque le joueur a perdu. Une édition descriptive (surface, ressenti, 13413 // notes...) doit rester possible tant que score et résultat n'ont pas été touchés. 13414 // Dès que l'un de ces deux champs change, la cohérence stricte redevient obligatoire. 13415 const resultatSportifInchange = TennisDomainRules.resultatSportifInchange(ancienMatch, saisie); 13416 const preparation = TennisDomainRules.preparerMatchSaisi(saisie, { 13417 dateMax: dateLocaleISO(new Date()), 13418 autoriserResultatHistorique: resultatSportifInchange 13419 }); 13420 if (!preparation.valide) { 13421 afficherErreursMatch(preparation.erreurs); 13422 toast(preparation.erreurs.join(' · '), true); 13423 return; 13424 } 13425 const obj = preparation.match; 13426 const tournamentId = f.tournament_id?.value || ''; 13427 obj.tournament_id = tournamentId && tournamentId !== '__missing__' ? tournamentId : null; 13428 obj.tenup_tournament_id = obj.tournament_id ? (f.tenup_tournament_id?.value || null) : null; 13429 if (obj.tournament_id) { 13430 const tournoiSelectionne = cache.tournois.find(t => t.id === obj.tournament_id); 13431 obj.tournoi = tournoiSelectionne?.nom || obj.tournoi; 13432 } 13433 // Défense côté interface en complément des policies RLS : un joueur ne peut 13434 // jamais détourner le formulaire pour écrire un match sur une autre fiche. 13435 if (!isCoach() && obj.joueur_id !== profile.joueur_id) { 13436 toast('Tu peux saisir uniquement tes propres matchs.', true); 13437 return; 13438 } 13439 13440 // --- Estampillage Palmyre --- 13441 // 1) Coach saisissant DEPUIS l'environnement Palmyre : rattachement automatique. 13442 // 2) Sinon, si la case "Match de la Palmyre" est présente : cochée = rattaché à 13443 // l'édition la plus récente, décochée = match académie (evenement_id null). 13444 const evActif = getActiveEvenement(); 13445 if (evActif) { 13446 obj.evenement_id = evActif.id; 13447 } else if (f.est_palmyre) { 13448 const edition = (listeSure(cache.evenements))[0] || null; 13449 obj.evenement_id = (f.est_palmyre.checked && edition) ? edition.id : null; 13450 } 13451 13452 const verrou = verrouillerSoumission(f); 13453 if (!verrou) return; 13454 13455 let error; 13456 let nouveauMatchId = null; 13457 try { 13458 if (id) { 13459 let requete = db.from('matches').update(obj).eq('id', id); 13460 if (ancienMatch?.updated_at) requete = requete.eq('updated_at', ancienMatch.updated_at); 13461 ({ error } = await updateConfirmee(requete, 'id,updated_at')); 13462 } else { 13463 obj.created_by = session.user.id; 13464 const res = await db.from('matches').insert(obj).select('id').single(); 13465 error = res.error; 13466 nouveauMatchId = res.data ? res.data.id : null; 13467 if (nouveauMatchId) obj.id = nouveauMatchId; 13468 } 13469 } finally { 13470 deverrouillerSoumission(verrou); 13471 } 13472 if (error) { toastErreur(error); return; } 13473 if (!id) effacerBrouillonMatch(); 13474 closeModal(); 13475 toast(id ? 'Match modifié' : 'Match ajouté'); 13476 13477 // Envois email â uniquement à la création (pas à la modification) pour éviter le spam 13478 if (!id) { 13479 const joueur = joueurById(obj.joueur_id); 13480 if (joueur) { 13481 // 1) Match ajouté par le coach pour un autre joueur (pas pour lui-même) 13482 const coachAjouteMatchPourAutre = isCoach() && joueur.id !== profile.joueur_id; 13483 if (coachAjouteMatchPourAutre) { 13484 await emailMatchAjouteParCoach(obj, joueur); 13485 } 13486 // Un match réellement joué et saisi par le joueur est envoyé aux coachs 13487 // qui ont activé cette préférence. Les WO ne sont pas des matchs joués. 13488 const matchSaisiParLeJoueur = !isCoach() && joueur.id === profile.joueur_id && !obj.walkover; 13489 if (matchSaisiParLeJoueur && nouveauMatchId) { 13490 await sendEmailEvent('match_coach', nouveauMatchId); 13491 } 13492 // 2) Perf notable : victoire contre mieux classé d'au moins 2 échelons 13493 // (les victoires par forfait/WO ne comptent pas comme une perf sportive) 13494 const perfCreee = detailBellePerf(obj, joueur.id); 13495 if (perfCreee) { 13496 if (perfCreee.ecart >= 2) {
13497 await emailPerfNotable(obj, joueur, perfCreee.ecart); 13498 } 13499 // 3) Notifier le COACH de TOUTE belle perf (écart ⥠1), in-app + email. 13500 // Passe par une fonction SECURITY DEFINER (le joueur n'a pas le droit 13501 // d'insérer une notif pour le coach ni d'accéder à son email). 13502 await notifierPerfCoach(obj, joueur, nouveauMatchId); 13503 } 13504 } 13505 } 13506 13507 // Niveau AVANT prise en compte du match (cache encore à l'état précédent) 13508 const etatAvantMatch = (obj.joueur_id && !isCoach() && obj.joueur_id === profile.joueur_id) 13509 ? (() => { const x = calculerXP(obj.joueur_id).xp; return { niveau: niveauDepuisXP(x).niveau, xp: x }; })() 13510 : null; 13511 await reloadTables(["matches"]); 13512 // Détecter/enregistrer de nouveaux badges pour le joueur concerné par le match. 13513 // On ne félicite (animation) que si c'est le joueur lui-même qui a saisi (pas le coach). 13514 if (obj.joueur_id) { 13515 if (!isCoach() && obj.joueur_id === profile.joueur_id) { 13516 await synchroniserBadgesEtCelebrer(obj.joueur_id, etatAvantMatch); 13517 } else { 13518 await synchroniserBadges(obj.joueur_id); 13519 } 13520 } 13521 // Feedback encourageant quand le JOUEUR lui-même saisit une DÃFAITE (growth mindset) : 13522 // on recadre la défaite en progrès, et on célèbre le beau combat le cas échéant. 13523 if (obj.joueur_id && !isCoach() && obj.joueur_id === profile.joueur_id && !obj.victoire) { 13524 const combat = estBelleDefaite(obj, obj.joueur_id); 13525 setTimeout(() => toast(combat 13526 ? 'ð¥ Beau combat ! Tu as pris un set à un joueur mieux classé. C\'est comme ça qu\'on progresse ð' 13527 : 'ðª Bravo d\'avoir joué et saisi ton match. C\'est en jouant qu\'on progresse.'), 1200); 13528 } 13529 render(); 13530} 13531 13532async function deleteMatch(id) { 13533 if (!(await demanderConfirmation('Supprimer ce match ?'))) return; 13534 const { error } = await db.from('matches').delete().eq('id', id); 13535 if (error) { toastErreur(error); return; } 13536 closeModal(); 13537 toast('Match supprimé'); 13538 await reloadTables(["matches"]); 13539 render(); 13540} 13541 13542function openMatchDetail(id) { openModalMatch(id); } 13543 13544// ============================================================ 13545// MODAL: TOURNOI (coach) 13546// ============================================================ 13547function openModalTournoi(tournoiId) { 13548 const t = tournoiId ? cache.tournois.find(x => x.id === tournoiId) : null; 13549 const isEdit = !!t; 13550 const creationPreAcademie = !isEdit && isPreAcademieEnv(); 13551 13552 if (!isCoach() && !isEdit) { 13553 toast('Les tournois officiels sont importés depuis TenâUp. Utilise « Tournoi introuvable » lors de la saisie dâun match.', true); 13554 return; 13555 } 13556 13557 // Côté joueur : pas d'édition d'un tournoi existant (juste créer ou consulter) 13558 if (!isCoach() && isEdit) { 13559 // On affiche les détails en lecture seule 13560 const dateStr = t.date_fin && t.date_fin !== t.date_debut ? `${formatDate(t.date_debut)} â ${formatDate(t.date_fin)}` : formatDate(t.date_debut); 13561 const inscrits = (listeSure(t.inscrits)); 13562 const jeSuisInscrit = profile.joueur_id && inscrits.includes(profile.joueur_id); 13563 const isPast = new Date(t.date_fin || t.date_debut).getTime() < Date.now() - 24*3600*1000; 13564 13565 const html = `<div class="modal-header"><h3 class="modal-title">${escapeHtml(t.nom)}</h3><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 13566 <div class="card" style="font-size:13px; line-height:1.7"> 13567 <div><strong>Dates :</strong> ${dateStr}</div> 13568 ${t.lieu ? `<div><strong>Lieu :</strong> ${escapeHtml(t.lieu)}</div>` : ''} 13569 ${t.categorie ? `<div><strong>Catégorie :</strong> ${escapeHtml(t.categorie)}</div>` : ''} 13570 ${(function () { 13571 const noms = inscritsNomsPoleCollectifs(t); 13572 if (noms.length > 0) return `<div><strong>Inscrits de l'académie (${noms.length}) :</strong><br>${escapeHtml(noms.join(', '))}</div>`; 13573 if (inscrits.length > 0) return `<div><strong>Inscrits de l'académie :</strong> ${inscrits.length}</div>`; 13574 return `<div><strong>Inscrits de l'académie :</strong> aucun pour l'instant</div>`; 13575 })()} 13576 </div> 13577 <div id="meteoEnrichieSlot"></div> 13578 ${jeSuisInscrit ? `<div class="alert success" style="margin-top:12px"><span class="icon">â</span><div class="alert-content">Tu es inscrit à ce tournoi.</div></div>` : ''} 13579 <div class="mt-2" style="text-align:center"> 13580 <a href="${escapeHtml(t.tenup_url || tenupTournoiUrl(t.tenup_id || t.tenup_code))}" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm" style="display:inline-block; text-decoration:none">ð¾ Voir le tournoi sur Ten'Up â</a> 13581 </div> 13582 <div class="flex gap-1 mt-2"> 13583 <button class="btn flex-1" data-ui-click="closeModal()">Fermer</button> 13584 ${!isPast ? (jeSuisInscrit 13585 ? `<button class="btn" data-ui-click="toggleTournoiInscription(${escapeHtml(encoderArgumentTexteUI(t.id))}, false); closeModal()">â© Me désinscrire</button>` 13586 : `<button class="btn btn-primary" data-ui-click="toggleTournoiInscription(${escapeHtml(encoderArgumentTexteUI(t.id))}, true); closeModal()">+ Je m'inscris</button>` 13587 ) : ''} 13588 </div>`; 13589 showModal(html); 13590 // Hydrate la météo (asynchrone). Date pertinente : début si à venir, aujourd'hui si en cours, rien si terminé. 13591 const dMeteo1 = dateMeteoTournoi(t); 13592 if (t.lieu && dMeteo1) hydrateMeteoEnrichie('meteoEnrichieSlot', t.lieu, dMeteo1); 13593 return; 13594 } 13595 13596 // Côté coach : édition complète. Côté joueur en mode "création" : formulaire simplifié 13597 const showInscrits = isCoach(); 13598 const officielVerrouille = !!(t && t.nature === 'officiel_fft' && t.source_creation === 'tenup_sync'); 13599 const html = `<div class="modal-header"><h3 class="modal-title">${isEdit ? 'Modifier tournoi' : 'Nouveau tournoi'}</h3><button class="modal-close" data-ui-click="closeModal()">Ã</button></div> 13600 <form data-ui-submit="saveTournoi(event, ${escapeHtml(encoderArgumentTexteUI(tournoiId || ''))})" data-official-locked="${officielVerrouille?'1':'0'}" data-inscrits-snapshot="${escapeHtml(JSON.stringify(listeSure((t && t.inscrits))))}"> 13601 ${officielVerrouille?'<div class="alert">Les informations officielles sont maîtrisées par TenâUp. Seules les données pédagogiques et les inscriptions Académie sont modifiables ici.</div>':''} 13602 <div class="form-row"><label class="form-label">Nom *</label><input type="text" name="nom" required ${officielVerrouille?'readonly':''} value="${t ? escapeHtml(t.nom) : ''}" placeholder="${creationPreAcademie ? 'Ex. Plateau rouge de Bouliac' : 'Ex. Compétition interne du club'}"></div> 13603 <div class="grid-2"> 13604 <div class="form-row"><label class="form-label">Date début *</label><input type="date" name="date_debut" required ${officielVerrouille?'readonly':''} value="${t ? t.date_debut : ''}"></div> 13605 <div class="form-row"><label class="form-label">Date fin</label><input type="date" name="date_fin" ${officielVerrouille?'readonly':''} value="${t ? t.date_fin || '' : ''}"></div> 13606 </div> 13607 <div class="form-row"><label class="form-label">Lieu</label><input type="text" name="lieu" ${officielVerrouille?'readonly':''} value="${t ? escapeHtml(t.lieu || '') : ''}" placeholder="Ville, club..."></div> 13608 ${creationPreAcademie ? `<input type="hidden" name="categorie" value="Plateau rouge"><div class="form-row"><label class="form-label">Type</label><div class="card" style="padding:10px 12px">Plateau rouge <span class="text-faint">· création locale (hors TenâUp)</span></div></div>` : `<div class="form-row"><label class="form-label">Catégorie/détails</label><input type="text" name="categorie" ${officielVerrouille?'readonly':''} value="${t ? escapeHtml(t.categorie || '') : ''}" placeholder="Ex. 15/16 ans, 15/3 à 30"></div>`} 13609 <div class="form-row"><label class="form-label">Note pédagogique Académie</label><textarea name="note_pedagogique" rows="3" placeholder="Objectif sportif, consigne ou contexte localâ¦">${escapeHtml(t?.pedagogie_json?.note || '')}</textarea></div> 13610 13611 ${showInscrits ? ` 13612 <div class="form-row"><label class="form-label">Joueurs inscrits</label><div id="inscritsCheck" style="max-height:200px; overflow-y:auto"> 13613 ${cache.joueurs.filter(TennisDomainRules.estJoueurActif).filter(j => !creationPreAcademie || joueurDansEnvCourant(j)).map(j => { 13614 const registration=t?TennisTournamentsTenup.registrationPour(cache.inscriptionsTournois,t.id,j.id):null; 13615 const statut=registration?.statut || ((t&&listeSure(t.inscrits).includes(j.id
13615))?'inscrit':'refuse'); 13616 return `<div style="display:grid;grid-template-columns:minmax(120px,1fr) minmax(145px,190px);gap:8px;align-items:center;padding:6px 0"><span>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)} <span class="text-faint">(${escapeHtml(j.classement || 'NC')})</span></span><select data-registration-player="${j.id}">${Object.entries(TennisTournamentsTenup.STATUTS_INSCRIPTION).map(([value,label])=>`<option value="${value}" ${value===statut?'selected':''}>${escapeHtml(label)}</option>`).join('')}</select></div>`; 13617 }).join('') || '<div class="text-faint">Aucun joueur</div>'} 13618 </div></div> 13619 ` : ` 13620 <div class="auth-info">En créant ce tournoi, tu seras automatiquement inscrit. Les autres joueurs pourront s'y inscrire ensuite depuis leur app.</div> 13621 `} 13622 13623 ${isEdit ? `<div class="mt-2" style="text-align:center"> 13624 <a href="${escapeHtml(t.tenup_url || tenupTournoiUrl(t.tenup_id || t.tenup_code))}" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm" style="display:inline-block; text-decoration:none">ð¾ Voir le tournoi sur Ten'Up â</a> 13625 </div>` : ''} 13626 13627 ${isEdit ? `<div id="meteoEnrichieSlot"></div>` : ''} 13628 13629 <div class="flex gap-1 mt-2"> 13630 ${isEdit && isCoach() ? `<button type="button" class="btn btn-danger" data-ui-click="deleteTournoi(${escapeHtml(encoderArgumentTexteUI(t.id))})">Supprimer</button>` : ''} 13631 <button type="button" class="btn flex-1" data-ui-click="closeModal()">Annuler</button> 13632 <button type="submit" class="btn btn-primary flex-1">${isEdit ? 'Enregistrer' : 'Créer'}</button> 13633 </div> 13634 </form>`; 13635 showModal(html); 13636 // Hydrate la météo si on est en édition d'un tournoi existant avec lieu 13637 const dMeteo2 = isEdit ? dateMeteoTournoi(t) : null; 13638 if (isEdit && t.lieu && dMeteo2) hydrateMeteoEnrichie('meteoEnrichieSlot', t.lieu, dMeteo2); 13639} 13640 13641async function saveTournoi(e, id) { 13642 e.preventDefault(); 13643 if (!isCoach()) { toast('Action réservée au coach', true); return; } 13644 const f = e.target; 13645 const verrou = verrouillerSoumission(f); 13646 if (!verrou) return; 13647 13648 const statutsInscription = Array.from(document.querySelectorAll('#inscritsCheck [data-registration-player]')).map(select => ({ playerId:select.dataset.registrationPlayer, statut:select.value })); 13649 const inscrits = statutsInscription.filter(row => ['inscrit','a_confirmer','liste_attente'].includes(row.statut)).map(row => row.playerId); 13650 13651 const obj = { 13652 nom: f.nom.value.trim(), 13653 date_debut: f.date_debut.value, 13654 date_fin: f.date_fin.value || f.date_debut.value, 13655 lieu: f.lieu.value.trim(), 13656 categorie: f.categorie.value.trim(), 13657 pedagogie_json: { ...(id ? cache.tournois.find(x=>x.id===id)?.pedagogie_json : {}), note:f.note_pedagogique?.value.trim() || '', ...(!id && isPreAcademieEnv() ? { environnement:'pre_academie' } : {}) }, 13658 updated_at: new Date().toISOString() 13659 }; 13660 13661 let error; 13662 let tournoiSauveId = id || null; 13663 try { 13664 if (id) { 13665 if (isCoach()) { 13666 if (f.dataset.officialLocked === '1') { 13667 ({ error } = await updateConfirmee(db.from('tournois').update({ pedagogie_json:obj.pedagogie_json, updated_at:obj.updated_at }).eq('id',id))); 13668 } else { 13669 let inscritsSnapshot = []; 13670 try { inscritsSnapshot = JSON.parse(f.dataset.inscritsSnapshot || '[]'); } catch (_) { inscritsSnapshot = []; } 13671 ({ error } = await db.rpc('coach_modifier_tournoi', { 13672 p_tournoi_id: id, 13673 p_nom: obj.nom, 13674 p_date_debut: obj.date_debut, 13675 p_date_fin: obj.date_fin, 13676 p_lieu: obj.lieu, 13677 p_categorie: obj.categorie, 13678 p_inscrits_snapshot: inscritsSnapshot, 13679 p_inscrits_souhaites: inscrits 13680 })); 13681 } 13682 } 13683 } else { 13684 obj.inscrits = inscrits; 13685 obj.created_by = session.user.id; 13686 obj.nature = 'competition_interne_club'; 13687 obj.source_creation = 'academie'; 13688 obj.statut_local = 'a_venir'; 13689 const creation = await db.from('tournois').insert(obj).select('id').single(); 13690 error = creation.error; 13691 tournoiSauveId = creation.data?.id || null; 13692 } 13693 } finally { 13694 deverrouillerSoumission(verrou); 13695 } 13696 if (error) { toastErreur(error); return; } 13697 if (isCoach() && tournoiSauveId) { 13698 for (const row of statutsInscription) { 13699 const avant=cache.inscriptionsTournois.find(r=>r.tournament_id===tournoiSauveId&&r.player_id===row.playerId)?.statut; 13700 if (avant===row.statut) continue; 13701 if (!avant && row.statut==='refuse') continue; 13702 const resultat = await db.rpc('coach_set_tournament_registration',{p_tournament_id:tournoiSauveId,p_player_id:row.playerId,p_status:row.statut}); 13703 if (resultat.error) { toastErreur(resultat.error); return; } 13704 } 13705 } 13706 closeModal(); 13707 toast(id ? 'Tournoi modifié' : 'Tournoi créé'); 13708 await refresh(); 13709} 13710 13711async function deleteTournoi(id) { 13712 if (!(await demanderConfirmation('Supprimer ce tournoi ?'))) return; 13713 const { error } = await db.from('tournois').delete().eq('id', id); 13714 if (error) { toastErreur(error); return; } 13715 closeModal(); 13716 toast('Tournoi supprimé'); 13717 await refresh(); 13718} 13719 13720// ============================================================ 13721// IMPORT TEN'UP (coach) 13722// ============================================================ 13723// Workflow : 13724// 1) openModalImportTenup : ouvre la modale avec drop zone 13725// 2) handleTenupFile : lit le PDF, extrait le texte via pdf.js 13726// 3) parseTenupText : parse le texte en liste de tournois 13727// 4) showTenupPreview : affiche la prévisualisation avec checkboxes catégories 13728// 5) confirmTenupImport : insère en base, gère les doublons 13729 13730function openModalImportTenup() { 13731 if (!isCoach()) return; 13732 gestionnaireBrouillons.definirTournoisTenup([]); 13733 13734 const html = `<div class="modal-header"> 13735 <h3 class="modal-title">Import Ten'Up</h3> 13736 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 13737 </div> 13738 <div class="auth-info mb-2"> 13739 Glisse-dépose ici un export PDF de Ten'Up (recherche de tournois). L'app va extraire automatiquement les tournois et te proposer de sélectionner les catégories à conserver pour chacun. 13740 </div> 13741 <div id="tenupDropZone" style="border:2px dashed var(--border-strong); border-radius:var(--radius); padding:30px 20px; text-align:center; cursor:pointer; transition:all 0.2s" data-ui-drop="onTenupDrop(event)" data-ui-dragover="onTenupDragOver(event, this)" data-ui-dragleave="onTenupDragLeave(event, this)" data-ui-click="document.getElementById('tenupFileInput').click()"> 13742 <div style="font-size:36px">ð</div> 13743 <div style="margin-top:8px; font-weight:600">Glisse ton PDF Ten'Up ici</div> 13744 <div class="text-faint" style="font-size:12px; margin-top:4px">ou clique pour parcourir</div> 13745 <input type="file" id="tenupFileInput" accept="application/pdf" style="display:none" data-ui-change="onTenupFileSelect(event)"> 13746 </div> 13747 <div id="tenupStatus" style="margin-top:14px; font-size:13px; min-height:20px"></div> 13748 <div id="tenupPreview" style="margin-top:14px"></div> 13749 <div class="flex gap-1 mt-2"> 13750 <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button> 13751 <button class="btn btn-primary flex-1" id="tenupImportBtn" data-ui-click="confirmTenupImport()" style="display:none">â Importer la sélection</button> 13752 </div>`; 13753 showModal(html); 13754} 13755 13756function onTenupDrop(e) { 13757 e.preventDefault(); 13758 const zone = document.getElementById('tenupDropZone'); 13759 if (zone) { zone.style.borderColor = 'var(--border-strong)'; zone.style.background = 'transparent'; } 13760 const file = e.dataTransfer.files[0]; 13761 if (file) handleTenupFile(file); 13762} 13763 13764function onTenupDragOver(e, zone) { 13765 e.preventDefault(); 13766 zone.style.borderColor = 'var(--orange)'; 13767 zone.style.background = 'rgba(243,146,0,0.05)'; 13768} 13769 13770function onTenupDragLeave(e, zone) { 13771 zone.style.borderColor = 'var(--border-strong)'; 13772 zone.style.background = 'transparent'; 13773} 13774 13775function onTenupFileSelect(e) { 13776 const file = e.target.files[0]; 13777 if (file) handleTenupFile(file); 13778} 13779 13780async function handleTenupFile(file) { 13781 const status = document.getElementById('tenupStatus'); 13782 if (!file.name.toLowerCase().endsWith('.pdf') && file.type !== 'application/pdf') { 13783 status.innerHTML = '<span style="color:var(--danger-text)">â Le fichier doit être un PDF</span>'; 13784 return; 13785 } 13786 status.innerHTML = '<span class="text-faint">Chargement du lecteur PDFâ¦</span>'; 13787 if (!await chargerPdfJs()) { 13788 status.innerHTML = '<span style="color:var(--danger-text)">Le lecteur PDF nâa pas pu être chargé. Vérifie ta connexion puis réessaie.</span>'; 13789 return; 13790 } 13791 13792 status.innerHTML = '<span class="text-faint">â³ Lecture du PDF...</span>'; 13793 13794 try { 13795 const arrayBuffer = await file.arrayBuffer(); 13796 const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer }).promise; 13797 let fullText = ''; 13798 for (let i = 1; i <= pdf.numPages; i++) { 13799 const page = await pdf.getPage(i); 13800 const content = await page.getTextContent(); 13801 // Reconstituer le texte avec sauts de ligne approximatifs (basé sur les coordonnées Y) 13802 let lastY = null;
13803 content.items.forEach(item => { 13804 const y = Math.round(item.transform[5]); 13805 if (lastY !== null && Math.abs(y - lastY) > 2) fullText += '\n'; 13806 else if (lastY !== null) fullText += ' '; 13807 fullText += item.str; 13808 lastY = y; 13809 }); 13810 fullText += '\n\n'; 13811 } 13812 13813 status.innerHTML = `<span class="text-faint">ð ${pdf.numPages} page${pdf.numPages>1?'s':''} lue${pdf.numPages>1?'s':''}, analyse en cours...</span>`; 13814 13815 const tournois = parseTenupText(fullText); 13816 if (tournois.length === 0) { 13817 status.innerHTML = '<span style="color:var(--warning-text)">â Aucun tournoi trouvé dans ce PDF. Vérifie que c\'est bien un export Ten\'Up.</span>'; 13818 return; 13819 } 13820 gestionnaireBrouillons.definirTournoisTenup(tournois); 13821 status.innerHTML = `<span style="color:var(--success-text)">â ${tournois.length} tournoi${tournois.length>1?'s':''} détecté${tournois.length>1?'s':''}</span>`; 13822 showTenupPreview(tournois); 13823 } catch (err) { 13824 journal.error('Erreur lecture PDF:', err); 13825 status.innerHTML = '<span style="color:var(--danger-text)">â Erreur de lecture : ' + escapeHtml(err.message || 'inconnue') + '</span>'; 13826 } 13827} 13828 13829// ============================================================ 13830// PARSER TEN'UP 13831// ============================================================ 13832// Format type observé : 13833// CLUB ORGANISATEUR EN MAJUSCULES 13834// Nom du tournoi 13835// DD/MM/YYYY au DD/MM/YYYY 13836// JUGE-ARBITRE : ... 13837// SURFACE(S) : ... 13838// PRIX EN ESPÃCE : ... 13839// PRIX EN LOTS : ... 13840// INSCRIPTIONS / PAIEMENT EN LIGNE : ... 13841// CODE : T XXXX 13842// ENGAGEMENTS : 13843// email@... 13844// INSTALLATIONS : NOM_LIEU 13845// Adresse 13846// Code postal Ville 13847// Tel 13848// Catégorie Epreuve Classement Droits 13849// <lignes catégories> 13850 13851function parseTenupText(text) { 13852 return TennisDomainRules.parseTenupTournois(text); 13853} 13854 13855function parseTenupMatchs(text) { 13856 return TennisDomainRules.parseTenupMatchs(text, getSaisonCourante()); 13857} 13858 13859function openModalImportMatchsTenup(joueurId) { 13860 const j = joueurById(joueurId); 13861 if (!j) { toast('Joueur introuvable', true); return; } 13862 if (!isCoach() && profile.joueur_id !== joueurId) { toast('Action non autorisée', true); return; } 13863 13864 const titre = isCoach() ? `Importer les matchs de ${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}` : 'Importer mes matchs depuis Ten\'Up'; 13865 const html = ` 13866 <div class="modal-header"> 13867 <h3 class="modal-title">${titre}</h3> 13868 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 13869 </div> 13870 <div class="auth-info mb-2" style="font-size:13px"> 13871 Sur Ten'Up, ouvre la page <strong>Palmarès / Résultats</strong> ${isCoach() ? 'du joueur' : 'de ton compte'}, sélectionne la liste des matchs, copie-la, puis colle-la ci-dessous. 13872 </div> 13873 <textarea id="tenupMatchsRaw" rows="8" style="width:100%; font-family:monospace; font-size:12px" placeholder="Colle ici le texte copié depuis Ten'Upâ¦"></textarea> 13874 <input type="hidden" id="tenupMatchsJoueurId" value="${joueurId}"> 13875 <div class="flex gap-1 mt-2"> 13876 <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button> 13877 <button class="btn btn-primary flex-1" data-ui-click="previewImportMatchs()">Analyser</button> 13878 </div> 13879 <div id="tenupMatchsPreview" style="margin-top:14px"></div> 13880 `; 13881 showModal(html); 13882} 13883 13884async function chargerMatchsExistantsImport(joueurId, matchsImportes) { 13885 const dates = (listeSure(matchsImportes)).map(match => match.date) 13886 .filter(date => /^\d{4}-\d{2}-\d{2}$/.test(date)).sort(); 13887 if (!dates.length) return { data: [], error: null }; 13888 const debut = TennisDomainRules.bornesSaison(TennisDomainRules.saisonPourDate(`${dates[0]}T12:00:00`))[0]; 13889 const fin = TennisDomainRules.bornesSaison(TennisDomainRules.saisonPourDate(`${dates[dates.length - 1]}T12:00:00`))[1]; 13890 const resultat = await db.from('matches').select(COLONNES_MATCH) 13891 .eq('joueur_id', joueurId) 13892 .gte('date', debut).lte('date', fin); 13893 if (resultat.error) return resultat; 13894 13895 const parId = new Map(); 13896 [...listeSure(cache.matches), ...listeSure(cache.matchesSaison), ...listeSure(resultat.data)] 13897 .filter(match => match && match.id && match.joueur_id === joueurId && match.date >= debut && match.date <= fin) 13898 .forEach(match => parId.set(match.id, match)); 13899 return { ...resultat, data: Array.from(parId.values()) }; 13900} 13901 13902function tournoiOfficielPourMatchImporte(match) { 13903 const cle = TennisDomainRules.cleTournoi(match && match.tournoi); 13904 if (!cle || !match?.date) return null; 13905 return listeSure(cache.tournois).find(tournoi => 13906 TennisDomainRules.cleTournoi(tournoi.nom) === cle 13907 && tournoi.date_debut <= match.date 13908 && (tournoi.date_fin || tournoi.date_debut) >= match.date 13909 ) || null; 13910} 13911 13912async function previewImportMatchs() { 13913 const raw = (document.getElementById('tenupMatchsRaw')?.value || '').trim(); 13914 const joueurId = document.getElementById('tenupMatchsJoueurId')?.value; 13915 const preview = document.getElementById('tenupMatchsPreview'); 13916 if (!preview) return; 13917 13918 if (!raw) { preview.innerHTML = `<div class="text-faint" style="font-size:13px">Colle d'abord le texte copié depuis Ten'Up.</div>`; return; } 13919 13920 const parsed = parseTenupMatchs(raw); 13921 if (parsed.length === 0) { 13922 preview.innerHTML = `<div class="card" style="padding:12px; border-left:3px solid var(--warning)"> 13923 <strong style="font-size:13px; color:var(--warning-text)">Aucun match détecté</strong> 13924 <div class="text-faint" style="font-size:12px; margin-top:4px">Le format n'a pas été reconnu. Vérifie que tu as bien copié la liste des matchs (avec les noms, classements, scores et dates).</div> 13925 </div>`; 13926 return; 13927 } 13928 13929 const { data: existants = [], error: erreurExistants } = await chargerMatchsExistantsImport(joueurId, parsed); 13930 if (erreurExistants) { 13931 journal.warn('[import TenUp prévisualisation]', erreurExistants); 13932 preview.innerHTML = '<div class="card" style="padding:12px;color:var(--danger-text)">Impossible de vérifier les doublons pour le moment. Réessaie dans quelques instants.</div>'; 13933 return; 13934 } 13935 const jImport = joueurById(joueurId); 13936 const jImportPalmyre = jImport && jImport.participe_palmyre; 13937 const dejaVus = new Set(); 13938 const vide = (v) => v === null || v === undefined || String(v).trim() === ''; 13939 13940 let nbNouveaux = 0, nbCompleter = 0, nbDoublons = 0, nbArchives = 0; 13941 const rows = parsed.map(mt => { 13942 const archive = mt._horsSaison; 13943 if (archive) nbArchives++; 13944 let statut, grise = false;
13945 const trouve = trouverMatchExistant(mt, existants.filter(e => !dejaVus.has(e.id))); 13946 if (!trouve) { 13947 nbNouveaux++; statut = `<span style="color:var(--success-text)">à importer${archive ? ' · archive' : ''}</span>`; 13948 } else { 13949 dejaVus.add(trouve.match.id); 13950 const e = trouve.match; 13951 const tournoiOfficiel = tournoiOfficielPourMatchImporte(mt); 13952 const apporte = Object.keys(TennisDomainRules.champsFusionMatchTenup(e, mt, trouve.probable || trouve.enrichissement)).length > 0 || 13953 (tournoiOfficiel && e.tournament_id !== tournoiOfficiel.id) || 13954 (vide(e.evenement_id) && jImportPalmyre && editionPalmyrePourDate(mt.date)); 13955 if (apporte) { 13956 nbCompleter++; statut = `<span style="color:var(--warning-text)">à compléter${archive ? ' · archive' : ''}</span>`; 13957 } else { 13958 nbDoublons++; statut = trouve.probable ? '<span style="color:var(--warning-text)">doublon probable</span>' : '<span class="text-faint">déjà présent</span>'; grise = true; 13959 } 13960 } 13961 const issueLabel = mt.victoire ? '<span style="color:var(--success-text)">V</span>' : '<span style="color:var(--danger-text)">D</span>'; 13962 const advAffiche = vide(mt.adversaire) ? '<span class="text-faint">(adversaire inconnu)</span>' : escapeHtml(mt.adversaire) + ` <span class="text-faint">(${escapeHtml(mt.adversaire_class)})</span>`; 13963 return `<tr style="${grise ? 'opacity:0.45' : ''}"> 13964 <td style="padding:4px 6px; font-size:11px">${formatDate(mt.date)}</td> 13965 <td style="padding:4px 6px; font-size:11px">${advAffiche}</td> 13966 <td style="padding:4px 6px; font-size:11px; text-align:center">${issueLabel}</td> 13967 <td style="padding:4px 6px; font-size:11px">${escapeHtml(mt.score || (mt._wo ? 'WO' : ''))}</td> 13968 <td style="padding:4px 6px; font-size:11px">${statut}</td> 13969 </tr>`; 13970 }).join(''); 13971 const nbActifs = nbNouveaux + nbCompleter; 13972 13973 preview.innerHTML = ` 13974 <div class="card" style="padding:10px 14px; margin-bottom:10px"> 13975 <strong style="font-size:13px">${parsed.length} match${parsed.length > 1 ? 's' : ''} détecté${parsed.length > 1 ? 's' : ''}</strong> 13976 <div class="text-faint" style="font-size:12px; margin-top:2px">${nbNouveaux} à importer${nbCompleter > 0 ? ' · ' + nbCompleter + ' à compléter' : ''} · ${nbDoublons} déjà présent${nbDoublons > 1 ? 's' : ''}${nbArchives > 0 ? ' · ' + nbArchives + ' en archive' : ''}</div> 13977 ${nbCompleter > 0 ? `<div class="text-faint" style="font-size:11px; margin-top:4px">« à compléter » : un match déjà présent auquel il manque une info (nom d'adversaire, scoreâ¦) que cet import apporte. Les données déjà saisies ne sont jamais écrasées.</div>` : ''} 13978 ${nbArchives > 0 ? `<div class="text-faint" style="font-size:11px; margin-top:4px">Les matchs des saisons précédentes seront importés à leur date d'origine et resteront exclus des statistiques de la saison actuelle.</div>` : ''} 13979 </div> 13980 <div style="max-height:260px; overflow:auto; border:1px solid var(--border); border-radius:var(--radius-sm)"> 13981 <table style="width:100%; border-collapse:collapse"> 13982 <thead><tr style="background:var(--bg-input); position:sticky; top:0"> 13983 <th style="padding:6px; font-size:10px; text-align:left">Date</th> 13984 <th style="padding:6px; font-size:10px; text-align:left">Adversaire</th> 13985 <th style="padding:6px; font-size:10px">Issue</th> 13986 <th style="padding:6px; font-size:10px; text-align:left">Score</th> 13987 <th style="padding:6px; font-size:10px; text-align:left">Statut</th> 13988 </tr></thead> 13989 <tbody>${rows}</tbody> 13990 </table> 13991 </div> 13992 <button class="btn btn-primary" style="width:100%; margin-top:12px" data-ui-click='doImportMatchsTenup()' ${nbActifs === 0 ? 'disabled' : ''}> 13993 ${nbActifs === 0 ? 'Rien à importer ni compléter' : 'Importer / compléter ' + nbActifs + ' match' + (nbActifs > 1 ? 's' : '')} 13994 </button> 13995 `; 13996 preview.dataset.parsed = JSON.stringify(parsed); 13997} 13998 13999function cleTournoi(nom) { 14000 return TennisDomainRules.cleTournoi(nom); 14001} 14002 14003function trouverMatchExistant(mt,e) { 14004 return TennisDomainRules.trouverMatchExistant(mt,e); 14005} 14006 14007async function doImportMatchsTenup() { 14008 const preview = document.getElementById('tenupMatchsPreview'); 14009 const joueurId = document.getElementById('tenupMatchsJoueurId')?.value; 14010 if (!preview || !joueurId) return; 14011 if (!isCoach() && profile.joueur_id !== joueurId) { toast('Action non autorisée', true); return; } 14012 14013 let parsed; 14014 try { parsed = JSON.parse(preview.dataset.parsed || '[]'); } catch (e) { parsed = []; } 14015 if (parsed.length === 0) { toast('Rien à importer', true); return; } 14016 14017 const { data: existants = [], error: erreurExistants } = await chargerMatchsExistantsImport(joueurId, parsed); 14018 if (erreurExistants) { toastErreur(erreurExistants); return; } 14019 14020 const jImport = joueurById(joueurId); 14021 const peutPalmyre = jImport && jImport.participe_palmyre; 14022 14023 const aInserer = []; 14024 const aCompleter = []; 14025 const rejetsImport = []; 14026 const dejaVus = new Set(); 14027 14028 parsed.forEach(mt => { 14029 const dispo = existants.filter(e => !dejaVus.has(e.id)); 14030 const trouve = trouverMatchExistant(mt, dispo); 14031 14032 if (!trouve) { 14033 const edition = peutPalmyre ? editionPalmyrePourDate(mt.date) : null; 14034 const tournoiOfficiel = tournoiOfficielPourMatchImporte(mt); 14035 const candidat = { 14036 joueur_id: joueurId, 14037 date: mt.date, 14038 type: mt.type, 14039 tournoi: mt.tournoi, 14040 tournament_id: tournoiOfficiel?.id || null, 14041 tenup_tournament_id: tournoiOfficiel?.tenup_id || null, 14042 adversaire: mt.adversaire, 14043 adversaire_class: mt.adversaire_class, 14044 adversaire2_nom: mt.adversaire2_nom || null, 14045 adversaire2_class: mt.adversaire2_class || null, 14046 format: mt.format || 'simple', 14047 victoire: mt.victoire, 14048 score: mt.score, 14049 ressenti: null, 14050 notes: mt.notes || '', 14051 walkover: !!mt._wo, 14052 evenement_id: edition ? edition.id : null, 14053 created_by: session.user.id 14054 }; 14055 const preparation = TennisDomainRules.preparerMatchSaisi(candidat, { 14056 dateMax: dateLocaleISO(new Date()), 14057 autoriserResultatHistorique: true, 14058 autoriserScoreIncompletHistorique: mt._abandon === true 14059 }); 14060 if (preparation.valide) aInserer.push(preparation.match); 14061 else rejetsImport.push({ match: mt, erreurs: preparation.erreurs }); 14062 return; 14063 } 14064 14065 const e = trouve.match; 14066 dejaVus.add(e.id); 14067 const patch = TennisDomainRules.champsFusionMatchTenup(e, mt, trouve.probable || trouve.enrichissement); 14068 const tournoiOfficiel = tournoiOfficielPourMatchImporte(mt); 14069 if (tournoiOfficiel && (trouve.probable || !e.tournament_id)) { 14070 patch.tournament_id = tournoiOfficiel.id; 14071 patch.tenup_tournament_id = tournoiOfficiel.tenup_id || null; 14072 } 14073 const vide = (v) => v === null || v === undefined || String(v).trim() === ''; 14074 if (vide(e.evenement_id) && peutPalmyre) { 14075 const edition = editionPalmyrePourDate(mt.date); 14076 if (edition) patch.evenement_id = edition.id; 14077 } 14078 14079 if (Object.keys(patch).length > 0) aCompleter.push({ id: e.id, patch }); 14080 }); 14081 14082 if (aInserer.length === 0 && aCompleter.length === 0) { 14083 const messageRejets = rejetsImport.length 14084 ? ` · ${rejetsImport.length} ligne${rejetsImport.length > 1 ? 's' : ''}
14084 invalide${rejetsImport.length > 1 ? 's' : ''}` 14085 : ''; 14086 toast(`Aucun nouveau match, aucun à compléter${messageRejets}`, true); 14087 if (rejetsImport.length) journal.warn('[import matchs rejetés]', rejetsImport); 14088 return; 14089 } 14090 14091 if (aInserer.length > 0) { 14092 const { error } = await db.from('matches').insert(aInserer); 14093 if (error) { 14094 journal.warn('[import TenUp insertion]', { 14095 code: error.code, 14096 message: error.message, 14097 details: error.details, 14098 hint: error.hint, 14099 nombreMatchs: aInserer.length, 14100 types: [...new Set(aInserer.map(match => match.type))] 14101 }); 14102 const contrainteType = error.code === '23514' && /matches_type_check/i.test(`${error.message || ''} ${error.details || ''}`); 14103 toast(contrainteType 14104 ? 'Un type de match TenâUp nâest pas encore accepté. Aucun match nâa été importé ; préviens le coach avec cette capture.' 14105 : `Import impossible (${error.code || 'erreur inconnue'}). Aucun match nâa été importé ; réessaie ou transmets ce code au coach.`, true); 14106 return; 14107 } 14108 } 14109 let nbCompletes=0; 14110 for (const c of aCompleter) { 14111 const { error } = await updateConfirmee(db.from('matches').update(c.patch).eq('id', c.id)); 14112 if (error) { journal.warn('[import complétion]', c.id, error); continue; } 14113 nbCompletes++; 14114 } 14115 14116 closeModal(); 14117 const bouts = []; 14118 if (aInserer.length) bouts.push(`${aInserer.length} importé${aInserer.length > 1 ? 's' : ''}`); 14119 if (nbCompletes) bouts.push(`${nbCompletes} complété${nbCompletes > 1 ? 's' : ''}`); 14120 if (rejetsImport.length) { 14121 bouts.push(`${rejetsImport.length} rejeté${rejetsImport.length > 1 ? 's' : ''}`); 14122 journal.warn('[import matchs rejetés]', rejetsImport); 14123 } 14124 toast(bouts.join(' · ') || 'Rien à faire'); 14125 await reloadTables(['matches']); 14126 render(); 14127} 14128 14129function showTenupPreview(tournois) { 14130 const preview = document.getElementById('tenupPreview'); 14131 const importBtn = document.getElementById('tenupImportBtn'); 14132 if (!preview) return; 14133 14134 // Détection des doublons existants en base 14135 // 1) Par code Ten'Up exact (le plus sûr) 14136 const codesExistants = new Set(cache.tournois.filter(t => t.tenup_code).map(t => t.tenup_code)); 14137 // 2) Par signature nom+date+lieu (rattrape les imports antérieurs avec d'autres codes) 14138 const signaturesExistantes = new Set(cache.tournois.map(t => { 14139 return `${(t.nom || '').toLowerCase().trim()}|${t.date_debut || ''}|${(t.lieu || '').toLowerCase().trim()}`; 14140 })); 14141 14142 let html = '<div class="section-title" style="margin-top:0">Prévisualisation</div>'; 14143 html += '<div class="text-faint mb-2" style="font-size:12px">Décoche les catégories qui ne te concernent pas. Les catégories jeunes (11-18 ans) sont pré-sélectionnées.</div>'; 14144 14145 tournois.forEach((t, idx) => { 14146 // Doublon si même code OU même signature nom+date+lieu 14147 const signature = `${(t.nom || '').toLowerCase().trim()}|${t.date_debut || ''}|${(t.lieu || '').toLowerCase().trim()}`; 14148 const doublon = (t.code && codesExistants.has(t.code)) || signaturesExistantes.has(signature); 14149 html += `<div class="card" style="margin-bottom:10px; padding:12px; ${doublon ? 'opacity:0.5; border-left:3px solid var(--warning)' : ''}"> 14150 <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px"> 14151 <div style="flex:1"> 14152 <label style="display:flex; gap:8px; align-items:flex-start; cursor:${doublon ? 'not-allowed' : 'pointer'}"> 14153 <input type="checkbox" data-tournoi-idx="${idx}" ${doublon ? 'disabled' : 'checked'} style="margin-top:4px; width:auto"> 14154 <div style="flex:1"> 14155 <strong style="font-size:14px">${escapeHtml(t.nom)}</strong> 14156 ${doublon ? '<span class="badge badge-warning" style="font-size:10px; margin-left:6px">déjà importé</span>' : ''} 14157 <div class="text-dim" style="font-size:12px; margin-top:2px"> 14158 ${escapeHtml(t.club)}${t.lieu ? ' · ' + escapeHtml(t.lieu) : ''} 14159 </div> 14160 <div class="text-faint" style="font-size:11px"> 14161 ${t.date_debut ? formatDate(t.date_debut) : '?'} â ${t.date_fin ? formatDate(t.date_fin) : '?'} 14162 ${t.code ? ' · ' + escapeHtml(t.code) : ''} 14163 </div> 14164 </div> 14165 </label> 14166 </div> 14167 </div> 14168 ${!doublon && t.categories.length > 0 ? ` 14169 <div style="margin-top:10px; padding-left:24px; border-left:1px solid var(--border)"> 14170 <div class="text-faint" style="font-size:11px; text-transform:uppercase; letter-spacing:1px; margin-bottom:6px">Catégories à conserver</div> 14171 ${t.categories.map((cat, catIdx) => ` 14172 <label style="display:inline-flex; gap:6px; align-items:center; padding:4px 8px; margin:2px; background:var(--bg-input); border-radius:var(--radius-sm); cursor:pointer; font-size:12px"> 14173 <input type="checkbox" data-cat-idx="${idx}-${catIdx}" ${cat.jeune ? 'checked' : ''} style="width:auto; margin:0"> 14174 ${escapeHtml(cat.label)} 14175 </label> 14176 `).join('')} 14177 </div> 14178 ` : ''} 14179 ${!doublon && t.categories.length === 0 ? `<div class="text-faint" style="font-size:11px; margin-top:8px">Aucune catégorie détectée â le tournoi sera importé avec son nom seul.</div>` : ''} 14180 </div>`; 14181 }); 14182 14183 preview.innerHTML = html; 14184 importBtn.style.display = ''; 14185} 14186 14187async function confirmTenupImport() { 14188 const tournoisParses = gestionnaireBrouillons.lireTournoisTenup(); 14189 if (tournoisParses.length === 0) return; 14190 14191 // Récupérer les sélections 14192 const selectionTournois = Array.from(document.querySelectorAll('input[data-tournoi-idx]:checked')) 14193 .map(c => parseInt(c.dataset.tournoiIdx)); 14194 if (selectionTournois.length === 0) { 14195 toast('Aucun tournoi sélectionné', true); 14196 return; 14197 } 14198 14199 // Pour chaque tournoi sélectionné, récupérer ses catégories cochées 14200 const catCheckboxes = document.querySelectorAll('input[data-cat-idx]:checked'); 14201 const catsByTournoi = {};
14202 catCheckboxes.forEach(c => { 14203 const [tIdx, cIdx] = c.dataset.catIdx.split('-').map(Number); 14204 if (!catsByTournoi[tIdx]) catsByTournoi[tIdx] = []; 14205 if (tournoisParses[tIdx]?.categories?.[cIdx]) catsByTournoi[tIdx].push(tournoisParses[tIdx].categories[cIdx].label); 14206 }); 14207 14208 const btn = document.getElementById('tenupImportBtn'); 14209 btn.disabled = true; 14210 btn.textContent = 'Import en cours...'; 14211 14212 const toInsert = []; 14213 selectionTournois.forEach(idx => { 14214 const t = tournoisParses[idx]; 14215 if (!t) return; 14216 const cats = catsByTournoi[idx] || []; 14217 const categorieStr = cats.length > 0 ? cats.join(' · ') : null; 14218 toInsert.push({ 14219 nom: t.nom, 14220 date_debut: t.date_debut, 14221 date_fin: t.date_fin || t.date_debut, 14222 lieu: t.lieu || null, 14223 categorie: categorieStr, 14224 tenup_code: t.code || null, 14225 inscrits: [], 14226 created_by: session.user.id 14227 }); 14228 }); 14229 14230 if (toInsert.length === 0) { 14231 toast('Rien à importer', true); 14232 btn.disabled = false; 14233 btn.textContent = 'â Importer la sélection'; 14234 return; 14235 } 14236 14237 // L'import officiel passe par la fonction serveur : journal, rapprochement et 14238 // unicité TenâUp sont ainsi identiques à la synchronisation automatique. 14239 const { data, error } = await db.functions.invoke('tenup-sync', { 14240 body:{ mode:'import', records:toInsert.map(t => ({...t,id:t.tenup_code,tenup_id:t.tenup_code})) } 14241 }); 14242 if (error || data?.error) { 14243 journal.error('TenUp import error:', error || data.error); 14244 toast('Import indisponible : aucune donnée existante nâa été modifiée.', true); 14245 btn.disabled = false; 14246 btn.textContent = 'â Importer la sélection'; 14247 return; 14248 } 14249 14250 gestionnaireBrouillons.definirTournoisTenup([]); 14251 closeModal(); 14252 toast(`${data.created} nouveau(x) · ${data.updated} mis à jour · ${data.duplicate} doublon(s) · ${data.anomalies} à vérifier · ${data.rejected} rejet(s)`); 14253 await refresh(); 14254} 14255 14256// Notifie chaque joueur actif (avec compte) dont le profil correspond à au moins un 14257// des tournois importés, selon le filtre sexe + âge + classement. Une seule notification 14258// par joueur, listant le nombre de tournois pertinents. 14259async function notifierJoueursNouveauxTournois(tournoisImportes) { 14260 if (!Array.isArray(tournoisImportes) || tournoisImportes.length === 0) return; 14261 // Filtre complet forcé (les 3 critères), indépendamment des préférences d'affichage du coach 14262 const criteres = { sexe: true, age: true, classement: true }; 14263 const joueurs = (listeSure(cache.joueurs)).filter(j => 14264 (!j.statut || j.statut === 'actif') && j.user_id 14265 ); 14266 14267 const notifs = []; 14268 joueurs.forEach(j => { 14269 const pertinents = tournoisImportes.filter(t => tournoiCorrespondAuJoueur(t, j, criteres)); 14270 if (pertinents.length === 0) return; 14271 const titre = pertinents.length === 1 14272 ? 'ð¾ Nouveau tournoi pour toi' 14273 : `ð¾ ${pertinents.length} nouveaux tournois pour toi`; 14274 const apercu = pertinents.slice(0, 3).map(t => t.nom).join(', '); 14275 const reste = pertinents.length > 3 ? `⦠(+${pertinents.length - 3})` : ''; 14276 const message = pertinents.length === 1 14277 ? `${pertinents[0].nom} correspond à ton profil. Va voir dans l'onglet Tournois si tu veux t'y inscrire.` 14278 : `${apercu}${reste} correspondent à ton profil. Va voir dans l'onglet Tournois.`; 14279 notifs.push({ 14280 user_id: j.user_id, 14281 type: 'nouveau_tournoi', 14282 titre, 14283 message, 14284 // Lien direct si un seul tournoi, sinon vue Tournois générale 14285 lien_type: pertinents.length === 1 ? 'tournoi' : null, 14286 lien_id: pertinents.length === 1 ? pertinents[0].id : null 14287 }); 14288 }); 14289 14290 if (notifs.length === 0) return; 14291 const { error } = await db.from('notifications').insert(notifs); 14292 if (error) { journal.warn('[notif tournois] insert:', error); return; } 14293} 14294 14295// ============================================================ 14296// IMPORT DE L'HISTORIQUE DE CLASSEMENT (copier-coller depuis Ten'Up / FFT) 14297// ============================================================ 14298// Parse le texte collé en points { date 'YYYY-MM-01', classement, horsSaison }, triés du plus ancien au plus récent. 14299// Seuls les points de la saison FFT en cours (sept. â août) sont retenus pour l'import, 14300// comme pour l'import de matchs (ne pas fausser les stats de l'année). 14301function parseClassementHisto(raw) { 14302 const [saisonDebut, saisonFin] = getPeriodeDates('saison'); 14303 return TennisDomainRules.parseClassementHistorique(raw, saisonDebut, saisonFin); 14304} 14305 14306// Modal d'import (coach uniquement). 14307function openModalImportClassement(joueurId) { 14308 if (!isCoach()) { toast('Action réservée au coach', true); return; } 14309 const j = joueurById(joueurId); 14310 if (!j) { toast('Joueur introuvable', true); return; } 14311 const html = ` 14312 <div class="modal-header"> 14313 <h3 class="modal-title">Importer l'historique de classement</h3> 14314 <button class="modal-close" data-ui-click="closeModal()">Ã</button> 14315 </div> 14316 <div class="auth-info mb-2" style="font-size:13px"> 14317 Sur Ten'Up, ouvre l'historique de classement de <strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong>, sélectionne le tableau (avec ou sans l'en-tête), copie-le, puis colle-le ci-dessous. Un point par mois sera enregistré. 14318 </div> 14319 <textarea id="classementHistoRaw" rows="8" style="width:100%; font-family:monospace; font-size:12px" placeholder="Colle ici le tableau copié (Saison, Clt., Calcul de classement, â¦)"></textarea> 14320 <input type="hidden" id="classementHistoJoueurId" value="${joueurId}"> 14321 <div class="flex gap-1 mt-2"> 14322 <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button> 14323 <button class="btn btn-primary flex-1" data-ui-click="previewImportClassement()">Analyser</button> 14324 </div> 14325 <div id="classementHistoPreview" style="margin-top:14px"></div> 14326 `; 14327 showModal(html); 14328} 14329 14330// Analyse + aperçu avant validation. 14331function previewImportClassement() { 14332 const raw = document.getElementById('classementHistoRaw').value; 14333 const joueurId = document.getElementById('classementHistoJoueurId').value; 14334 const preview = document.getElementById('classementHistoPreview'); 14335 const tous = parseClassementHisto(raw); 14336 const points = tous.filter(p => !p.horsSaison); 14337 const nbHorsSaison = tous.length - points.length; 14338 const saison = getSaisonCourante(); 14339 14340 if (tous.length === 0) { 14341 preview.innerHTML = `<div class="text-faint" style="font-size:13px; padding:10px; text-align:center">Aucune ligne reconnue. Vérifie que tu as bien collé le tableau (colonnes séparées par des tabulations).</div>`; 14342 return; 14343 } 14344 if (points.length === 0) { 14345 preview.innerHTML = `<div class="text-faint" style="font-size:13px; padding:10px; text-align:center">Aucun point dans la saison ${saison} (sept. â août). ${nbHorsSaison} point${nbHorsSaison > 1 ? 's' : ''} hors saison ignoré${nbHorsSaison > 1 ? 's' : ''}.</div>`; 14346 return; 14347 } 14348 14349 // Classements non reconnus dans l'échelle FFT (seraient mal positionné
14349s sur la courbe) 14350 const inconnus = [...new Set(points.map(p => p.classement).filter(c => CLASSEMENTS.indexOf(c) < 0))]; 14351 14352 let lignes = points.map(p => { 14353 const ok = CLASSEMENTS.indexOf(p.classement) >= 0; 14354 return `<div style="display:flex; justify-content:space-between; padding:3px 0; font-size:12px; ${ok ? '' : 'color:var(--danger)'}"> 14355 <span>${escapeHtml(p.source)}</span> 14356 <strong>${escapeHtml(p.classement)}${ok ? '' : ' â ï¸'}</strong> 14357 </div>`; 14358 }).join(''); 14359 14360 preview.innerHTML = ` 14361 <div class="card" style="padding:10px 14px"> 14362 <div style="font-weight:600; font-size:13px; margin-bottom:6px">${points.length} point${points.length > 1 ? 's' : ''} dans la saison ${saison}${nbHorsSaison > 0 ? ` · <span class="text-faint">${nbHorsSaison} hors saison ignoré${nbHorsSaison > 1 ? 's' : ''}</span>` : ''}</div> 14363 <div style="max-height:200px; overflow-y:auto">${lignes}</div> 14364 </div> 14365 ${inconnus.length > 0 ? `<div class="text-faint" style="font-size:12px; margin-top:8px; color:var(--danger)">â ï¸ Classement(s) non reconnu(s) dans l'échelle FFT : ${escapeHtml(inconnus.join(', '))}. Vérifie l'orthographe (ex. 30/2, 15/1).</div>` : ''} 14366 <div class="auth-info mt-2" style="font-size:12px">Cet import <strong>remplacera</strong> tout l'historique de classement existant de ce joueur.</div> 14367 <button class="btn btn-primary btn-full mt-2" data-ui-click="confirmImportClassement()">Importer ${points.length} point${points.length > 1 ? 's' : ''}</button> 14368 `; 14369} 14370 14371// Ãcrit en base : remplace l'historique du joueur par les points importés. 14372async function confirmImportClassement() { 14373 if (!isCoach()) { toast('Action réservée au coach', true); return; } 14374 const raw = document.getElementById('classementHistoRaw').value; 14375 const joueurId = document.getElementById('classementHistoJoueurId').value; 14376 const points = parseClassementHisto(raw).filter(p => !p.horsSaison); 14377 if (points.length === 0) { toast('Rien à importer (saison en cours)', true); return; } 14378 14379 // Construire les entrées : un point par mois. ancien = classement du mois précédent 14380 // (ou null pour le tout premier point). source = 'import' pour ne pas polluer l'onglet Perfs. 14381 const userId = session && session.user && session.user.id; 14382 const rows = points.map((p, i) => ({ 14383 joueur_id: joueurId, 14384 ancien_classement: i > 0 ? points[i - 1].classement : null, 14385 nouveau_classement: p.classement, 14386 date_changement: p.date + 'T00:00:00', 14387 valide_par: userId || null, 14388 source: 'import' 14389 })); 14390 14391 const verrou = verrouillerSoumission($('modalBody'), 'Importâ¦'); 14392 if (!verrou) return; 14393 14394 // Remplacement atomique côté PostgreSQL : si validation, suppression ou 14395 // insertion échoue, l'historique précédent reste intégralement en place. 14396 let resultat; 14397 try { 14398 resultat = await db.rpc('coach_remplacer_historique_classement', { 14399 p_joueur_id: joueurId, 14400 p_points: rows.map(({ ancien_classement, nouveau_classement, date_changement }) => ({ 14401 ancien_classement, 14402 nouveau_classement, 14403 date_changement 14404 })) 14405 }); 14406 } finally { 14407 deverrouillerSoumission(verrou); 14408 } 14409 if (resultat.error) { toastErreur(resultat.error); return; } 14410 14411 closeModal(); 14412 toast(`${rows.length} point${rows.length > 1 ? 's' : ''} de classement importé${rows.length > 1 ? 's' : ''} â `); 14413 await reloadTables(['joueurs', 'classement_historique', 'classement_demandes']); 14414 render(); 14415} 14416 14417// ============================================================ 14418// MODAL: INVITATION (coach) 14419// ============================================================ 14420async function genererInvitationDepuisModal() { 14421 const id = $('inviteJoueurId').value; 14422 await genererInvitation(id); 14423 closeModal(); 14424} 14425 14426let realtimeChannel = null; 14427let _realtimeTimer = null; 14428let _realtimeReconnectTimer = null; 14429const _realtimeTablesEnAttente = new Set(); 14430 14431function annulerRechargeRealtime() { 14432 if (_realtimeTimer) clearTimeout(_realtimeTimer); 14433 if (_realtimeReconnectTimer) clearTimeout(_realtimeReconnectTimer); 14434 _realtimeTimer = null; 14435 _realtimeReconnectTimer = null; 14436 _realtimeTablesEnAttente.clear(); 14437} 14438 14439function programmerRechargeRealtime(table) { 14440 _realtimeTablesEnAttente.add(table); 14441 if (_realtimeTimer) return; 14442 _realtimeTimer = setTimeout(async () => { 14443 _realtimeTimer = null; 14444 const tables = [..._realtimeTablesEnAttente]; 14445 _realtimeTablesEnAttente.clear(); 14446 try { 14447 await reloadTables(tables); 14448 } catch (e) { journal.warn('[realtime] reload groupé', tables, e); } 14449 demanderRender(); 14450 }, 120); 14451} 14452 14453function rattraperDonneesRealtime() { 14454 if (document.visibilityState === 'hidden') return; 14455 const tables = ['joueurs','matches','entrainements','tournois','inscriptions_tournois','bilans','presences','seances_cours','creneaux_cours','creneaux_cours_joueurs','classement_historique','classement_demandes','creneaux_palmyre','axes_travail_palmyre','bilans_evenement','evenements','badges_joueurs','preferences_notifications','config','notifications']; 14456 if (isCoach()) tables.push('notes_joueur','observations_palmyre','debriefs_palmyre','invitations','imports_tournois'); 14457 reloadTables(tables).then(demanderRender).catch(e => journal.warn('[realtime] rattrapage global', e)); 14458} 14459 14460function viderDonneesSession() { 14461 annulerRechargeRealtime(); 14462 gestionnaireBrouillons.purger(); 14463 if (realtimeChannel) { 14464 db.removeChannel(realtimeChannel); 14465 realtimeChannel = null; 14466 }
14467 Object.keys(chartInstances).forEach(destroyChart); 14468 avatarService.vider?.(); 14469 cache = creerCacheVide(); 14470 exportsPdf = null; 14471 invaliderIndexMatchs(); 14472 etatApp.definirSaisonAnalyse(getSaisonCourante()); 14473 profile = null; 14474 document.documentElement.removeAttribute('data-startup-ms'); 14475 document.documentElement.removeAttribute('data-startup-role'); 14476} 14477 14478function subscribeRealtime() { 14479 annulerRechargeRealtime(); 14480 if (realtimeChannel) { 14481 db.removeChannel(realtimeChannel); 14482 realtimeChannel = null; 14483 } 14484 14485 const onChange = (table) => () => programmerRechargeRealtime(table); 14486 14487 realtimeChannel = db.channel('pole-tennis-changes') 14488 .on('postgres_changes', { event: '*', schema: 'public', table: 'joueurs' }, onChange('joueurs')) 14489 .on('postgres_changes', { event: '*', schema: 'public', table: 'matches' }, onChange('matches')) 14490 .on('postgres_changes', { event: '*', schema: 'public', table: 'entrainements' }, onChange('entrainements')) 14491 .on('postgres_changes', { event: '*', schema: 'public', table: 'tournois' }, onChange('tournois')) 14492 .on('postgres_changes', { event: '*', schema: 'public', table: 'tournament_import_runs' }, onChange('imports_tournois')) 14493 .on('postgres_changes', { event: '*', schema: 'public', table: 'player_tournament_registrations' }, onChange('inscriptions_tournois')) 14494 .on('postgres_changes', { event: '*', schema: 'public', table: 'bilans' }, onChange('bilans')) 14495 .on('postgres_changes', { event: '*', schema: 'public', table: 'notes_joueur' }, onChange('notes_joueur')) 14496 .on('postgres_changes', { event: '*', schema: 'public', table: 'presences' }, onChange('presences')) 14497 .on('postgres_changes', { event: '*', schema: 'public', table: 'seances_cours' }, onChange('seances_cours')) 14498 .on('postgres_changes', { event: '*', schema: 'public', table: 'creneaux_cours' }, onChange('creneaux_cours')) 14499 .on('postgres_changes', { event: '*', schema: 'public', table: 'creneaux_cours_joueurs' }, onChange('creneaux_cours_joueurs')) 14500 .on('postgres_changes', { event: '*', schema: 'public', table: 'classement_historique' }, onChange('classement_historique')) 14501 .on('postgres_changes', { event: '*', schema: 'public', table: 'creneaux_palmyre' }, onChange('creneaux_palmyre')) 14502 .on('postgres_changes', { event: '*', schema: 'public', table: 'observations_palmyre' }, onChange('observations_palmyre')) 14503 .on('postgres_changes', { event: '*', schema: 'public', table: 'axes_travail_palmyre' }, onChange('axes_travail_palmyre')) 14504 .on('postgres_changes', { event: '*', schema: 'public', table: 'debriefs_palmyre' }, onChange('debriefs_palmyre')) 14505 .on('postgres_changes', { event: '*', schema: 'public', table: 'bilans_evenement' }, onChange('bilans_evenement')) 14506 .on('postgres_changes', { event: '*', schema: 'public', table: 'evenements' }, onChange('evenements')) 14507 .on('postgres_changes', { event: '*', schema: 'public', table: 'classement_demande' }, onChange('classement_demandes')) 14508 .on('postgres_changes', { event: '*', schema: 'public', table: 'badges_joueurs' }, onChange('badges_joueurs')) 14509 .on('postgres_changes', { event: '*', schema: 'public', table: 'config' }, onChange('config')) 14510 .on('postgres_changes', { event: '*', schema: 'public', table: 'invitations' }, onChange('invitations')) 14511 .on('postgres_changes', { event: '*', schema: 'public', table: 'preferences_notifications' }, onChange('preferences_notifications')) 14512 .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'notifications' }, async (payload) => { 14513 await loadNotifications(); 14514 if (payload.new && payload.new.user_id === session?.user?.id) { 14515 toast('ð ' + (payload.new.titre || 'Nouvelle notification')); 14516 } 14517 demanderRender(); 14518 }) 14519 .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'notifications' }, async () => { 14520 await loadNotifications(); 14521 demanderRender(); 14522 }) 14523 .on('postgres_changes', { event: 'DELETE', schema: 'public', table: 'notifications' }, async () => { 14524 await loadNotifications(); 14525 demanderRender(); 14526 }) 14527 .subscribe(status => { 14528 if (status === 'SUBSCRIBED') { 14529 if (_realtimeReconnectTimer) clearTimeout(_realtimeReconnectTimer); 14530 _realtimeReconnectTimer = null; 14531 loadNotifications().catch(e => journal.warn('[realtime] recharge notifications', e)); 14532 rattraperDonneesRealtime(); 14533 } 14534 if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') { 14535 journal.warn('[realtime] canal indisponible, nouvelle tentative automatique', status); 14536 if (!_realtimeReconnectTimer) { 14537 _realtimeReconnectTimer = setTimeout(() => { 14538 _realtimeReconnectTimer = null; 14539 if (session?.user?.id) subscribeRealtime(); 14540 }, 1500); 14541 } 14542 } 14543 }); 14544} 14545 14546document.addEventListener('visibilitychange', () => { 14547 if (document.visibilityState === 'visible' && session?.user?.id) { 14548 subscribeRealtime(); 14549 } 14550}); 14551 14552let _renderEnAttente = false;
14553function demanderRender() { 14554 const modalEl = document.getElementById('modal'); 14555 const modaleOuverte = modalEl && modalEl.classList.contains('show'); 14556 if (modaleOuverte) { 14557 _renderEnAttente = true; // sera traité à la fermeture (voir closeModal) 14558 return; 14559 } 14560 render(); 14561} 14562 14563let _appInitialized = false; 14564let _appStarting = false; 14565 14566async function onAuthChange(eventType) { 14567 if (_appStarting) return; 14568 _appStarting = true; 14569 const demarrageAt = performance.now(); 14570 try { 14571 const { data: { session: sess } } = await db.auth.getSession(); 14572 session = sess; 14573 14574 if (!session) { 14575 // Pas connecté â écran d'auth 14576 _appInitialized = false; 14577 viderDonneesSession(); 14578 $('loading-screen').style.display = 'none'; 14579 $('auth-screen').style.display = ''; 14580 $('app-wrapper').style.display = 'none'; 14581 associerLabelsFormulaires($('auth-screen')); 14582 actualiserAuditActionsUI(); 14583 return; 14584 } 14585 14586 if (_appInitialized && (eventType === 'TOKEN_REFRESHED' || eventType === 'SIGNED_IN' || eventType === 'INITIAL_SESSION')) { 14587 return; 14588 } 14589 14590 // Connecté â charger profile + data 14591 profile = await avecDelai(loadProfile(), 15000, 'PROFIL_DELAI_DEPASSE'); 14592 if (!profile) { 14593 toast('Profil introuvable, déconnexion', true); 14594 setTimeout(() => db.auth.signOut(), 1500); 14595 return; 14596 } 14597 14598 // Update header : on affiche uniquement le prénom (avec fallback sur l'email si absent) 14599 $('user-name').textContent = profile.prenom || profile.email; 14600 $('user-role').textContent = profile.role; 14601 updateHeaderAvatar(); // 1ère mise à jour (initiales seulement à ce stade, pas encore de cache.joueurs) 14602 14603 // Première navigation selon rôle â UNIQUEMENT au premier chargement. 14604 // Aux passages suivants, on conserve l'écran courant. 14605 if (!_appInitialized) { 14606 etatApp.definirEcran(isCoach() ? 'dashboard' : 'my'); 14607 } 14608 14609 await avecDelai(loadAllData(), 30000, 'DONNEES_DELAI_DEPASSE'); 14610 updateHeaderAvatar(); // 2ème mise à jour : maintenant on a accès à cache.joueurs[].photo_url 14611 updateHeaderPalmyreBadge(); // badge "Palmyre" si le joueur est en stage cette semaine 14612 await avecDelai(loadNotifications(), 15000, 'NOTIFICATIONS_DELAI_DEPASSE'); 14613 14614 Promise.all([ 14615 checkAndCreateTournoiReminders().catch(e => journal.warn('[rappel tournoi]', e)), 14616 checkAndCreateTrimestreReminders().catch(e => journal.warn('[rappel trimestre]', e)), 14617 checkAndCreateSaisieMatchReminders().catch(e => journal.warn('[rappel saisie]', e)), 14618 (!isCoach() ? genererRappelsPresenceJoueur().catch(e => journal.warn('[rappel présence]', e)) : Promise.resolve()) 14619 ]).then(() => loadNotifications()).catch(e => journal.warn('[rappels arrière-plan]', e)); 14620 14621 subscribeRealtime(); 14622 14623 // Rattrapage automatique des badges (coach uniquement, une fois par session, en 14624 // arrière-plan) : enregistre les badges déjà mérités par tous les joueurs pour que 14625 // le fil d'actualité de l'académie soit complet. Ne bloque pas l'affichage. 14626 setTimeout(() => { rattrapageBadgesAuto().catch(e => journal.warn('[rattrapage auto]', e)); }, 2000); 14627 14628 $('loading-screen').style.display = 'none'; 14629 $('auth-screen').style.display = 'none'; 14630 $('app-wrapper').style.display = ''; 14631 14632 _appInitialized = true; 14633 14634 buildNavMenu(); 14635 render(); 14636 14637 // Mesure locale uniquement : aucun envoi réseau et aucune donnée nominative. 14638 // Sert à décider sur des chiffres si les historiques doivent être chargés à la demande. 14639 const demarrageMs = Math.round(performance.now() - demarrageAt); 14640 document.documentElement.dataset.startupMs = String(demarrageMs); 14641 document.documentElement.dataset.startupRole = isCoach() ? 'coach' : 'joueur'; 14642 journal.info('[performance démarrage]', { 14643 duree_ms: demarrageMs, 14644 role: document.documentElement.dataset.startupRole, 14645 joueurs: (listeSure(cache.joueurs)).length, 14646 matches: (listeSure(cache.matchesSaison)).length, 14647 entrainements: (listeSure(cache.entrainements)).length, 14648 presences: (listeSure(cache.presences)).length 14649 }); 14650 14651 // Affiche les nouveautés si l'app vient d'être mise à jour 14652 checkNouveautes(); 14653 } catch (error) { 14654 const idErreur = creerIdErreur(); 14655 journal.error(`[démarrage application ${idErreur}]`, error); 14656 _appInitialized = false;
14657 const loading = $('loading-screen'); 14658 if (loading) { 14659 loading.style.display = ''; 14660 loading.innerHTML = `<div class="card" style="max-width:430px;margin:24px;padding:22px;text-align:center;border-top:4px solid var(--danger)"> 14661 <div style="font-size:20px;font-weight:800;margin-bottom:8px">Impossible de charger l'application</div> 14662 <div class="text-dim" style="font-size:13px;margin-bottom:8px">${navigator.onLine === false ? 'Tu es actuellement hors ligne. Reconnecte-toi puis réessaie.' : "Vérifie ta connexion puis réessaie. Tes données n'ont pas été modifiées."}</div> 14663 <div class="text-faint" style="font-size:11px;margin-bottom:16px">Référence : ${idErreur}</div> 14664 <button type="button" class="btn btn-primary btn-full" data-ui-click="retryAppStart()">Réessayer</button> 14665 </div>`; 14666 } 14667 $('auth-screen').style.display = 'none'; 14668 $('app-wrapper').style.display = 'none'; 14669 } finally { 14670 _appStarting = false; 14671 } 14672} 14673 14674function retryAppStart() { 14675 const loading = $('loading-screen'); 14676 if (loading) loading.innerHTML = '<div class="spinner"></div><span class="sr-only">Nouvelle tentative de chargementâ¦</span>'; 14677 onAuthChange('RETRY'); 14678} 14679 14680db.auth.onAuthStateChange((_event, _session) => { 14681 onAuthChange(_event).catch(error => journal.error('[auth non gérée]', error)); 14682}); 14683 14684window.addEventListener('offline', mettreAJourEtatReseau); 14685window.addEventListener('online', () => { 14686 mettreAJourEtatReseau(); 14687 toast('Connexion rétablie'); 14688 if (!_appInitialized && session) retryAppStart(); 14689}); 14690mettreAJourEtatReseau(); 14691 14692$('modal').addEventListener('click', (e) => { 14693 if (e.target.id === 'modal') closeModal(); 14694}); 14695 14696document.addEventListener('click', (e) => { 14697 // Fermer le panneau notifications si clic en dehors 14698 const panel = $('notifsPanel'); 14699 const btn = $('btnNotifs'); 14700 if (panel && panel.style.display !== 'none') { 14701 if (!panel.contains(e.target) && btn && !btn.contains(e.target)) { 14702 panel.style.display = 'none'; 14703 } 14704 } 14705 // Fermer le menu utilisateur si clic en dehors 14706 const userMenu = $('userMenu'); 14707 const userBtn = $('userMenuBtn'); 14708 if (userMenu && userMenu.style.display !== 'none') { 14709 if (!userMenu.contains(e.target) && userBtn && !userBtn.contains(e.target)) { 14710 userMenu.style.display = 'none'; 14711 } 14712 } 14713}); 14714 14715let pendingUpdateWorker = null; 14716const SW_UPDATE_INTERVAL_MS = 30 * 1000; 14717const SW_UPDATE_THROTTLE_MS = 20 * 1000; 14718let derniereVerificationSw = 0; 14719 14720function verifierServiceWorker(reg, { force = false } = {}) { 14721 if (!reg) return Promise.resolve(false); 14722 const maintenant = Date.now(); 14723 if (!force && maintenant - derniereVerificationSw < SW_UPDATE_THROTTLE_MS) { 14724 return Promise.resolve(false); 14725 } 14726 derniereVerificationSw = maintenant; 14727 return reg.update().then(() => true).catch(() => false); 14728} 14729 14730if ('serviceWorker' in navigator && location.protocol !== 'file:') { 14731 navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' }).then((reg) => { 14732 window._swRegistration = reg; 14733 14734 setInterval(() => verifierServiceWorker(reg), SW_UPDATE_INTERVAL_MS); 14735 14736 document.addEventListener('visibilitychange', () => { 14737 if (document.visibilityState === 'visible') { 14738 verifierServiceWorker(reg); 14739 rafraichirAvatarsAuPremierPlan().catch(error => journal.warn('[avatars] renouvellement:', error)); 14740 } 14741 }); 14742 window.addEventListener('focus', () => { 14743 verifierServiceWorker(reg); 14744 rafraichirAvatarsAuPremierPlan().catch(error => journal.warn('[avatars] renouvellement:', error)); 14745 }); 14746 14747 if (reg.waiting && navigator.serviceWorker.controller) { 14748 pendingUpdateWorker = reg.waiting; 14749 showUpdateBanner(); 14750 } 14751 14752 reg.addEventListener('updatefound', () => { 14753 const newWorker = reg.installing; 14754 if (newWorker) { 14755 newWorker.addEventListener('statechange', () => { 14756 if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { 14757 pendingUpdateWorker = newWorker; 14758 showUpdateBanner(); 14759 } 14760 }); 14761 } 14762 }); 14763 14764 // Quand le SW prend le contrôle (après que l'utilisateur a cliqué "Recharger"), on reload 14765 let refreshing = false;
14766 navigator.serviceWorker.addEventListener('controllerchange', () => { 14767 if (refreshing) return; 14768 refreshing = true; 14769 window.location.reload(); 14770 }); 14771 14772 verifierServiceWorker(reg, { force: true }); 14773 }).catch((e) => journal.warn('[SW] register failed:', e)); 14774} 14775 14776function attendreWorkerEnAttente(reg, delaiMs = 8000) { 14777 return new Promise((resolve) => { 14778 const debut = Date.now(); 14779 const verifier = () => { 14780 const worker = reg?.waiting || pendingUpdateWorker; 14781 if (worker) { 14782 pendingUpdateWorker = worker; 14783 resolve(worker); 14784 return; 14785 } 14786 if (Date.now() - debut >= delaiMs) { 14787 resolve(null); 14788 return; 14789 } 14790 setTimeout(verifier, 150); 14791 }; 14792 verifier(); 14793 }); 14794} 14795 14796// Vérification manuelle des mises à jour (bouton dans le menu utilisateur). 14797async function verifierMajManuelle() { 14798 const menu = $('userMenu'); if (menu) menu.style.display = 'none'; 14799 const reg = window._swRegistration; 14800 if (!reg) { toast('Vérification indisponible', true); return; } 14801 toast('Recherche de mise à jourâ¦'); 14802 try { 14803 await verifierServiceWorker(reg, { force: true }); 14804 // L'installation peut prendre plusieurs secondes sur un réseau mobile. 14805 // Attendre réellement le worker évite d'annoncer à tort que l'app est à jour. 14806 const worker = await attendreWorkerEnAttente(reg); 14807 if (worker && navigator.serviceWorker.controller) showUpdateBanner(); 14808 else toast('Tu es déjà à la dernière version â '); 14809 } catch (e) { 14810 toast('Vérification impossible pour le moment', true); 14811 } 14812} 14813 14814function showUpdateBanner() { 14815 // Ãvite les doublons 14816 if (document.getElementById('updateBanner')) return; 14817 14818 const banner = document.createElement('div'); 14819 banner.id = 'updateBanner'; 14820 banner.style.cssText = 'position:fixed; top:0; left:0; right:0; z-index:9999; background:#071d1f; color:#fffdf8; padding:calc(10px + env(safe-area-inset-top, 0px)) max(16px, env(safe-area-inset-right, 0px)) 10px max(16px, env(safe-area-inset-left, 0px)); border-bottom:3px solid #ff7900; display:flex; align-items:center; justify-content:space-between; gap:12px; box-shadow:0 12px 30px rgba(0,0,0,0.32); font-size:14px; font-weight:700'; 14821 banner.innerHTML = ` 14822 <div style="display:flex; align-items:center; gap:10px; flex:1; min-width:0"> 14823 <img src="assets/v4/menu/update.webp" alt="" style="width:34px;height:34px;object-fit:contain;filter:drop-shadow(0 3px 5px rgba(0,0,0,.3))"> 14824 <span style="overflow:hidden; text-overflow:ellipsis">Nouvelle version disponible</span> 14825 </div> 14826 <div style="display:flex; gap:8px; align-items:center; flex-shrink:0"> 14827 <button data-ui-click="applyUpdate()" style="background:#dfff35; color:#071d1f; border:0; padding:8px 16px; clip-path:polygon(6px 0,100% 0,calc(100% - 6px) 100%,0 100%); font-weight:800; cursor:pointer; font-size:13px">Recharger</button> 14828 <button data-ui-click="dismissUpdateBanner()" title="Plus tard" aria-label="Fermer l'annonce de mise à jour" style="background:transparent; color:#fffdf8; border:1px solid rgba(255,255,255,0.45); border-radius:0; width:30px; height:30px; cursor:pointer; font-size:18px; padding:0; line-height:1">Ã</button> 14829 </div> 14830 `; 14831 document.body.insertBefore(banner, document.body.firstChild); 14832 14833 // Décale le contenu pour ne pas être caché par le bandeau 14834 document.body.style.paddingTop = banner.offsetHeight + 'px'; 14835} 14836 14837function applyUpdate() { 14838 // On relit reg.waiting AU MOMENT DU CLIC (la référence mémorisée peut être périmée : 14839 // worker déjà activé par un autre onglet, devenu redondant, etc.). 14840 const reg = window._swRegistration; 14841 const worker = (reg && reg.waiting) || pendingUpdateWorker; 14842 if (worker) { 14843 worker.postMessage({ type: 'SKIP_WAITING' }); 14844 // Filet de sécurité : 'controllerchange' ne se déclenche QUE si skipWaiting produit 14845 // réellement un changement de contrôleur. En cas de course (worker déjà actif, 14846 // redondant, autre onglet), l'event ne vient jamais â sans ce fallback, le bouton 14847 // « ne fait rien ». On force donc le rechargement si rien ne s'est produit sous 2 s. 14848 // (Si controllerchange se déclenche d'abord, la page a déjà navigué â ce timer ne 14849 // s'exécute pas.) 14850 setTimeout(() => { window.location.reload(); }, 2000); 14851 } else { 14852 // Aucun worker en attente : on recharge directement. 14853 window.location.reload(); 14854 } 14855} 14856 14857function dismissUpdateBanner() { 14858 const banner = document.getElementById('updateBanner'); 14859 if (banner) { 14860 banner.remove(); 14861 document.body.style.paddingTop = ''; 14862 } 14863 clearTimeout(window._updateBannerReminder); 14864 window._updateBannerReminder = setTimeout(() => { 14865 const reg = window._swRegistration; 14866 if (reg?.waiting) showUpdateBanner(); 14867 }, 30 * 60 * 1000); 14868} 14869 14870// Démarrage 14871handlePasswordRecovery(); 14872onAuthChange(); 14873loadAppVersion();
14873 // Affiche la version sur l'écran de login et le menu
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.