diff --git a/CHANGELOG_SESSION.md b/CHANGELOG_SESSION.md index d393a30..4c76f46 100644 --- a/CHANGELOG_SESSION.md +++ b/CHANGELOG_SESSION.md @@ -471,6 +471,23 @@ * **Batería de Pruebas Automatizadas:** * Creación de tests unitarios y de integración en `tests/test_services_and_dto.py`, `tests/test_jwt_and_api.py` y `tests/test_security_chain.py` (18/18 pruebas aprobadas). +--- + +## 28. Selector de Vista Universal: Tabla / Grilla vs. Tarjetas / Mosaico (2026-09-05) +* **Arquitectura Extensible de Visualización (`app/static/js/view_switcher.js` y `app/templates/partials/view_switcher.html`):** + * Desarrollo del módulo `ViewSwitcherManager` que desacopla la lógica de presentación de los registros. Permite añadir futuras vistas (ej. kanban, timeline, compacta) mediante atributos de datos `data-view` y `data-view-container` sin tocar el backend. + * **Conmutación Inmediata:** Cambio instantáneo de vista en el cliente sin recarga de página. + * **Persistencia de Preferencias:** Almacenamiento local mediante `localStorage` (`view_preference_{context}`) para conservar la elección del usuario entre sesiones. + * **Preservación de Estado y Filtros:** Inyección reactiva del parámetro `display` en formularios de búsqueda/filtro y actualización silenciosa de la URL (`window.history.replaceState`), garantizando que búsquedas, ordenamientos y paginaciones conserven la vista activa. +* **Integración en Módulo de Aulas y Espacios (`app/templates/classrooms/list.html`):** + * **Vista de Tabla / Grilla:** Columnas detalladas (Código, Edificio/Sede, Piso, Capacidad, Equipamiento, Ocupación D/S/M, Estado y Acciones). + * **Vista de Tarjetas / Mosaico:** Cuadrícula responsive (1 a 4 columnas) con badges semánticos, indicadores de piso y barras de porcentaje de uso. +* **Integración en Módulo de Reservas y Cronograma (`app/templates/schedule/list.html`):** + * **Vista de Tabla:** Análisis denso de reservas con estado, comisión, horario y aula. + * **Vista de Tarjetas:** Fichas visuales con badges destacados de fecha/hora, materia, turno, docente a cargo y enlaces a aulas virtuales. +* **Pruebas y Verificación:** + * Incorporación de tests en `tests/test_view_switcher.py`, validando renderizado de selectores y contenedores en ambas rutas (20/20 pruebas aprobadas). + diff --git a/README.md b/README.md index 0fcec62..2335009 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,21 @@ El menú desplegable **Gestión** en la barra de navegación organiza las áreas * **Tipificaciones de Hitos:** Catálogo de tipos de hitos de evaluación académica. * **Sincronización Sheets:** Integración con Google Sheets para actualización en lote. +### 🔀 9. Selector de Vista Universal (Tabla / Grilla vs. Tarjetas / Mosaico) +La plataforma implementa una arquitectura desacoplada de visualización (`app/static/js/view_switcher.js` y `app/templates/partials/view_switcher.html`) que permite al usuario alternar instantáneamente entre diferentes formatos de presentación sin modificar los datos ni perder los filtros o búsquedas activas: +* **Vista de Tabla / Grilla (`▤ Tabla`):** + * Presentación tabular en filas y columnas de alta densidad informativa. + * Muestra múltiples atributos simultáneamente (edificio, piso, aforo, métricas de ocupación, estado, acciones). + * Ideal para consulta y comparación masiva de registros. +* **Vista de Tarjetas / Mosaico (`▦ Tarjetas`):** + * Presentación visual en tarjetas independientes distribuidas automáticamente según el ancho de pantalla (1 a 4 columnas). + * Enfoque en la información destacada con badges semánticos, barras de uso y botones de acceso rápido. +* **Características del Selector:** + * **Cambio Inmediato:** Conmutación instantánea en el cliente sin recarga de página (`d-none` / manipulación de DOM). + * **Persistencia de Preferencia:** Memorización automática mediante `localStorage` para conservar la elección del usuario en futuras visitas. + * **Preservación de Filtros:** Inyección automática del parámetro `display` en formularios y sincronización de URL sin recarga (`history.replaceState`). + * **Arquitectura Extensible:** Permite registrar futuras vistas (kanban, calendario, compacta) mediante atributos `data-view` y `data-view-container` sin alterar la lógica de negocio. + --- ## 🛠️ Stack Tecnológico diff --git a/app/static/css/style.css b/app/static/css/style.css index bf38817..7952e30 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -174,4 +174,36 @@ background-color: #FFFFFF !important; color: #000000 !important; } +} + +/* View Switcher Controls */ +.view-switcher-group { + flex-direction: row !important; + display: inline-flex !important; +} + +.view-switcher-group .btn { + margin-bottom: 0 !important; + font-weight: 600; + font-size: 0.82rem; + padding: 0.35rem 0.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease-in-out; +} + +.view-switcher-group .btn.active { + box-shadow: 0 2px 6px rgba(var(--brand-primary-rgb), 0.25); +} + +.reservation-card { + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; + border-radius: 0.75rem; +} + +.reservation-card:hover { + transform: translateY(-2px); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important; + border-color: var(--bs-primary) !important; } \ No newline at end of file diff --git a/app/static/js/view_switcher.js b/app/static/js/view_switcher.js new file mode 100644 index 0000000..64ab135 --- /dev/null +++ b/app/static/js/view_switcher.js @@ -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(); +})(); diff --git a/app/templates/base.html b/app/templates/base.html index e0c079d..c99e669 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -208,6 +208,8 @@ + +