feat(ui): implementar selector de vista universal (tabla/grilla vs tarjetas/mosaico) con persistencia

This commit is contained in:
Carlos Tello
2026-09-05 01:54:55 -03:00
parent 69b8b7ccef
commit 7d342bf8cc
9 changed files with 637 additions and 141 deletions
+17
View File
@@ -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).
+15
View File
@@ -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
+32
View File
@@ -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;
}
+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();
})();
+2
View File
@@ -208,6 +208,8 @@
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
<!-- View Switcher JS -->
<script src="{{ url_for('static', filename='js/view_switcher.js') }}"></script>
<!-- Show flash messages -->
<script>
+115 -6
View File
@@ -253,13 +253,30 @@
</div>
</div>
<!-- Grilla de Aulas Físicas -->
<!-- Barra de Resumen y Selector de Vista -->
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div class="small text-muted">
<i class="bi bi-door-open me-1"></i>
<span>{% trans %}Total:{% endtrans %} <strong class="text-body">{{ classrooms.total if classrooms else 0 }}</strong> {% trans %}aulas encontradas{% endtrans %}</span>
</div>
<div class="d-flex align-items-center gap-2">
<span class="small text-muted d-none d-sm-inline">{% trans %}Modo de visualización:{% endtrans %}</span>
{% with context_id='classrooms', default_view='cards' %}
{% include 'partials/view_switcher.html' %}
{% endwith %}
</div>
</div>
<!-- Grilla y Tabla de Aulas Físicas -->
{% if classrooms and classrooms.items %}
<div class="row g-3 mt-1">
{% for classroom in classrooms.items %}
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
<div class="card classroom-card border shadow-sm h-100">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<!-- 1. VISTA DE TARJETAS / MOSAICO -->
<div data-view-context="classrooms" data-view-container="cards">
<div class="row g-3 mt-1">
{% for classroom in classrooms.items %}
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
<div class="card classroom-card border shadow-sm h-100">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div>
<!-- Encabezado con Código y Estado -->
<div class="d-flex justify-content-between align-items-start mb-2">
@@ -364,6 +381,98 @@
</div>
</div>
{% endfor %}
</div>
</div>
<!-- 2. VISTA DE TABLA / GRILLA (AULAS FÍSICAS) -->
<div data-view-context="classrooms" data-view-container="table" class="d-none">
<div class="card border-0 shadow-sm mt-1">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase">
<tr>
<th class="ps-3">{% trans %}Aula / Código{% endtrans %}</th>
<th>{% trans %}Edificio / Sede{% endtrans %}</th>
<th>{% trans %}Piso{% endtrans %}</th>
<th>{% trans %}Capacidad{% endtrans %}</th>
<th>{% trans %}Equipamiento{% endtrans %}</th>
<th>{% trans %}Ocupación (D / S / M){% endtrans %}</th>
<th>{% trans %}Estado{% endtrans %}</th>
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for classroom in classrooms.items %}
{% set metrics = occupancy_metrics.get(classroom.id, {'day_pct': 0, 'day_count': 0, 'week_pct': 0, 'week_count': 0, 'month_pct': 0, 'month_count': 0}) %}
<tr>
<td class="ps-3">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="fw-bold text-primary text-decoration-none">
<i class="bi bi-door-open me-1"></i>{{ classroom.code }}
</a>
{% if classroom.description %}
<small class="text-muted d-block text-truncate" style="max-width: 200px;">{{ classroom.description }}</small>
{% endif %}
</td>
<td>
<span class="badge bg-primary-subtle text-primary border">
<i class="bi bi-buildings me-1"></i>{{ classroom.building_name }}
</span>
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border">
{{ classroom.floor_display }}
</span>
</td>
<td>
<strong class="text-body">{{ classroom.capacity }}</strong> <span class="small text-muted">{% trans %}asientos{% endtrans %}</span>
</td>
<td>
<span class="badge bg-body-tertiary text-body border">
<i class="bi bi-tools me-1"></i>{{ classroom.resources|length if classroom.resources else 0 }} {% trans %}recursos{% endtrans %}
</span>
</td>
<td>
<div class="d-flex align-items-center gap-1">
<span class="badge border {% if metrics.day_pct >= 80 %}metric-high{% elif metrics.day_pct >= 40 %}metric-medium{% else %}metric-low{% endif %}" title="{% trans %}Ocupación Día{% endtrans %}">
D: {{ metrics.day_pct }}%
</span>
<span class="badge border {% if metrics.week_pct >= 80 %}metric-high{% elif metrics.week_pct >= 40 %}metric-medium{% else %}metric-low{% endif %}" title="{% trans %}Ocupación Semana{% endtrans %}">
S: {{ metrics.week_pct }}%
</span>
<span class="badge border {% if metrics.month_pct >= 80 %}metric-high{% elif metrics.month_pct >= 40 %}metric-medium{% else %}metric-low{% endif %}" title="{% trans %}Ocupación Mes{% endtrans %}">
M: {{ metrics.month_pct }}%
</span>
</div>
</td>
<td>
<span class="badge rounded-pill {% if classroom.is_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %}">
{% if classroom.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %}
</span>
</td>
<td class="text-end pe-3">
<div class="btn-group btn-group-sm">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-primary" title="{% trans %}Ver Detalles{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}" class="btn btn-outline-secondary" title="{% trans %}Editar{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}" class="d-inline mb-0">
<button type="submit" class="btn btn-outline-{% if classroom.is_active %}warning{% else %}success{% endif %}"
title="{% if classroom.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
<i class="bi bi-{% if classroom.is_active %}pause{% else %}play{% endif %}"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Paginación Aulas Físicas -->
+21
View File
@@ -0,0 +1,21 @@
{# Componente reutilizable: Selector de vista (Tabla / Grilla vs. Tarjetas / Mosaico) #}
<div class="btn-group btn-group-sm shadow-sm view-switcher-group"
role="group"
aria-label="{% trans %}Selector de Vista{% endtrans %}"
data-context="{{ context_id|default('default') }}"
data-default-view="{{ default_view|default('cards') }}">
<button type="button"
class="btn btn-outline-secondary view-switcher-btn"
data-view="table"
title="{% trans %}Vista de Tabla / Grilla (filas, columnas y análisis denso){% endtrans %}">
<i class="bi bi-table me-1"></i>
<span class="d-none d-sm-inline">{% trans %}Tabla{% endtrans %}</span>
</button>
<button type="button"
class="btn btn-outline-secondary view-switcher-btn"
data-view="cards"
title="{% trans %}Vista de Tarjetas / Mosaico (amigable, visual y responsive){% endtrans %}">
<i class="bi bi-grid-fill me-1"></i>
<span class="d-none d-sm-inline">{% trans %}Tarjetas{% endtrans %}</span>
</button>
</div>
+251 -135
View File
@@ -131,145 +131,261 @@
</div>
</div>
<!-- Reservations Table -->
<!-- Header de Resumen y Selector de Vista -->
{% if reservations and reservations.items %}
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-table text-primary me-2"></i>{% trans %}Registros Encontrados{% endtrans %}
<span class="badge bg-secondary-subtle text-secondary border ms-2">{{ reservations.total }} {% trans %}reservas{% endtrans %}</span>
</h5>
<small class="text-muted">{% trans %}Página{% endtrans %} {{ reservations.page }} {% trans %}de{% endtrans %} {{ reservations.pages }}</small>
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div class="small text-muted">
<i class="bi bi-calendar-check text-primary me-1"></i>
<span>{% trans %}Total:{% endtrans %} <strong class="text-body">{{ reservations.total }}</strong> {% trans %}reservas encontradas{% endtrans %}</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase">
<tr>
<th class="ps-3">{% trans %}Fecha y Horario{% endtrans %}</th>
<th>{% trans %}Aula / Piso{% endtrans %}</th>
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
<th>{% trans %}Turno / Comisión{% endtrans %}</th>
<th>{% trans %}Capacidad{% endtrans %}</th>
<th>{% trans %}Estado{% endtrans %}</th>
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for reservation in reservations.items %}
<tr>
<td class="ps-3">
<div class="fw-bold">{{ reservation.start_time.strftime('%d/%m/%Y') }}</div>
<small class="text-muted font-monospace">{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs</small>
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
</span>
<small class="text-muted d-block">
{% if reservation.classroom and reservation.classroom.floor == 0 %}PB{% elif reservation.classroom %}Piso {{ reservation.classroom.floor }}{% endif %}
{% if reservation.classroom and reservation.classroom.building %} · {{ reservation.classroom.building }}{% endif %}
</small>
</td>
<td>
<div class="fw-bold text-primary">{{ reservation.subject_name }}</div>
<small class="badge bg-secondary-subtle text-secondary font-monospace">{{ reservation.subject_code }}</small>
</td>
<td>
<span class="badge bg-info-subtle text-info border me-1">
{{ reservation.shift or 'Tarde' }}
</span>
<small class="text-muted">{{ reservation.commission.code if reservation.commission else 'C1' }}</small>
</td>
<td>
<span class="small">{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity if reservation.classroom else '-' }}</span>
</td>
<td>
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
<span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
</span>
{% elif reservation.status == 'PENDING' or reservation.status.value == 'PENDING' %}
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">
{% trans %}Cancelada{% endtrans %}
</span>
{% endif %}
</td>
<td class="text-end pe-3">
{% if reservation.effective_virtual_link %}
<a href="{{ reservation.effective_virtual_link }}" target="_blank"
class="btn btn-sm btn-outline-success py-0 px-2 me-1" title="{% trans %}Unirse a Clase Virtual (Zoom/Meet){% endtrans %}">
<i class="bi bi-camera-video"></i>
</a>
{% endif %}
{% if current_user.can_edit_reservation(reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="{% trans %}Modificar Reserva{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
{% if current_user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-success py-0 px-2 ms-1" title="{% trans %}Confirmar{% endtrans %}">
<i class="bi bi-check-lg"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if reservations.pages > 1 %}
<div class="card-footer bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<small class="text-muted">{% trans %}Mostrando 20 registros por página{% endtrans %}</small>
<nav>
<ul class="pagination pagination-sm mb-0">
{% if reservations.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for p in reservations.iter_pages(left_edge=2, left_current=2, right_current=2, right_edge=2) %}
{% if p %}
{% if p == reservations.page %}
<li class="page-item active"><span class="page-link">{{ p }}</span></li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a>
</li>
{% endif %}
{% else %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% endif %}
{% endfor %}
{% if reservations.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
<div class="d-flex align-items-center gap-2">
<span class="small text-muted d-none d-sm-inline">{% trans %}Modo de visualización:{% endtrans %}</span>
{% with context_id='reservations', default_view='table' %}
{% include 'partials/view_switcher.html' %}
{% endwith %}
</div>
</div>
<!-- 1. VISTA DE TABLA / GRILLA -->
<div data-view-context="reservations" data-view-container="table">
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-table text-primary me-2"></i>{% trans %}Registros en Formato Tabla{% endtrans %}
</h5>
<small class="text-muted">{% trans %}Página{% endtrans %} {{ reservations.page }} {% trans %}de{% endtrans %} {{ reservations.pages }}</small>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase">
<tr>
<th class="ps-3">{% trans %}Fecha y Horario{% endtrans %}</th>
<th>{% trans %}Aula / Piso{% endtrans %}</th>
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
<th>{% trans %}Turno / Comisión{% endtrans %}</th>
<th>{% trans %}Capacidad{% endtrans %}</th>
<th>{% trans %}Estado{% endtrans %}</th>
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for reservation in reservations.items %}
<tr>
<td class="ps-3">
<div class="fw-bold">{{ reservation.start_time.strftime('%d/%m/%Y') }}</div>
<small class="text-muted font-monospace">{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs</small>
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
</span>
<small class="text-muted d-block">
{% if reservation.classroom and reservation.classroom.floor == 0 %}PB{% elif reservation.classroom %}Piso {{ reservation.classroom.floor }}{% endif %}
{% if reservation.classroom and reservation.classroom.building %} · {{ reservation.classroom.building }}{% endif %}
</small>
</td>
<td>
<div class="fw-bold text-primary">{{ reservation.subject_name }}</div>
<small class="badge bg-secondary-subtle text-secondary font-monospace">{{ reservation.subject_code }}</small>
</td>
<td>
<span class="badge bg-info-subtle text-info border me-1">
{{ reservation.shift or 'Tarde' }}
</span>
<small class="text-muted">{{ reservation.commission.code if reservation.commission else 'C1' }}</small>
</td>
<td>
<span class="small">{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity if reservation.classroom else '-' }}</span>
</td>
<td>
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
<span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
</span>
{% elif reservation.status == 'PENDING' or reservation.status.value == 'PENDING' %}
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">
{% trans %}Cancelada{% endtrans %}
</span>
{% endif %}
</td>
<td class="text-end pe-3">
{% if reservation.effective_virtual_link %}
<a href="{{ reservation.effective_virtual_link }}" target="_blank"
class="btn btn-sm btn-outline-success py-0 px-2 me-1" title="{% trans %}Unirse a Clase Virtual (Zoom/Meet){% endtrans %}">
<i class="bi bi-camera-video"></i>
</a>
{% endif %}
{% if current_user.can_edit_reservation(reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="{% trans %}Modificar Reserva{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
{% if current_user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-success py-0 px-2 ms-1" title="{% trans %}Confirmar{% endtrans %}">
<i class="bi bi-check-lg"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 2. VISTA DE TARJETAS / MOSAICO -->
<div data-view-context="reservations" data-view-container="cards" class="d-none">
<div class="row g-3 mb-3">
{% for reservation in reservations.items %}
<div class="col-xxl-3 col-xl-4 col-lg-6 col-md-6 col-sm-12">
<div class="card border shadow-sm h-100 reservation-card">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div>
<!-- Header de tarjeta -->
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span class="badge bg-primary text-white font-monospace mb-1">
<i class="bi bi-calendar-event me-1"></i>{{ reservation.start_time.strftime('%d/%m/%Y') }}
</span>
<div class="small fw-bold text-muted font-monospace">
<i class="bi bi-clock me-1"></i>{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
</div>
</div>
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
<span class="badge rounded-pill bg-success-subtle text-success border border-success-subtle">{% trans %}Confirmada{% endtrans %}</span>
{% elif reservation.status == 'PENDING' or reservation.status.value == 'PENDING' %}
<span class="badge rounded-pill bg-warning-subtle text-warning border border-warning-subtle">{% trans %}Pendiente{% endtrans %}</span>
{% else %}
<span class="badge rounded-pill bg-danger-subtle text-danger border border-danger-subtle">{% trans %}Cancelada{% endtrans %}</span>
{% endif %}
</div>
<!-- Asignatura y código -->
<h6 class="card-title fw-bold text-primary mb-1 text-truncate" title="{{ reservation.subject_name }}">
{{ reservation.subject_name }}
</h6>
<div class="d-flex flex-wrap gap-1 mb-2">
<span class="badge bg-secondary-subtle text-secondary font-monospace" style="font-size: 0.7rem;">
{{ reservation.subject_code }}
</span>
<span class="badge bg-info-subtle text-info border" style="font-size: 0.7rem;">
{{ reservation.shift or 'Tarde' }}
</span>
{% if reservation.commission %}
<span class="badge bg-body-tertiary text-body border" style="font-size: 0.7rem;">
{{ reservation.commission.name }}
</span>
{% endif %}
</div>
<!-- Ubicación / Aula -->
<div class="p-2 rounded bg-body-tertiary border mb-2 small">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="text-muted"><i class="bi bi-door-open text-primary me-1"></i>{% trans %}Espacio:{% endtrans %}</span>
{% if reservation.classroom %}
<span class="fw-bold font-monospace text-body">{{ reservation.classroom.code }}</span>
{% else %}
<span class="text-muted">{% trans %}Sin asignar{% endtrans %}</span>
{% endif %}
</div>
<div class="d-flex align-items-center justify-content-between small text-muted">
<span>{% trans %}Sede:{% endtrans %} {{ reservation.classroom.building_name if reservation.classroom else '-' }}</span>
<span>{{ reservation.classroom.floor_display if reservation.classroom else '-' }}</span>
</div>
</div>
<!-- Docente y Asistentes -->
<div class="small text-muted d-flex justify-content-between align-items-center mb-2">
<span class="text-truncate me-1" title="{{ reservation.user.name if reservation.user else 'No asignado' }}">
<i class="bi bi-person me-1"></i>{{ reservation.user.name if reservation.user else 'Sin docente' }}
</span>
<span>
<i class="bi bi-people me-1"></i>{{ reservation.expected_attendees }} {% trans %}alumnos{% endtrans %}
</span>
</div>
</div>
<!-- Acciones -->
<div class="d-flex justify-content-end gap-1 pt-2 border-top">
{% if reservation.effective_virtual_link %}
<a href="{{ reservation.effective_virtual_link }}" target="_blank"
class="btn btn-sm btn-outline-success py-1 px-2" title="{% trans %}Clase Virtual{% endtrans %}">
<i class="bi bi-camera-video"></i>
</a>
{% endif %}
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}" class="btn btn-sm btn-outline-secondary py-1 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
{% if current_user.can_edit_reservation(reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}" class="btn btn-sm btn-outline-warning py-1 px-2" title="{% trans %}Modificar{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
{% if current_user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}" class="btn btn-sm btn-outline-success py-1 px-2" title="{% trans %}Confirmar{% endtrans %}">
<i class="bi bi-check-lg"></i>
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Paginación Compartida (visible en ambas vistas) -->
{% if reservations.pages > 1 %}
<div class="card border-0 shadow-sm py-2 px-3 d-flex flex-row justify-content-between align-items-center mb-3">
<small class="text-muted">{% trans %}Página{% endtrans %} {{ reservations.page }} {% trans %}de{% endtrans %} {{ reservations.pages }} ({{ reservations.total }} {% trans %}reservas{% endtrans %})</small>
<nav>
<ul class="pagination pagination-sm mb-0">
{% if reservations.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for p in reservations.iter_pages(left_edge=2, left_current=2, right_current=2, right_edge=2) %}
{% if p %}
{% if p == reservations.page %}
<li class="page-item active"><span class="page-link">{{ p }}</span></li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a>
</li>
{% endif %}
{% else %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% endif %}
{% endfor %}
{% if reservations.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
{% else %}
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5">
+45
View File
@@ -0,0 +1,45 @@
import unittest
from app import create_app, db
from app.models.user import User
class TestViewSwitcher(unittest.TestCase):
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.app.config['WTF_CSRF_ENABLED'] = False
self.client = self.app.test_client()
self.app_context = self.app.app_context()
self.app_context.push()
# Login con usuario admin para acceder a las vistas protegidas
self.user = User.query.filter_by(is_active=True).first()
if self.user:
with self.client.session_transaction() as sess:
sess['_user_id'] = str(self.user.id)
sess['_fresh'] = True
def tearDown(self):
self.app_context.pop()
def test_classrooms_view_switcher_rendered(self):
res = self.client.get('/classrooms/?view_mode=physical')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
# Verificar que el selector de vista está presente
self.assertIn('view-switcher-group', html)
self.assertIn('data-context="classrooms"', html)
self.assertIn('data-view-container="cards"', html)
self.assertIn('data-view-container="table"', html)
def test_reservations_view_switcher_rendered(self):
res = self.client.get('/schedule/list')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
# Verificar que el selector de vista está presente
self.assertIn('view-switcher-group', html)
self.assertIn('data-context="reservations"', html)
self.assertIn('data-view-container="table"', html)
self.assertIn('data-view-container="cards"', html)
if __name__ == '__main__':
unittest.main()