feat(ui): implementar selector de vista universal (tabla/grilla vs tarjetas/mosaico) con persistencia
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -175,3 +175,35 @@
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -253,9 +253,26 @@
|
||||
</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">
|
||||
|
||||
<!-- 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">
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
@@ -131,13 +131,27 @@
|
||||
</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="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="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 Encontrados{% endtrans %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border ms-2">{{ reservations.total }} {% trans %}reservas{% endtrans %}</span>
|
||||
<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>
|
||||
@@ -228,11 +242,115 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<!-- 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-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>
|
||||
<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 %}
|
||||
@@ -268,8 +386,6 @@
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user