/** * UX Manager - Gestionnaire d'expérience utilisateur modulaire * Version: 3.0.0 - Corrigée et Optimisée * Description: Gère tous les aspects de l'UX : navigation, panels contextuels, notifications, accessibilité * Auteur: Portail RH Inter Santé * Date: 2025.12.22 - Version finale */ // ============================================================================ // CLASSE PRINCIPALE - UXManager // ============================================================================ /** * Classe principale qui orchestre tous les gestionnaires d'expérience utilisateur * Elle initialise et coordonne les différents sous-managers */ class UXManager { /** * Constructeur principal - Initialise tous les sous-managers */ constructor() { // Initialisation des sous-managers this.navigation = new NavigationManager(); this.contextPanel = new ContextPanelManager(); this.notifications = new NotificationManager(); this.accessibility = new AccessibilityManager(); this.performance = new PerformanceManager(); this.language = new LanguageManager(); // Lancement de l'initialisation this.init(); } /** * Méthode d'initialisation principale * Appelée automatiquement par le constructeur */ init() { //console.log('[UX Manager] Initialisation du système d\'expérience utilisateur...'); // Initialisation séquentielle des sous-managers this.navigation.init(); this.contextPanel.init(); this.notifications.init(); this.accessibility.init(); this.performance.init(); this.language.init(); // Gestion de session utilisateur this.checkSession(); // Initialisation du Service Worker this.initServiceWorker(); //console.log('[UX Manager] Initialisation terminée avec succès'); } /** * Basculer l'état du panneau contextuel * @public - Méthode accessible globalement */ toggleContextPanel() { this.contextPanel.toggle(); } /** * Vérifier et gérer l'expiration de session utilisateur * @private - Méthode interne de gestion de session */ checkSession() { // Récupérer la durée de session depuis le DOM const dureeSession = parseInt(document.getElementById('dureeSession')?.value) || 30; const dureeMinutes = dureeSession * 60 * 1000; // Conversion en millisecondes // Vérifier régulièrement l'activité de l'utilisateur setInterval(() => { const derniereAction = sessionStorage.getItem('derniere_action'); const maintenant = Date.now(); // Si inactivité dépassant la durée autorisée if (derniereAction && (maintenant - derniereAction > dureeMinutes)) { this.showSessionWarning(); } }, 60000); // Vérification toutes les minutes } /** * Afficher un avertissement d'expiration de session * @private - Méthode d'affichage de notification */ showSessionWarning() { const isAnglophone = window.appConfig?.isAnglophone || false; const messages = { title: isAnglophone ? 'Session Warning' : 'Avertissement de session', text: isAnglophone ? 'Your session will expire soon. Do you want to extend it?' : 'Votre session va bientôt expirer. Souhaitez-vous la prolonger?', confirm: isAnglophone ? 'Extend' : 'Prolonger', cancel: isAnglophone ? 'Logout' : 'Déconnexion' }; // Utiliser SweetAlert pour une notification élégante Swal.fire({ title: messages.title, text: messages.text, icon: 'warning', showCancelButton: true, confirmButtonText: messages.confirm, cancelButtonText: messages.cancel, allowOutsideClick: false, allowEscapeKey: false }).then((result) => { if (result.isConfirmed) { // Prolonger la session sessionStorage.setItem('derniere_action', Date.now()); this.accessibility.announce( isAnglophone ? 'Session extended' : 'Session prolongée' ); } else { // Déconnexion window.location.href = window.appConfig?.racineWeb + 'Connexion/deconnecter'; } }); } /** * Initialiser le Service Worker pour le mode hors-ligne * @private - Méthode d'initialisation PWA */ initServiceWorker() { if ('serviceWorker' in navigator) { navigator.serviceWorker.ready.then(registration => { //console.log('[UX Manager] Service Worker actif:', registration.scope); }).catch(error => { //console.warn('[UX Manager] Service Worker non disponible:', error); }); } } } // ============================================================================ // SOUS-CLASSE - NavigationManager (GESTION DES MENUS) // ============================================================================ /** * Gestionnaire spécialisé pour la navigation et les menus * Contrôle l'ouverture/fermeture des menus dépliables */ class NavigationManager { /** * Constructeur - Initialise l'état de navigation */ constructor() { this.currentOpenMenu = null; // Menu actuellement ouvert this.activeMenuId = null; // Menu actif basé sur la page courante this.isInitialized = false; // État d'initialisation } /** * Initialiser le système de navigation * @public - Méthode appelée par UXManager */ init() { if (this.isInitialized) return; // console.log('[Navigation] Initialisation du système de menus...'); // Appliquer le correctif d'urgence pour les menus multiples ouverts this.applyEmergencyFix(); // Configuration des comportements this.setupActiveMenu(); this.setupMenuBehavior(); this.setupKeyboardNavigation(); // Vérification finale après initialisation setTimeout(() => { this.validateMenuConsistency(); }, 300); this.isInitialized = true; //console.log('[Navigation] Système de menus initialisé'); } /** * Correctif d'urgence - Force un seul menu ouvert au chargement * @private - Méthode de correction initiale */ applyEmergencyFix() { // Détecter le menu actif depuis la configuration const activeParentId = window.appConfig?.activeParentId; if (activeParentId !== null && activeParentId !== '') { this.activeMenuId = `submenu${activeParentId}`; //console.log('[Navigation] Correctif: Menu actif détecté:', this.activeMenuId); // Forcer la fermeture de tous les autres menus this.forceSingleMenu(); } } /** * Forcer l'ouverture d'un seul menu (celui actif) * @private - Méthode de correction forcée */ forceSingleMenu() { if (!this.activeMenuId) return; //console.log('[Navigation] Forçage: Un seul menu ouvert ->', this.activeMenuId); // Étape 1: Fermer TOUS les menus document.querySelectorAll('.nav-submenu').forEach(menu => { if (menu.id !== this.activeMenuId) { this.closeMenu(menu.id, true); // true = force close } }); // Étape 2: Ouvrir le menu actif setTimeout(() => { this.openMenu(this.activeMenuId); this.currentOpenMenu = this.activeMenuId; }, 50); } /** * Configurer le menu actif basé sur la page courante * @private - Méthode de configuration initiale */ setupActiveMenu() { const activeParentId = window.appConfig?.activeParentId; const activeLink = window.appConfig?.activeLink; //console.log('[Navigation] Configuration:', { activeParentId, activeLink }); // CORRECTION : Si "Accueil" mais activeParentId != 0, corriger if (activeLink === 'Accueil' && activeParentId !== '0') { //console.warn('[Navigation] Correction: Accueil devrait être menu 0, pas', activeParentId); this.activeMenuId = 'submenu0'; // Mettre à jour la config if (window.appConfig) { window.appConfig.activeParentId = '0'; } } // Cas normal else if (activeParentId !== null && activeParentId !== '') { this.activeMenuId = `submenu${activeParentId}`; } else { this.activeMenuId = null; } //console.log('[Navigation] Menu actif final:', this.activeMenuId); // Appliquer if (this.activeMenuId) { setTimeout(() => { this.openMenu(this.activeMenuId); this.currentOpenMenu = this.activeMenuId; }, 100); } } /** * Configurer les comportements interactifs des menus * @private - Méthode d'attachement des événements */ setupMenuBehavior() { // Pour chaque lien de menu avec sous-menu (dépliable) document.querySelectorAll('.nav-link[data-bs-toggle="collapse"]').forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const targetId = link.getAttribute('href').substring(1); // Enlever le # //console.log('[Navigation] Clic détecté sur menu:', targetId); // Logique de basculement intelligent if (this.currentOpenMenu === targetId) { // Si on clique sur le menu déjà ouvert, on le ferme this.closeMenu(targetId); this.currentOpenMenu = null; } else { // Fermer le menu précédent si différent if (this.currentOpenMenu && this.currentOpenMenu !== targetId) { this.closeMenu(this.currentOpenMenu); } // Ouvrir le nouveau menu this.openMenu(targetId); this.currentOpenMenu = targetId; } }); }); // Fermer les menus en cliquant en dehors de la sidebar document.addEventListener('click', (e) => { if (!e.target.closest('.app-sidebar')) { // Ne pas fermer le menu actif s'il correspond à la page courante if (this.currentOpenMenu !== this.activeMenuId) { this.closeAllMenus(); } } }); // Empêcher la fermeture accidentelle en cliquant dans le sous-menu document.querySelectorAll('.nav-submenu').forEach(menu => { menu.addEventListener('click', (e) => { e.stopPropagation(); }); }); } /** * Ouvrir un menu spécifique * @param {string} menuId - ID du menu à ouvrir * @private - Méthode d'ouverture de menu */ openMenu(menuId) { const menu = document.getElementById(menuId); const link = document.querySelector(`[href="#${menuId}"]`); if (menu && link) { // Vérifier si le menu est déjà ouvert if (menu.classList.contains('show')) { //console.log('[Navigation] Menu déjà ouvert:', menuId); return; } //console.log('[Navigation] Ouverture du menu:', menuId); // 1. Fermer tous les autres menus document.querySelectorAll('.nav-submenu.show').forEach(otherMenu => { if (otherMenu.id !== menuId) { otherMenu.classList.remove('show'); const otherLink = document.querySelector(`[href="#${otherMenu.id}"]`); if (otherLink) { otherLink.setAttribute('aria-expanded', 'false'); otherLink.classList.remove('active'); const arrow = otherLink.querySelector('.nav-arrow'); if (arrow) arrow.style.transform = 'rotate(0deg)'; } } }); // 2. Ouvrir le menu sélectionné avec animations menu.classList.add('show'); link.setAttribute('aria-expanded', 'true'); link.classList.add('active'); // 3. Animer la flèche const arrow = link.querySelector('.nav-arrow'); if (arrow) arrow.style.transform = 'rotate(90deg)'; // 4. Annoncer pour l'accessibilité const menuName = link.querySelector('.nav-text')?.textContent || 'Menu'; window.appUX?.accessibility.announce(`${menuName} ouvert`); } } /** * Fermer un menu spécifique * @param {string} menuId - ID du menu à fermer * @param {boolean} force - Forcer la fermeture même pour le menu actif * @private - Méthode de fermeture de menu */ closeMenu(menuId, force = false) { const menu = document.getElementById(menuId); const link = document.querySelector(`[href="#${menuId}"]`); if (menu && link) { // Ne pas fermer le menu actif s'il correspond à la page courante (sauf si forcé) if (!force && menuId === this.activeMenuId) { //console.log('[Navigation] Menu actif, fermeture bloquée:', menuId); return; } //console.log('[Navigation] Fermeture du menu:', menuId); // Appliquer les changements visuels menu.classList.remove('show'); link.setAttribute('aria-expanded', 'false'); link.classList.remove('active'); // Réinitialiser la flèche const arrow = link.querySelector('.nav-arrow'); if (arrow) arrow.style.transform = 'rotate(0deg)'; } } /** * Fermer tous les menus (sauf le menu actif) * @public - Méthode accessible globalement */ closeAllMenus() { //console.log('[Navigation] Fermeture de tous les menus (sauf actif)'); document.querySelectorAll('.nav-submenu.show').forEach(menu => { if (menu.id !== this.activeMenuId) { this.closeMenu(menu.id); } }); // Réinitialiser l'état si aucun menu actif if (!this.activeMenuId) { this.currentOpenMenu = null; } } /** * Fermer tous les menus sauf celui actif * @public - Alias pour closeAllMenus avec nom plus explicite */ closeAllExceptActive() { this.closeAllMenus(); } /** * Configurer la navigation au clavier * @private - Méthode d'accessibilité clavier */ setupKeyboardNavigation() { document.addEventListener('keydown', (e) => { // Échap: fermer tous les menus if (e.key === 'Escape') { this.closeAllMenus(); } // Flèches: navigation dans les menus if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { const focused = document.activeElement; if (focused.classList.contains('nav-link')) { e.preventDefault(); this.navigateMenu(focused, e.key); } } }); } /** * Naviguer dans les menus avec les flèches * @param {HTMLElement} currentElement - Élément actuellement focus * @param {string} direction - Direction de navigation ('ArrowDown' ou 'ArrowUp') * @private - Méthode de navigation clavier */ navigateMenu(currentElement, direction) { const allLinks = Array.from(document.querySelectorAll('.nav-link')); const currentIndex = allLinks.indexOf(currentElement); if (direction === 'ArrowDown' && currentIndex < allLinks.length - 1) { allLinks[currentIndex + 1].focus(); } else if (direction === 'ArrowUp' && currentIndex > 0) { allLinks[currentIndex - 1].focus(); } } /** * Vérifier la cohérence des menus * @private - Méthode de validation */ validateMenuConsistency() { //console.log('[Navigation] Vérification de cohérence...'); // 1. Vérifier que le menu actif est ouvert if (this.activeMenuId) { const activeMenu = document.getElementById(this.activeMenuId); if (activeMenu && !activeMenu.classList.contains('show')) { //console.warn('[Navigation] Menu actif non ouvert, correction...'); this.openMenu(this.activeMenuId); } } // 2. Vérifier qu'un seul menu est ouvert const openMenus = document.querySelectorAll('.nav-submenu.show'); if (openMenus.length > 1) { //console.warn(`[Navigation] ${openMenus.length} menus ouverts, correction...`); this.forceSingleMenu(); } // 3. Vérifier la correspondance page/menu this.validatePageMenuMatch(); } /** * Valider la correspondance entre la page et le menu ouvert * @private - Méthode de validation avancée */ validatePageMenuMatch() { const activeLink = window.appConfig?.activeLink; if (!activeLink) return; // Chercher si la page active est dans le menu ouvert const openMenu = document.querySelector('.nav-submenu.show'); if (openMenu) { const hasActiveLink = openMenu.querySelector(`a[href*="${activeLink}"]`); if (!hasActiveLink) { //console.warn(`[Navigation] Page "${activeLink}" non trouvée dans le menu ouvert ${openMenu.id}`); // Essayer de trouver le bon menu this.autoDetectCorrectMenu(); } } } /** * Détecter automatiquement le menu correct * @private - Méthode d'auto-détection */ autoDetectCorrectMenu() { const activeLink = window.appConfig?.activeLink; if (!activeLink) return; //console.log(`[Navigation] Auto-détection du menu pour "${activeLink}"...`); // Chercher dans tous les menus document.querySelectorAll('.nav-submenu').forEach(menu => { const link = menu.querySelector(`a[href*="${activeLink}"]`); if (link) { const correctMenuId = menu.id; //console.log(`[Navigation] Trouvé dans ${correctMenuId}`); // Mettre à jour this.activeMenuId = correctMenuId; if (window.appConfig) { const menuNum = correctMenuId.replace('submenu', ''); window.appConfig.activeParentId = menuNum; } // Ouvrir le bon menu setTimeout(() => { this.openMenu(correctMenuId); this.currentOpenMenu = correctMenuId; }, 100); } }); } /** * Ouvrir un menu par son ID (méthode publique) * @param {string} menuId - ID du menu à ouvrir * @public - Méthode accessible globalement */ openMenuById(menuId) { this.openMenu(menuId); this.currentOpenMenu = menuId; } } // ============================================================================ // SOUS-CLASSE - ContextPanelManager (PANEL CONTEXTUEL) // ============================================================================ /** * Gestionnaire du panneau contextuel latéral * Contrôle l'affichage et les interactions du panel d'outils */ class ContextPanelManager { /** * Constructeur - Initialise les références DOM */ constructor() { this.panel = document.getElementById('contextPanel'); this.toggleButton = document.querySelector('.context-toggle'); this.proximityArea = document.querySelector('.proximity-hover-area'); this.isOpen = false; this.isInitialized = false; } /** * Initialiser le panel contextuel * @public - Méthode appelée par UXManager */ init() { if (this.isInitialized || !this.panel) return; //console.log('[Context Panel] Initialisation...'); this.setupProximityDetection(); this.setupKeyboardControls(); this.isInitialized = true; } /** * Configurer la détection de proximité (hover) * @private - Méthode d'interaction au survol */ setupProximityDetection() { if (!this.proximityArea || !this.toggleButton) return; // Animation au survol de la zone de proximité this.proximityArea.addEventListener('mouseenter', () => { this.toggleButton.style.opacity = '1'; this.toggleButton.style.transform = 'scale(1.1)'; }); this.proximityArea.addEventListener('mouseleave', () => { if (!this.isOpen) { this.toggleButton.style.opacity = '0.2'; this.toggleButton.style.transform = 'scale(1)'; } }); // Opacité initiale this.toggleButton.style.opacity = '0.2'; this.toggleButton.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; } /** * Configurer les contrôles clavier * @private - Méthode d'accessibilité clavier */ setupKeyboardControls() { document.addEventListener('keydown', (e) => { // Ctrl+Shift+C : basculer le panel if (e.ctrlKey && e.shiftKey && e.key === 'C') { e.preventDefault(); this.toggle(); } // Échap : fermer le panel si ouvert if (e.key === 'Escape' && this.isOpen) { this.close(); } }); } /** * Basculer l'état du panel (ouvrir/fermer) * @public - Méthode accessible globalement */ toggle() { if (this.isOpen) { this.close(); } else { this.open(); } } /** * Ouvrir le panel contextuel * @private - Méthode d'ouverture */ open() { this.panel.classList.add('open'); this.toggleButton.setAttribute('aria-expanded', 'true'); this.toggleButton.style.opacity = '1'; this.isOpen = true; // Focus sur le bouton de fermeture pour l'accessibilité setTimeout(() => { const closeBtn = this.panel.querySelector('.context-close'); if (closeBtn) closeBtn.focus(); }, 300); //console.log('[Context Panel] Ouvert'); window.appUX?.accessibility.announce('Panneau contextuel ouvert'); } /** * Fermer le panel contextuel * @private - Méthode de fermeture */ close() { this.panel.classList.remove('open'); this.toggleButton.setAttribute('aria-expanded', 'false'); this.toggleButton.style.opacity = '0.2'; this.isOpen = false; // Retourner le focus au bouton toggle this.toggleButton.focus(); //console.log('[Context Panel] Fermé'); window.appUX?.accessibility.announce('Panneau contextuel fermé'); } } // ============================================================================ // SOUS-CLASSE - NotificationManager (GESTION DES NOTIFICATIONS) // ============================================================================ /** * Gestionnaire des notifications et messages système */ class NotificationManager { /** * Constructeur - Initialise le compteur de notifications */ constructor() { this.countElement = document.getElementById('notificationCount'); this.unreadCount = 0; this.pollingInterval = null; } /** * Initialiser le système de notifications * @public - Méthode appelée par UXManager */ init() { //console.log('[Notifications] Initialisation...'); this.loadNotifications(); this.setupPolling(); } /** * Charger les notifications depuis le serveur * @private - Méthode de chargement initial */ loadNotifications() { // Simulation - À remplacer par un appel API réel const mockCount = 3; // Exemple : 3 notifications non lues this.updateCount(mockCount); //console.log('[Notifications] Chargement simulé:', mockCount, 'notification(s)'); } /** * Mettre à jour le compteur de notifications * @param {number} count - Nouveau nombre de notifications * @private - Méthode de mise à jour d'affichage */ updateCount(count) { this.unreadCount = count; if (this.countElement) { // Mettre à jour l'élément visuel this.countElement.textContent = count; this.countElement.style.display = count > 0 ? 'flex' : 'none'; // Mettre à jour le titre de la page const baseTitle = document.title.replace(/^\(\d+\)\s*/, ''); document.title = count > 0 ? `(${count}) ${baseTitle}` : baseTitle; // Annoncer pour l'accessibilité si nouveau message if (count > 0) { const message = window.appConfig?.isAnglophone ? `${count} new notification${count > 1 ? 's' : ''}` : `${count} nouvelle${count > 1 ? 's' : ''} notification${count > 1 ? 's' : ''}`; window.appUX?.accessibility.announce(message); } } } /** * Configurer le polling pour les nouvelles notifications * @private - Méthode de vérification périodique */ setupPolling() { // Vérifier les nouvelles notifications toutes les 30 secondes this.pollingInterval = setInterval(() => { this.checkNewNotifications(); }, 30000); } /** * Vérifier les nouvelles notifications (méthode à implémenter) * @private - Méthode d'appel API */ checkNewNotifications() { // À implémenter : appel AJAX pour vérifier les nouvelles notifications // console.log('[Notifications] Vérification des nouvelles notifications...'); } /** * Afficher la modale des messages * @public - Méthode accessible globalement */ showMessagesModal() { const modalElement = document.getElementById('messagesModal'); if (!modalElement) { //console.error('[Notifications] Modale des messages non trouvée'); return; } const modal = new bootstrap.Modal(modalElement); modal.show(); // Charger les messages this.loadMessages(); //console.log('[Notifications] Affichage de la modale des messages'); } /** * Charger les messages dans la modale * @private - Méthode de chargement de contenu */ loadMessages() { const container = document.getElementById('div_messagerie'); if (!container) return; // Simulation de chargement - À remplacer par un appel API réel const messages = window.appConfig?.isAnglophone ? [ { title: 'New prescription', time: '5 minutes ago', content: 'A new prescription has been created for the beneficiary.' }, { title: 'Exemption approved', time: '2 hours ago', content: 'Your exemption request has been approved.' } ] : [ { title: 'Nouvelle prescription', time: 'Il y a 5 minutes', content: 'Une nouvelle prescription a été créée pour le bénéficiaire.' }, { title: 'Dérogation approuvée', time: 'Il y a 2 heures', content: 'Votre demande de dérogation a été approuvée.' } ]; // Générer le HTML des messages let messagesHTML = '
${msg.content}