1/***********************************************************/ 2/* */ 3/* Plataforma e-ducativa - Argentina */ 4/* */ 5/* Copyright (c) 2026 de e-ducativa Educación Virtual S.A. */ 6/* */ 7/***********************************************************/ 8/** 9 * @file Navigation.js 10 * @brief Modulo unificado de navegacion para Frontend y Backend 11 * 12 * Maneja topbar, icon sidebar, menu de usuario y QR para acceso movil. 13 */ 14 15import { Modal as ModalBase, isBackend } from 'Educativa/Modules/Aula/Base'; 16import { popupAyuda, Utils } from 'Educativa/Modules/Base'; 17import { Paginador, PaginadorVista } from 'Educativa/Modules/PaginadorSimple'; 18import { LangSwitcher } from 'Educativa/Modules/Aula/Components/LangSwitcher'; 19import { resetSpotlights } from 'Educativa/EducativaSpotlightTour'; 20 21// Dict, EduOpen, url_dir y QRCode son globales disponibles en toda la aplicacion 22 23// Indicador de novedad (punto verde) del menu de usuario. Onboarding: 24// invita a abrir "Perfil y preferencias". El contador de aperturas se 25// persiste por navegador en localStorage; al llegar al limite el punto 26// desaparece. 27const PERFIL_INDICATOR_KEY = 'perfil_preferencias_visitas'; 28const PERFIL_INDICATOR_LIMIT = 3; 29 30/** 31 * Clase Navigation - Gestiona toda la navegacion del sistema 32 */ 33export class Navigation { 34 constructor(config) { 35 // Configuracion 36 this.username = config.username; 37 this.user_nombre_completo = config.user_nombre_completo; 38 this.url_thumb = config.url_thumb; 39 this.url_dir = config.url_dir; 40 this.id_curso = config.id_curso || 0; 41 this.sesiones_activas = config.sesiones_activas || 0; 42 this.url_ayuda = config.url_ayuda; 43 this.gestor_academico = config.gestor_academico === 'true' || config.gestor_academico === true; 44 this.is_backend = config.is_backend === 'true' || config.is_backend === true; 45 this.in_proctorizer = config.in_proctorizer === 'true' || config.in_proctorizer === true; 46 47 // En backend, el MenuAdmin renderiza un <a id="ayuda-link"> con la URL 48 // contextual correcta (incluye help_secc). Usarlo si esta disponible. 49 if ( this.is_backend ) { 50 var ayudaLink = document.getElementById( 'ayuda-link' ); 51 if ( ayudaLink && ayudaLink.href ) { 52 this.url_ayuda = ayudaLink.href; 53 } 54 } 55 56 // Elementos del DOM 57 this.trigger = document.querySelector('[data-user-menu-trigger]'); 58 this.dropdown = document.querySelector('.topbar-user-dropdown'); 59 60 // Estado 61 this.isOpen = false; 62 this.perfilIndicator = null; 63 this.perfilMenuIndicator = null; 64 65 // Estado QR 66 this.qrModal = null; 67 this.qr_container = null; 68 this.qr_data = null; 69 this.qr_update = null; 70 this.qrcode = null; 71 this.showingQR = false; 72 this.temporal_access = null; 73 74 // Inicializar 75 this.init(); 76 } 77 78 init() { 79 if (!this.trigger || !this.dropdown) { 80 // Silenciosamente no inicializar si no hay elementos 81 // (caso de iframe como personal.cgi) 82 return; 83 } 84 85 // Instanciar el lang switcher si esta presente en el DOM (bug #18341). 86 if ( document.querySelector( '[data-lang-switcher]' ) ) { 87 const langSwitcher = new LangSwitcher(); 88 langSwitcher.render(); 89 } 90 91 // Generar contenido del dropdown 92 this.renderDropdownContent(); 93 94 // Punto verde de novedad en el menu de usuario (onboarding perfil) 95 this.initPerfilNoveltyIndicator(); 96 97 // Event listener para abrir/cerrar 98 this.trigger.addEventListener('click', () => { 99 this.toggle(); 100 }); 101 102 // Cerrar al hacer click fuera 103 document.addEventListener('click', (e) => { 104 if (this.isOpen && !this.dropdown.contains(e.target) && !this.trigger.contains(e.target)) { 105 this.close(); 106 } 107 }); 108 109 // Cerrar con Escape 110 document.addEventListener('keydown', (e) => { 111 if (e.key === 'Escape' && this.isOpen) this.close(); 112 }); 113 114 // Inicializar toggle movil de sidebar 115 this.initMobileToggle(); 116 117 // Inicializar pin de sidebar (solo desktop)
118 this.initPinToggle(); 119 120 // Deshabilitar transiciones del sidebar durante resize 121 this.initResizeHandler(); 122 123 // Boton scroll to top 124 this.initScrollToTop(); 125 } 126 127 /** 128 * Inicializa el toggle de la sidebar para movil 129 */ 130 initMobileToggle() { 131 const toggleBtn = document.querySelector('.sidebar-toggle'); 132 const sidebar = document.querySelector('.icon-sidebar'); 133 const overlay = document.querySelector('.sidebar-overlay'); 134 135 if (!toggleBtn || !sidebar || !overlay) { 136 return; 137 } 138 139 // Toggle al hacer click en el boton 140 toggleBtn.addEventListener('click', () => { 141 sidebar.classList.toggle('show'); 142 overlay.classList.toggle('show'); 143 }); 144 145 // Cerrar al hacer click en el overlay 146 overlay.addEventListener('click', () => { 147 sidebar.classList.remove('show'); 148 overlay.classList.remove('show'); 149 }); 150 151 // Cerrar al hacer click en el boton de cerrar del sidebar 152 const closeBtn = sidebar.querySelector('.icon-sidebar-close'); 153 if (closeBtn) { 154 closeBtn.addEventListener('click', () => { 155 sidebar.classList.remove('show'); 156 overlay.classList.remove('show'); 157 }); 158 } 159 } 160 161 /** 162 * Crea el boton flotante de scroll-to-top que aparece al hacer scroll 163 */ 164 initScrollToTop() { 165 const btn = document.createElement( 'div' ); 166 btn.id = 'scroll-to-top'; 167 btn.classList.add( 'scroll_top' ); 168 169 if ( this.in_proctorizer ) { 170 btn.classList.add( 'in_proctorizer' ); 171 172 const wrapper = document.querySelector( '.wrapper_floating_components' ); 173 if ( wrapper ) { 174 wrapper.classList.add( 'in_proctorizer' ); 175 } 176 } 177 178 const icon = document.createElement( 'i' ); 179 icon.classList.add( 'icono', 'fas', 'fa-chevron-up' ); 180 btn.appendChild( icon ); 181 document.body.appendChild( btn ); 182 183 btn.addEventListener( 'click', () => { 184 document.querySelector( 'body' ).scrollIntoView( { behavior: 'smooth' } ); 185 } ); 186 187 window.addEventListener( 'scroll', () => { 188 btn.classList.toggle( 'show', window.scrollY >= 150 ); 189 } ); 190 } 191 192 /** 193 * Deshabilita transiciones del sidebar durante resize para evitar 194 * el flash visual al cruzar breakpoints 195 */ 196 initResizeHandler() { 197 let resizeTimer; 198 window.addEventListener('resize', () => { 199 document.body.classList.add('resize-in-progress'); 200 clearTimeout(resizeTimer); 201 resizeTimer = setTimeout(() => { 202 document.body.classList.remove('resize-in-progress'); 203 }, 100); 204 }); 205 } 206 207 /** 208 * Inicializa el pin de la sidebar para desktop 209 * Persiste el estado en localStorage 210 */ 211 initPinToggle() { 212 if (document.body.classList.contains('backend')) return; 213 214 const STORAGE_KEY = 'icon_sidebar_pinned'; 215 const sidebar = document.querySelector('.icon-sidebar'); 216 const pinBtn = sidebar?.querySelector('.icon-sidebar-pin'); 217 218 if (!pinBtn || !sidebar) return; 219 220 // Ancho minimo de viewport para aplicar el pin expandido 221 // (evita solape con .menu_secciones del aula en anchos medios) 222 const wideQuery = window.matchMedia('(min-width: 1660px)'); 223 224 // Aplica el pin al DOM solo si la preferencia esta activa Y el 225 // viewport es suficientemente ancho. La preferencia se conserva. 226 const applyPinState = () => { 227 const prefers = localStorage.getItem(STORAGE_KEY) === 'true'; 228 const active = prefers && wideQuery.matches; 229 sidebar.classList.toggle('pinned', active); 230 document.body.classList.toggle('sidebar-pinned', active); 231 }; 232 233 applyPinState(); 234 wideQuery.addEventListener('change', applyPinState); 235 236 pinBtn.addEventListener('click', () => { 237 // En anchos donde el pin no se puede aplicar, el boton no hace nada 238 if (!wideQuery.matches) return; 239 const prefers = localStorage.getItem(STORAGE_KEY) !== 'true';
240 localStorage.setItem(STORAGE_KEY, String(prefers)); 241 applyPinState(); 242 }); 243 } 244 245 renderDropdownContent() { 246 // Limpiar contenido previo 247 this.dropdown.innerHTML = ''; 248 249 // Crear lista 250 const ul = document.createElement('ul'); 251 ul.className = 'topbar-user-menu-list'; 252 253 // Perfil y preferencias 254 this.addPerfilOption(ul); 255 256 // QR (login movil) - solo si no es gestor academico 257 if (!this.gestor_academico) { 258 this.addQROption(ul); 259 } 260 261 // Ayuda 262 this.addAyudaOption(ul); 263 264 // Tour Campus 265 this.addTourCampusOption(ul); 266 267 // Contactar al administrador 268 this.addSoporteOption(ul); 269 270 // Cerrar sesiones remotas (si hay mas de 1 sesion activa) 271 if (this.sesiones_activas > 1) { 272 this.addRemoteSessionsOption(ul); 273 } 274 275 // Cerrar sesion 276 this.addLogoutOption(ul); 277 278 this.dropdown.appendChild(ul); 279 } 280 281 addPerfilOption(ul) { 282 const li = document.createElement('li'); 283 li.className = 'topbar-user-menu-item'; 284 285 const link = document.createElement('a'); 286 link.href = '#'; 287 link.className = 'topbar-user-menu-link'; 288 link.textContent = Dict.termcap('PERFIL_Y_PREFERENCIAS'); 289 290 link.addEventListener('click', (e) => { 291 e.preventDefault(); 292 if (this.esUsuarioAnonimo()) { 293 alert(Dict.termcap('ESTA_FUNCION_NO_ESTA_DISPONIBLE_PARA_USUARIOS_ANONIMOS')); 294 return; 295 } 296 this.registerPerfilVisit(); 297 this.openPerfilModal(); 298 this.close(); 299 }); 300 301 li.appendChild(link); 302 303 // Punto verde de novedad: refuerza el onboarding dentro del menu. 304 // Lo agrega addPerfilOption (y no initPerfilNoveltyIndicator) para 305 // que sobreviva al re-render del dropdown en finishRemoteSessions. 306 // Se inserta inline dentro del link, pegado al texto. 307 if (!this.esUsuarioAnonimo() && 308 this.getPerfilVisitCount() < PERFIL_INDICATOR_LIMIT) { 309 const indicator = document.createElement('span'); 310 indicator.className = 'topbar-user-menu-indicator'; 311 link.appendChild(indicator); 312 this.perfilMenuIndicator = indicator; 313 } 314 315 ul.appendChild(li); 316 } 317 318 addQROption(ul) { 319 const li = document.createElement('li'); 320 li.className = 'topbar-user-menu-item'; 321 322 const link = document.createElement('a'); 323 link.href = '#'; 324 link.className = 'topbar-user-menu-link'; 325 link.textContent = Dict.term('QR_OPCION_MENU_DESPLEGABLE'); 326 327 link.addEventListener('click', (e) => { 328 e.preventDefault(); 329 this.openQRModal(); 330 this.close(); 331 }); 332 333 li.appendChild(link); 334 ul.appendChild(li); 335 } 336
337 addAyudaOption(ul) { 338 const li = document.createElement('li'); 339 li.className = 'topbar-user-menu-item'; 340 341 const link = document.createElement('a'); 342 link.href = '#'; 343 link.className = 'topbar-user-menu-link'; 344 link.textContent = Dict.termcap('AYUDA'); 345 346 link.addEventListener('click', (e) => { 347 e.preventDefault(); 348 popupAyuda({ url: this.url_ayuda }); 349 this.close(); 350 }); 351 352 li.appendChild(link); 353 ul.appendChild(li); 354 } 355 356 addTourCampusOption(ul) { 357 if ( this.esUsuarioAnonimo() ) { return; } 358 359 const li = document.createElement('li'); 360 li.className = 'topbar-user-menu-item'; 361 362 const link = document.createElement('a'); 363 link.href = '#'; 364 link.className = 'topbar-user-menu-link'; 365 link.textContent = Dict.termcap('TOUR_CAMPUS'); 366 367 link.addEventListener('click', (e) => { 368 e.preventDefault(); 369 resetSpotlights(); 370 }); 371 372 li.appendChild(link); 373 ul.appendChild(li); 374 } 375 376 addSoporteOption(ul) { 377 const li = document.createElement('li'); 378 li.className = 'topbar-user-menu-item'; 379 380 const link = document.createElement('a'); 381 link.href = '#'; 382 link.className = 'topbar-user-menu-link'; 383 link.textContent = Dict.termcap('CONTACTAR_AL_ADMINISTRADOR'); 384 385 link.addEventListener('click', (e) => { 386 e.preventDefault(); 387 this.openSoporteModal(); 388 this.close(); 389 }); 390 391 li.appendChild(link); 392 ul.appendChild(li); 393 } 394 395 addRemoteSessionsOption(ul) { 396 const li = document.createElement('li'); 397 li.className = 'topbar-user-menu-item'; 398 399 const link = document.createElement('a'); 400 link.href = '#'; 401 link.className = 'topbar-user-menu-link'; 402 link.textContent = Dict.termcap('CERRAR_SESIONES_REMOTAS'); 403 404 link.addEventListener('click', (e) => { 405 e.preventDefault(); 406 this.finishRemoteSessions(); 407 this.close(); 408 }); 409 410 li.appendChild(link); 411 ul.appendChild(li); 412 } 413 414 addLogoutOption(ul) { 415 const li = document.createElement('li'); 416 li.className = 'topbar-user-menu-item topbar-user-menu-item-logout'; 417 418 const link = document.createElement('a'); 419 link.href = '#'; 420 link.className = 'topbar-user-menu-link'; 421 link.textContent = Dict.termcap('SALIR'); 422 423 link.addEventListener('click', (e) => { 424 e.preventDefault(); 425 this.cerrarSesion(); 426 }); 427 428 li.appendChild(link); 429 ul.appendChild(li); 430 } 431 432 /** 433 * Inicializa el modal de Perfil si no existe 434 */ 435 initPerfilModal() { 436 if (this.modal_window) { 437 return; 438 } 439 440 // Crear iframe para personal.cgi 441 const iframe = document.createElement('iframe'); 442 iframe.style.width = '100%'; 443 iframe.style.height = '100%'; 444 iframe.style.border = '0'; 445 iframe.setAttribute('src', this.url_dir + 'personal.cgi'); 446 447 // Crear modal usando Modal global de Comunes.js 448 this.modal_window = new Modal({ 449 content: iframe, 450 header: { show: false }, 451 id: 'perfil_modal_nav', 452 classes: ['wrapper_size_large', 'perfil_nav'] 453 }); 454 455 // Agregar clase 'iframe' al contenido del modal para remover padding 456 this.modal_window.modalContent.classList.add('iframe'); 457 } 458 459 openPerfilModal() { 460 this.initPerfilModal(); 461 this.modal_window.show(); 462 } 463 464 openQRModal() { 465 this.initQRModal(); 466 this.getQRAccesoTemporal(); 467 } 468 469 openSoporteModal() { 470 // Abrir modal de contacto con administrador 471 if (typeof EduOpen !== 'undefined') { 472 const idCursoParam = this.id_curso ? '&id_curso=' + this.id_curso : ''; 473 EduOpen({ 474 url: this.url_dir + 'mensajeria.cgi?mail_to_admin=1' + idCursoParam, 475 id: 'comunes_mensajeria', 476 exitButtonOnly: true, 477 classname: 'comunes_mensajeria' 478 }); 479 } 480 } 481 482 finishRemoteSessions() { 483 // Cierra sesiones remotas via acceso.cgi finish_remote_sessions 484 const formData = new FormData(); 485 formData.append('wAccion', 'finish_remote_sessions'); 486 487 fetch(this.url_dir + 'qr.cgi', { 488 method: 'POST', 489 body: formData 490 }).then((response) => { 491 if (response.ok) { 492 alert(Dict.termcap('CERRAR_SESIONES_REMOTAS_SUCCESS')); 493 // Recargar el dropdown para remover la opcion 494 this.sesiones_activas = 1; 495 this.renderDropdownContent(); 496 } 497 }).catch((error) => { 498 console.error('Error cerrando sesiones remotas:', error); 499 }); 500 } 501 502 cerrarSesion() { 503 EducativaDraftManager.clearAll(); 504 const id_curso = this.id_curso || 0; 505 window.location.href = this.url_dir + 'acceso.cgi?wAccion=logoff&id_curso=' + id_curso; 506 } 507 508 toggle() { 509 if ( this.in_proctorizer ) return; 510 511 if (this.isOpen) { 512 this.close(); 513 } else { 514 this.open(); 515 } 516 } 517 518 open() { 519 this.dropdown.style.display = 'block'; 520 this.trigger.classList.add('active'); 521 this.isOpen = true; 522 } 523 524 close() { 525 this.dropdown.style.display = 'none'; 526 this.trigger.classList.remove('active'); 527 this.isOpen = false;
528 } 529 530 esUsuarioAnonimo() { 531 return this.username === '_anonimo'; 532 } 533 534 /** 535 * Lee del localStorage la cantidad de aperturas de "Perfil y 536 * preferencias" registradas en este navegador. 537 * @returns {number} 538 */ 539 getPerfilVisitCount() { 540 const stored = parseInt(localStorage.getItem(PERFIL_INDICATOR_KEY), 10); 541 return isNaN(stored) ? 0 : stored; 542 } 543 544 /** 545 * Inyecta el punto verde de novedad sobre el menu de usuario mientras 546 * el usuario no haya abierto "Perfil y preferencias" el numero de veces 547 * definido por PERFIL_INDICATOR_LIMIT (onboarding). 548 */ 549 initPerfilNoveltyIndicator() { 550 if (this.esUsuarioAnonimo()) { 551 return; 552 } 553 554 if (this.getPerfilVisitCount() >= PERFIL_INDICATOR_LIMIT) { 555 return; 556 } 557 558 const indicator = document.createElement('span'); 559 indicator.className = 'topbar-user-indicator'; 560 this.trigger.appendChild(indicator); 561 this.perfilIndicator = indicator; 562 } 563 564 /** 565 * Registra una apertura de "Perfil y preferencias" e incrementa el 566 * contador del localStorage. Al alcanzar el limite remueve los puntos 567 * verdes de la topbar y del item del menu de usuario. 568 */ 569 registerPerfilVisit() { 570 const count = this.getPerfilVisitCount() + 1; 571 localStorage.setItem(PERFIL_INDICATOR_KEY, String(count)); 572 573 if (count < PERFIL_INDICATOR_LIMIT) { 574 return; 575 } 576 577 if (this.perfilIndicator) { 578 this.perfilIndicator.remove(); 579 this.perfilIndicator = null; 580 } 581 582 if (this.perfilMenuIndicator) { 583 this.perfilMenuIndicator.remove(); 584 this.perfilMenuIndicator = null; 585 } 586 } 587 588 /** 589 * Actualiza los datos del usuario (nombre completo y foto de perfil) 590 * Llamado desde personal.cgi cuando el usuario modifica sus datos 591 * @param {Object} data 592 * @param {string} data.nombreCompleto - Nombre completo del usuario 593 * @param {string} data.urlThumb - URL de la foto de perfil 594 */ 595 actualizarDatosUsuario({ nombreCompleto, urlThumb }) { 596 if (nombreCompleto) { 597 this.user_nombre_completo = nombreCompleto; 598 // Actualizar el nombre en el DOM si es necesario 599 const userNameElement = document.querySelector('.topbar-user-name'); 600 if (userNameElement) { 601 userNameElement.textContent = nombreCompleto; 602 } 603 } 604 605 if (urlThumb) { 606 this.url_thumb = urlThumb; 607 // Actualizar la imagen en el DOM si existe 608 const avatarElement = document.querySelector('.topbar-user-avatar'); 609 if (avatarElement) { 610 avatarElement.src = urlThumb; 611 } 612 } 613 } 614 615 // ========================================================================= 616 // Funcionalidad QR para acceso movil 617 // ========================================================================= 618 619 /** 620 * Inicializa el modal de QR si no existe 621 */ 622 initQRModal() { 623 if (this.qrModal) { 624 return; 625 } 626 627 this.qrModal = new ModalBase({ 628 classname: 'qr', 629 close_hdlr: this.closeQRModal.bind(this) 630 }); 631 632 const aclaracion = document.createElement('div'); 633 aclaracion.classList.add('desc'); 634 aclaracion.textContent = Dict.term('QR_SCAN_DESCRIPCION'); 635 this.qrModal.container.appendChild(aclaracion); 636 637 const qrContainer = document.createElement('div'); 638 qrContainer.classList.add('qr_container'); 639 this.qr_container = qrContainer; 640 this.qrModal.container.appendChild(qrContainer); 641 } 642 643 /** 644 * Handler para cerrado de ventana modal de QR 645 */ 646 closeQRModal() { 647 this.showingQR = false; 648 } 649 650 /** 651 * Realiza llamada ajax a qr.cgi para generar un acceso temporal 652 * El link generado sera usado por la app al leer el codigo QR 653 */ 654 getQRAccesoTemporal() { 655 const id_curso_actual = this.id_curso || 0; 656 657 const qr_params = { 658 'id_curso': id_curso_actual, 659 'wAccion': 'get_qr_link', 660 'cgi': this.getQRFwdLink() 661 }; 662 const postParams = Utils.prepareFormData(qr_params); 663 664 fetch(url_dir + 'qr.cgi', { 665 method: "POST", 666 body: postParams 667 }).then(response => { 668 if (!response.ok) { 669 throw new Error(`Request failed: ${response.status}`); 670 } 671 return response.text(); 672 }).then((responseText) => { 673 const json = JSON.parse(responseText); 674 let link; 675 if (json['data'] && json['data']['link']) { 676 link = json['data']['link']; 677 this.temporal_access = json['data']['temporal_access']; 678 } 679 this.showQRCode(link); 680 }).catch((error) => { 681 // Error silently handled 682 }); 683 } 684 685 /** 686 * Link al que se redirigira la app al loguearse mediante QR 687 * @returns {string} URL relativa al CGI actual 688 */ 689 getQRFwdLink() {
690 if (isBackend()) { 691 return 'escritorio.cgi'; 692 } else { 693 let link; 694 const path_array = window.location.pathname.split('/'); 695 const cgi = path_array[path_array.length - 1]; 696 697 if (cgi) { 698 link = cgi + window.location.search; 699 return link; 700 } 701 } 702 } 703 704 /** 705 * Muestra el codigo QR en el modal 706 * @param {string} link - URL a codificar en el QR 707 */ 708 showQRCode(link) { 709 const qrData = document.createElement('div'); 710 qrData.classList.add('qr_data'); 711 const qrOverlay = document.createElement('img'); 712 qrOverlay.classList.add('qr_overlay'); 713 qrData.appendChild(qrOverlay); 714 this.qr_data = qrData; 715 716 this.qr_container.replaceChildren(qrData); 717 718 const qrOk = this.loadQRCode(link); 719 720 if (!qrOk) { 721 return; 722 } 723 724 this.showQRModal(); 725 } 726 727 /** 728 * Genera el codigo QR usando la libreria QRCode.js 729 * @param {string} link - URL a codificar 730 * @returns {boolean} true si se genero correctamente 731 */ 732 loadQRCode(link) { 733 try { 734 this.qrcode = new QRCode(this.qr_data, { 735 text: link, 736 width: 300, 737 height: 300, 738 colorDark: "#000000", 739 colorLight: "#ffffff", 740 correctLevel: QRCode.CorrectLevel.H 741 }); 742 return true; 743 } catch (e) { 744 // Error generating QR code 745 return false; 746 } 747 } 748 749 /** 750 * Muestra el modal con el QR 751 */ 752 showQRModal() { 753 this.showingQR = true; 754 this.qrModal.show(); 755 setTimeout(this.showUpdateQR.bind(this), 60000); 756 setTimeout(this.checkAppLogin.bind(this), 5000); 757 } 758 759 /** 760 * Oculta el modal de QR 761 */ 762 hideQRModal() { 763 this.showingQR = false; 764 this.qrModal.hide(); 765 } 766 767 /** 768 * Handler del timeout de la ventana modal de QR 769 * Muestra boton para refrescar el codigo despues de 60 segundos 770 */ 771 showUpdateQR() { 772 this.showingQR = false; 773 774 const qrUpdate = document.createElement('div'); 775 qrUpdate.classList.add('qr_update'); 776 qrUpdate.title = Dict.termcap('QR_UPDATE_DESCRIPCION'); 777 778 const syncIcon = document.createElement('i'); 779 syncIcon.classList.add('far', 'fa-sync-alt', 'icon'); 780 qrUpdate.appendChild(syncIcon); 781 782 this.qr_update = qrUpdate; 783 784 this.qr_data.appendChild(qrUpdate); 785 this.qr_update.addEventListener('click', this.getQRAccesoTemporal.bind(this)); 786 } 787 788 /** 789 * Verifica periodicamente si el usuario se logueo desde la app 790 * Cierra el modal automaticamente cuando detecta el login 791 */ 792 checkAppLogin() { 793 fetch(url_dir + 'qr.cgi', { 794 method: 'POST', 795 body: Utils.prepareFormData({ 796 'wAccion': 'check_app_login', 797 'temporal_access': this.temporal_access 798 }) 799 }).then(response => { 800 if (!response.ok) { 801 throw new Error("request Failed"); 802 } 803 return response.text(); 804 }).then((responseText) => { 805 const json = JSON.parse(responseText); 806 if (this.showingQR) { 807 if (json['data'] && json['data']['logged']) { 808 this.hideQRModal(); 809 } else { 810 setTimeout(this.checkAppLogin.bind(this), 5000); 811 } 812 } 813 }).catch((e) => { 814 // Error checking app login silently handled 815 }); 816 } 817 818 /** 819 * Establece el breadcrumb extendido en la topbar. 820 * Generico: reutilizable en cualquier contexto. 821 * Los elementos se agregan directamente al nav (sin container), 822 * marcados con data-breadcrumb-ext para poder limpiarlos. 823 * 824 * NOTA: los href de links se asignan directamente al atributo href del <a>. 825 * Deben ser rutas relativas al aula (ej. "?id_curso=123") o absolutas con 826 * protocolo (ej. "https://..."). Sin protocolo, el browser los resuelve 827 * como relativos al path actual del CGI. 828 * 829 * @param {Array<{href: string, title: string, text: string}>} links 830 * @param {string} currentText - Texto nivel actual 831 * @param {string} currentTitle - Title nivel actual 832 */ 833 setTopbarBreadcrumb({ links = [], currentText = '', currentTitle = '' }) { 834 const nav = document.querySelector('.topbar-breadcrumb'); 835 if (!nav) return; 836 837 this.clearTopbarBreadcrumb(); 838 839 links.forEach((link) => { 840 nav.appendChild(this.#createBcSeparator()); 841 842 const anchor = document.createElement('a'); 843 anchor.href = link.href; 844 anchor.title = link.title || ''; 845 anchor.className = 'breadcrumb-item'; 846 anchor.dataset.isBreadcrumbExtend = 'true'; 847 anchor.textContent = link.text; 848 nav.appendChild(anchor); 849 }); 850 851 if (currentText) { 852 var sep = document.createElement('i'); 853 sep.className = 'breadcrumb-separator fas fa-chevron-right'; 854 nav.appendChild(sep); 855 856 const span = document.createElement('span'); 857 span.className = 'breadcrumb-item breadcrumb-current'; 858 span.title = currentTitle || ''; 859 span.textContent = currentText; 860 nav.appendChild(span); 861 } 862 863 // Refresh breadcrumb collapse 864 const bcNav = document.querySelector('.topbar-breadcrumb'); 865 if (bcNav && bcNav._eduBreadcrumb) { 866 bcNav._eduBreadcrumb.refresh(); 867 } 868 } 869 870 /** 871 * Actualiza solo el nivel hoja del breadcrumb extendido existente. 872 * 873 * @param {string} currentText - Nuevo texto 874 * @param {string} currentTitle - Nuevo title 875 */ 876 updateTopbarBreadcrumbLeaf({ currentText = '', currentTitle = '' }) { 877 const current = document.querySelector('.topbar-breadcrumb .breadcrumb-current'); 878 if (!current) return; 879 current.textContent = currentText; 880 current.title = currentTitle; 881 882 // Refresh breadcrumb collapse 883 const bcNav = document.querySelector('.topbar-breadcrumb'); 884 if (bcNav && bcNav._eduBreadcrumb) { 885 bcNav._eduBreadcrumb.refresh(); 886 } 887 } 888 889 /** 890 * Remueve el breadcrumb extendido de la topbar. 891 */ 892 clearTopbarBreadcrumb() { 893 document.querySelectorAll('.topbar-breadcrumb [data-is-breadcrumb-extend="true"]') 894 .forEach((el) => el.remove()); 895 896 // Refresh breadcrumb collapse 897 const bcNav = document.querySelector('.topbar-breadcrumb'); 898 if (bcNav && bcNav._eduBreadcrumb) { 899 bcNav._eduBreadcrumb.refresh(); 900 } 901 } 902 903 /** 904 * Crea un separador chevron para el breadcrumb. 905 * @returns {HTMLElement} 906 */ 907 #createBcSeparator() { 908 const sep = document.createElement('i'); 909 sep.className = 'breadcrumb-separator fas fa-chevron-right'; 910 sep.dataset.isBreadcrumbExtend = 'true'; 911 return sep; 912 } 913} 914 915/** 916 * Clase MensajesNoLeidos - Popup de mensajes no leidos con paginador 917 */ 918export class MensajesNoLeidos { 919 constructor(args) { 920 // Propiedades de instancia 921 this.id_abr = 'MC'; 922 this.id = 'Correo'; 923 this.className = 'mensajes_function'; 924 this.lock = false;
925 926 // Merge args 927 Object.assign(this, args); 928 929 // Calcular milisegundos de hoy para comparaciones de fecha 930 const hoy = new Date(); 931 this.mil_hoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate()).getTime(); 932 933 this.order_option = {}; 934 this.cursos_option = {}; 935 this.title = Dict.termcap('CORREO_NO_LEIDO'); 936 937 // Headers para el paginador 938 this.headers = [ 939 { 940 id: 'fecha', 941 tipo: 'date', 942 sort: 1, 943 nombre: Dict.termcap('FECHA') 944 }, 945 { 946 id: 'asunto', 947 tipo: 'string', 948 sort: 1, 949 nombre: Dict.termcap('ASUNTO') 950 }, 951 { 952 id: 'remitente', 953 tipo: 'string', 954 sort: 1, 955 nombre: Dict.termcap('REMITENTE') 956 }, 957 { 958 id: 'grupo', 959 tipo: 'string', 960 sort: 1, 961 nombre: Dict.termcap('GRUPO') 962 } 963 ]; 964 965 // Inicializar paginador 966 this.pg = new Paginador({ 967 id: 'pg_' + this.id_abr, 968 source_url: url_dir + 'mensajes_no_leidos.cgi', 969 source_params: { 970 wAccion: 'modulo_correo' 971 }, 972 page_size: this.page_size, 973 total_registros: this.total_registros, 974 headers: this.headers, 975 order: this.order, 976 order_asc: this.order_asc, 977 filter: this.filter, 978 keywords: this.keywords 979 }); 980 981 // Observers del paginador 982 this.pg.observe('beforeUpdate', this.onBeforeUpdatePaginado.bind(this)); 983 this.pg.observe('update', this.onUpdatePaginado.bind(this)); 984 985 // Crear estructura HTML 986 this.createMenuBox(); 987 988 // Limpiar el container (remueve el spinner) 989 this.container.innerHTML = ''; 990 this.container.appendChild(this.box); 991 992 // Layout basico 993 this.correos_container = document.createElement('div'); 994 this.box_body.appendChild(this.correos_container); 995 996 this.paginador_container = document.createElement('div'); 997 this.box_footer.appendChild(this.paginador_container); 998 999 // Renderizar paginador 1000 this.renderPaginador(); 1001 1002 // Cargar primera pagina 1003 this.pg.setPagina({ 1004 total_registros: this.total_registros, 1005 page: 1, 1006 order: this.order, 1007 order_asc: this.order_asc, 1008 filter: this.filter, 1009 keywords: this.keywords 1010 }, this.items); 1011 } 1012 1013 /** 1014 * Helper para capitalizar primera letra 1015 */ 1016 capitalize(str) { 1017 if (!str) return ''; 1018 return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); 1019 } 1020 1021 /** 1022 * Helper para escapar HTML 1023 */ 1024 escapeHTML(str) { 1025 const div = document.createElement('div'); 1026 div.textContent = str; 1027 return div.innerHTML; 1028 } 1029 1030 /** 1031 * Callback antes de actualizar paginado (muestra spinner) 1032 */ 1033 onBeforeUpdatePaginado() { 1034 this.lock = true; 1035 if (!this.box_body.classList.contains('el_loading')) { 1036 this.box_body.classList.add('el_loading'); 1037 } 1038 } 1039 1040 /** 1041 * Callback despues de actualizar paginado 1042 */ 1043 onUpdatePaginado() { 1044 if (this.box_body.classList.contains('el_loading')) { 1045 this.box_body.classList.remove('el_loading'); 1046 } 1047 this.items = this.pg.getData(); 1048 1049 if (this.items.length === 0) { 1050 this.sinCorreos(); 1051 this.lock = false; 1052 return; 1053 } 1054 1055 this.updateMainBody(); 1056 this.lock = false; 1057 } 1058 1059 /** 1060 * Muestra mensaje cuando no hay correos 1061 */ 1062 sinCorreos() { 1063 const mensajeVacio = document.createElement('div'); 1064 mensajeVacio.className = 'seccion_vacia'; 1065 mensajeVacio.textContent = Dict.termcap('NO_EXISTEN_MENSAJES_NO_LEIDOS'); 1066 this.correos_container.innerHTML = ''; 1067 this.correos_container.appendChild(mensajeVacio); 1068 } 1069 1070 /** 1071 * Actualiza el body principal con la lista de mensajes 1072 */ 1073 updateMainBody() { 1074 const ul = document.createElement('ul'); 1075 ul.className = 'lista_mensajes'; 1076 1077 for (let i = 0; i < this.items.length; i++) { 1078 const item = this.items[i]; 1079 const fecha = Utils.parseDate(item.fecha); 1080 const textoFecha = this.capitalize(this.formatDate(fecha)); 1081 1082 const li = document.createElement('li'); 1083 1084 const link = document.createElement('a'); 1085 link.href = url_dir + 'webmail.cgi?wAccion=VerEmail&wIdBandeja=Inbox&wIdEmail=' + 1086 item.id + '&id_curso=' + item.id_curso; 1087 1088 // Subject con adjunto opcional 1089 const subjectDiv = document.createElement('div'); 1090 subjectDiv.className = 'subject linea_unica'; 1091 1092 if (item.adjunto) { 1093 const adjuntoIcon = document.createElement('i'); 1094 adjuntoIcon.className = 'fal fa-paperclip adjunto'; 1095 subjectDiv.appendChild(adjuntoIcon); 1096 } 1097 1098 const subjectText = document.createTextNode(item.asunto); 1099 subjectDiv.appendChild(subjectText); 1100 link.appendChild(subjectDiv); 1101 1102 // From + Aula 1103 const fromDiv = document.createElement('div'); 1104 fromDiv.className = 'linea_unica'; 1105 1106 const fromSpan = document.createElement('span'); 1107 fromSpan.className = 'from'; 1108 fromSpan.textContent = item.nombre; 1109 fromDiv.appendChild(fromSpan); 1110 1111 const aulaSpan = document.createElement('span'); 1112 aulaSpan.className = 'aula'; 1113 aulaSpan.innerHTML = ' - ' + this.escapeHTML(item.nombre_curso); 1114 fromDiv.appendChild(aulaSpan); 1115 link.appendChild(fromDiv); 1116 1117 // Fecha 1118 const dateDiv = document.createElement('div'); 1119 dateDiv.className = 'date linea_unica'; 1120 dateDiv.textContent = textoFecha; 1121 link.appendChild(dateDiv); 1122 1123 li.appendChild(link); 1124 ul.appendChild(li); 1125 } 1126 1127 this.correos_container.innerHTML = ''; 1128 this.correos_container.appendChild(ul); 1129 } 1130 1131 /**
1132 * Renderiza los controles del paginador 1133 */ 1134 renderPaginador() { 1135 this.pg_view = new PaginadorVista({ 1136 paginador: this.pg, 1137 target: this.id + '-pg', 1138 resumen_pie_txt: Dict.term('CANT_MENSAJES'), 1139 resumen_pie_txt_singular: Dict.term('CANT_MENSAJE') 1140 }); 1141 1142 // Limpiar contenedor 1143 this.paginador_container.innerHTML = ''; 1144 1145 const control = this.pg_view.renderPageControl('reducido'); 1146 if (control) { 1147 this.paginador_container.appendChild(control); 1148 } 1149 } 1150 1151 /** 1152 * Crea la estructura HTML del menu box 1153 */ 1154 createMenuBox() { 1155 // Header 1156 this.box_header = document.createElement('header'); 1157 this.box_header.className = 'mensajes_header'; 1158 1159 const h2 = document.createElement('h2'); 1160 h2.className = 'mensajes_titulo'; 1161 h2.textContent = this.title; 1162 this.box_header.appendChild(h2); 1163 1164 // Boton cerrar 1165 this.cerrar = document.createElement('button'); 1166 this.cerrar.type = 'button'; 1167 this.cerrar.className = 'cerrar_bt'; 1168 1169 const cerrarIcon = document.createElement('i'); 1170 cerrarIcon.className = 'cerrar fas fa-times'; 1171 this.cerrar.appendChild(cerrarIcon); 1172 1173 this.cerrar.addEventListener('click', (e) => { 1174 if (this.fbar && this.fbar.hideMensajeNoLeidos) { 1175 this.fbar.hideMensajeNoLeidos(e); 1176 } 1177 }); 1178 1179 this.box_header.appendChild(this.cerrar); 1180 1181 // Body 1182 this.box_body = document.createElement('div'); 1183 this.box_body.className = 'body'; 1184 1185 // Footer 1186 this.box_footer = document.createElement('footer'); 1187 1188 // Box principal 1189 this.box = document.createElement('section'); 1190 this.box.className = this.className; 1191 this.box.id = this.id; 1192 this.box.appendChild(this.box_header); 1193 this.box.appendChild(this.box_body); 1194 this.box.appendChild(this.box_footer); 1195 } 1196 1197 /** 1198 * Formatea una fecha de forma amigable 1199 */ 1200 formatDate(fecha) { 1201 const milFecha = new Date(fecha.getFullYear(), fecha.getMonth(), fecha.getDate()).getTime(); 1202 const dif = (this.mil_hoy - milFecha) / (1000 * 60 * 60 * 24); 1203 const showAnio = new Date().getFullYear() !== fecha.getFullYear(); 1204 1205 if (dif < 1) { 1206 return Dict.termlc('HOY') + ', ' + Utils.dateFormatTime(fecha) + ' hs'; 1207 } else if (dif >= 1 && dif < 2) { 1208 return Dict.term('AYER') + ', ' + Utils.dateFormatTime(fecha) + ' hs'; 1209 } else { 1210 let termino = ''; 1211 const values = { 1212 dia: fecha.getDate(), 1213 mes: Utils.dateFormatMonthName(fecha), 1214 hora: Utils.dateFormatTime(fecha) 1215 }; 1216 1217 if (showAnio) { 1218 termino = 'DIA_DE_MES_DE_ANIO_HORA'; 1219 values.anio = fecha.getFullYear(); 1220 } else { 1221 termino = 'DIA_DE_MES_HORA'; 1222 } 1223 1224 return Dict.interpolate(Dict.term(termino), values); 1225 } 1226 } 1227} 1228 1229if (typeof window !== 'undefined') { 1230 if (!window.Educativa) window.Educativa = {}; 1231 if (!window.Educativa.Aula) window.Educativa.Aula = {}; 1232 window.Educativa.Aula.MensajesNoLeidos = MensajesNoLeidos; 1233}
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.