feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia

This commit is contained in:
2026-09-18 23:04:48 -03:00
parent 47b50ede7e
commit 1080acd43a
240 changed files with 8354 additions and 8886 deletions
+491
View File
@@ -0,0 +1,491 @@
class GeneticAlgorithmManager {
constructor() {
this.apiBaseUrl = '/api/genetic';
this.setupEventListeners();
}
setupEventListeners() {
// Initialize any UI elements if needed
document.addEventListener('DOMContentLoaded', () => {
this.initializeUI();
});
}
initializeUI() {
// Set up event listeners for genetic algorithm buttons
const optimizeBtn = document.getElementById('optimize-assignments-btn');
const previewBtn = document.getElementById('preview-optimization-btn');
const applyBtn = document.getElementById('apply-optimization-btn');
if (optimizeBtn) {
optimizeBtn.addEventListener('click', () => this.handleOptimizeReservations());
}
if (previewBtn) {
previewBtn.addEventListener('click', () => this.handlePreviewOptimization());
}
if (applyBtn) {
applyBtn.addEventListener('click', () => this.handleApplyOptimization());
}
}
async optimizeReservations(commissionIds, startDate = null, endDate = null) {
try {
const requestBody = {
commission_ids: commissionIds
};
if (startDate) {
requestBody.start_date = startDate;
}
if (endDate) {
requestBody.end_date = endDate;
}
const response = await fetch(`${this.apiBaseUrl}/optimize`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCSRFToken()
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Optimization failed');
}
return await response.json();
} catch (error) {
console.error('Error optimizing reservations:', error);
throw error;
}
}
async previewOptimization(commissionIds) {
try {
const response = await fetch(`${this.apiBaseUrl}/preview-optimization`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCSRFToken()
},
body: JSON.stringify({
commission_ids: commissionIds,
dry_run: true
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Preview failed');
}
return await response.json();
} catch (error) {
console.error('Error previewing optimization:', error);
throw error;
}
}
async applyOptimization(reservations) {
try {
const response = await fetch(`${this.apiBaseUrl}/apply-optimization`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCSRFToken()
},
body: JSON.stringify({
reservations: reservations
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to apply optimization');
}
return await response.json();
} catch (error) {
console.error('Error applying optimization:', error);
throw error;
}
}
async getAlgorithmStatus() {
try {
const response = await fetch(`${this.apiBaseUrl}/algorithm-status`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCSRFToken()
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to get algorithm status');
}
return await response.json();
} catch (error) {
console.error('Error getting algorithm status:', error);
throw error;
}
}
async handleOptimizeReservations() {
try {
// Get selected commissions from UI
const commissionIds = this.getSelectedCommissionIds();
if (commissionIds.length === 0) {
this.showAlert('Please select at least one commission to optimize.', 'warning');
return;
}
this.showLoading(true);
const result = await this.optimizeReservations(commissionIds);
if (result.success) {
this.displayOptimizationResults(result.data);
this.showAlert('Optimization completed successfully!', 'success');
} else {
this.showAlert('Optimization failed: ' + (result.error || 'Unknown error'), 'danger');
}
} catch (error) {
this.showAlert('Error during optimization: ' + error.message, 'danger');
} finally {
this.showLoading(false);
}
}
async handlePreviewOptimization() {
try {
const commissionIds = this.getSelectedCommissionIds();
if (commissionIds.length === 0) {
this.showAlert('Please select at least one commission to preview.', 'warning');
return;
}
this.showLoading(true);
const result = await this.previewOptimization(commissionIds);
if (result.success) {
this.displayPreviewResults(result.data);
this.showAlert('Preview generated successfully!', 'success');
} else {
this.showAlert('Preview failed: ' + (result.error || 'Unknown error'), 'danger');
}
} catch (error) {
this.showAlert('Error during preview: ' + error.message, 'danger');
} finally {
this.showLoading(false);
}
}
async handleApplyOptimization() {
try {
const pendingReservations = this.getPendingReservations();
if (!pendingReservations || pendingReservations.length === 0) {
this.showAlert('No pending reservations to apply.', 'warning');
return;
}
if (!confirm('Are you sure you want to apply these reservations? This will create actual reservation records.')) {
return;
}
this.showLoading(true);
const result = await this.applyOptimization(pendingReservations);
if (result.success) {
this.showAlert(`Successfully applied ${result.data.applied_count} reservations!`, 'success');
this.clearPendingReservations();
// Redirect to reservations page
window.location.href = '/schedule';
} else {
this.showAlert('Failed to apply reservations: ' + (result.error || 'Unknown error'), 'danger');
}
} catch (error) {
this.showAlert('Error applying reservations: ' + error.message, 'danger');
} finally {
this.showLoading(false);
}
}
getSelectedCommissionIds() {
const checkboxes = document.querySelectorAll('input[name="commission_ids"]:checked');
return Array.from(checkboxes).map(cb => parseInt(cb.value));
}
getPendingReservations() {
// Try to get from localStorage or a hidden form field
const stored = localStorage.getItem('pending_optimizations');
return stored ? JSON.parse(stored) : null;
}
storePendingReservations(reservations) {
localStorage.setItem('pending_optimizations', JSON.stringify(reservations));
}
clearPendingReservations() {
localStorage.removeItem('pending_optimizations');
}
displayOptimizationResults(data) {
// Store the results for potential application
this.storePendingReservations(data.reservations);
// Create results display
const resultsContainer = document.getElementById('optimization-results');
if (!resultsContainer) return;
const scorePercentage = (data.fitness_score * 100).toFixed(1);
resultsContainer.innerHTML = `
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="mb-0">Optimization Results</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="text-center">
<h4 class="text-primary">${data.assigned_reservations}</h4>
<p class="mb-0">Reservations Assigned</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<h4 class="text-info">${scorePercentage}%</h4>
<p class="mb-0">Fitness Score</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<h4 class="text-warning">${data.total_requests}</h4>
<p class="mb-0">Total Requests</p>
</div>
</div>
</div>
${data.conflicts.length > 0 ? `
<div class="alert alert-warning">
<strong>Warning:</strong> ${data.conflicts.length} potential conflicts detected.
</div>
` : ''}
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="geneticManager.viewReservationDetails()">
View Details
</button>
<button class="btn btn-success" onclick="geneticManager.handleApplyOptimization()">
Apply Reservations
</button>
<button class="btn btn-secondary" onclick="geneticManager.clearResults()">
Clear
</button>
</div>
</div>
</div>
`;
}
displayPreviewResults(data) {
const previewContainer = document.getElementById('preview-results');
if (!previewContainer) return;
const commissionsHtml = data.commission_details.map(commission => `
<tr>
<td>${commission.code}</td>
<td>${commission.name}</td>
<td>${commission.current_students}/${commission.max_students}</td>
</tr>
`).join('');
const optimization = data.optimization_result;
previewContainer.innerHTML = `
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="mb-0">Optimization Preview</h5>
</div>
<div class="card-body">
<div class="row mb-4">
<div class="col-md-6">
<h6>Commissions to Optimize</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Students</th>
</tr>
</thead>
<tbody>
${commissionsHtml}
</tbody>
</table>
</div>
</div>
<div class="col-md-6">
<h6>Expected Results</h6>
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between">
<span>Reservations Created:</span>
<strong>${optimization.assigned_reservations}</strong>
</li>
<li class="list-group-item d-flex justify-content-between">
<span>Fitness Score:</span>
<strong>${(optimization.fitness_score * 100).toFixed(1)}%</strong>
</li>
<li class="list-group-item d-flex justify-content-between">
<span>Conflicts:</span>
<strong class="${optimization.conflicts.length > 0 ? 'text-danger' : 'text-success'}">
${optimization.conflicts.length}
</strong>
</li>
</ul>
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="geneticManager.handleOptimizeReservations()">
Run Full Optimization
</button>
<button class="btn btn-secondary" onclick="geneticManager.clearPreview()">
Close Preview
</button>
</div>
</div>
</div>
`;
}
viewReservationDetails() {
const reservations = this.getPendingReservations();
if (!reservations || reservations.length === 0) {
this.showAlert('No reservation details available.', 'info');
return;
}
// Create modal or redirect to detailed view
const detailsHtml = reservations.map((res, index) => `
<tr>
<td>${index + 1}</td>
<td>${res.purpose}</td>
<td>Classroom ${res.classroom_id}</td>
<td>${new Date(res.start_time).toLocaleString()}</td>
<td>${new Date(res.end_time).toLocaleString()}</td>
<td>${res.expected_attendees}</td>
</tr>
`).join('');
const modal = document.getElementById('detailsModal');
if (modal) {
modal.querySelector('.modal-body').innerHTML = `
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>#</th>
<th>Purpose</th>
<th>Classroom</th>
<th>Start Time</th>
<th>End Time</th>
<th>Attendees</th>
</tr>
</thead>
<tbody>
${detailsHtml}
</tbody>
</table>
</div>
`;
const bootstrapModal = new bootstrap.Modal(modal);
bootstrapModal.show();
}
}
clearResults() {
const resultsContainer = document.getElementById('optimization-results');
if (resultsContainer) {
resultsContainer.innerHTML = '';
}
this.clearPendingReservations();
}
clearPreview() {
const previewContainer = document.getElementById('preview-results');
if (previewContainer) {
previewContainer.innerHTML = '';
}
}
showLoading(show) {
const loadingSpinner = document.getElementById('loading-spinner');
if (loadingSpinner) {
loadingSpinner.style.display = show ? 'block' : 'none';
}
}
showAlert(message, type = 'info') {
// Create and show alert
const alertContainer = document.getElementById('alert-container');
if (!alertContainer) return;
const alertHtml = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
alertContainer.insertAdjacentHTML('beforeend', alertHtml);
// Auto-dismiss after 5 seconds
setTimeout(() => {
const alert = alertContainer.lastElementChild;
if (alert) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}
}, 5000);
}
getCSRFToken() {
// Get CSRF token from meta tag or cookie
const metaTag = document.querySelector('meta[name="csrf-token"]');
if (metaTag) {
return metaTag.getAttribute('content');
}
// Fallback to cookie
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'csrf_token') {
return decodeURIComponent(value);
}
}
return '';
}
}
// Initialize the genetic algorithm manager
const geneticManager = new GeneticAlgorithmManager();
// Export for global access
window.geneticManager = geneticManager;
+303
View File
@@ -0,0 +1,303 @@
// Main JavaScript file for Edu-Space Admin
$(document).ready(function() {
// Initialize tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Initialize popovers
var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
var popoverList = popoverTriggerList.map(function (popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl);
});
// Auto-hide alerts after 5 seconds
setTimeout(function() {
$('.alert.fade').alert('close');
}, 5000);
// Confirm delete actions
$('.delete-confirm').on('click', function(e) {
if (!confirm('Are you sure you want to delete this item? This action cannot be undone.')) {
e.preventDefault();
return false;
}
});
// Form validation enhancements
$('form').on('submit', function() {
var form = $(this);
var submitBtn = form.find('button[type="submit"]');
// Show loading state
submitBtn.prop('disabled', true);
var originalText = submitBtn.html();
submitBtn.html('<i class="bi bi-hourglass-split me-2"></i>Loading...');
// Re-enable after 10 seconds in case of error
setTimeout(function() {
submitBtn.prop('disabled', false);
submitBtn.html(originalText);
}, 10000);
});
// Copy to clipboard functionality
$('.copy-to-clipboard').on('click', function() {
var text = $(this).data('text');
if (!text) {
text = $(this).text();
}
navigator.clipboard.writeText(text).then(function() {
showToast('Copied to clipboard!', 'success');
}).catch(function() {
showToast('Failed to copy to clipboard', 'error');
});
});
// Quick search functionality
$('#quickSearch').on('input', function() {
var searchTerm = $(this).val().toLowerCase();
$('.searchable-item').each(function() {
var item = $(this);
var text = item.text().toLowerCase();
if (text.includes(searchTerm)) {
item.show();
} else {
item.hide();
}
});
});
// Initialize datepickers
$('.datetimepicker').each(function() {
$(this).flatpickr({
enableTime: true,
dateFormat: "Y-m-d H:i",
time_24hr: true,
minDate: "today"
});
});
$('.datepicker').each(function() {
$(this).flatpickr({
dateFormat: "Y-m-d",
minDate: "today"
});
});
// Auto-save functionality for multi-step forms
$('.auto-save').on('change input', function() {
var form = $(this).closest('form');
var formData = form.serialize();
// Save to localStorage
localStorage.setItem('autosave_' + form.attr('id'), formData);
showToast('Draft saved automatically', 'info');
});
// Restore auto-saved data
$('form.auto-save-restore').each(function() {
var form = $(this);
var savedData = localStorage.getItem('autosave_' + form.attr('id'));
if (savedData) {
// Show restore prompt
if (confirm('Found unsaved changes. Would you like to restore them?')) {
var params = new URLSearchParams(savedData);
params.forEach(function(value, key) {
form.find('[name="' + key + '"]').val(value);
});
showToast('Draft restored', 'info');
}
}
});
// Print functionality
$('.btn-print').on('click', function() {
window.print();
});
// Export functionality
$('.btn-export').on('click', function() {
var format = $(this).data('format');
var url = $(this).data('url');
if (format === 'excel') {
window.open(url + '?export=excel', '_blank');
} else if (format === 'pdf') {
window.open(url + '?export=pdf', '_blank');
} else if (format === 'csv') {
window.open(url + '?export=csv', '_blank');
}
});
// Keyboard shortcuts
$(document).on('keydown', function(e) {
// Ctrl/Cmd + K for search
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
$('#quickSearch').focus();
}
// Escape to close modals
if (e.key === 'Escape') {
$('.modal.show').modal('hide');
}
});
// Initialize tooltips dynamically for AI-generated content
initializeDynamicTooltips();
});
// Utility functions
function showToast(message, type = 'info') {
var toastHtml = `
<div class="toast align-items-center text-white bg-${type === 'error' ? 'danger' : type} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
var toastContainer = $('.toast-container');
var toastElement = $(toastHtml);
toastContainer.append(toastElement);
var toast = new bootstrap.Toast(toastElement[0]);
toast.show();
// Remove toast element after it's hidden
toastElement.on('hidden.bs.toast', function() {
$(this).remove();
});
}
function confirmAction(message, callback) {
if (confirm(message)) {
callback();
}
}
function formatDateTime(dateTimeString) {
var date = new Date(dateTimeString);
return date.toLocaleString();
}
function formatDate(dateString) {
var date = new Date(dateString);
return date.toLocaleDateString();
}
function formatTime(timeString) {
var date = new Date(timeString);
return date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
function initializeDynamicTooltips() {
// Initialize tooltips for dynamically generated content
$('[data-bs-toggle="tooltip"]:not([aria-describedby])').each(function() {
new bootstrap.Tooltip(this);
});
}
// API Helper functions
function apiCall(url, method = 'GET', data = null) {
return $.ajax({
url: url,
method: method,
data: data,
headers: {
'X-CSRFToken': csrfToken || ''
}
});
}
function handleApiError(xhr) {
if (xhr.responseJSON && xhr.responseJSON.message) {
showToast(xhr.responseJSON.message, 'error');
} else {
showToast('An error occurred. Please try again.', 'error');
}
}
// Form validation helpers
function validateForm(form) {
var isValid = true;
var errors = [];
form.find('[required]').each(function() {
var field = $(this);
if (!field.val()) {
isValid = false;
errors.push(field.attr('data-label') || field.attr('name'));
field.addClass('is-invalid');
} else {
field.removeClass('is-invalid');
}
});
if (!isValid) {
showToast('Please fill in the required fields: ' + errors.join(', '), 'error');
}
return isValid;
}
// Image preview functionality
function previewImage(input, previewElement) {
if (input.files && input.files[0]) {
var reader = new FileReader();
reader.onload = function(e) {
previewElement.attr('src', e.target.result);
};
reader.readAsDataURL(input.files[0]);
}
}
// Export to CSV function
function exportToCSV(data, filename) {
var csv = '';
var headers = Object.keys(data[0]);
// Add headers
csv += headers.join(',') + '\n';
// Add data
data.forEach(function(row) {
headers.forEach(function(header, index) {
if (index > 0) csv += ',';
csv += '"' + (row[header] || '') + '"';
});
csv += '\n';
});
// Create download link
var hiddenElement = document.createElement('a');
hiddenElement.href = 'data:text/csv;charset=utf-8,' + encodeURI(csv);
hiddenElement.target = '_blank';
hiddenElement.download = filename + '.csv';
hiddenElement.click();
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Theme Toggle Manager - Edu-Space Admin
* Manejo de Modo Claro y Modo Oscuro con persistencia en localStorage y anti-flickering.
*/
(function() {
'use strict';
// 1. Obtener tema almacenado o preferencia del sistema
function getPreferredTheme() {
const storedTheme = localStorage.getItem('theme');
if (storedTheme) {
return storedTheme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// 2. Aplicar tema al elemento <html>
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
updateToggleButtons(theme);
}
// 3. Actualizar iconos de los botones de toggle
function updateToggleButtons(theme) {
const buttons = document.querySelectorAll('.theme-toggle-btn');
buttons.forEach(btn => {
const icon = btn.querySelector('i');
if (icon) {
if (theme === 'dark') {
icon.className = 'bi bi-sun-fill';
btn.setAttribute('aria-label', 'Cambiar a modo claro');
btn.setAttribute('title', 'Modo Claro');
} else {
icon.className = 'bi bi-moon-stars-fill';
btn.setAttribute('aria-label', 'Cambiar a modo oscuro');
btn.setAttribute('title', 'Modo Oscuro');
}
}
});
}
// 4. Función global para alternar tema
window.toggleTheme = function() {
const currentTheme = document.documentElement.getAttribute('data-bs-theme') || getPreferredTheme();
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', newTheme);
applyTheme(newTheme);
// Si el usuario está autenticado, sincronizar asíncronamente con su perfil en el backend
if (document.body && document.body.getAttribute('data-user-authenticated') === 'true') {
fetch('/api/user/theme', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ theme: newTheme })
}).catch(err => {
console.warn('No se pudo sincronizar el tema con el servidor:', err);
});
}
};
// 5. Inicializar tema inmediatamente (anti-flicker)
const initialTheme = getPreferredTheme();
applyTheme(initialTheme);
// 6. Al cargar el DOM, registrar eventos en los botones de toggle
document.addEventListener('DOMContentLoaded', () => {
updateToggleButtons(document.documentElement.getAttribute('data-bs-theme') || initialTheme);
document.querySelectorAll('.theme-toggle-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
window.toggleTheme();
});
});
});
// 7. Escuchar cambios de preferencia del sistema si el usuario no fijó una manual
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
})();
+139
View File
@@ -0,0 +1,139 @@
/**
* ViewSwitcher — Gestor Universal y Extensible de Modos de Visualización
*
* Permite alternar instantáneamente entre vista de Tabla (grilla densa)
* y vista de Tarjetas (mosaico responsive), preservando filtros, búsquedas
* y persistiendo la preferencia del usuario en localStorage.
*/
(function () {
'use strict';
class ViewSwitcherManager {
constructor() {
this.init();
}
init() {
document.addEventListener('DOMContentLoaded', () => {
this.setupViewSwitchers();
});
}
setupViewSwitchers() {
const switchers = document.querySelectorAll('.view-switcher-group');
if (!switchers.length) return;
switchers.forEach(group => {
const context = group.getAttribute('data-context') || 'default';
const defaultView = group.getAttribute('data-default-view') || 'cards';
// Determinar vista inicial:
// 1. URL search param (?display=...)
// 2. localStorage
// 3. defaultView del contenedor
const urlParams = new URLSearchParams(window.location.search);
const urlView = urlParams.get('display');
const storedView = localStorage.getItem(`view_preference_${context}`);
const activeView = urlView || storedView || defaultView;
this.applyView(context, activeView, false);
// Asignar listeners a los botones del selector
const buttons = group.querySelectorAll('.view-switcher-btn');
buttons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const targetView = btn.getAttribute('data-view');
if (targetView) {
this.applyView(context, targetView, true);
}
});
});
});
}
/**
* Aplica la vista seleccionada en el contexto dado.
* @param {string} context - Identificador del módulo (ej: 'classrooms', 'reservations')
* @param {string} viewName - Nombre de la vista ('table', 'cards', etc.)
* @param {boolean} persist - Si debe persistirse en localStorage y actualizar URL
*/
applyView(context, viewName, persist = true) {
// 1. Actualizar botones activos en el grupo selector
const group = document.querySelector(`.view-switcher-group[data-context="${context}"]`);
if (group) {
const buttons = group.querySelectorAll('.view-switcher-btn');
buttons.forEach(btn => {
const btnView = btn.getAttribute('data-view');
if (btnView === viewName) {
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-primary', 'active');
btn.setAttribute('aria-pressed', 'true');
} else {
btn.classList.remove('btn-primary', 'active');
btn.classList.add('btn-outline-secondary');
btn.setAttribute('aria-pressed', 'false');
}
});
}
// 2. Conmutar visibilidad de los contenedores de vista
const containers = document.querySelectorAll(`[data-view-context="${context}"]`);
let matched = false;
containers.forEach(container => {
const containerView = container.getAttribute('data-view-container');
if (containerView === viewName) {
container.classList.remove('d-none');
container.removeAttribute('aria-hidden');
matched = true;
} else {
container.classList.add('d-none');
container.setAttribute('aria-hidden', 'true');
}
});
// Fallback: si la vista solicitada no tiene contenedor, restaurar a la primera visible
if (!matched && containers.length > 0) {
containers[0].classList.remove('d-none');
viewName = containers[0].getAttribute('data-view-container') || 'cards';
}
// 3. Persistir preferencia y actualizar formularios y URL
if (persist) {
try {
localStorage.setItem(`view_preference_${context}`, viewName);
} catch (e) {
console.warn('localStorage no disponible para guardar preferencia de vista:', e);
}
// Actualizar parámetro display en la URL sin recargar
const url = new URL(window.location.href);
url.searchParams.set('display', viewName);
window.history.replaceState({}, '', url);
}
// 4. Sincronizar campo hidden 'display' en formularios de búsqueda/filtro
const forms = document.querySelectorAll('form');
forms.forEach(form => {
let hiddenInput = form.querySelector('input[name="display"]');
if (!hiddenInput) {
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'display';
form.appendChild(hiddenInput);
}
hiddenInput.value = viewName;
});
// 5. Emitir evento personalizado para componentes dinámicos
window.dispatchEvent(new CustomEvent('viewModeChanged', {
detail: { context, view: viewName }
}));
}
}
// Instanciar singleton
window.ViewSwitcher = new ViewSwitcherManager();
})();