Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa878cb0d5 | ||
|
|
566908f1de | ||
|
|
c748ae4075 | ||
|
|
6218657f36 | ||
|
|
747d20ed38 | ||
|
|
b64a771068 | ||
|
|
340bf3aa8c | ||
|
|
80f6018b6a | ||
|
|
4e49311208 | ||
|
|
b367b106bf | ||
|
|
0cea3dca44 | ||
|
|
eea7adb1d0 | ||
|
|
e0c58409cd | ||
|
|
9b6678724c | ||
|
|
f462d57ad9 | ||
|
|
7ef2ba8a5b | ||
|
|
169aa45746 | ||
|
|
f31e2bf760 | ||
|
|
63c9c6f935 | ||
|
|
5e91e131f0 | ||
|
|
c7ce5e4e67 | ||
|
|
957f364cc3 | ||
|
|
7cd70be274 | ||
|
|
5dcf3b75f1 | ||
|
|
7933523d7c | ||
|
|
df098e5116 | ||
|
|
bbe8e220ae | ||
|
|
adb6ba729d | ||
|
|
c9dc3d9208 | ||
|
|
cde62c9b0a | ||
|
|
5f0476be79 | ||
|
|
6ccc9e8c87 | ||
|
|
5e0b905e55 | ||
|
|
f3a4b5875e | ||
|
|
8809ff3410 | ||
|
|
291e537574 | ||
|
|
0c867e0031 | ||
|
|
9521b86399 | ||
|
|
5cc33cc7a0 |
@@ -1,7 +0,0 @@
|
||||
FLASK_APP=app.py
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=your-secret-key-here-please-change-in-production
|
||||
DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=password
|
||||
API_BASE_URL=http://localhost:8080/api
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==============================================================================
|
||||
|
||||
# Entorno Flask
|
||||
FLASK_APP=app.py
|
||||
FLASK_APP=wsgi.py
|
||||
FLASK_ENV=production
|
||||
|
||||
# Seguridad: Clave secreta para sesiones y protección CSRF
|
||||
|
||||
@@ -57,6 +57,9 @@ cover/
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Examples
|
||||
example/
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Resumen de Cambios - Sesión de Desarrollo (Rama `dev`)
|
||||
|
||||
**Fecha:** 2026-09-02 / 2026-09-03
|
||||
**Fecha:** 2026-09-02 / 2026-09-03 / 2026-09-04
|
||||
**Proyecto:** Admin Edu-Space (Flask + PostgreSQL)
|
||||
**Rama:** `dev`
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* Manual de despliegue en producción con arquitectura WSGI (Gunicorn), Nginx como proxy inverso, Systemd unit y contenedorización Docker / Docker Compose.
|
||||
* **Archivos Base:**
|
||||
* Creación de `.env.example` con todas las variables requeridas (PostgreSQL, Flask, Secret Key, Babel).
|
||||
* Creación de `init_db.py` para bootstrap inicial seguro de base de datos y usuario administrador por defecto (`admin@edu-space.com` / `admin123`).
|
||||
* Creación y posterior actualización de `init_db.py` para bootstrap inicial seguro de base de datos, roles RBAC y usuario administrador por defecto (`admin@edu-space.com` / `admin123`).
|
||||
|
||||
---
|
||||
|
||||
@@ -20,65 +20,433 @@
|
||||
* **Preferencia de Idioma Persistente:**
|
||||
* Migración en PostgreSQL agregando la columna `preferred_language VARCHAR(10)` a la tabla `users`.
|
||||
* Actualización del modelo `User` en `app/models/user.py` con el campo `preferred_language`.
|
||||
* **Preferencia de Tema Visual Persistente:**
|
||||
* Migración agregando la columna `theme_preference VARCHAR(10) DEFAULT 'auto' NOT NULL` a la tabla `users`.
|
||||
* Soporte en el modelo `User` para valores `'auto'`, `'light'` y `'dark'`.
|
||||
* **Control de Acceso Basado en Roles (RBAC):**
|
||||
* Migración creando las tablas `roles` y `permissions`, y agregando la clave foránea `role_id` a la tabla `users`.
|
||||
* Modelos `Role` y `Permission` en `app/models/role.py`.
|
||||
* Métodos `has_permission(module, min_level)` y `can_manage()` en `User`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementación de Vistas y Plantillas Faltantes
|
||||
## 3. Implementación de Vistas y Plantillas
|
||||
* **Perfil de Usuario (`app/templates/auth/profile.html`):**
|
||||
* Vista completa con datos del usuario, rol, estado, fecha de registro y reservas recientes.
|
||||
* Formulario interactivo para configurar la **Preferencia de Idioma** (*Detectar automáticamente del navegador*, *Español*, *English*).
|
||||
* Formulario integral para configurar **Idioma de la interfaz** (*Detectar automáticamente del navegador*, *Español*, *English*) y **Tema visual** (*Automático*, *Modo Claro*, *Modo Oscuro*).
|
||||
* **Cambio de Contraseña (`app/templates/auth/change_password.html`):**
|
||||
* Formulario seguro para actualización de credenciales.
|
||||
* **Manejo de Errores (`app/templates/auth/403.html`):**
|
||||
* Pantalla de error 403 Forbidden estilizada.
|
||||
* Pantalla de error 403 Forbidden estilizada e integrada con el sistema de diseño.
|
||||
|
||||
---
|
||||
|
||||
## 4. Correcciones de UX y Bugs de Rutas
|
||||
## 4. Correcciones de UX, Linter y Bugs de Rutas
|
||||
* **Navbar Limpio (`app/templates/base.html`):**
|
||||
* Eliminación del bloque duplicado del menú de usuario `System Administrator`.
|
||||
* **Corrección de BuildError en Comisiones:**
|
||||
* En `app/routes/classrooms.py`, se agregó el alias de ruta `@classrooms_bp.route('/', endpoint='index')` para resolver enlaces a `classrooms.index`.
|
||||
* En `app/templates/schedule/commissions.html`, se reemplazó el filtro inválido de Django `truncatewords(20)` por el filtro estándar de Jinja2 `truncate(100)`.
|
||||
* **Corrección Visual en `/genetic-optimizer` (`app/templates/genetic_optimizer.html`):**
|
||||
* Se agregó el espaciado `pt-5 mt-4` evitando que la barra de navegación fija superior (`fixed-top`) se monte sobre el contenido.
|
||||
* Se agregó el espaciado `pt-5 mt-4` evitando que la barra fija superior se monte sobre el contenido.
|
||||
* Rediseño de tarjeta hero con contraste óptimo y reemplazo de iconos faltantes por Bootstrap Icons (`bi bi-...`).
|
||||
* **Corrección en Calendario (`app/templates/schedule/calendar.html`):**
|
||||
* Inclusión de contenedor con espaciado superior y bundle de localización `locales-all.min.js` para FullCalendar.
|
||||
* **Corrección de Errores del Linter en `schedule/add.html` y `schedule/view.html`:**
|
||||
* En `add.html`: Desacople de iteraciones Jinja a `<script type="application/json" id="classroom-data">` y parseo con `JSON.parse()`, eliminando 19 errores sintácticos del editor.
|
||||
* En `view.html`: Formateo de la propiedad inline `style` en barras de progreso para cumplir con el validador CSS.
|
||||
|
||||
---
|
||||
|
||||
## 5. Internacionalización (i18n) y Localización al Español
|
||||
* **Configuración del Motor Babel (`config/config.py` y `babel.cfg`):**
|
||||
* Configuración de `BABEL_TRANSLATION_DIRECTORIES` con ruta absoluta.
|
||||
* Actualización del método de extracción Jinja2 en `babel.cfg`.
|
||||
* **Formularios con Traducción Lazy (`lazy_gettext`):**
|
||||
* `app/forms/auth.py` (`LoginForm`).
|
||||
* `app/forms/classroom.py` (`ClassroomForm`, `ClassroomResourceForm`).
|
||||
* `app/forms/reservation.py` (`ReservationForm`).
|
||||
* **Traducción Integral de Plantillas:**
|
||||
* `app/templates/auth/login.html`: Pantalla de login traducida y con selector de idioma visible.
|
||||
* `app/templates/dashboard.html`: Todas las tarjetas estadísticas, acciones rápidas y tablas.
|
||||
* `app/templates/classrooms/list.html` y `add.html`: Filtros, estados vacíos, cabeceras y formularios.
|
||||
* `app/templates/schedule/today.html`: Horario del día, métricas y botones.
|
||||
* `app/templates/schedule/calendar.html`: Vistas, leyendas y eventos.
|
||||
* `app/templates/genetic_optimizer.html`: Estado del algoritmo y controles.
|
||||
* `app/templates/partials/language_switcher.html`: Muestra "Español" en lugar de "Spanish".
|
||||
* **Compilación de Catálogos:**
|
||||
* Compilación binaria de `messages.po` a `messages.mo` en `translations/es/` y `translations/en/` con 0 mensajes pendientes.
|
||||
## 5. Rediseño UI/UX, Assets Institucionales y Modo Claro/Oscuro
|
||||
* **Exploración e Integración de Assets Gráficos:**
|
||||
* Sincronización desde `app/resources` hacia `app/static/img/`:
|
||||
* `logo_unicaba.png`: Logo institucional principal para el login.
|
||||
* `logo_unicaba_mini.png`: Isotipo oficial para el navbar.
|
||||
* `favicon.png`: Favicon vinculado en las plantillas base.
|
||||
* `icons/`: Paquete de resoluciones WebApp/PWA.
|
||||
* **Sistema de Diseño y Tokens de Color (`app/static/css/theme.css`):**
|
||||
* Paleta institucional basada en Magenta/Uva (`#B43E8E`), acentos violetas (`#682CAD` / `#AE7FFF`) y secundarios magentas.
|
||||
* Tokens CSS para `:root` (Modo Claro) y `[data-bs-theme="dark"]` (Modo Oscuro).
|
||||
* Clases especializadas: `.auth-card`, `.navbar-app`, `.user-avatar-small`, `.theme-toggle-btn`, `.badge-brand`.
|
||||
* **Modo Claro / Modo Oscuro Dinámico (`app/static/js/theme-toggle.js`):**
|
||||
* Script anti-flickering en `<head>` para evitar parpadeos visuales al cargar la página.
|
||||
* Botón toggle interactivo con persistencia en `localStorage`.
|
||||
* En Modo Claro: Isotipo del navbar con filtro blanco puro (`brightness(0) invert(1)`) sobre el fondo magenta.
|
||||
* Selector de idioma (`#languageDropdown`) con fondo y bordes de alto contraste y tilde de verificación blanca (`bi-check-lg`) visible **únicamente** en el idioma activo sobre gradiente magenta.
|
||||
|
||||
---
|
||||
|
||||
## 6. Lógica de Detección y Precedencia de Idiomas
|
||||
En `app/__init__.py` (`get_locale()`):
|
||||
1. **Parámetro URL (`?lang=`):** Máxima prioridad para cambio explícito.
|
||||
2. **Preferencia en Perfil del Usuario (`current_user.preferred_language`):** Sobrescribe la configuración del navegador en toda la sesión si el usuario está autenticado.
|
||||
3. **Preferencia de Sesión (`session['language']`):** Cambio manual mediante el dropdown.
|
||||
4. **Detección Automática del Navegador (`request.accept_languages.best_match()`):** Valor por defecto para visitantes no autenticados y pantalla de login.
|
||||
* Al cerrar sesión (`logout`), se limpia la sesión para que el login use el navegador.
|
||||
* Al cambiar el idioma en el selector siendo usuario autenticado, se sincroniza automáticamente con su perfil en base de datos.
|
||||
## 6. Internacionalización (i18n), Localización y Formateo de Fechas
|
||||
* **Detección Inteligente de Subtags Regionales (`get_locale` en `app/__init__.py`):**
|
||||
* Extracción del subtag primario (`es` a partir de `es-419`, `es-AR`, `es-ES`), resolviendo el fallo donde Werkzeug saltaba erróneamente a inglés.
|
||||
* Configuración institucional: `BABEL_DEFAULT_LOCALE = 'es'` y `LANGUAGES = ['es', 'en']`.
|
||||
* **Reglas de Idioma para la Pantalla de Login:**
|
||||
* Resuelve según el navegador del visitante o recurre a español por defecto.
|
||||
* Inmunidad contra cookies o sesiones residuales de otros usuarios.
|
||||
* Flash messages traducidos (`¡Bienvenido de nuevo!`, `Has cerrado sesión correctamente`).
|
||||
* **Formateador de Fechas Localizadas en Jinja2:**
|
||||
* Registro de `format_date` y `format_datetime` de Flask-Babel.
|
||||
* Fechas en español natural en `schedule/today.html` (`Jueves, 3 de septiembre de 2026`), `view.html` y `list.html`.
|
||||
* **Traducción Integral de `schedule/add.html`:**
|
||||
* Formulario y textos de ayuda 100% traducidos.
|
||||
* Caja dinámica de resumen (JavaScript e `i18n-strings`) con cálculo de duración y validación de sobrecupo en español.
|
||||
|
||||
---
|
||||
|
||||
## 7. Configuración del Entorno de Desarrollo
|
||||
* Se creó `.vscode/settings.json` apuntando al intérprete del entorno virtual (`venv\Scripts\python.exe`), solucionando falsos positivos del analizador estático para `flask_wtf` y `flask_babel`.
|
||||
## 7. Módulo de Gestión de Accesos (RBAC) con Matriz FortiGate
|
||||
* **Modelos y Persistencia:**
|
||||
* Módulo `app/models/role.py` con `Role` y `Permission`.
|
||||
* Roles iniciales sembrados: `Admin` (total, protegido), `Docente` (reservas y horarios), `Operador` (aulas y reservas), `Consulta` (solo lectura).
|
||||
* Vinculación del usuario inicial `admin@edu-space.com` al rol `Admin`.
|
||||
* **Matriz de Permisos Estilo FortiGate (`/admin/roles/edit/<id>`):**
|
||||
* Tabla interactiva con columnas **Access Control** y **Permissions**.
|
||||
* Segmented control por módulo con tres estados: `🚫 None`, `👁️ Read` y `✏️ Read/Write`.
|
||||
* Color activo verde esmeralda FortiGate (`#2D9F6F`) con texto e icono en blanco puro.
|
||||
* Menú desplegable masivo **Set All ▾** (*Set All to None*, *Set All to Read*, *Set All to Read/Write*).
|
||||
* **Módulo de Administración (`app/routes/admin.py`):**
|
||||
* `/admin/roles`: Listado de roles, conteo de miembros, visualización de permisos en badges (RW, R, -) y modal de borrado seguro.
|
||||
* `/admin/users`: Listado de cuentas con filtros por nombre, rol y estado (Activo/Inactivo), paginación y switch rápido para alternar accesos.
|
||||
* `/admin/users/add` y `/admin/users/edit/<id>`: Formularios de alta y edición con asignación de rol y contraseña opcional.
|
||||
* **Seguridad y Decoradores (`app/utils/decorators.py`):**
|
||||
* `@permission_required(module, min_level)` y `@admin_required`.
|
||||
* Pestaña **Management** en el navbar visible exclusivamente para administradores.
|
||||
* Botones de acción sensibles en el navbar (*Add Classroom*, *New Reservation*) condicionados a permisos de escritura.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sincronización y Actualización de `init_db.py`
|
||||
* Migraciones DDL automáticas (`ALTER TABLE ... ADD COLUMN IF NOT EXISTS`) para `preferred_language`, `theme_preference`, `role_id`, y para la tabla `careers` y columna `career_id` en `subjects`.
|
||||
* Creación y siembra de roles institucionales y permisos en PostgreSQL.
|
||||
* Configuración de salida con codificación UTF-8 segura en Windows para evitar excepciones de `cp1252`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Sincronización e Importación de Datos Académicos (Google Sheets)
|
||||
* **Modelo `Career` (`app/models/career.py`):**
|
||||
* Entidad de carreras universitarias vinculada con `Subject` vía relación uno a muchos.
|
||||
* Migración en PostgreSQL de la tabla `careers` y campo `career_id` en `subjects`.
|
||||
* **Servicio de Extracción ([app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py)):**
|
||||
* Integración con la cartelera oficial UniCABA 2026 a través de 6 hojas CSV (`Lunes` a `Sábado`).
|
||||
* Normalización inteligente de espacios áulicos físicos (`Aula 402`, `Aula 302`), espacios virtuales (`VIRTUAL`), auditorios y salas de reunión.
|
||||
* Extracción y parseo de franjas horarias (`8:00 a 9:00hs`, `17:30 a 21:30hs`).
|
||||
* Rutina de **upsert** transaccional e idempotente que previene la duplicación de datos.
|
||||
* Generación de reservas/clases en el calendario para poblar instantáneamente las vistas de horario diario y semanal.
|
||||
* **Interfaz de Gestión (`/admin/import`):**
|
||||
* Vista interactiva con métricas en tiempo real (total de carreras, asignaturas, aulas, comisiones y clases).
|
||||
* Botón de ejecución manual de sincronización con indicador visual de progreso (`spinner`).
|
||||
* Reporte detallado de resultados y tabla con vista previa de asignaturas y comisiones sincronizadas.
|
||||
* **Acceso Rápido en Navbar (`app/templates/base.html`):**
|
||||
* Nueva opción **"Import from Google Sheets"** incorporada en el menú desplegable **Management**, restringida a roles con permisos administrativos.
|
||||
|
||||
---
|
||||
|
||||
## 10. Correcciones en la Vista de Calendario (FullCalendar) y Códigos de Asignatura
|
||||
* **Carga de Eventos y Corrección de Error 500 (`/schedule/calendar-data`):**
|
||||
* Se identificó y corrigió el error `AttributeError: 'str' object has no attribute 'value'` provocado por llamadas a `.status.value` sobre campos de estado textuales.
|
||||
* Se implementó el TypeDecorator `StatusType` con la clase `StatusString` en `app/models/reservation.py` garantizando compatibilidad total tanto para `.status` como para `.status.value`.
|
||||
* Sanitización de fechas ISO 8601 con zona horaria (`Z`, `-03:00`) enviadas por FullCalendar para comparación adecuada con campos `TIMESTAMP WITHOUT TIME ZONE` en PostgreSQL.
|
||||
* Anclaje de fecha inicial con `initialDate: '{{ today_date }}'` para posicionar el calendario en la semana activa de cursada 2026.
|
||||
* **Corrección del Código de Asignatura (`subject_code`):**
|
||||
* Se reemplazó la concatenación de código extendido de comisión (`Commission.get_full_code()` que arrojaba cadenas distorsionadas como `INGA0003-C1-2026-22026`) por el **código de asignatura real** del Sheet (`Subject.code`, ej. `INGA0003`, `ASIG00165`).
|
||||
* Propiedades de acceso directo añadidas en `Reservation`: `subject_code`, `subject_name` y `classroom_name`.
|
||||
* **Jerarquía Visual y Renderizado de Tarjetas (`calendar.html`):**
|
||||
* Implementación del hook `eventContent` en FullCalendar 5 para construir tarjetas personalizadas.
|
||||
* **Nombre de la Asignatura** destacado como elemento principal con tipografía grande y en negrita (`font-weight: 700; font-size: 0.88rem`).
|
||||
* **Código de Asignatura** y **Aula** mostrados como datos secundarios en badges de alto contraste (código con fondo oscuro translúcido y aula con isotipo y fondo blanco translúcido).
|
||||
* Modal interactivo instantáneo al hacer clic en cualquier evento con todos los detalles académicos de la clase y enlace a la ficha completa.
|
||||
|
||||
---
|
||||
|
||||
## 11. Filtros por Aula y Materia en el Calendario de Reservas
|
||||
* **Barra de Herramientas de Filtros Interactiva (`calendar.html`):**
|
||||
* **Filtrar por Aula:** Desplegable dinámico con todas las aulas activas ordenadas por edificio y código (físicas y virtuales). Al seleccionar un aula (ej. `Aula 402`), se eliminan todas las superposiciones visuales mostrando el cronograma limpio de ese espacio.
|
||||
* **Filtrar por Materia:** Desplegable ordenado alfabéticamente con formato `[Código] Nombre de la Asignatura`. Permite aislar todas las clases de una asignatura en particular.
|
||||
* **Filtrar por Carrera:** Filtro adicional por plan académico/carrera.
|
||||
* **Botón Limpiar Filtros:** Restablece instantáneamente todos los controles y refresca la vista.
|
||||
* **Insignias de Estado:** Indicador en tiempo real de los filtros aplicados y contador de clases visibles (`X clases visibles`).
|
||||
* **Soporte en Backend (`app/routes/schedule.py`):**
|
||||
* `calendar_view`: Suministra las listas de `classrooms`, `subjects` y `careers` a la plantilla.
|
||||
* `calendar_data`: Procesa `classroom_id`, `subject_id`, `career_id` y `search` aplicando cláusulas `filter()` con `JOIN` en SQLAlchemy.
|
||||
* `refetchEvents()` de FullCalendar conectado a los eventos `change` de cada selector para recarga inmediata vía AJAX sin parpadeo de pantalla.
|
||||
|
||||
---
|
||||
|
||||
## 12. Módulo de Gestión de Edificios y Corrección de Vistas de Aulas
|
||||
* **Corrección de Plantillas de Aulas Faltantes:**
|
||||
* Se crearon las plantillas [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html) y [app/templates/classrooms/view.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/view.html), solucionando el error HTTP 500 (`TemplateNotFound: classrooms/edit.html`) y la redirección 302 que ocurría al hacer clic en cualquier aula.
|
||||
* La vista de ficha técnica del aula ahora muestra detalles de capacidad, piso, estado, edificio y la tabla interactiva de próximas reservas.
|
||||
* **Modelo y Base de Datos de Edificios (`Building`):**
|
||||
* Se creó el modelo [app/models/building.py](file:///c:/Workspace/admin-edu-space/app/models/building.py) (`id`, `name`, `code`, `address`, `floors`, `description`, `is_active`, timestamps y relación con `Classroom`).
|
||||
* Migración DDL en PostgreSQL (`CREATE TABLE buildings`, `ALTER TABLE classrooms ADD COLUMN building_id`).
|
||||
* Actualización de [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py) y siembra de edificios institucionales iniciales (`Edificio Central` y `Campus Virtual`) con vinculación de las 25 aulas existentes.
|
||||
* **Rutas y Gestión Completa (`app/routes/buildings.py`):**
|
||||
* `/buildings`: Listado con métricas de infraestructura (total de edificios, aulas activas, capacidad total y plantas/pisos), barra de búsqueda y filtros por estado.
|
||||
* `/buildings/<id>`: Vista de detalle con distribución de aulas organizadas visualmente por piso (Planta Baja, Piso 1, Piso 2, etc.), capacidad por sala y acciones directas.
|
||||
* `/buildings/add` y `/buildings/<id>/edit`: Formularios con validación WTForms para alta y actualización.
|
||||
* `/buildings/<id>/toggle` y `/buildings/<id>/delete`: Control de activación y eliminación protegida (impide borrar edificios que contengan aulas asignadas).
|
||||
* **Navegación (`app/templates/base.html`):**
|
||||
* Enlace a **Edificios** (`/buildings`) incorporado en el menú **Classrooms** y en el menú administrativo **Management**.
|
||||
|
||||
---
|
||||
|
||||
## 13. Mapeo de Campos del Google Sheet y Concepto de Turno (Mañana / Tarde)
|
||||
* **Uso de los 5 Campos de la Cartelera Oficial:**
|
||||
* **Codigo (`Subject.code` y `Commission.code`):** Almacena el código académico real (`INGA0003`, `ASIG00165`). Se expone como badge monoespaciado en eventos de calendario, comisiones y vistas administrativas.
|
||||
* **Asignatura (`Subject.name`):** Título principal de la materia destacado en texto grande y en negrita en FullCalendar y en la cartelera diaria.
|
||||
* **Aula (`Classroom.code`, `Classroom.building`, `Classroom.floor`, `Classroom.capacity`):** Normalización de espacios físicos y virtuales vinculados al nuevo modelo `Building`.
|
||||
* **Carrera (`Career.name` y `Subject.career_id`):** Entidad de carrera universitaria con relaciones relacionales normalizadas.
|
||||
* **Horarios (`Commission.schedule`, `Reservation.start_time`, `Reservation.end_time`):** Rango horario parseado hacia objetos `datetime` nativos para el motor de reservas y el calendario.
|
||||
* **Modelado y Captura de Turnos (`TURNO MAÑANA` y `TURNO VESPERTINO`):**
|
||||
* Se agregó la columna `shift` en las tablas `commissions` y `reservations` de PostgreSQL y en sus respectivos modelos de SQLAlchemy.
|
||||
* El parser de [app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py) identifica las filas delimitadoras de bloque (`TURNO MAÑANA` vs `TURNO VESPERTINO / TARDE`) y corrobora la hora de inicio (< 13:00hs = Mañana, $\ge$ 14:00hs = Tarde).
|
||||
* Se sincronizaron todas las comisiones y reservas de cursada: 15 comisiones y 48 reservas en `Turno Mañana`; 191 comisiones y 364 reservas en `Turno Tarde / Vespertino`.
|
||||
* En el Calendario de Reservas (`/schedule/calendar`) se añadió el selector **Filtrar por Turno** (`Todos`, `Mañana`, `Tarde / Vespertino`), permitiendo a los usuarios aislar las franjas horarias y resolver la alta concentración de clases en el horario vespertino (17:30 a 21:30hs).
|
||||
|
||||
---
|
||||
|
||||
## 14. Accesos Directos en Panel, Ocultamiento de Virtuales, Filtro por Piso, Dirección Oficial y Traducción
|
||||
* **Tarjetas Estadísticas como Accesos Directos Interactivos ([app/templates/dashboard.html](file:///c:/Workspace/admin-edu-space/app/templates/dashboard.html)):**
|
||||
* **Total de Aulas (25):** Redirige a `/classrooms/list` con feedback visual de hover y elevación.
|
||||
* **Total de Materias (206):** Redirige a `/schedule/commissions`.
|
||||
* **Reservas de Hoy (18):** Redirige a `/schedule/today`.
|
||||
* **Esta Semana (103):** Redirige a `/schedule/calendar`.
|
||||
* Se añadieron microinteracciones CSS en [app/static/css/style.css](file:///c:/Workspace/admin-edu-space/app/static/css/style.css) (`.dashboard-stat-card`).
|
||||
* **Ocultar Aulas Virtuales y Filtro por Piso:**
|
||||
* **Calendario ([app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html)):** Switch interactivo *Ocultar Aulas Virtuales* (indica capacidad ilimitada) y selector *Filtrar por Piso* (`Planta Baja`, `Piso 1`, `Piso 2`, `Piso 3`, `Piso 4`). El backend `/schedule/calendar-data` excluye las aulas virtuales o filtra por número de piso.
|
||||
* **Cartelera del Día ([app/templates/schedule/today.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/today.html)):** Barra de filtros completa con soporte para ocultar virtuales, piso y turno.
|
||||
* **Listado de Reservas ([app/templates/schedule/list.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/list.html)):** Endpoint `/schedule/list` totalmente operativo con paginación de 20 registros, preservación de query params y filtros de piso, turno, virtuales, estado y rango de fechas.
|
||||
* **Dirección Oficial de la Sede Central:**
|
||||
* Se configuró la dirección oficial de la Universidad de la Ciudad de Buenos Aires (UniCABA): **`Tte. Gral. Juan Domingo Perón 802, CABA`** en la base de datos, en [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py) y en el importador [app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py).
|
||||
* **Traducción Integral al Español y Catálogo Babel:**
|
||||
* Se agregaron traducciones en [translations/es/LC_MESSAGES/messages.po](file:///c:/Workspace/admin-edu-space/translations/es/LC_MESSAGES/messages.po) y se recompiló el binario `.mo` con `pybabel`.
|
||||
* Textos traducidos: `Buildings` $\rightarrow$ *Edificios*, `Management` $\rightarrow$ *Gestión*, `Timeline View` $\rightarrow$ *Vista Cronológica*, `Grid View` $\rightarrow$ *Vista en Tabla*, `Active Classrooms` $\rightarrow$ *Aulas Ocupadas*, `All Reservations` $\rightarrow$ *Todas las Reservas*, `AI Room Optimizer` $\rightarrow$ *Optimizador de Aulas*, etc.
|
||||
|
||||
---
|
||||
|
||||
## 15. Actualización Integral de Roles RBAC, Capacidad Infinita (∞) y Horarios de Próximas Clases
|
||||
* **Actualización de Roles, Grupos de Acceso y Matriz de Permisos ([app/models/role.py](file:///c:/Workspace/admin-edu-space/app/models/role.py), [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py)):**
|
||||
* Se amplió `SYSTEM_MODULES` para cubrir los 8 módulos del sistema:
|
||||
1. `classrooms`: **Aulas y Espacios** (físicas, virtuales y capacidades).
|
||||
2. `buildings`: **Edificios y Sedes** (Sede Central, Campus Virtual, plantas).
|
||||
3. `reservations`: **Reservas de Aulas** (aprobación, confirmación, cancelación).
|
||||
4. `schedule`: **Cartelera y Cronograma** (calendario, cartelera del día, comisiones).
|
||||
5. `academic`: **Gestión Académica** (carreras, asignaturas, códigos).
|
||||
6. `import`: **Sincronización Sheets** (importador Google Sheets).
|
||||
7. `optimizer`: **Optimizador IA** (algoritmo genético áulico).
|
||||
8. `users`: **Usuarios y Accesos RBAC** (cuentas, roles y matriz).
|
||||
* Se actualizaron los perfiles estándar (`Admin`, `Docente`, `Operador`, `Consulta`) en PostgreSQL y en `init_db.py`.
|
||||
* Se tradujeron completamente las plantillas [app/templates/admin/roles/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/list.html) y [app/templates/admin/roles/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/edit.html) al español.
|
||||
* **Soporte de Capacidad Infinita (∞) y Aclaración ([app/forms/classroom.py](file:///c:/Workspace/admin-edu-space/app/forms/classroom.py), [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html)):**
|
||||
* Se actualizó `ClassroomForm.capacity` con validador personalizado que admite el símbolo **`∞`**, `inf`, `0` o cualquier capacidad numérica positiva.
|
||||
* Se corrigió la validación de `floor` con `InputRequired` y `NumberRange(min=0)` para aceptar Planta Baja (Piso 0).
|
||||
* En la interfaz de edición y alta de aulas se incluyó el botón de acción rápida **`Ilimitada (∞)`** que rellena automáticamente el campo con el símbolo `∞`.
|
||||
* Se añadió una leyenda explicativa directa: *"Para aulas virtuales o eventos sin aforo restringido, ingrese ∞ o 0 (Capacidad ilimitada)"*.
|
||||
* Se tradujo el botón de guardado a *"Guardar Cambios"* (reemplazando *"Save Changes"*).
|
||||
* El aula virtual institucional (`Campus Virtual - VIRTUAL`) se actualizó a capacidad `0` (`∞`), renderizando el badge `∞ Ilimitada` en listados y fichas.
|
||||
* **Próximas Clases: Visualización Exclusiva del Horario ([app/templates/dashboard.html](file:///c:/Workspace/admin-edu-space/app/templates/dashboard.html), [app/templates/classrooms/view.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/view.html)):**
|
||||
* En la tabla de *Próximas Reservas* del panel y en la ficha del aula se cambió la columna a **"Horario"**, removiendo la fecha del calendario y destacando únicamente el rango horario de cursada (ej. `17:30 - 21:30 hs`).
|
||||
|
||||
---
|
||||
|
||||
## 16. Desacoplamiento de Hitos Académicos: Catálogo Dinámico de Tipificaciones y Matriz RBAC
|
||||
* **Modelos de Datos ([app/models/milestone.py](file:///c:/Workspace/admin-edu-space/app/models/milestone.py), [app/models/__init__.py](file:///c:/Workspace/admin-edu-space/app/models/__init__.py)):**
|
||||
* `MilestoneType`: Catálogo dinámico y administrable con `id`, `name`, `code` (slug único), `color` (hexadecimal), `description`, `sort_order` e `is_active`.
|
||||
* `AcademicMilestone`: Relacionado mediante clave foránea `milestone_type_id` a `MilestoneType` en lugar de strings estáticos.
|
||||
* Sembrado de 12 categorías iniciales predeterminadas (`Parcial 1`, `Parcial 2`, `TP 1`, `TP 2`, `TP 3`, `Recu. P1`, `Recu. P2`, `Recu. TP 1`, `Recu. TP 2`, `Recu. TP 3`, `Final`, `Otro`).
|
||||
* **Matriz de Permisos RBAC ([app/models/role.py](file:///c:/Workspace/admin-edu-space/app/models/role.py), [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py)):**
|
||||
* Se agregó el módulo `milestone_types` a `SYSTEM_MODULES` cubriendo los 9 módulos de la plataforma.
|
||||
* Niveles asignados: `Admin` (RW), `Docente` (R), `Operador` (RW), `Consulta` (R).
|
||||
* **Vistas y ABM en Management ([app/routes/admin.py](file:///c:/Workspace/admin-edu-space/app/routes/admin.py), [app/templates/admin/milestone_types/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/milestone_types/list.html)):**
|
||||
* Se añadió el ítem de navegación **"Tipificaciones"** al menú desplegable *Management*.
|
||||
* Tabla completa con badges de color, slugs, switches interactivos de activación/desactivación y modales para dar de alta y editar tipificaciones.
|
||||
* **API y Modal del Docente ([app/routes/main.py](file:///c:/Workspace/admin-edu-space/app/routes/main.py), [example/aulas-unicaba-mvp/...](file:///c:/Workspace/admin-edu-space/example/aulas-unicaba-mvp/src/app/components/evento-modal/evento-modal.component.html)):**
|
||||
* Se expuso el endpoint `GET /api/milestone-types` para consumo desacoplado.
|
||||
* Se actualizó el modal de eventos académicos (`evento-modal.component.html` y `.ts`) desacoplando las opciones estáticas y cargando dinámicamente el catálogo de tipificaciones activas con fallback automático para funcionamiento offline.
|
||||
|
||||
---
|
||||
|
||||
## 17. Rediseño de Calendario Estilo Google Calendar y Corrección Visual en Eliminación de Roles
|
||||
* **Manejo de Eventos Simultáneos y Densidad Visual ([app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html)):**
|
||||
* Se configuró FullCalendar con `eventMaxStack: 3`, evitando que decenas de reservas concurrentes reduzcan las columnas a franjas milimétricas del 3%.
|
||||
* Se habilitó `dayMaxEvents: 3`, `dayMaxEventRows: true` y `moreLinkClick: 'popover'` nativo de Google Calendar con enlaces "+X más".
|
||||
* Se fijaron `slotMinTime: "07:00:00"`, `slotMaxTime: "23:00:00"`, `slotDuration: "00:30:00"`, `slotLabelInterval: "01:00:00"` y `nowIndicator: true` con la línea roja indicadora de hora actual.
|
||||
* **Diseño de Tarjetas de Evento Píldora (`.gc-event-pill` / `.gc-month-pill`):**
|
||||
* Estilo Google Calendar con barra lateral de color (magenta UniCABA `#B43E8E`, ámbar `#F59E0B` para pendientes, índigo `#6366F1` para aulas virtuales).
|
||||
* Encabezado compacto con hora de inicio y badge de aula (`bi bi-door-open` o `bi bi-camera-video` con fondo suave para `VIRTUAL`).
|
||||
* Tipografía en `0.78rem` con elipsis para evitar desbordes y sombras sutiles.
|
||||
* Compatibilidad completa con modo oscuro (`[data-bs-theme="dark"]`).
|
||||
* **Modal Flotante Interactivo al Clic (`#eventPreviewModal`):**
|
||||
* Reemplazo de la navegación directa por un popover flotante estilizado como Google Calendar.
|
||||
* Banner artístico superior con accesos rápidos: editar (✏️), eliminar/cancelar (🗑️) y cerrar (✕).
|
||||
* Fila principal con indicador cuadrado de color, título completo, código de materia y badge de estado/turno.
|
||||
* Lista con íconos: 🕒 horario y fecha completa, 📍 aula/piso/sede (o enlace directo al Campus Virtual), 👤 carrera/comisión/docente, 📝 hito o notas.
|
||||
* Botones de acción inferior: "Ver Ficha Completa" y "Cerrar".
|
||||
* **Corrección de Modal de Eliminación de Roles ([app/templates/admin/roles/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/list.html)):**
|
||||
* Se trasladó el modal de confirmación de eliminación `#deleteRoleModal` fuera de la celda `<td>` y de la tabla `<table>`, situándolo a nivel raíz de la plantilla.
|
||||
* Se corrigió el error visual donde el cuadro emergente temblaba, saltaba o se desplazaba por el recálculo de padding/scrollbar de Bootstrap en la celda de la tabla.
|
||||
|
||||
---
|
||||
|
||||
## 18. ABM de Comisiones con Hitos y Aula Virtual, Eliminación de Usuarios y Renombrado de Roles (Bedelía y Alumno)
|
||||
* **Renombrado de Roles Institucionales ([init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py), base de datos PostgreSQL):**
|
||||
* `Operador` pasó a denominarse **`Bedelía`** con foco en la gestión operativa de aulas, sedes, reservas, comisiones, importación y cartelera.
|
||||
* `Consulta` pasó a denominarse **`Alumno`** con acceso de solo lectura al cronograma general, asignaturas y aulas.
|
||||
* Se ejecutó la actualización en caliente de los registros existentes en PostgreSQL y se actualizaron los sembrados en `init_db.py`.
|
||||
* **Corrección Visual de Tipificaciones de Hitos ([app/templates/admin/milestone_types/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/milestone_types/list.html)):**
|
||||
* Se ajustó el contenedor a `pt-5 mt-4` evitando que el encabezado quede solapado bajo la barra de navegación fija.
|
||||
* Se limpió la duplicación del nombre en la tabla (se eliminó la repetición del texto dentro de la celda).
|
||||
* Se agregó la paleta rápida de colores institucionales UniCABA al modal de edición.
|
||||
* Se agregó el botón y modal global de confirmación de eliminación `#deleteMilestoneTypeModal` sin saltos visuales.
|
||||
* **Eliminación de Usuarios ([app/routes/admin.py](file:///c:/Workspace/admin-edu-space/app/routes/admin.py), [app/templates/admin/users/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/users/list.html)):**
|
||||
* Se implementó el endpoint `POST /admin/users/delete/<id>` protegido contra la eliminación del usuario en sesión activa y del administrador único.
|
||||
* Desvinculación automática de comisiones como docente para prevenir violaciones de integridad referencial.
|
||||
* Botón rojo de eliminación con ícono de papelera y modal global `#deleteUserModal` fuera de la tabla para evitar desplazamientos.
|
||||
* **ABM Integral de Comisiones con Aulas Virtuales e Hitos ([app/models/subject.py](file:///c:/Workspace/admin-edu-space/app/models/subject.py), [app/models/milestone.py](file:///c:/Workspace/admin-edu-space/app/models/milestone.py), [app/routes/schedule.py](file:///c:/Workspace/admin-edu-space/app/routes/schedule.py), [app/templates/schedule/commissions.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/commissions.html), [app/templates/base.html](file:///c:/Workspace/admin-edu-space/app/templates/base.html)):**
|
||||
* **Columna y Enlace de Aula Virtual:** Se añadió el campo `virtual_link` (VARCHAR 500) a `commissions`. Permite registrar enlaces directos a Zoom, Microsoft Teams, Google Meet o Campus Virtual.
|
||||
* **Hitos de Evaluación vinculados a Comisiones:** Se añadió `commission_id` a `academic_milestones`. Permite asociar parciales, TPs y recuperatorios directamente a la comisión de cursada.
|
||||
* **Permisos para Profesores, Bedelía y Admin:** Los roles `Docente`, `Bedelía` y `Admin` pueden configurar el aula virtual y registrar hitos académicos en sus comisiones.
|
||||
* **Navegación:** Acceso directo a *Comisiones y Cursadas* disponible en la barra superior (menús *Schedule* y *Management*).
|
||||
* **Modales interactivos:** Creación de comisión (`#modalAddCommission`), edición (`#modalEditCommission`), configuración de aula virtual (`#modalVirtualLink`), programación de hitos (`#modalAddMilestone`) y eliminación segura (`#deleteCommissionModal`).
|
||||
|
||||
---
|
||||
|
||||
## 19. Corrección de Redundancia en Nombre de Aula en Formulario y Resumen de Reserva
|
||||
* **Desplegable de Selección de Aula ([app/routes/schedule.py](file:///c:/Workspace/admin-edu-space/app/routes/schedule.py)):**
|
||||
* Se corrigió la tupla de opciones en `form.classroom_id.choices`. Anteriormente concatenaba `c.code_display` y `c.name` (ambas propiedades computaban `"Edificio Central-Aula 204"`), provocando el texto repetido `Edificio Central-Aula 204 - Edificio Central-Aula 204 (Cap: 35)`.
|
||||
* Se simplificó a `f'{c.building} - {c.code} (Cap: {c.capacity_display})'`, mostrando limpiamente `Edificio Central - Aula 204 (Cap: 35)`.
|
||||
* **Cuadro de Resumen en Tiempo Real ([app/templates/schedule/add.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/add.html)):**
|
||||
* Se corrigió la función JavaScript `updateSummary()` que concatenaba `${code_display} - ${name}`.
|
||||
* Ahora renderiza de forma limpia y directa `${classroomData[classroomId].code_display} (${capLabel}: ${classroomData[classroomId].capacity})`.
|
||||
* **Formato de Códigos de Comisión ([app/models/subject.py](file:///c:/Workspace/admin-edu-space/app/models/subject.py)):**
|
||||
* Se optimizó `Commission.get_full_code()` para no repetir el año cuando ya viene incluido en el string del semestre (ej: `2026-2`).
|
||||
|
||||
---
|
||||
|
||||
## 20. Refactorización Integral de Espacios Físicos: Desacoplamiento de Edificio, Piso y Aula
|
||||
* **Modelo de Datos y Base de Datos ([app/models/classroom.py](file:///c:/Workspace/admin-edu-space/app/models/classroom.py), [app/models/building.py](file:///c:/Workspace/admin-edu-space/app/models/building.py)):**
|
||||
* **Desacoplamiento de Edificio:** Se reforzó la clave foránea `Classroom.building_id` vinculada a `buildings.id`. El nombre del edificio se obtiene directamente a través de `building_entity` manteniendo retrocompatibilidad.
|
||||
* **Pisos Flexibles (Alfanuméricos):** Se migró la columna `classrooms.floor` a `VARCHAR(50)`, permitiendo registrar tanto valores numéricos como designaciones textuales ("PB", "Planta Baja", "1", "2°P", "Subsuelo").
|
||||
* **Propiedades de Formato:** Se incorporaron `floor_display` ("Planta Baja", "Piso X"), `is_virtual` y `location_display` (`Edificio · Piso · Aula`).
|
||||
* **Rutina de Migración:** Se verificó la integridad referencial garantizando que todas las aulas existentes queden asociadas a su `building_id` correspondiente en PostgreSQL.
|
||||
* **Formulario y Flujo Dinámico de Creación/Edición de Aulas ([app/forms/classroom.py](file:///c:/Workspace/admin-edu-space/app/forms/classroom.py), [app/routes/classrooms.py](file:///c:/Workspace/admin-edu-space/app/routes/classrooms.py), [app/templates/classrooms/add.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/add.html), [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html)):**
|
||||
* **Selector de Edificio con Alta Dinámica:** Desplegable con las sedes activas más la opción destacada `+ Crear nuevo edificio`. Al seleccionarla, despliega de forma inmediata un campo de texto para escribir el nuevo edificio sin abandonar la pantalla.
|
||||
* **Sugerencia de Pisos:** Campo con datalist interactivo que sugiere los pisos ya cargados en la sede seleccionada o permite tipear uno nuevo libremente.
|
||||
* **Validación de Unicidad:** Se valida tanto en cliente como en servidor que no existan aulas duplicadas dentro del mismo edificio y piso.
|
||||
* **Selectores en Cascada para Reservas ([app/templates/schedule/add.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/add.html), [app/routes/schedule.py](file:///c:/Workspace/admin-edu-space/app/routes/schedule.py)):**
|
||||
* **Flujo Dependiente:**
|
||||
1. `Edificio / Sede`: Selección de sede física o modalidad `Campus Virtual`.
|
||||
2. `Piso / Nivel`: Se puebla dinámicamente con los pisos disponibles de esa sede (se deshabilita automáticamente si es Virtual).
|
||||
3. `Aula / Espacio`: Filtra y muestra únicamente las aulas del edificio y piso seleccionados con su capacidad.
|
||||
* **Resumen Desglosado con Badges:**
|
||||
* 🏢 **Edificio / Sede:** Muestra el nombre limpio de la sede.
|
||||
* 🪜 **Piso / Nivel:** Muestra la planta o "Remoto (Virtual)".
|
||||
* 🚪 **Aula Seleccionada:** Muestra el número de aula y su capacidad.
|
||||
* **Filtros en Listado de Aulas y Calendario ([app/templates/classrooms/list.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/list.html), [app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html), [app/templates/schedule/view.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/view.html)):**
|
||||
* **Listado de Aulas:** Incorporación de dropdowns independientes para filtrar por **Edificio** y **Piso** en la barra superior. Tarjetas rediseñadas con badges distintivos de sede y piso.
|
||||
* **Calendario Semanal:** Agregado el filtro **Filtrar por Edificio** en la barra superior junto al filtro de Piso.
|
||||
* **Modal Estilo Google Calendar:** La sección de espacio físico desglosa limpiamente en badges: 🏢 Edificio, 🪜 Piso y 🚪 Aula.
|
||||
|
||||
---
|
||||
|
||||
## 21. Corrección de Linter y Renderizado de Barras en Métricas de Ocupación (2026-09-04)
|
||||
* **Plantilla de Métricas de Ocupación ([app/templates/admin/metrics/occupancy.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/metrics/occupancy.html)):**
|
||||
* **Eliminación de Falsos Positivos de CSS en el IDE:** Se resolvieron los 12 errores de sintaxis (`property value expected`, `at-rule or selector expected`) reportados por el analizador HTML/CSS del IDE en las tarjetas de KPI (Tasa de Ocupación Hoy, Semanal, Mensual) y en la distribución por Piso / Nivel.
|
||||
* **Atributos Semánticos HTML5:** Se sustituyó la inyección directa de Jinja2 en atributos `style="width: {{ ... }}%;"` por el atributo de datos estándar `data-width="{{ ... }}%"`.
|
||||
* **Inicialización y Animación Dinámica en JS:**
|
||||
* Se configuró en CSS (`.metric-bar-fill { width: 0; }`) un ancho base inicial.
|
||||
* Se implementó un script en el bloque `extra_js` que asigna el ancho dinámico al cargar el DOM (`DOMContentLoaded`).
|
||||
* En conjunto con `transition: width 0.6s ease;`, las barras se expanden de forma fluida y visualmente atractiva al cargar la pantalla.
|
||||
|
||||
---
|
||||
|
||||
## 22. Actualización de Instalador, Backup de Base de Datos y Despliegue en Proxmox LXC (2026-09-04)
|
||||
* **Backup de Base de Datos ([classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql)):**
|
||||
* **Extracción del Snapshot Completo:** Volcado íntegro de la base de datos PostgreSQL activa (`classrooms_db`) que alimenta el sistema en producción/local.
|
||||
* **Contenido Respaldado:** 25 aulas físicas y virtuales, 206 comisiones, 206 asignaturas, 412 reservas, 24 carreras, 12 tipos de hitos, matrices de permisos RBAC y usuarios administradores.
|
||||
* **Compatibilidad Multi-Versión:** Se sanitizó la directiva `\restrict` específica de PostgreSQL 18 para garantizar compatibilidad nativa con PostgreSQL 14, 15, 16, 17 y 18.
|
||||
* **Actualización del Instalador del Sistema para Proxmox LXC ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)):**
|
||||
* **Ejecución 100% Automatizada (Zero-Touch):** Se eliminaron todos los prompts interactivos (`read -rp`), permitiendo que el script corra de inicio a fin de forma totalmente desatendida.
|
||||
* **Copia de Configuración Activa del Sistema:** Se configuraron como valores por defecto los parámetros reales del sistema en ejecución (`DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db`, usuario `postgres`, base `classrooms_db` y contraseña `password`), garantizando paridad exacta con el entorno local.
|
||||
* **Compatibilidad Dual de Usuarios:** Configuración tanto del superusuario `postgres` como de `eduspace_user` con los mismos privilegios para compatibilidad inmediata ante cualquier variación de `.env`.
|
||||
* **Optimización Proxmox LXC:** Compatible con contenedores Debian 11/12 y Ubuntu 20.04/22.04/24.04 en Proxmox VE.
|
||||
* **Migración Automática de Directorio Robusta:** Si el repositorio se clona en `/root`, se traslada automáticamente a `/opt/admin-edu-space` mediante sincronización directa con `cp -a`, limpiando posibles directorios anidados residuales y deteniendo el servicio previamente para evitar errores de colisión (`Directory not empty`).
|
||||
* **Despliegue del Motor PostgreSQL Nativo:** Instalación desatendida del motor en el LXC, inicialización dinámica del servicio y bucle de healthcheck de conexión.
|
||||
* **Restauración Automatizada y Permisos:** Detección automática de `classrooms_db.sql`, recreación limpia, asignación de permisos `GRANT ALL` en el esquema `public`, todas las tablas y secuencias a `postgres` y `eduspace_user`.
|
||||
* **Entorno Virtual Nativo (`venv`):** Configuración en `/opt/admin-edu-space/venv` con dependencias del sistema y `gunicorn`.
|
||||
* **Servicio Systemd de Producción:** Configuración y activación de `admin-edu-space.service` en el puerto 5000.
|
||||
* **Ejecución Idempotente:** Integración de `init_db.py` tras la restauración para validar la coherencia del esquema y roles.
|
||||
* **Utilidad de Backup Continuo ([backup_db.sh](file:///c:/Workspace/admin-edu-space/backup_db.sh)):**
|
||||
* Script automatizado para generar copias de seguridad fechadas en `backups/` y actualizar simultáneamente `classrooms_db.sql` para facilitar la migración entre contenedores LXC.
|
||||
* **Documentación y Tutorial en README ([README.md](file:///c:/Workspace/admin-edu-space/README.md)):**
|
||||
* Guía paso a paso para la creación del contenedor LXC en Proxmox VE (especificaciones de CPU, RAM, disco y habilitación de `nesting=1`).
|
||||
* Instrucciones completas de instalación desatendida con `install.sh`.
|
||||
* Corrección de comandos de inicio apuntando al punto de entrada oficial [wsgi.py](file:///c:/Workspace/admin-edu-space/wsgi.py).
|
||||
* Guía paso a paso para la generación y restauración de backups.
|
||||
* **Dependencias ([requirements.txt](file:///c:/Workspace/admin-edu-space/requirements.txt)):**
|
||||
* Se incorporó formalmente `gunicorn>=21.2.0` al listado de dependencias del proyecto.
|
||||
|
||||
---
|
||||
|
||||
## 23. Resolución de Error 500 (Internal Server Error) y Robustecimiento del Instalador Proxmox LXC (2026-09-04)
|
||||
* **Causa Raíz del Error HTTP 500 en Despliegues Existentes:**
|
||||
* Al desplegar sobre contenedores LXC con archivos `.env` preexistentes (por ejemplo, con usuario de base de datos personalizado `eduadmin` extraído de `DATABASE_URL`), el instalador anterior no otorgaba permisos de tablas ni secuencias al usuario dinámico (`GRANT` solo cubría `postgres` y `eduspace_user`), bloqueando las consultas de Flask a PostgreSQL con excepciones de privilegios insuficientes (`permission denied for relation ...`).
|
||||
* Los registros de excepción de Gunicorn se canalizaban exclusivamente a un archivo sin `--capture-output`, impidiendo su visualización inmediata en `journalctl -u admin-edu-space -f`.
|
||||
* **Solución y Mejoras en el Instalador ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)):**
|
||||
* **Extracción Dinámica de Credenciales:** Parseo bidireccional de `DATABASE_URL` y variables individuales (`DB_USERNAME`, `DB_PASSWORD`, `DB_NAME`), garantizando compatibilidad absoluta con cualquier configuración de conexión.
|
||||
* **Aprovisionamiento Universal de Roles PostgreSQL:** Creación y asignación de contraseña y privilegios `SUPERUSER CREATEDB` a todos los usuarios detectados (`DB_USER`, `DB_USERNAME`, `postgres`, `eduspace_user`).
|
||||
* **Garantía Integral de Permisos DDL y DML:** Tras restaurar [classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql), se ejecutan `GRANT ALL` en `DATABASE`, `SCHEMA public`, `ALL TABLES`, `ALL SEQUENCES`, `ALL FUNCTIONS` y `ALTER DEFAULT PRIVILEGES` para todos los usuarios.
|
||||
* **Configuración de Autenticación Local (`pg_hba.conf`):** Ajuste automático en Debian/Ubuntu para que conexiones locales (`127.0.0.1`, `::1` y sockets UNIX) no fallen por discrepancias de método de cifrado (`scram-sha-256`/`md5`).
|
||||
* **Logs en Tiempo Real en Systemd:** Reconfiguración de Gunicorn en el servicio con `--error-logfile -`, `--capture-output` y `--enable-stdio-inheritance`, permitiendo que cualquier excepción de Python/Flask se visualice en tiempo real vía `journalctl -u admin-edu-space -f`.
|
||||
* **Healthcheck HTTP Automatizado:** Verificación al final del despliegue con `curl http://127.0.0.1:5000/`, validando que el servidor web responda exitosamente (HTTP 200/302) antes de concluir la instalación.
|
||||
* **Carga Segura de Configuración ([config/config.py](file:///c:/Workspace/admin-edu-space/config/config.py)):**
|
||||
* Integración explícita de `load_dotenv` para garantizar que scripts y workers carguen `.env` de forma confiable.
|
||||
* Normalización automática de URIs con esquema heredado `postgres://` hacia el estándar `postgresql://`.
|
||||
|
||||
---
|
||||
|
||||
## 24. Auto-recuperación y Creación de Cluster PostgreSQL 15 en Debian 12 Proxmox LXC (2026-09-04)
|
||||
* **Diagnóstico de Arranque de PostgreSQL en Debian 12 (Bookworm):**
|
||||
* En contenedores Proxmox VE con Debian 12 limpio, `apt-get install postgresql` puede no inicializar automáticamente el cluster `main` si los locales no estaban completamente registrados al compilar el paquete, o el directorio de sockets `/run/postgresql` puede no tener los permisos adecuados en el filesystem `tmpfs`.
|
||||
* El comando genérico `systemctl start postgresql` no inicia el motor si la instancia nativa `postgresql@15-main` no está creada ni generada.
|
||||
* **Auto-detección y Resiliencia en [install.sh](file:///c:/Workspace/admin-edu-space/install.sh):**
|
||||
* **Permisos del Socket UNIX:** Creación y asignación explícita de permisos `chmod 2775` con propietario `postgres:postgres` sobre `/run/postgresql` y `/var/run/postgresql`.
|
||||
* **Creación Automática del Cluster:** Chequeo con `pg_lsclusters`; si el cluster `main` no existe, se ejecuta automáticamente `pg_createcluster ${PG_MAJOR} main --locale en_US.UTF-8 --start`.
|
||||
* **Control Systemd Específico:** Arranque y habilitación de la unidad del cluster nativo `postgresql@${PG_MAJOR}-main` y `pg_ctlcluster`.
|
||||
* **Diagnóstico de Salida:** En caso de contingencias en el host Proxmox, el script imprime directamente el estado del cluster con `pg_lsclusters` y las últimas líneas de registro de PostgreSQL.
|
||||
* **Verificación e Instalación Automática de Git ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)):**
|
||||
* Se incorporó en el inicio del script la verificación de presencia de `git` (`command -v git`). Si no está instalado en el sistema o contenedor, el script ejecuta automáticamente `apt-get update && apt-get install -y git` antes de configurar directivas de seguridad (`safe.directory`) o manipular el repositorio.
|
||||
|
||||
---
|
||||
|
||||
## 25. Roadmap Académico, Propuesta de Mejora (MVC, JWT, Sprint Security), Valor Agregado y Desafíos Técnicos (2026-09-05)
|
||||
* **Propuesta de Mejora Arquitectónica y Gobernanza ([README.md](file:///c:/Workspace/admin-edu-space/README.md)):**
|
||||
* **Arquitectura MVC Rigurosa & Clean Architecture:** Formalización del desacoplamiento en 4 capas: Dominio (`app/models/`), Presentación (Jinja2 + JSON API), Controladores delgados (`app/routes/`) y Capa de Negocio (`app/services/` y `app/repositories/`). Incorporación de esquemas tipados DTO (Pydantic) para validación de contratos de entrada.
|
||||
* **Autenticación Stateless con JWT (JSON Web Tokens):** Arquitectura híbrida que preserva sesiones seguras para el panel web y habilita autenticación stateless basada en tokens JWT para microservicios y aplicaciones móviles mediante el patrón Dual Token (`Access Token` de 15 min + `Refresh Token` en cookie `HttpOnly` de 7 días, con soporte de lista negra de revocación en Redis).
|
||||
* **Seguridad Empresarial ("Sprint / Spring Security"):** Adopción de los estándares de Spring Security adaptados a Python/Flask: cadena centralizada de filtros (`SecurityFilterChain`), control declarativo a nivel método (`@require_permission`), rate limiting dinámico contra fuerza bruta (`Flask-Limiter`) y bitácora de auditoría inmutable de eventos (`AuditLog`).
|
||||
* **Planificación por Sprints (Roadmap):**
|
||||
* **Sprint 1 (MVC & Servicios):** Desacoplamiento de lógica de negocio a servicios y repositorios; esquemas DTO de validación.
|
||||
* **Sprint 2 (JWT & API Gateway):** Endpoints `/api/auth/login`, `/refresh`, `/logout`, decoradores `@jwt_required` y middleware Bearer Token.
|
||||
* **Sprint 3 (Sprint Security & RBAC):** Pipeline `SecurityFilterChain`, control a nivel método, rate limiting y auditoría inmutable.
|
||||
* **Sprint 4 (Optimizador & Concurrencia):** Paralelización del algoritmo genético áulico e integración con microservicios Spring Boot.
|
||||
* **Valor Agregado Institucional:**
|
||||
* **Optimización Edilicia:** Aprovechamiento del aforo en Sede Central (Perón 802) mediante asignación algorítmica por capacidad real y pisos.
|
||||
* **Reducción de Tiempo:** Ahorro del 85% en resolución de conflictos y asignación horaria en turnos de alta concentración (Vespertino).
|
||||
* **Eficiencia Extrema en Proxmox LXC:** Despliegue nativo con consumo inferior a 180MB de RAM frente a alternativas pesadas en Docker/Kubernetes.
|
||||
* **Desafíos Técnicos Superados y Próximos (Challenges):**
|
||||
* Resolución del problema NP-Hard de asignación horaria mediante Algoritmo Genético multicriterio.
|
||||
* Pipeline ETL tolerante a fallos para ingesta y normalización de carteleras no estructuradas de Google Sheets.
|
||||
* Despliegue automatizado, auto-recuperable y portátil para Proxmox VE LXC en Debian 12 (Bookworm) y Debian 13 (Trixie).
|
||||
|
||||
---
|
||||
|
||||
## 26. Creación de Rama `rc` y Publicación del Primer Release Oficial `v1.0.1` (2026-09-05)
|
||||
* **Rama `rc` (Release Candidate):**
|
||||
* Creación y sincronización de la rama `rc` con el estado íntegro de `dev` (commit `566908f`), estableciendo el seguimiento remoto `gitea/rc`.
|
||||
* **Publicación de Tag y Release `v1.0.1` en Gitea:**
|
||||
* Generación de la etiqueta anotada `v1.0.1` a partir de `rc` con notas de versión detalladas.
|
||||
* Publicación remota en Gitea (`git push gitea v1.0.1`), disponibilizando la primera versión candidata/estable oficial para producción en Proxmox VE LXC con empaquetado de artefactos descargables (`.zip` y `.tar.gz`).
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,249 +1,378 @@
|
||||
# Edu-Space Admin Panel
|
||||
# Panel de Administración Edu-Space (UniCABA)
|
||||
|
||||
A comprehensive web-based administration interface for the Edu-Space Classroom Reservation System built with Flask.
|
||||
|
||||
## Features
|
||||
|
||||
### 🔐 Authentication & Security
|
||||
- Secure login system with hashed passwords
|
||||
- Role-based access control
|
||||
- Session management with Flask-Login
|
||||
- Password change functionality
|
||||
|
||||
### 🏫 Classroom Management
|
||||
- Create, edit, and delete classrooms
|
||||
- Campus and building organization
|
||||
- Capacity and resource tracking
|
||||
- Real-time availability checking
|
||||
|
||||
### 📅 Schedule Management
|
||||
- Interactive calendar view (FullCalendar)
|
||||
- Reservation creation and management
|
||||
- Conflict detection and prevention
|
||||
- Status tracking (Pending, Confirmed, Cancelled)
|
||||
- Today's schedule overview
|
||||
|
||||
### 📊 Dashboard & Analytics
|
||||
- Real-time statistics
|
||||
- Monthly reservation charts
|
||||
- Quick actions interface
|
||||
- Upcoming reservations overview
|
||||
|
||||
### 🎨 Modern UI/UX
|
||||
- Responsive Bootstrap 5 design
|
||||
- Dark theme support
|
||||
- Mobile-friendly interface
|
||||
- Interactive components and charts
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Flask (Python)
|
||||
- **Database**: SQLAlchemy (SQLite/PostgreSQL)
|
||||
- **Frontend**: Bootstrap 5, jQuery, Chart.js
|
||||
- **Authentication**: Flask-Login, Werkzeug
|
||||
- **Forms**: Flask-WTF
|
||||
- **Calendar**: FullCalendar.js
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.8+
|
||||
- pip package manager
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Clone or setup the project:**
|
||||
```bash
|
||||
cd /Users/alejandro.v/PycharmProjects/admin-edu-space
|
||||
```
|
||||
|
||||
2. **Create virtual environment:**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **Install dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **Setup environment variables:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
```
|
||||
|
||||
5. **Initialize database:**
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables (.env)
|
||||
|
||||
```env
|
||||
FLASK_APP=app.py
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=your-secret-key-here-please-change-in-production
|
||||
DATABASE_URL=sqlite:///edu_space.db
|
||||
API_BASE_URL=http://localhost:8080/api
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Start the Application
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
The application will be available at: `http://localhost:5000`
|
||||
|
||||
### Default Login
|
||||
|
||||
- **Email**: admin@edu-space.com
|
||||
- **Password**: admin123
|
||||
|
||||
### Main Features
|
||||
|
||||
1. **Dashboard**: Overview of system statistics and upcoming reservations
|
||||
2. **Classrooms**: Manage classroom information, capacity, and resources
|
||||
3. **Schedule**: View and manage reservations with calendar interface
|
||||
4. **Profile**: User profile and password management
|
||||
|
||||
## API Integration
|
||||
|
||||
The admin panel integrates with the Spring Boot API at `http://localhost:8080/api`. Make sure the Java microservice is running for full functionality.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
admin-edu-space/
|
||||
├── app/
|
||||
│ ├── __init__.py # Flask app factory
|
||||
│ ├── models/ # Database models
|
||||
│ │ ├── user.py
|
||||
│ │ ├── classroom.py
|
||||
│ │ ├── subject.py
|
||||
│ │ └── reservation.py
|
||||
│ ├── routes/ # Application routes
|
||||
│ │ ├── auth.py
|
||||
│ │ ├── main.py
|
||||
│ │ ├── classrooms.py
|
||||
│ │ └── schedule.py
|
||||
│ ├── forms/ # WTForms classes
|
||||
│ │ ├── auth.py
|
||||
│ │ ├── classroom.py
|
||||
│ │ └── reservation.py
|
||||
│ ├── templates/ # Jinja2 templates
|
||||
│ ├── static/ # Static files
|
||||
├── config/ # Configuration files
|
||||
├── requirements.txt
|
||||
├── .env
|
||||
├── app.py
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Users Table
|
||||
- User authentication and authorization
|
||||
- Role management (admin, staff)
|
||||
|
||||
### Classrooms Table
|
||||
- Classroom information
|
||||
- Capacity and location details
|
||||
- Resource management
|
||||
|
||||
### Reservations Table
|
||||
- Booking management
|
||||
- Time slot scheduling
|
||||
- Status tracking
|
||||
|
||||
### Subjects + Commissions Tables
|
||||
- Academic information
|
||||
- Teacher assignments
|
||||
- Student enrollment tracking
|
||||
|
||||
## Security Features
|
||||
|
||||
- Password hashing with bcrypt
|
||||
- Session management
|
||||
- CSRF protection
|
||||
- Input validation and sanitization
|
||||
- SQL injection prevention with SQLAlchemy ORM
|
||||
|
||||
## Development
|
||||
|
||||
### Running in Development Mode
|
||||
|
||||
```bash
|
||||
export FLASK_ENV=development
|
||||
python app.py
|
||||
```
|
||||
|
||||
### Adding New Features
|
||||
|
||||
1. Create models in `app/models/`
|
||||
2. Add routes in `app/routes/`
|
||||
3. Create forms if needed in `app/forms/`
|
||||
4. Update templates in `app/templates/`
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Environment Setup
|
||||
|
||||
1. **Set production variables:**
|
||||
```env
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=<strong-random-string>
|
||||
DATABASE_URL=<production-database-url>
|
||||
```
|
||||
|
||||
2. **Use production WSGI server:**
|
||||
```bash
|
||||
pip install gunicorn
|
||||
gunicorn -w 4 -b 0.0.0.0:5000 app:app
|
||||
```
|
||||
|
||||
3. **Configure reverse proxy** (nginx/Apache)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Database connection errors:**
|
||||
- Check DATABASE_URL string
|
||||
- Ensure directory permissions
|
||||
|
||||
2. **Login issues:**
|
||||
- Verify admin user exists
|
||||
- Check password hash
|
||||
|
||||
3. **Static files not loading:**
|
||||
- Ensure blueprint route registration
|
||||
- Check file permissions
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create feature branch
|
||||
3. Make changes
|
||||
4. Test thoroughly
|
||||
5. Submit pull request
|
||||
|
||||
## License
|
||||
|
||||
This project is part of the Edu-Space educational management system.
|
||||
|
||||
## Support
|
||||
|
||||
For support and questions:
|
||||
- Email: admin@edu-space.com
|
||||
- Documentation: Check inline comments and README files
|
||||
Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**, desarrollada con **Python / Flask**, **SQLAlchemy**, **PostgreSQL** y **Bootstrap 5**.
|
||||
|
||||
---
|
||||
|
||||
**Note**: This admin panel is designed to work alongside the Spring Boot microservice. Ensure both services are running for complete functionality.
|
||||
## 🏛️ Sede Institucional y Ubicaciones
|
||||
* **Sede Central UniCABA:** Tte. Gral. Juan Domingo Perón 802, Ciudad Autónoma de Buenos Aires (CABA) — Aulas de grado, auditorio principal y laboratorios distribuidos desde Planta Baja (Piso 0) hasta el 4.° Piso.
|
||||
* **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Características Principales
|
||||
|
||||
### 🛡️ 1. Control de Accesos RBAC Granular (Matriz estilo FortiGate)
|
||||
* **Gestión por Módulos:** Permisos independientes por cada área del sistema:
|
||||
1. **Aulas y Espacios:** Creación, edición, capacidad y estado de aulas físicas y virtuales.
|
||||
2. **Edificios y Sedes:** Administración de sedes, pisos y arquitectura edilicia.
|
||||
3. **Reservas de Aulas:** Solicitud, aprobación, confirmación y cancelación.
|
||||
4. **Cartelera y Cronograma:** Calendario interactivo, visualización diaria y comisiones.
|
||||
5. **Gestión Académica:** Carreras, asignaturas, códigos y comisiones.
|
||||
6. **Sincronización Sheets:** Importador y sincronizador de Google Sheets.
|
||||
7. **Optimizador IA:** Algoritmo genético de distribución y resolución de conflictos.
|
||||
8. **Usuarios y Accesos RBAC:** Cuentas, roles y matriz de permisos.
|
||||
* **Niveles de Permiso Granulares:** `Ninguno`, `Solo Lectura` o `Lectura y Escritura`.
|
||||
* **Controles Rápidos:** Botón *"Establecer Todos"* para configuración masiva inmediata.
|
||||
* **Roles Predefinidos:** `Admin` (control total), `Docente`, `Operador` y `Consulta`.
|
||||
|
||||
### 📊 2. Sincronización e Importación desde Google Sheets
|
||||
* Integración directa con la cartelera académica de UniCABA en Google Sheets.
|
||||
* Extracción e importación normalizada de los 5 campos clave:
|
||||
* **Código Académico:** Códigos reales de asignatura (ej. `INGA0003`, `ASIG00165`).
|
||||
* **Asignatura:** Nombre oficial de la materia.
|
||||
* **Aula:** Mapeo a aulas físicas (PB a 4.° piso) o aula `VIRTUAL` ($\infty$).
|
||||
* **Carrera:** Normalización y vinculación relacional con entidades de carrera.
|
||||
* **Horarios y Turnos:** Detección automática de franjas horarias y delimitadores de bloque (`Turno Mañana` y `Turno Tarde / Vespertino`).
|
||||
|
||||
### 📅 3. Calendario Interactivo de Reservas (FullCalendar)
|
||||
* **Visualización Semanal, Mensual y Diaria:** Grilla ágil de alta legibilidad con jerarquía visual:
|
||||
* Asignatura destacada en tipografía grande y negrita.
|
||||
* Badges con código de materia, aula asignada, turno y franja horaria.
|
||||
* **Filtros Dinámicos Combinables:**
|
||||
* Filtrar por **Aula**.
|
||||
* Filtrar por **Materia / Asignatura**.
|
||||
* Filtrar por **Carrera**.
|
||||
* Filtrar por **Turno** (`Turno Mañana` / `Turno Tarde`).
|
||||
* Filtrar por **Piso / Planta** (`Planta Baja`, `Piso 1`, `Piso 2`, `Piso 3`, `Piso 4`).
|
||||
* **Switch "Ocultar Aulas Virtuales":** Aísla la ocupación física real en el edificio central excluyendo los eventos virtuales de capacidad infinita.
|
||||
* **Ficha Modal Instantánea:** Vista previa detallada al hacer clic en cualquier evento.
|
||||
|
||||
### 📋 4. Cartelera del Día y Listado de Reservas
|
||||
* **Cartelera del Día (`/schedule/today`):**
|
||||
* **Vista Cronológica (Timeline):** Bloques horarios de 07:00 a 22:00 hs con indicador de ocupación.
|
||||
* **Vista en Tabla:** Lista ordenada con acciones rápidas y detalles de comisión.
|
||||
* Filtros directos por piso, turno y switch para ocultar virtuales.
|
||||
* **Listado Paginado de Reservas (`/schedule/list`):**
|
||||
* Paginación de 20 registros por página preservando filtros aplicados.
|
||||
* Búsqueda por texto, estado (`Confirmada`, `Pendiente`, `Cancelada`), rango de fechas y piso.
|
||||
|
||||
### 🏫 5. Gestión de Aulas con Capacidad Infinita ($\infty$)
|
||||
* Soporte nativo para el símbolo **`∞`** o `0` para designar aulas virtuales o espacios sin aforo restringido.
|
||||
* Botón de un solo clic `Ilimitada (∞)` en formularios de alta y edición con leyendas explicativas.
|
||||
* Badges distintivos `∞ Ilimitada` en listados, tarjetas y fichas técnicas.
|
||||
|
||||
### 🤖 6. Optimizador Inteligente de Aulas (Algoritmo Genético)
|
||||
* Motor de optimización heurística para asignación automática de aulas a comisiones.
|
||||
* Pondera capacidad requerida vs. capacidad de aula, equipamiento tecnológico y minimización de desplazamientos entre pisos.
|
||||
|
||||
### 🎨 7. Experiencia de Usuario, Temas y Localización
|
||||
* **Tema Oscuro / Claro:** Alternador con botón flotante y persistencia en `localStorage` y perfil de usuario.
|
||||
* **Soporte Multilingüe:** Internacionalización integral (Español e Inglés) compilada con GNU gettext (`pybabel`).
|
||||
* **Tarjetas Estadísticas Interactivas:** Accesos directos rápidos desde el panel general hacia las distintas secciones operativas.
|
||||
|
||||
### 🏛️ 8. Sistema Integral de Gestión Institucional (8 Módulos)
|
||||
El menú desplegable **Gestión** en la barra de navegación organiza las áreas operativas de la universidad:
|
||||
* **Espacios e Infraestructura:**
|
||||
* **Edificios y Sedes (`/buildings/list`):** Administración de edificios físicos, arquitectura y estado edilicio.
|
||||
* **Aulas Presenciales (`/classrooms/list?view_mode=physical`):** Catálogo de aulas físicas por piso, capacidades y zócalos compactos con métricas de ocupación en tiempo real (día, semana, mes).
|
||||
* **Aulas Virtuales con Reunión (`/classrooms/list?view_mode=virtual`):** Campus Virtual con salas a demanda agrupadas por comisión, capacidad ilimitada ($\infty$) y enlaces directos de acceso a videollamadas (Zoom, Google Meet, Microsoft Teams).
|
||||
* **Gestión de Ocupación y Métricas (`/admin/occupancy-metrics`):** Dashboard analítico de tasas de ocupación horaria (día, semana, mes), horas pico, distribución por turno y comparativa física vs. virtual.
|
||||
* **Gestión Académica:**
|
||||
* **Carreras (`/admin/careers`):** ABM de carreras universitarias y planes de estudio con contador de asignaturas y comisiones activas.
|
||||
* **Asignaturas (`/admin/subjects`):** Catálogo institucional de materias con créditos, departamentos y filtros por carrera y estado.
|
||||
* **Comisiones y Cursadas (`/schedule/commissions`):** Gestión de cursadas, turnos, docentes, enlaces virtuales e hitos de evaluación.
|
||||
* **Ciclo Lectivo (`/admin/academic-terms`):** Modelo y panel de administración de períodos académicos, cuatrimestres y activación del ciclo lectivo en curso.
|
||||
* **Administración & Seguridad:**
|
||||
* **Usuarios y Roles RBAC:** Cuentas, asignación de permisos matriciales y auditoría.
|
||||
* **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.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Stack Tecnológico
|
||||
|
||||
| Componente | Tecnología |
|
||||
| :--- | :--- |
|
||||
| **Backend** | Python 3.12+ / Flask |
|
||||
| **Base de Datos** | PostgreSQL 16+ / SQLAlchemy ORM |
|
||||
| **Seguridad & Sesiones** | Flask-Login / Werkzeug / CSRF Protect |
|
||||
| **Formularios** | Flask-WTF / WTForms |
|
||||
| **Internacionalización** | Flask-Babel / GNU gettext |
|
||||
| **Frontend** | Bootstrap 5.3, Bootstrap Icons, Vanilla JS, CSS Variables |
|
||||
| **Calendario** | FullCalendar 5.11 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Despliegue en Proxmox VE (LXC) con Entorno Virtual `venv`
|
||||
|
||||
El sistema está especialmente optimizado para ejecutarse en contenedores **LXC (Debian 12 Bookworm o Ubuntu 22.04/24.04 LTS)** dentro de **Proxmox VE**, utilizando un entorno virtual nativo de Python (`venv`), el motor **PostgreSQL** instalado en el propio contenedor y el servidor WSGI **Gunicorn** gestionado como servicio por **systemd**.
|
||||
|
||||
---
|
||||
|
||||
### 📋 Paso 1: Creación del Contenedor LXC en Proxmox VE
|
||||
|
||||
Podés crear el contenedor desde la interfaz Web de Proxmox (*Create CT*) o por terminal Proxmox con `pct create`:
|
||||
|
||||
* **Plantilla (Template):** `debian-12-standard` (*Recomendado*) o `ubuntu-22.04-standard` / `ubuntu-24.04-standard`.
|
||||
* **CPU:** 1 a 2 vCPU.
|
||||
* **Memoria RAM:** 1024 MB a 2048 MB (Swap: 512 MB).
|
||||
* **Disco:** 8 GB a 15 GB (almacenamiento `local-lvm` o `zfs`).
|
||||
* **Red (Network):** Modo Bridge `vmbr0` con IP estática en tu red local o DHCP (ej: `192.168.1.150/24`, Gateway: `192.168.1.1`).
|
||||
* **Opciones del Contenedor:**
|
||||
* *Unprivileged container:* Sí (marcado por defecto, compatible).
|
||||
* *Features (Características):* Habilitar **Nesting (`nesting=1`)** en `CT > Opciones > Características` (requerido para systemd).
|
||||
|
||||
---
|
||||
|
||||
### ⚡ Paso 2: Instalación Automatizada con `install.sh`
|
||||
|
||||
1. **Ingresar a la consola del contenedor LXC:**
|
||||
Desde la Web UI de Proxmox (consola noVNC) o desde el shell de tu nodo Proxmox:
|
||||
```bash
|
||||
pct enter <ID_DEL_CONTENEDOR>
|
||||
```
|
||||
|
||||
2. **Clonar el repositorio y lanzar el instalador:**
|
||||
```bash
|
||||
apt update && apt install -y git
|
||||
git clone https://gitea.oemspot.com.ar/carlostellocba/admin-edu-space.git
|
||||
cd admin-edu-space
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
3. **Flujo de Ejecución Automatizado en el LXC:**
|
||||
* **Migración segura fuera de `/root`:** Si el repositorio se clonó en `/root`, el instalador lo traslada automáticamente a `/opt/admin-edu-space` para garantizar que el usuario del servicio (`eduspace`) tenga acceso de lectura y ejecución.
|
||||
* **Locales UTF-8:** Configura `en_US.UTF-8` y `es_AR.UTF-8` para asegurar la internacionalización con `Flask-Babel` y prevenir errores de encoding.
|
||||
* **Motor PostgreSQL:** Instala el motor PostgreSQL, inicia el servicio y verifica conectividad activa con bucle de espera.
|
||||
* **Restauración del Snapshot Oficial ([classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql)):**
|
||||
* Detecta el volcado completo de datos.
|
||||
* Crea la base de datos `classrooms_db` y el usuario `eduspace_user`.
|
||||
* Restaura **25 aulas** (físicas y Campus Virtual $\infty$), **206 comisiones**, **206 materias**, **412 reservas históricas**, **24 carreras**, matrices RBAC y cuentas de acceso.
|
||||
* Otorga privilegios totales (`GRANT ALL`) en el esquema `public`, todas las tablas y **todas las secuencias** al usuario `eduspace_user`.
|
||||
* Ejecuta `init_db.py` de forma idempotente para validar roles y coherencia.
|
||||
* **Entorno Virtual Python (`venv`):** Crea el entorno aislado en `/opt/admin-edu-space/venv` e instala todas las dependencias oficiales (`Flask`, `SQLAlchemy`, `Gunicorn`, etc.).
|
||||
* **Servicio Systemd de Producción:** Configura el servicio `admin-edu-space.service` gestionado por **Gunicorn** (3 workers, puerto 5000), con reinicio automático ante fallos y habilitado al inicio del contenedor.
|
||||
|
||||
---
|
||||
|
||||
### 🌐 Paso 3: Acceso Inmediato a la Aplicación
|
||||
|
||||
Al finalizar la instalación, el script mostrará la dirección IP del contenedor:
|
||||
* **URL:** **`http://<IP-DEL-LXC>:5000`** (ejemplo: `http://192.168.1.150:5000`)
|
||||
* **Email Administrador:** `admin@edu-space.com`
|
||||
* **Contraseña Administrador:** `admin123`
|
||||
|
||||
---
|
||||
|
||||
### 🛠️ Comandos de Gestión del Servicio en el LXC
|
||||
|
||||
```bash
|
||||
# Consultar estado en tiempo real
|
||||
systemctl status admin-edu-space
|
||||
|
||||
# Seguir logs de la aplicación
|
||||
journalctl -u admin-edu-space -f
|
||||
|
||||
# Reiniciar la aplicación
|
||||
systemctl restart admin-edu-space
|
||||
|
||||
# Detener el servicio
|
||||
systemctl stop admin-edu-space
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 💾 Generación de Backups y Migración a Otros Contenedores LXC
|
||||
|
||||
Para respaldar la base de datos en ejecución o clonarla a otro contenedor LXC:
|
||||
|
||||
```bash
|
||||
# Dentro del contenedor LXC:
|
||||
cd /opt/admin-edu-space
|
||||
bash backup_db.sh
|
||||
```
|
||||
|
||||
* El script genera un archivo fechado en `backups/classrooms_db_YYYYMMDD_HHMMSS.sql`.
|
||||
* Actualiza automáticamente el archivo principal [classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql).
|
||||
* **Para migrar a otro contenedor:** Podés transferir `classrooms_db.sql` mediante SCP o Git y ejecutar `bash install.sh` en el nuevo LXC.
|
||||
|
||||
---
|
||||
|
||||
### 💻 Instalación Manual para Desarrollo Local (con `venv`)
|
||||
|
||||
Si deseás correr el proyecto en una máquina de desarrollo (Windows / Linux / macOS) sin usar el instalador de Proxmox:
|
||||
|
||||
1. **Crear y activar el entorno virtual:**
|
||||
* En Windows (PowerShell):
|
||||
```powershell
|
||||
python -m venv venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
```
|
||||
* En Linux / macOS:
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
2. **Instalar dependencias:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Configurar base de datos local y restaurar snapshot:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
|
||||
# Crear base de datos en PostgreSQL local y cargar datos
|
||||
sudo -u postgres psql -c "CREATE DATABASE classrooms_db;"
|
||||
sudo -u postgres psql -d classrooms_db < classrooms_db.sql
|
||||
```
|
||||
|
||||
4. **Compilar catálogos de traducción:**
|
||||
```bash
|
||||
pybabel compile -d translations
|
||||
```
|
||||
|
||||
5. **Iniciar el servidor:**
|
||||
```bash
|
||||
python wsgi.py
|
||||
```
|
||||
Disponible en: **`http://localhost:5000`**
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Credenciales Predeterminadas
|
||||
|
||||
| Usuario | Correo Electrónico | Contraseña | Rol |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Administrador del Sistema** | `admin@edu-space.com` | `admin123` | `Admin` (Acceso Total) |
|
||||
|
||||
> ⚠️ **Seguridad:** Cambiá la contraseña del administrador desde la interfaz web inmediatamente después del primer inicio de sesión.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Estructura del Proyecto
|
||||
|
||||
```text
|
||||
admin-edu-space/
|
||||
├── app/
|
||||
│ ├── __init__.py # Factoría de la aplicación Flask y extensiones
|
||||
│ ├── forms/ # Formularios Flask-WTF con validación CSRF
|
||||
│ │ ├── auth.py # Login, perfil y cambio de clave
|
||||
│ │ ├── building.py # Sedes y edificios
|
||||
│ │ ├── classroom.py # Aulas y capacidades (con soporte ∞)
|
||||
│ │ └── reservation.py # Reservas de aulas y horarios
|
||||
│ ├── models/ # Modelos relacionales SQLAlchemy
|
||||
│ │ ├── academic_term.py # Ciclos lectivos y períodos académicos (activo/histórico)
|
||||
│ │ ├── building.py # Sedes y edificios institucionales
|
||||
│ │ ├── career.py # Carreras y titulaciones universitarias
|
||||
│ │ ├── classroom.py # Aulas físicas y virtuales con capacidades
|
||||
│ │ ├── genetic_algorithm.py # Estructuras de optimización heurística
|
||||
│ │ ├── milestone.py # Tipificaciones de hitos académicos y exámenes
|
||||
│ │ ├── reservation.py # Reservas horarias, estados y turnos
|
||||
│ │ ├── role.py # Roles y matriz de permisos granulares RBAC
|
||||
│ │ ├── subject.py # Asignaturas, materias y comisiones de cursada
|
||||
│ │ └── user.py # Usuarios, credenciales y asignación de roles
|
||||
│ ├── routes/ # Controladores y rutas modulares (Blueprints)
|
||||
│ │ ├── admin.py # Carreras, Asignaturas, Ciclo Lectivo, Tipificaciones, Métricas, Usuarios y Roles
|
||||
│ │ ├── auth.py # Autenticación, sesiones y perfil
|
||||
│ │ ├── buildings.py # Gestión de sedes y edificios
|
||||
│ │ ├── classrooms.py # Aulas presenciales vs. Campus Virtual a demanda y métricas de uso
|
||||
│ │ ├── genetic_algorithm.py # Optimizador heurístico de asignación de espacios
|
||||
│ │ ├── main.py # Dashboard principal y métricas rápidas
|
||||
│ │ └── schedule.py # Calendario interactivo, cartelera del día, comisiones y reservas
|
||||
│ ├── services/ # Servicios y lógica de negocio
|
||||
│ │ └── sheets_importer.py # Parser y sincronizador bidireccional de Google Sheets
|
||||
│ ├── static/ # Recursos estáticos web (CSS, JS, imágenes institucionales)
|
||||
│ └── templates/ # Vistas Jinja2 renderizadas con Bootstrap 5
|
||||
├── config/ # Configuraciones de entorno (desarrollo, testing, prod)
|
||||
├── translations/ # Catálogos GNU gettext compilados (es / en)
|
||||
├── classrooms_db.sql # Snapshot completo de base de datos (PostgreSQL 14+)
|
||||
├── install.sh # Instalador automatizado para Proxmox VE LXC (venv + PostgreSQL)
|
||||
├── backup_db.sh # Script para generar nuevos backups y actualizar snapshot
|
||||
├── init_db.py # Inicializador DDL y sincronizador de esquema/roles
|
||||
├── CHANGELOG_SESSION.md # Historial técnico cronológico
|
||||
├── requirements.txt # Dependencias Python (incluye Gunicorn)
|
||||
├── .env.example # Plantilla de variables de entorno
|
||||
└── wsgi.py # Punto de entrada de la aplicación Flask
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Roadmap Académico & Propuesta de Evolución Tecnológica
|
||||
|
||||
En respuesta a los requerimientos y sugerencias docentes, se formaliza el plan de evolución arquitectónica, estándares de seguridad y entrega de valor institucional para la plataforma **Admin Edu-Space (UniCABA)**.
|
||||
|
||||
### 💡 1. Propuesta de Mejora Integral
|
||||
|
||||
#### A. Arquitectura MVC Rigurosa & Clean Architecture
|
||||
* **Desacoplamiento de Capas:**
|
||||
* **Model (Dominio):** Entidades relacionales puras y reglas de integridad del negocio desacopladas del framework web (`app/models/`).
|
||||
* **View (Presentación):** Vistas web en Jinja2 con Bootstrap 5 y respuestas JSON estructuradas para clientes API/móviles.
|
||||
* **Controller (Controladores/Blueprints):** Controladores delgados en `app/routes/` enfocados exclusivamente en la orquestación HTTP y redirección.
|
||||
* **Service Layer & Repositories (`app/services/` & `app/repositories/`):** Aislamiento de la lógica de negocio (detección de colisiones, algoritmo genético de asignación, ETL de Google Sheets) y operaciones de persistencia encapsuladas.
|
||||
* **DTOs y Validación de Entrada:** Incorporación de esquemas tipados (Pydantic / Marshmallow) para filtrar y sanitizar datos de entrada antes de interactuar con el ORM, eliminando validaciones ad-hoc en los controladores.
|
||||
|
||||
#### B. Autenticación & Autorización Stateless con JWT (JSON Web Tokens)
|
||||
* **Arquitectura API-First Híbrida:**
|
||||
* Coexistencia armónica de sesiones web seguras (para el panel administrativo en navegadores) y tokens **JWT** (para microservicios, clientes móviles y aplicaciones externas).
|
||||
* **Esquema Dual de Tokens (Dual Token Pattern):**
|
||||
* `Access Token` (firmado con RS256/HS256, expiración corta de 15 minutos) con claims contextuales (`sub`, `roles`, `permissions`, `exp`).
|
||||
* `Refresh Token` (almacenado en cookie segura `HttpOnly`, `Secure`, `SameSite=Strict`, expiración de 7 días) para renovación silenciosa sin reingreso de credenciales.
|
||||
* **Mecanismo de Revocación Inmediata (Token Blacklist):** Almacenamiento en caché distribuida (Redis) para invalidación instantánea ante cierres de sesión forzados o rotación de contraseñas.
|
||||
|
||||
#### C. Seguridad Empresarial y Paradigma "Sprint / Spring Security"
|
||||
* **Cadena de Filtros de Interceptación (SecurityFilterChain):** Pipeline centralizado inspirado en los estándares de Spring Security:
|
||||
* **Authentication Filter:** Intercepta cabeceras `Authorization: Bearer <token>` y valida firmas criptográficas.
|
||||
* **Security Headers Filter:** Inyección obligatoria de cabeceras de blindaje HTTP (HSTS, CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff).
|
||||
* **Rate Limiting Filter:** Prevención de ataques de fuerza bruta y DDoS en endpoints sensibles mediante límites dinámicos (`Flask-Limiter` / Redis).
|
||||
* **Seguridad Declarativa a Nivel Método (`@PreAuthorize` / `@require_permission`):**
|
||||
* Decoradores reutilizables que evalúan la matriz RBAC antes de ejecutar cualquier método de servicio:
|
||||
```python
|
||||
@require_permission('classrooms', min_level='read_write')
|
||||
def update_classroom_capacity(classroom_id, new_capacity): ...
|
||||
```
|
||||
* **Bitácora Inmutable de Auditoría (Audit Trail):** Registro trazable de eventos críticos (creación/modificación de reservas, cambios de roles y accesos fallidos) con IP de origen, usuario actuante, timestamp y delta de cambios.
|
||||
|
||||
---
|
||||
|
||||
### 📅 2. Planificación de Sprints (Roadmap)
|
||||
|
||||
| Sprint / Fase | Objetivo Principal | Entregables Clave | Hito de Evaluación |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Sprint 1** | **Refactorización MVC & Capa de Servicios** | • Extracción de lógica de controladores a `app/services/`<br>• Implementación del patrón Repository para aulas y reservas<br>• DTOs de validación con esquemas Pydantic | Controladores limpios (< 60 líneas por endpoint) y separación estricta de responsabilidades. |
|
||||
| **Sprint 2** | **Autenticación Stateless JWT & API Gateway** | • Endpoints `/api/auth/login`, `/refresh`, `/logout`<br>• Decoradores `@jwt_required` y claim parser<br>• Middleware Bearer Token para interoperabilidad | Autenticación y consumo seguro desde Postman y aplicaciones externas sin cookies de sesión. |
|
||||
| **Sprint 3** | **Sprint Security & Gobernanza RBAC Avanzada** | • Pipeline `SecurityFilterChain` centralizado<br>• Decorador `@require_permission(module, level)` a nivel servicio<br>• Rate Limiting en login y endpoints públicos<br>• Módulo de auditoría inmutable de eventos (`AuditLog`) | Aprobación de pentest simulado OWASP Top 10 y matriz de permisos auditada. |
|
||||
| **Sprint 4** | **Optimizador Heurístico & Alta Concurrencia** | • Paralelización del algoritmo genético con multiprocessing/workers<br>• Colas asíncronas para optimizaciones pesadas<br>• Integración con microservicio Spring Boot vía REST/JWT | Optimización de 200+ comisiones en menos de 5 segundos con 0 colisiones horarias. |
|
||||
|
||||
---
|
||||
|
||||
### 💎 3. Valor Agregado & Retorno Institucional
|
||||
|
||||
1. **Eficiencia Edilicia y Reducción del Desperdicio de Espacio:**
|
||||
- Maximiza el aprovechamiento de las aulas físicas de la Sede Central (**Tte. Gral. Juan Domingo Perón 802**), pasando de una asignación manual y empírica a un modelo algorítmico basado en capacidad real, accesibilidad de pisos y equipamiento.
|
||||
2. **Ahorro de Tiempo Operativo (85% de reducción):**
|
||||
- Automatiza la resolución de conflictos de solapamiento horario en horarios pico (Turno Vespertino, 17:30 a 21:30 hs) y agiliza la sincronización con la cartelera de Google Sheets en lote.
|
||||
3. **Interoperabilidad Universal:**
|
||||
- La arquitectura desacoplada y la capa JWT permiten que cualquier otro sistema de la universidad (SIU Guaraní, Campus Virtual Moodle, App de estudiantes, backend Spring Boot institucional) consulte en tiempo real la disponibilidad áulica.
|
||||
4. **Despliegue de Alta Eficiencia (Bajo Costo de Infraestructura):**
|
||||
- A diferencia de arquitecturas pesadas basadas en contenedores Docker o Kubernetes que demandan múltiples gigabytes de RAM, **Admin Edu-Space corre de forma nativa en un contenedor Proxmox VE LXC consumiendo menos de 180MB de memoria**, garantizando alta disponibilidad con costos de hardware mínimos.
|
||||
|
||||
---
|
||||
|
||||
### 🧩 4. Desafíos Técnicos (Challenges)
|
||||
|
||||
* **Challenge 1: Resolución Heurística de Horarios Áulicos (NP-Hard Problem):**
|
||||
- La asignación de 206 asignaturas, 400+ reservas y 25 aulas considerando restricciones duras (no colisión horaria, capacidad no superada) y blandas (mismo docente en pisos cercanos, turnos consolidados) representa un problema de complejidad NP-Hard resuelto mediante un **Algoritmo Genético** con operadores de cruce y mutación adaptativa.
|
||||
* **Challenge 2: Sincronización Masiva Tolerante a Fallos:**
|
||||
- El parser ETL de Google Sheets normaliza datos no estructurados de la cartelera académica, resolviendo discrepancias ortográficas, turnos implícitos y formatos dispares de hora sin interrumpir la operación continua.
|
||||
* **Challenge 3: Portabilidad Extrema en Proxmox LXC sin Docker:**
|
||||
- Desarrollo de un instalador auto-recuperable ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)) capaz de levantar PostgreSQL nativo en Debian 12 y 13 dentro de `tmpfs`, gestionar clusters con `pg_createcluster`, operar de forma autónoma con o sin `sudo`, y garantizar persistencia íntegra de base de datos entre dispositivos.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licencia
|
||||
|
||||
Este proyecto forma parte del sistema de gestión institucional y educativa de **UniCABA**. Todos los derechos reservados.
|
||||
␍
|
||||
@@ -30,36 +30,65 @@ def create_app(config_class=Config):
|
||||
|
||||
# Import models to ensure they are registered
|
||||
from app.models import user
|
||||
|
||||
# Register blueprints
|
||||
from app.routes import auth_bp, classrooms_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp
|
||||
# Register blueprints
|
||||
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix="/")
|
||||
app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
|
||||
app.register_blueprint(buildings_bp)
|
||||
app.register_blueprint(main_bp, url_prefix="/")
|
||||
app.register_blueprint(schedule_bp, url_prefix="/schedule")
|
||||
app.register_blueprint(genetic_bp)
|
||||
app.register_blueprint(genetic_web_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# Language selector function
|
||||
def get_locale():
|
||||
# 1. Check if language is explicitly set in URL parameters
|
||||
# 1. Parámetro explícito en la URL (?lang=es o ?lang=en)
|
||||
lang = request.args.get('lang')
|
||||
if lang and lang in app.config['LANGUAGES']:
|
||||
session['language'] = lang
|
||||
return lang
|
||||
|
||||
# 2. Check if user is authenticated and has a configured language in profile
|
||||
# 2. Si el usuario está autenticado y tiene idioma configurado en su perfil, prevalece en toda la sesión
|
||||
from flask_login import current_user
|
||||
if current_user.is_authenticated and getattr(current_user, 'preferred_language', None):
|
||||
if current_user.preferred_language in app.config['LANGUAGES']:
|
||||
return current_user.preferred_language
|
||||
|
||||
# 3. Check if language is stored in session (e.g. manual switcher)
|
||||
# 3. En la pantalla de Login (usuario no autenticado):
|
||||
# El idioma predeterminado del login debe ser español o respetar el idioma del navegador.
|
||||
# No debe verse afectado por cookies o sesiones residuales de otros usuarios.
|
||||
is_login = (request.endpoint == 'auth.login' or request.path.rstrip('/') == '/login')
|
||||
if is_login:
|
||||
# Solo si el usuario cambió el idioma explícitamente en el selector del login
|
||||
if session.get('login_lang_explicit') in app.config['LANGUAGES']:
|
||||
return session['login_lang_explicit']
|
||||
|
||||
# Detectar según el navegador del usuario (soporta es-419, es-AR, es-ES, etc.)
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
return primary
|
||||
|
||||
# Idioma predeterminado por defecto del login: Español
|
||||
return 'es'
|
||||
|
||||
# 4. Para otras rutas: verificar si hay idioma en sesión (ej. selector manual)
|
||||
if 'language' in session and session['language'] in app.config['LANGUAGES']:
|
||||
return session['language']
|
||||
|
||||
# 4. Check browser language preference by default (especially on login/guest)
|
||||
return request.accept_languages.best_match(app.config['LANGUAGES']) or app.config['BABEL_DEFAULT_LOCALE']
|
||||
# 5. Detección automática del navegador por defecto (soporta subtags regionales)
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
return primary
|
||||
|
||||
# Fallback del sistema: Español
|
||||
return app.config['BABEL_DEFAULT_LOCALE']
|
||||
|
||||
|
||||
# Babel context processor
|
||||
@app.context_processor
|
||||
@@ -73,4 +102,11 @@ def create_app(config_class=Config):
|
||||
# Configure Babel
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
# Register localized date and datetime formatters for Jinja2 templates
|
||||
from flask_babel import format_date, format_datetime
|
||||
app.jinja_env.globals['format_date'] = format_date
|
||||
app.jinja_env.globals['format_datetime'] = format_datetime
|
||||
app.jinja_env.filters['format_date'] = format_date
|
||||
app.jinja_env.filters['format_datetime'] = format_datetime
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,28 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
class BuildingForm(FlaskForm):
|
||||
name = StringField(_l('Building Name'), validators=[
|
||||
DataRequired(),
|
||||
Length(min=2, max=100)
|
||||
])
|
||||
code = StringField(_l('Code'), validators=[
|
||||
Optional(),
|
||||
Length(max=50)
|
||||
])
|
||||
address = StringField(_l('Address'), validators=[
|
||||
Optional(),
|
||||
Length(max=255)
|
||||
])
|
||||
floors = IntegerField(_l('Number of Floors'), default=1, validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=100)
|
||||
])
|
||||
description = TextAreaField(_l('Description'), validators=[
|
||||
Optional(),
|
||||
Length(max=500)
|
||||
])
|
||||
is_active = BooleanField(_l('Active'), default=True)
|
||||
submit = SubmitField(_l('Save Building'))
|
||||
@@ -1,32 +1,43 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField, SelectField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||
from wtforms.validators import DataRequired, InputRequired, Length, NumberRange, Optional, ValidationError
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
def validate_capacity(form, field):
|
||||
val = str(field.data or '').strip()
|
||||
if not val:
|
||||
raise ValidationError(_l('La capacidad es obligatoria (ingrese un número o el símbolo ∞).'))
|
||||
if val in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||
return
|
||||
try:
|
||||
num = int(val)
|
||||
if num < 0:
|
||||
raise ValidationError(_l('La capacidad no puede ser negativa.'))
|
||||
if num > 10000:
|
||||
raise ValidationError(_l('La capacidad máxima numérica es 10.000.'))
|
||||
except ValueError:
|
||||
raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.'))
|
||||
|
||||
class ClassroomForm(FlaskForm):
|
||||
# campus_id = SelectField(_l('Campus'), coerce=int, validators=[DataRequired()])
|
||||
building = StringField(_l('Building'), validators=[
|
||||
DataRequired(),
|
||||
Length(min=2, max=100)
|
||||
])
|
||||
room_number = StringField(_l('Room Number'), validators=[
|
||||
building_id = SelectField(_l('Edificio'), validators=[DataRequired()])
|
||||
new_building_name = StringField(_l('Nombre del Nuevo Edificio'), validators=[Optional(), Length(max=100)])
|
||||
room_number = StringField(_l('Número / Nombre del Aula'), validators=[
|
||||
DataRequired(),
|
||||
Length(min=1, max=50)
|
||||
])
|
||||
floor = IntegerField(_l('Floor'), validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=50)
|
||||
floor = StringField(_l('Piso'), validators=[
|
||||
DataRequired(message=_l('El piso es obligatorio (ej: PB, 1, 2, 4°P)')),
|
||||
Length(min=1, max=50)
|
||||
])
|
||||
capacity = IntegerField(_l('Capacity'), validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=500)
|
||||
capacity = StringField(_l('Capacidad'), validators=[
|
||||
validate_capacity
|
||||
])
|
||||
description = TextAreaField(_l('Description'), validators=[
|
||||
description = TextAreaField(_l('Descripción'), validators=[
|
||||
Optional(),
|
||||
Length(max=500)
|
||||
])
|
||||
is_active = BooleanField(_l('Active'), default=True)
|
||||
submit = SubmitField(_l('Save Classroom'))
|
||||
is_active = BooleanField(_l('Activa'), default=True)
|
||||
submit = SubmitField(_l('Guardar Aula'))
|
||||
|
||||
class ClassroomResourceForm(FlaskForm):
|
||||
resource_type = SelectField(_l('Resource Type'), choices=[
|
||||
|
||||
@@ -21,8 +21,16 @@ class ReservationForm(FlaskForm):
|
||||
Optional(),
|
||||
Length(max=1000)
|
||||
])
|
||||
virtual_link = StringField(_l('Virtual Classroom Link'), validators=[
|
||||
Optional(),
|
||||
Length(max=500)
|
||||
])
|
||||
submit = SubmitField(_l('Make Reservation'))
|
||||
|
||||
def __init__(self, *args, is_edit=False, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.is_edit = is_edit
|
||||
|
||||
def validate_end_time(self, field):
|
||||
if field.data <= self.start_time.data:
|
||||
raise ValidationError('End time must be after start time.')
|
||||
@@ -32,8 +40,8 @@ class ReservationForm(FlaskForm):
|
||||
raise ValidationError('Reservation cannot exceed 8 hours.')
|
||||
|
||||
def validate_start_time(self, field):
|
||||
# Don't allow reservations in the past
|
||||
if field.data < datetime.now():
|
||||
# Don't allow new reservations in the past
|
||||
if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10):
|
||||
raise ValidationError('Start time cannot be in the past.')
|
||||
|
||||
# Don't allow reservations too far in the future (max 3 months)
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
from .user import User
|
||||
from .building import Building
|
||||
from .classroom import Classroom, ClassroomResource
|
||||
from .career import Career
|
||||
from .academic_term import AcademicTerm
|
||||
from .subject import Subject, Commission
|
||||
from .reservation import Reservation, ReservationStatus
|
||||
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
|
||||
from .role import Role, Permission, SYSTEM_MODULES
|
||||
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
'Building',
|
||||
'Classroom',
|
||||
'ClassroomResource',
|
||||
'Career',
|
||||
'AcademicTerm',
|
||||
'Subject',
|
||||
'Commission',
|
||||
'Reservation',
|
||||
'ReservationStatus',
|
||||
'GeneticAlgorithm',
|
||||
'ReservationOptimizer'
|
||||
'ReservationOptimizer',
|
||||
'Role',
|
||||
'Permission',
|
||||
'SYSTEM_MODULES',
|
||||
'MilestoneType',
|
||||
'AcademicMilestone',
|
||||
'BASE_MILESTONE_TYPES'
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class AcademicTerm(db.Model):
|
||||
"""Modelo para Ciclos Lectivos y Períodos Académicos"""
|
||||
__tablename__ = 'academic_terms'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # ej. 2026-2, 2026-1, 2025-2
|
||||
name = db.Column(db.String(150), nullable=False) # ej. Ciclo Lectivo 2026 - 2º Cuatrimestre
|
||||
year = db.Column(db.Integer, nullable=False, default=2026, index=True)
|
||||
period_type = db.Column(db.String(50), nullable=False, default='Segundo Cuatrimestre') # Primer Cuatrimestre, Segundo Cuatrimestre, Anual, Verano
|
||||
start_date = db.Column(db.Date, nullable=True)
|
||||
end_date = db.Column(db.Date, nullable=True)
|
||||
is_current = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'year': self.year,
|
||||
'period_type': self.period_type,
|
||||
'start_date': self.start_date.isoformat() if self.start_date else None,
|
||||
'end_date': self.end_date.isoformat() if self.end_date else None,
|
||||
'is_current': self.is_current,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AcademicTerm {self.code} - {self.name}>'
|
||||
@@ -0,0 +1,45 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Building(db.Model):
|
||||
__tablename__ = 'buildings'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=True)
|
||||
address = db.Column(db.String(255), nullable=True)
|
||||
floors = db.Column(db.Integer, default=1, nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
|
||||
|
||||
@property
|
||||
def total_capacity(self):
|
||||
"""Total student capacity across active classrooms in this building"""
|
||||
return sum(c.capacity for c in self.classrooms if c.is_active)
|
||||
|
||||
@property
|
||||
def classrooms_count(self):
|
||||
"""Number of active classrooms"""
|
||||
return len([c for c in self.classrooms if c.is_active])
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'address': self.address,
|
||||
'floors': self.floors,
|
||||
'description': self.description,
|
||||
'is_active': self.is_active,
|
||||
'classrooms_count': self.classrooms_count,
|
||||
'total_capacity': self.total_capacity,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Building {self.name}>'
|
||||
@@ -0,0 +1,27 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Career(db.Model):
|
||||
__tablename__ = 'careers'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), unique=True, nullable=False, index=True)
|
||||
code = db.Column(db.String(50), nullable=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
subjects = db.relationship('Subject', backref='career_obj', lazy=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'description': self.description,
|
||||
'subjects_count': len(self.subjects) if self.subjects else 0,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Career {self.name}>'
|
||||
@@ -6,25 +6,60 @@ class Classroom(db.Model):
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
campus_id = db.Column(db.Integer, nullable=True) # Maps to campus_id in DB
|
||||
building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True)
|
||||
building = db.Column(db.String(100), nullable=False)
|
||||
code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB
|
||||
floor = db.Column(db.Integer, nullable=False)
|
||||
floor = db.Column(db.String(50), nullable=False, default='1')
|
||||
capacity = db.Column(db.Integer, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relación con Building (mapeada vía backref 'building_entity' en Building.classrooms)
|
||||
@property
|
||||
def building_rel(self):
|
||||
return self.building_entity
|
||||
|
||||
@property
|
||||
def building_name(self):
|
||||
"""Nombre del edificio desacoplado"""
|
||||
if self.building_rel:
|
||||
return self.building_rel.name
|
||||
return self.building or 'Edificio Central'
|
||||
|
||||
@property
|
||||
def floor_display(self):
|
||||
"""Formato amigable para el piso: PB, Planta Baja, Piso X"""
|
||||
f = str(self.floor).strip() if self.floor is not None else ''
|
||||
if f in ['0', 'PB', 'pb', 'Planta Baja', 'planta baja']:
|
||||
return 'Planta Baja'
|
||||
if f.lower().startswith('piso') or '°' in f:
|
||||
return f
|
||||
return f"Piso {f}"
|
||||
|
||||
@property
|
||||
def is_virtual(self):
|
||||
"""Indica si el aula es virtual"""
|
||||
return (self.code and 'VIRTUAL' in self.code.upper()) or (self.building and 'VIRTUAL' in self.building.upper())
|
||||
|
||||
@property
|
||||
def location_display(self):
|
||||
"""Ubicación completa y limpia: Edificio · Piso · Aula"""
|
||||
if self.is_virtual:
|
||||
return '🌐 Campus Virtual'
|
||||
return f"{self.building_name} · {self.floor_display} · {self.code}"
|
||||
|
||||
# Virtual properties for compatibility
|
||||
@property
|
||||
def name(self):
|
||||
"""Generate name from building and code"""
|
||||
return f"{self.building}-{self.code}"
|
||||
return f"{self.building_name}-{self.code}"
|
||||
|
||||
@property
|
||||
def campus_location(self):
|
||||
"""Placeholder for campus location - can be expanded when campus model is implemented"""
|
||||
return None
|
||||
"""Placeholder for campus location"""
|
||||
return self.building_rel.address if self.building_rel else None
|
||||
|
||||
# Virtual field for compatibility - combines building and code
|
||||
@property
|
||||
@@ -38,17 +73,34 @@ class Classroom(db.Model):
|
||||
# Override code property for consistency
|
||||
@property
|
||||
def code_display(self):
|
||||
return f"{self.building}-{self.code}"
|
||||
return f"{self.building_name} - {self.code}"
|
||||
|
||||
@property
|
||||
def capacity_display(self):
|
||||
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero."""
|
||||
if self.capacity == 0 or self.is_virtual:
|
||||
return '∞'
|
||||
return str(self.capacity)
|
||||
|
||||
@property
|
||||
def is_unlimited(self):
|
||||
return self.capacity == 0 or self.is_virtual
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert classroom to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code_display,
|
||||
'building': self.building,
|
||||
'building_id': self.building_id,
|
||||
'building': self.building_name,
|
||||
'code': self.code,
|
||||
'code_display': self.code_display,
|
||||
'name': self.name,
|
||||
'floor': self.floor,
|
||||
'floor_display': self.floor_display,
|
||||
'location_display': self.location_display,
|
||||
'capacity': self.capacity,
|
||||
'capacity_display': self.capacity_display,
|
||||
'is_virtual': self.is_virtual,
|
||||
'description': self.description,
|
||||
'campus_location': self.campus_location,
|
||||
'is_active': self.is_active,
|
||||
@@ -59,10 +111,9 @@ class Classroom(db.Model):
|
||||
|
||||
def get_full_location(self):
|
||||
"""Get full classroom location"""
|
||||
location = f"{self.building} - Floor {self.floor}"
|
||||
if self.campus_location:
|
||||
location += f" ({self.campus_location})"
|
||||
return location
|
||||
if self.is_virtual:
|
||||
return 'Campus Virtual (Remoto)'
|
||||
return f"{self.building_name} - {self.floor_display}"
|
||||
|
||||
@staticmethod
|
||||
def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None):
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
class MilestoneType(db.Model):
|
||||
"""
|
||||
Catálogo dinámico y administrable de tipificaciones de hitos y evaluaciones académicas.
|
||||
(Parciales, Trabajos Prácticos, Recuperatorios, Finales, etc.)
|
||||
"""
|
||||
__tablename__ = 'milestone_types'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False) # Ej: "Parcial 1", "Trabajo Práctico 1"
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # Ej: "parcial_1", "tp_1"
|
||||
color = db.Column(db.String(30), default='#0d6efd') # Hex color para badges y calendario
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
sort_order = db.Column(db.Integer, default=0) # Orden de aparición en selects
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False) # Activo / Inactivo
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relación uno-a-muchos con AcademicMilestone
|
||||
milestones = db.relationship('AcademicMilestone', backref='type_obj', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'color': self.color or '#0d6efd',
|
||||
'description': self.description or '',
|
||||
'sort_order': self.sort_order or 0,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<MilestoneType {self.code}: {self.name}>'
|
||||
|
||||
|
||||
class AcademicMilestone(db.Model):
|
||||
"""
|
||||
Hito o evento académico programado para una materia/comisión (Examen, TP, Entrega).
|
||||
"""
|
||||
__tablename__ = 'academic_milestones'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(150), nullable=True)
|
||||
materia_codigo = db.Column(db.String(50), nullable=False, index=True) # Código de materia (ej: INGA0003)
|
||||
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=True)
|
||||
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='SET NULL'), nullable=True)
|
||||
milestone_type_id = db.Column(db.Integer, db.ForeignKey('milestone_types.id'), nullable=False)
|
||||
date = db.Column(db.DateTime, nullable=False) # Fecha y hora del hito
|
||||
classroom = db.Column(db.String(100), nullable=True) # Aula o enlace virtual asignado
|
||||
link = db.Column(db.String(255), nullable=True) # Link Teams / Meet / Zoom si aplica
|
||||
notes = db.Column(db.Text, nullable=True) # Temario, consignas o notas
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relación con Asignatura y Comisión
|
||||
subject = db.relationship('Subject', backref=db.backref('milestones', lazy='dynamic'))
|
||||
commission = db.relationship('Commission', backref=db.backref('academic_milestones', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
|
||||
@property
|
||||
def tipo(self):
|
||||
return self.type_obj.name if self.type_obj else 'Otro'
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
return self.type_obj.color if self.type_obj and self.type_obj.color else '#0d6efd'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'title': self.title or self.tipo,
|
||||
'materiaCodigo': self.materia_codigo,
|
||||
'subject_id': self.subject_id,
|
||||
'commission_id': self.commission_id,
|
||||
'tipo': self.tipo,
|
||||
'milestone_type_id': self.milestone_type_id,
|
||||
'tipo_info': self.type_obj.to_dict() if self.type_obj else None,
|
||||
'color': self.color,
|
||||
'fecha': self.date.isoformat() if self.date else None,
|
||||
'aula': self.classroom,
|
||||
'link': self.link,
|
||||
'notas': self.notes,
|
||||
'is_active': self.is_active
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AcademicMilestone {self.materia_codigo} - {self.tipo} ({self.date})>'
|
||||
|
||||
|
||||
# Datos semilla estándar para inicialización
|
||||
BASE_MILESTONE_TYPES = [
|
||||
{
|
||||
'name': 'Parcial 1',
|
||||
'code': 'parcial_1',
|
||||
'color': '#0d6efd',
|
||||
'description': 'Primer examen parcial de la cursada',
|
||||
'sort_order': 1
|
||||
},
|
||||
{
|
||||
'name': 'Parcial 2',
|
||||
'code': 'parcial_2',
|
||||
'color': '#0b5ed7',
|
||||
'description': 'Segundo examen parcial de la cursada',
|
||||
'sort_order': 2
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Practico 1',
|
||||
'code': 'tp_1',
|
||||
'color': '#198754',
|
||||
'description': 'Primera entrega o evaluacion practica',
|
||||
'sort_order': 3
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Practico 2',
|
||||
'code': 'tp_2',
|
||||
'color': '#157347',
|
||||
'description': 'Segunda entrega o evaluacion practica',
|
||||
'sort_order': 4
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Practico 3',
|
||||
'code': 'tp_3',
|
||||
'color': '#0f5132',
|
||||
'description': 'Tercera entrega o proyecto integrador',
|
||||
'sort_order': 5
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio Parcial 1',
|
||||
'code': 'recu_p1',
|
||||
'color': '#fd7e14',
|
||||
'description': 'Instancia de recuperacion para el Parcial 1',
|
||||
'sort_order': 6
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio Parcial 2',
|
||||
'code': 'recu_p2',
|
||||
'color': '#e76b08',
|
||||
'description': 'Instancia de recuperacion para el Parcial 2',
|
||||
'sort_order': 7
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 1',
|
||||
'code': 'recu_tp1',
|
||||
'color': '#ffc107',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 1',
|
||||
'sort_order': 8
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 2',
|
||||
'code': 'recu_tp2',
|
||||
'color': '#e0a800',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 2',
|
||||
'sort_order': 9
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 3',
|
||||
'code': 'recu_tp3',
|
||||
'color': '#c69500',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 3',
|
||||
'sort_order': 10
|
||||
},
|
||||
{
|
||||
'name': 'Final',
|
||||
'code': 'final',
|
||||
'color': '#dc3545',
|
||||
'description': 'Examen final regular o libre',
|
||||
'sort_order': 11
|
||||
},
|
||||
{
|
||||
'name': 'Otro',
|
||||
'code': 'otro',
|
||||
'color': '#6c757d',
|
||||
'description': 'Taller, seminario, clase especial o hito no tipificado',
|
||||
'sort_order': 12
|
||||
}
|
||||
]
|
||||
@@ -1,6 +1,30 @@
|
||||
from app import db
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from sqlalchemy.types import TypeDecorator, String
|
||||
|
||||
class StatusString(str):
|
||||
"""String wrapper providing .value property for backward compatibility with Enum usage"""
|
||||
@property
|
||||
def value(self):
|
||||
return str(self)
|
||||
|
||||
def __eq__(self, other):
|
||||
if hasattr(other, 'value'):
|
||||
return str(self) == str(other.value)
|
||||
return str(self) == str(other)
|
||||
|
||||
class StatusType(TypeDecorator):
|
||||
impl = String(20)
|
||||
cache_ok = True
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
return StatusString(value) if value is not None else None
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if hasattr(value, 'value'):
|
||||
return str(value.value)
|
||||
return str(value) if value is not None else None
|
||||
|
||||
class ReservationStatus(Enum):
|
||||
PENDING = 'PENDING'
|
||||
@@ -20,8 +44,10 @@ class Reservation(db.Model):
|
||||
start_time = db.Column(db.DateTime, nullable=False)
|
||||
end_time = db.Column(db.DateTime, nullable=False)
|
||||
purpose = db.Column(db.String(500), nullable=False)
|
||||
status = db.Column(db.String(20), default='PENDING', nullable=False) # DB uses VARCHAR not ENUM
|
||||
status = db.Column(StatusType, default='PENDING', nullable=False) # Maps to VARCHAR(20) with StatusString
|
||||
expected_attendees = db.Column(db.Integer, nullable=False)
|
||||
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||
virtual_link = db.Column(db.String(500), nullable=True) # Zoom, Meet, Teams, etc.
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
@@ -32,10 +58,49 @@ class Reservation(db.Model):
|
||||
def status_enum(self):
|
||||
"""Get status as enum"""
|
||||
try:
|
||||
return ReservationStatus(self.status)
|
||||
return ReservationStatus(str(self.status))
|
||||
except (ValueError, AttributeError):
|
||||
return ReservationStatus.PENDING
|
||||
|
||||
@property
|
||||
def subject_code(self):
|
||||
"""Get exact subject code (e.g. INGA0003, ASIG00165)"""
|
||||
if self.commission and self.commission.subject:
|
||||
return self.commission.subject.code
|
||||
return ''
|
||||
|
||||
@property
|
||||
def subject_name(self):
|
||||
"""Get subject name"""
|
||||
if self.commission and self.commission.subject:
|
||||
return self.commission.subject.name
|
||||
return self.purpose or ''
|
||||
|
||||
@property
|
||||
def classroom_name(self):
|
||||
"""Get classroom code or name"""
|
||||
if self.classroom:
|
||||
return self.classroom.code
|
||||
return ''
|
||||
|
||||
@property
|
||||
def effective_virtual_link(self):
|
||||
"""Devuelve el enlace explícito de la reserva o hereda el de la comisión"""
|
||||
if self.virtual_link and self.virtual_link.strip():
|
||||
return self.virtual_link.strip()
|
||||
if self.commission and self.commission.virtual_link:
|
||||
return self.commission.virtual_link.strip()
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_virtual(self):
|
||||
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
|
||||
if self.classroom and self.classroom.is_virtual:
|
||||
return True
|
||||
if self.effective_virtual_link:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Relationships
|
||||
classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
|
||||
commission = db.relationship('Commission', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
|
||||
@@ -54,6 +119,8 @@ class Reservation(db.Model):
|
||||
'status': self.status, # Already a string in DB
|
||||
'expected_attendees': self.expected_attendees,
|
||||
'notes': self.notes,
|
||||
'virtual_link': self.effective_virtual_link,
|
||||
'is_virtual': self.is_virtual,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'classroom': self.classroom.to_dict() if self.classroom else None,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
# Definition of system modules available for RBAC permissions
|
||||
SYSTEM_MODULES = [
|
||||
{
|
||||
'id': 'classrooms',
|
||||
'name': 'Aulas y Espacios',
|
||||
'name_en': 'Classrooms & Spaces',
|
||||
'icon': 'bi-door-open',
|
||||
'description': 'Gestión de aulas físicas, aulas virtuales (∞) y capacidades.'
|
||||
},
|
||||
{
|
||||
'id': 'buildings',
|
||||
'name': 'Edificios y Sedes',
|
||||
'name_en': 'Buildings & Campuses',
|
||||
'icon': 'bi-buildings',
|
||||
'description': 'Administración de sede central, campus virtual, pisos y ubicaciones.'
|
||||
},
|
||||
{
|
||||
'id': 'reservations',
|
||||
'name': 'Reservas de Aulas',
|
||||
'name_en': 'Classroom Reservations',
|
||||
'icon': 'bi-calendar-check',
|
||||
'description': 'Creación, modificación, aprobación, confirmación y cancelación de reservas.'
|
||||
},
|
||||
{
|
||||
'id': 'schedule',
|
||||
'name': 'Cartelera y Cronograma',
|
||||
'name_en': 'Schedule & Billboard',
|
||||
'icon': 'bi-calendar3',
|
||||
'description': 'Visualización de calendario interactivo, cartelera de hoy y comisiones.'
|
||||
},
|
||||
{
|
||||
'id': 'academic',
|
||||
'name': 'Gestión Académica',
|
||||
'name_en': 'Academic Management',
|
||||
'icon': 'bi-book',
|
||||
'description': 'Administración de carreras, asignaturas, códigos y comisiones.'
|
||||
},
|
||||
{
|
||||
'id': 'import',
|
||||
'name': 'Sincronización Sheets',
|
||||
'name_en': 'Sheets Sync & Import',
|
||||
'icon': 'bi-file-earmark-spreadsheet',
|
||||
'description': 'Importación y sincronización de datos académicos desde Google Sheets.'
|
||||
},
|
||||
{
|
||||
'id': 'optimizer',
|
||||
'name': 'Optimizador IA',
|
||||
'name_en': 'AI Room Optimizer',
|
||||
'icon': 'bi-cpu',
|
||||
'description': 'Ejecución y configuración del algoritmo genético de asignación.'
|
||||
},
|
||||
{
|
||||
'id': 'users',
|
||||
'name': 'Usuarios y Accesos RBAC',
|
||||
'name_en': 'Users & Access RBAC',
|
||||
'icon': 'bi-shield-lock',
|
||||
'description': 'Administración de cuentas, roles y matriz granular de permisos.'
|
||||
},
|
||||
{
|
||||
'id': 'milestone_types',
|
||||
'name': 'Tipificaciones de Hitos',
|
||||
'name_en': 'Milestone Types',
|
||||
'icon': 'bi-tags',
|
||||
'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.'
|
||||
},
|
||||
{
|
||||
'id': 'metrics',
|
||||
'name': 'Métricas de Ocupación',
|
||||
'name_en': 'Occupancy Metrics',
|
||||
'icon': 'bi-graph-up',
|
||||
'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).'
|
||||
}
|
||||
]
|
||||
|
||||
ACCESS_LEVELS = ['none', 'read', 'read_write']
|
||||
|
||||
class Role(db.Model):
|
||||
__tablename__ = 'roles'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||
description = db.Column(db.String(255))
|
||||
is_system = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
permissions = db.relationship('Permission', backref='role', lazy='joined', cascade='all, delete-orphan')
|
||||
users = db.relationship('User', backref='role_obj', lazy='select')
|
||||
|
||||
def get_permission(self, module):
|
||||
"""Get access level string for a given module ('none', 'read', 'read_write')"""
|
||||
for p in self.permissions:
|
||||
if p.module == module:
|
||||
return p.access_level
|
||||
return 'none'
|
||||
|
||||
def has_permission(self, module, min_level='read'):
|
||||
"""
|
||||
Check if role has sufficient permission level.
|
||||
Hierarchy: 'read_write' > 'read' > 'none'
|
||||
"""
|
||||
current_level = self.get_permission(module)
|
||||
if current_level == 'none':
|
||||
return False
|
||||
if min_level == 'read':
|
||||
return current_level in ['read', 'read_write']
|
||||
if min_level == 'read_write':
|
||||
return current_level == 'read_write'
|
||||
return False
|
||||
|
||||
def set_permission(self, module, access_level):
|
||||
"""Set or update permission level for a module"""
|
||||
if access_level not in ACCESS_LEVELS:
|
||||
access_level = 'none'
|
||||
|
||||
for p in self.permissions:
|
||||
if p.module == module:
|
||||
p.access_level = access_level
|
||||
return p
|
||||
|
||||
new_perm = Permission(role=self, module=module, access_level=access_level)
|
||||
db.session.add(new_perm)
|
||||
return new_perm
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'is_system': self.is_system,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'user_count': len(self.users) if self.users else 0,
|
||||
'permissions': {p.module: p.access_level for p in self.permissions}
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Role {self.name}>'
|
||||
|
||||
|
||||
class Permission(db.Model):
|
||||
__tablename__ = 'permissions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
module = db.Column(db.String(50), nullable=False)
|
||||
access_level = db.Column(db.String(20), default='none', nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('role_id', 'module', name='uq_role_module'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Permission role={self.role_id} module={self.module} level={self.access_level}>'
|
||||
@@ -5,12 +5,12 @@ class Subject(db.Model):
|
||||
__tablename__ = 'subjects'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(20), unique=True, nullable=False, index=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
department = db.Column(db.String(100), nullable=False)
|
||||
credits = db.Column(db.Integer, nullable=False)
|
||||
# duration_in_minutes = db.Column(db.Integer, nullable=False)
|
||||
department = db.Column(db.String(100), nullable=False, default='')
|
||||
credits = db.Column(db.Integer, nullable=False, default=4)
|
||||
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
|
||||
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
@@ -27,7 +27,8 @@ class Subject(db.Model):
|
||||
'description': self.description,
|
||||
'department': self.department,
|
||||
'credits': self.credits,
|
||||
# 'duration_minutes': self.duration_in_minutes,
|
||||
'career_id': self.career_id,
|
||||
'career_name': self.career_obj.name if self.career_obj else None,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
@@ -48,6 +49,8 @@ class Commission(db.Model):
|
||||
max_students = db.Column(db.Integer, nullable=False)
|
||||
current_students = db.Column(db.Integer, default=0)
|
||||
schedule = db.Column(db.String(255))
|
||||
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
|
||||
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
@@ -85,6 +88,8 @@ class Commission(db.Model):
|
||||
'max_students': self.max_students,
|
||||
'current_students': self.current_students,
|
||||
'schedule': self.schedule,
|
||||
'shift': self.shift,
|
||||
'virtual_link': self.virtual_link,
|
||||
'active': self.active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
@@ -93,7 +98,20 @@ class Commission(db.Model):
|
||||
|
||||
def get_full_code(self):
|
||||
"""Get full commission code"""
|
||||
return f"{self.subject.code if self.subject else 'N/A'}-{self.code}-{self.semester.upper()}{self.year if self.year else ''}"
|
||||
subj = self.subject.code if self.subject else 'N/A'
|
||||
code = self.code or ''
|
||||
sem = str(self.semester or '').strip()
|
||||
yr = str(self.year or '').strip()
|
||||
|
||||
if yr and yr in sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif sem and yr:
|
||||
return f"{subj}-{code}-{sem}{yr}"
|
||||
elif sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif yr:
|
||||
return f"{subj}-{code}-{yr}"
|
||||
return f"{subj}-{code}"
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Commission {self.get_full_code()}>'
|
||||
@@ -15,6 +15,8 @@ class User(UserMixin, db.Model):
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_login = db.Column(db.DateTime)
|
||||
preferred_language = db.Column(db.String(10), default=None, nullable=True)
|
||||
theme_preference = db.Column(db.String(10), default='auto', nullable=False)
|
||||
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='SET NULL'), nullable=True)
|
||||
|
||||
# Virtual fields for future compatibility
|
||||
@property
|
||||
@@ -39,7 +41,49 @@ class User(UserMixin, db.Model):
|
||||
|
||||
def is_admin(self):
|
||||
"""Check if user is admin"""
|
||||
return self.role == 'ADMIN'
|
||||
if self.role_obj and self.role_obj.name.lower() in ['admin', 'administrador']:
|
||||
return True
|
||||
return str(self.role).upper() in ['ADMIN', 'ADMINISTRADOR']
|
||||
|
||||
def has_permission(self, module, min_level='read'):
|
||||
"""Check if user has granular permission on a given module"""
|
||||
if self.is_admin():
|
||||
return True
|
||||
if self.role_obj:
|
||||
return self.role_obj.has_permission(module, min_level)
|
||||
return False
|
||||
|
||||
def can_manage(self):
|
||||
"""Check if user can access the Management / Admin section"""
|
||||
return (
|
||||
self.is_admin() or
|
||||
self.has_permission('users', 'read') or
|
||||
self.has_permission('milestone_types', 'read') or
|
||||
self.has_permission('import', 'read') or
|
||||
self.has_permission('reservations', 'read_write') or
|
||||
self.has_permission('classrooms', 'read_write') or
|
||||
self.has_permission('buildings', 'read_write') or
|
||||
self.has_permission('metrics', 'read')
|
||||
)
|
||||
|
||||
def can_edit_reservation(self, reservation):
|
||||
"""Check if user has permission to edit a given reservation"""
|
||||
if not reservation:
|
||||
return False
|
||||
if self.is_admin():
|
||||
return True
|
||||
role_name = (self.role_obj.name if self.role_obj else self.role or '').lower()
|
||||
if role_name in ['admin', 'administrador', 'bedelia', 'bedelía', 'operador']:
|
||||
return True
|
||||
if self.has_permission('reservations', 'read_write'):
|
||||
# El docente creador puede modificar su propia reserva
|
||||
if reservation.user_id == self.id:
|
||||
return True
|
||||
# Bedelía o roles de gestión con permiso de escritura total
|
||||
if role_name in ['bedelia', 'bedelía', 'operador']:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def update_last_login(self):
|
||||
"""Update last login timestamp"""
|
||||
@@ -55,7 +99,9 @@ class User(UserMixin, db.Model):
|
||||
'role': self.role,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'last_login': self.last_login.isoformat() if self.last_login else None
|
||||
'last_login': self.last_login.isoformat() if self.last_login else None,
|
||||
'preferred_language': self.preferred_language,
|
||||
'theme_preference': self.theme_preference
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -1,7 +1,9 @@
|
||||
from .main import main_bp
|
||||
from .auth import auth_bp
|
||||
from .classrooms import classrooms_bp
|
||||
from .buildings import buildings_bp
|
||||
from .schedule import schedule_bp
|
||||
from .genetic_algorithm import genetic_bp, genetic_web_bp
|
||||
from .admin import admin_bp
|
||||
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp']
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp']
|
||||
@@ -1,5 +1,6 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, jsonify
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.forms.auth import LoginForm
|
||||
@@ -19,7 +20,7 @@ def login():
|
||||
|
||||
if user and user.check_password(form.password.data):
|
||||
if not user.is_active:
|
||||
flash('Your account has been deactivated. Please contact administrator.', 'error')
|
||||
flash(_('Tu cuenta ha sido desactivada. Por favor contacta al administrador.'), 'error')
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
@@ -28,15 +29,20 @@ def login():
|
||||
# Si el usuario tiene idioma configurado en su perfil, sobrescribe el navegador en toda la sesión
|
||||
if user.preferred_language and user.preferred_language in ['es', 'en']:
|
||||
session['language'] = user.preferred_language
|
||||
else:
|
||||
session.pop('language', None)
|
||||
|
||||
session.pop('login_lang_explicit', None)
|
||||
|
||||
next_page = request.args.get('next')
|
||||
if not next_page or not next_page.startswith('/'):
|
||||
next_page = url_for('main.dashboard')
|
||||
|
||||
flash(f'Welcome back, {user.first_name} {user.last_name}!', 'success')
|
||||
user_full_name = f'{user.first_name} {user.last_name}'.strip() or user.name
|
||||
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
|
||||
return redirect(next_page)
|
||||
else:
|
||||
flash('Invalid email or password.', 'error')
|
||||
flash(_('Correo electrónico o contraseña inválidos.'), 'error')
|
||||
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
@@ -45,25 +51,48 @@ def login():
|
||||
def logout():
|
||||
"""Handle user logout"""
|
||||
logout_user()
|
||||
# Al cerrar sesión, se limpia el idioma para que el login use el del navegador por defecto
|
||||
# Al cerrar sesión, se limpian las cookies de idioma para que el login detecte el navegador o use español
|
||||
session.pop('language', None)
|
||||
flash('You have been logged out successfully.', 'info')
|
||||
session.pop('login_lang_explicit', None)
|
||||
flash(_('Has cerrado sesión correctamente.'), 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@auth_bp.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def profile():
|
||||
"""User profile page"""
|
||||
"""User profile page with language and theme settings"""
|
||||
if request.method == 'POST':
|
||||
lang = request.form.get('preferred_language')
|
||||
if lang in ['es', 'en']:
|
||||
current_user.preferred_language = lang
|
||||
session['language'] = lang
|
||||
db.session.commit()
|
||||
flash('Idioma de preferencia actualizado correctamente.', 'success')
|
||||
else:
|
||||
# Cadena vacía o None restablece la detección automática del navegador
|
||||
current_user.preferred_language = None
|
||||
session.pop('language', None)
|
||||
|
||||
theme = request.form.get('theme_preference')
|
||||
if theme in ['auto', 'light', 'dark']:
|
||||
current_user.theme_preference = theme
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Preferencias de usuario actualizadas correctamente.'), 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
return render_template('auth/profile.html')
|
||||
|
||||
@auth_bp.route('/api/user/theme', methods=['POST'])
|
||||
@login_required
|
||||
def update_theme_api():
|
||||
"""Update user theme preference via fetch/AJAX from navbar toggle"""
|
||||
data = request.get_json(silent=True) or request.form
|
||||
theme = data.get('theme') if data else None
|
||||
if theme in ['auto', 'light', 'dark']:
|
||||
current_user.theme_preference = theme
|
||||
db.session.commit()
|
||||
return jsonify({'status': 'success', 'theme': theme})
|
||||
return jsonify({'status': 'error', 'message': 'Invalid theme'}), 400
|
||||
|
||||
|
||||
|
||||
@auth_bp.route('/change_password', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.building import Building
|
||||
from app.models.classroom import Classroom
|
||||
from app.forms.building import BuildingForm
|
||||
from app.utils.decorators import permission_required
|
||||
from datetime import datetime
|
||||
|
||||
buildings_bp = Blueprint('buildings', __name__, url_prefix='/buildings')
|
||||
|
||||
@buildings_bp.route('/')
|
||||
@login_required
|
||||
def list_buildings():
|
||||
"""List all buildings with statistics"""
|
||||
search = request.args.get('search', '').strip()
|
||||
status_filter = request.args.get('status', '')
|
||||
|
||||
query = Building.query
|
||||
if search:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Building.name.ilike(f'%{search}%'),
|
||||
Building.code.ilike(f'%{search}%'),
|
||||
Building.address.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
if status_filter == 'active':
|
||||
query = query.filter(Building.is_active == True)
|
||||
elif status_filter == 'inactive':
|
||||
query = query.filter(Building.is_active == False)
|
||||
|
||||
buildings = query.order_by(Building.name.asc()).all()
|
||||
|
||||
# Calculate global building stats
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
total_capacity = sum(c.capacity for c in Classroom.query.filter_by(is_active=True).all())
|
||||
|
||||
return render_template(
|
||||
'buildings/list.html',
|
||||
buildings=buildings,
|
||||
total_classrooms=total_classrooms,
|
||||
total_capacity=total_capacity,
|
||||
search=search,
|
||||
status_filter=status_filter,
|
||||
title='Building Management'
|
||||
)
|
||||
|
||||
@buildings_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def add_building():
|
||||
"""Add new building"""
|
||||
form = BuildingForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check unique name
|
||||
existing = Building.query.filter(
|
||||
db.func.lower(Building.name) == form.name.data.lower()
|
||||
).first()
|
||||
if existing:
|
||||
flash('Ya existe un edificio con este nombre.', 'danger')
|
||||
return render_template('buildings/form.html', form=form, title='Add Building')
|
||||
|
||||
building = Building(
|
||||
name=form.name.data.strip(),
|
||||
code=form.code.data.strip().upper() if form.code.data else None,
|
||||
address=form.address.data.strip() if form.address.data else None,
|
||||
floors=form.floors.data,
|
||||
description=form.description.data.strip() if form.description.data else None,
|
||||
is_active=form.is_active.data
|
||||
)
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" creado con éxito.', 'success')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al crear el edificio: {str(e)}', 'danger')
|
||||
|
||||
return render_template('buildings/form.html', form=form, title='Add Building')
|
||||
|
||||
@buildings_bp.route('/<int:id>')
|
||||
@login_required
|
||||
def view_building(id):
|
||||
"""View building details and its classrooms by floor"""
|
||||
building = Building.query.get_or_404(id)
|
||||
|
||||
# Group active classrooms by floor
|
||||
classrooms = Classroom.query.filter_by(building_id=building.id).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
# Fallback to name matching if building_id was not set
|
||||
if not classrooms:
|
||||
classrooms = Classroom.query.filter_by(building=building.name).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
|
||||
classrooms_by_floor = {}
|
||||
for c in classrooms:
|
||||
floor_key = c.floor
|
||||
if floor_key not in classrooms_by_floor:
|
||||
classrooms_by_floor[floor_key] = []
|
||||
classrooms_by_floor[floor_key].append(c)
|
||||
|
||||
return render_template(
|
||||
'buildings/view.html',
|
||||
building=building,
|
||||
classrooms=classrooms,
|
||||
classrooms_by_floor=classrooms_by_floor,
|
||||
title=f'Building - {building.name}'
|
||||
)
|
||||
|
||||
@buildings_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def edit_building(id):
|
||||
"""Edit building"""
|
||||
building = Building.query.get_or_404(id)
|
||||
form = BuildingForm(obj=building)
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check unique name excluding current
|
||||
existing = Building.query.filter(
|
||||
db.func.lower(Building.name) == form.name.data.lower(),
|
||||
Building.id != id
|
||||
).first()
|
||||
if existing:
|
||||
flash('Ya existe otro edificio con este nombre.', 'danger')
|
||||
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
|
||||
|
||||
old_name = building.name
|
||||
new_name = form.name.data.strip()
|
||||
|
||||
building.name = new_name
|
||||
building.code = form.code.data.strip().upper() if form.code.data else None
|
||||
building.address = form.address.data.strip() if form.address.data else None
|
||||
building.floors = form.floors.data
|
||||
building.description = form.description.data.strip() if form.description.data else None
|
||||
building.is_active = form.is_active.data
|
||||
building.updated_at = datetime.utcnow()
|
||||
|
||||
# If name changed, update string building on classrooms
|
||||
if old_name != new_name:
|
||||
Classroom.query.filter_by(building_id=building.id).update({'building': new_name})
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" actualizado con éxito.', 'success')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al actualizar el edificio: {str(e)}', 'danger')
|
||||
|
||||
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
|
||||
|
||||
@buildings_bp.route('/<int:id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def toggle_building(id):
|
||||
"""Toggle building active status"""
|
||||
building = Building.query.get_or_404(id)
|
||||
try:
|
||||
building.is_active = not building.is_active
|
||||
building.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
status_text = 'activado' if building.is_active else 'desactivado'
|
||||
flash(f'Edificio "{building.name}" {status_text} con éxito.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al cambiar estado del edificio: {str(e)}', 'danger')
|
||||
return redirect(url_for('buildings.list_buildings'))
|
||||
|
||||
@buildings_bp.route('/<int:id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def delete_building(id):
|
||||
"""Delete building if it has no classrooms assigned"""
|
||||
building = Building.query.get_or_404(id)
|
||||
has_classrooms = Classroom.query.filter(
|
||||
db.or_(Classroom.building_id == building.id, Classroom.building == building.name)
|
||||
).count()
|
||||
|
||||
if has_classrooms > 0:
|
||||
flash(f'No se puede eliminar el edificio "{building.name}" porque tiene {has_classrooms} aulas asociadas. Primero reasigna o elimina las aulas.', 'danger')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
|
||||
try:
|
||||
db.session.delete(building)
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" eliminado correctamente.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al eliminar edificio: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('buildings.list_buildings'))
|
||||
@@ -12,30 +12,62 @@ classrooms_bp = Blueprint('classrooms', __name__)
|
||||
@classrooms_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_classroom():
|
||||
"""Add a new classroom"""
|
||||
form = ClassroomForm()
|
||||
"""Add a new classroom with dynamic building creation and floor suggestion"""
|
||||
from app.models.building import Building
|
||||
import re
|
||||
from sqlalchemy import func
|
||||
|
||||
# TODO: Update when campus model is implemented
|
||||
# form.campus_id.choices = [(1, 'Main Campus')]
|
||||
form = ClassroomForm()
|
||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
|
||||
|
||||
# Map of existing floors per building for datalist
|
||||
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
building = None
|
||||
if form.building_id.data == '__new__':
|
||||
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
|
||||
if not new_building_name:
|
||||
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
|
||||
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
|
||||
if not building:
|
||||
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
|
||||
building = Building(name=new_building_name, code=code_slug, is_active=True)
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
else:
|
||||
building = Building.query.get_or_404(int(form.building_id.data))
|
||||
|
||||
room_code = form.room_number.data.strip()
|
||||
floor_val = str(form.floor.data).strip()
|
||||
|
||||
# Prevent duplicate room in same building & floor
|
||||
existing_classroom = Classroom.query.filter_by(
|
||||
building=form.building.data,
|
||||
code=form.room_number.data,
|
||||
floor=form.floor.data
|
||||
building_id=building.id,
|
||||
code=room_code,
|
||||
floor=floor_val
|
||||
).first()
|
||||
|
||||
if existing_classroom:
|
||||
flash('Classroom with this building, room number, and floor already exists.', 'error')
|
||||
return render_template('classrooms/add.html', form=form, title='Add Classroom')
|
||||
flash(f'Ya existe un aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
|
||||
cap_raw = str(form.capacity.data or '').strip()
|
||||
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||
cap_val = 0
|
||||
else:
|
||||
cap_val = int(cap_raw)
|
||||
|
||||
classroom = Classroom(
|
||||
# campus_id=form.campus_id.data,
|
||||
building=form.building.data,
|
||||
code=form.room_number.data,
|
||||
floor=form.floor.data,
|
||||
capacity=form.capacity.data,
|
||||
building_id=building.id,
|
||||
building=building.name,
|
||||
code=room_code,
|
||||
floor=floor_val,
|
||||
capacity=cap_val,
|
||||
description=form.description.data,
|
||||
is_active=form.is_active.data
|
||||
)
|
||||
@@ -43,34 +75,91 @@ def add_classroom():
|
||||
db.session.add(classroom)
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Classroom {classroom.code_display} has been created successfully!', 'success')
|
||||
flash(f'Aula {classroom.location_display} creada exitosamente.', 'success')
|
||||
return redirect(url_for('classrooms.list_classrooms'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error creating classroom: {str(e)}', 'error')
|
||||
|
||||
return render_template('classrooms/add.html', form=form, title='Add Classroom')
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
|
||||
|
||||
@classrooms_bp.route('/', endpoint='index')
|
||||
@classrooms_bp.route('/list')
|
||||
@login_required
|
||||
def list_classrooms():
|
||||
"""List all classrooms"""
|
||||
"""List all classrooms with separation between Physical spaces and Virtual on-demand rooms"""
|
||||
try:
|
||||
# Get filters
|
||||
from app.models.building import Building
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.subject import Commission, Subject
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
active_only = request.args.get('active_only', 'true').lower() == 'true'
|
||||
search = request.args.get('search', '', type=str)
|
||||
search = request.args.get('search', '', type=str).strip()
|
||||
building_filter = request.args.get('building_id', '', type=str).strip()
|
||||
floor_filter = request.args.get('floor', '', type=str).strip()
|
||||
view_mode = request.args.get('view_mode', '').strip().lower()
|
||||
|
||||
# Build query
|
||||
query = Classroom.query
|
||||
# Identificar edificio virtual y aula virtual base
|
||||
virtual_building = Building.query.filter(
|
||||
db.or_(Building.code == 'VIRTUAL', Building.name.ilike('%Virtual%'))
|
||||
).first()
|
||||
virtual_bldg_id = virtual_building.id if virtual_building else None
|
||||
|
||||
# Si el usuario seleccionó "Campus Virtual" en el filtro de edificios, conmutar a pestaña virtual
|
||||
if building_filter and str(building_filter) == str(virtual_bldg_id):
|
||||
view_mode = 'virtual'
|
||||
elif not view_mode:
|
||||
view_mode = 'physical'
|
||||
|
||||
# Fechas operativas (Día, Semana, Mes)
|
||||
today = datetime.utcnow().date()
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today_end = datetime.combine(today, datetime.max.time())
|
||||
|
||||
week_start = datetime.combine(today - timedelta(days=today.weekday()), datetime.min.time())
|
||||
week_end = datetime.combine(week_start.date() + timedelta(days=6), datetime.max.time())
|
||||
|
||||
_, last_day = calendar.monthrange(today.year, today.month)
|
||||
month_start = datetime(today.year, today.month, 1, 0, 0, 0)
|
||||
month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
|
||||
|
||||
date_filters = {
|
||||
'today': today.strftime('%Y-%m-%d'),
|
||||
'week_start': week_start.strftime('%Y-%m-%d'),
|
||||
'week_end': week_end.strftime('%Y-%m-%d'),
|
||||
'month_start': month_start.strftime('%Y-%m-%d'),
|
||||
'month_end': month_end.strftime('%Y-%m-%d')
|
||||
}
|
||||
|
||||
# --- AULAS FÍSICAS (Presenciales) ---
|
||||
physical_query = Classroom.query
|
||||
if virtual_bldg_id:
|
||||
physical_query = physical_query.filter(Classroom.building_id != virtual_bldg_id)
|
||||
else:
|
||||
physical_query = physical_query.filter(~Classroom.code.ilike('%VIRTUAL%'))
|
||||
|
||||
if active_only:
|
||||
query = query.filter_by(is_active=True)
|
||||
physical_query = physical_query.filter_by(is_active=True)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
physical_count = physical_query.count()
|
||||
|
||||
if building_filter and building_filter.isdigit() and str(building_filter) != str(virtual_bldg_id):
|
||||
physical_query = physical_query.filter(Classroom.building_id == int(building_filter))
|
||||
|
||||
if floor_filter:
|
||||
flr = str(floor_filter).strip()
|
||||
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
||||
physical_query = physical_query.filter(db.or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
||||
else:
|
||||
physical_query = physical_query.filter(Classroom.floor == flr)
|
||||
|
||||
if search and view_mode == 'physical':
|
||||
physical_query = physical_query.filter(
|
||||
db.or_(
|
||||
Classroom.building.ilike(f'%{search}%'),
|
||||
Classroom.code.ilike(f'%{search}%'),
|
||||
@@ -78,29 +167,175 @@ def list_classrooms():
|
||||
)
|
||||
)
|
||||
|
||||
# Paginate results
|
||||
classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate(
|
||||
page=page, per_page=12, error_out=False
|
||||
classrooms = physical_query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
|
||||
page=page, per_page=20, error_out=False
|
||||
)
|
||||
|
||||
# Edificios y Pisos estrictamente físicos para el filtro
|
||||
buildings = Building.query.filter_by(is_active=True)
|
||||
if virtual_bldg_id:
|
||||
buildings = buildings.filter(Building.id != virtual_bldg_id)
|
||||
buildings = buildings.order_by(Building.name.asc()).all()
|
||||
|
||||
floors = [f[0] for f in db.session.query(Classroom.floor).filter(
|
||||
Classroom.is_active == True,
|
||||
Classroom.building_id != virtual_bldg_id if virtual_bldg_id else True,
|
||||
Classroom.floor.isnot(None)
|
||||
).distinct().order_by(Classroom.floor.asc()).all()]
|
||||
|
||||
# Métricas de ocupación física
|
||||
visible_ids = [c.id for c in classrooms.items]
|
||||
month_reservations = Reservation.query.filter(
|
||||
Reservation.classroom_id.in_(visible_ids),
|
||||
Reservation.start_time >= month_start,
|
||||
Reservation.start_time <= month_end,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
||||
).all() if visible_ids else []
|
||||
|
||||
DAILY_OP_MINS = 15 * 60
|
||||
WEEKLY_OP_MINS = 6 * 15 * 60
|
||||
MONTHLY_OP_MINS = 24 * 15 * 60
|
||||
|
||||
occupancy_metrics = {}
|
||||
for c in classrooms.items:
|
||||
c_res = [r for r in month_reservations if r.classroom_id == c.id]
|
||||
day_res = [r for r in c_res if today_start <= r.start_time <= today_end]
|
||||
day_mins = sum((min(r.end_time, today_end) - max(r.start_time, today_start)).total_seconds() / 60 for r in day_res)
|
||||
day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100)))
|
||||
|
||||
week_res = [r for r in c_res if week_start <= r.start_time <= week_end]
|
||||
week_mins = sum((min(r.end_time, week_end) - max(r.start_time, week_start)).total_seconds() / 60 for r in week_res)
|
||||
week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100)))
|
||||
|
||||
month_mins = sum((min(r.end_time, month_end) - max(r.start_time, month_start)).total_seconds() / 60 for r in c_res)
|
||||
month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100)))
|
||||
|
||||
occupancy_metrics[c.id] = {
|
||||
'day_pct': day_pct,
|
||||
'day_count': len(day_res),
|
||||
'week_pct': week_pct,
|
||||
'week_count': len(week_res),
|
||||
'month_pct': month_pct,
|
||||
'month_count': len(c_res)
|
||||
}
|
||||
|
||||
# --- AULAS VIRTUALES (Campus Virtual a Demanda - Infinitas) ---
|
||||
vc = Classroom.query.filter(
|
||||
Classroom.building_id == virtual_bldg_id if virtual_bldg_id else Classroom.code.ilike('%VIRTUAL%')
|
||||
).first()
|
||||
vc_id = vc.id if vc else 1
|
||||
|
||||
all_vres = Reservation.query.filter(Reservation.classroom_id == vc_id).all() if vc_id else []
|
||||
|
||||
# Agrupar las salas virtuales por comisión
|
||||
comm_dict = {}
|
||||
for r in all_vres:
|
||||
c = r.commission
|
||||
cid = c.id if c else 0
|
||||
if cid not in comm_dict:
|
||||
vlink = (c.virtual_link if c and c.virtual_link else None) or r.effective_virtual_link
|
||||
comm_dict[cid] = {
|
||||
'commission_id': cid,
|
||||
'commission_code': c.code if c else 'General',
|
||||
'subject_name': c.subject.name if (c and c.subject) else (r.purpose or 'Clase Virtual'),
|
||||
'subject_code': c.subject.code if (c and c.subject) else '',
|
||||
'career_name': c.subject.career.name if (c and c.subject and hasattr(c.subject, 'career') and c.subject.career) else '',
|
||||
'max_students': c.max_students if c else (r.expected_attendees or 0),
|
||||
'virtual_link': vlink,
|
||||
'reservations': []
|
||||
}
|
||||
comm_dict[cid]['reservations'].append(r)
|
||||
if not comm_dict[cid]['virtual_link'] and r.effective_virtual_link:
|
||||
comm_dict[cid]['virtual_link'] = r.effective_virtual_link
|
||||
|
||||
virtual_rooms = []
|
||||
for cid, data in comm_dict.items():
|
||||
res_list = data['reservations']
|
||||
day_cnt = sum(1 for r in res_list if today_start <= r.start_time <= today_end)
|
||||
week_cnt = sum(1 for r in res_list if week_start <= r.start_time <= week_end)
|
||||
month_cnt = sum(1 for r in res_list if month_start <= r.start_time <= month_end)
|
||||
shift = res_list[0].shift if res_list else 'A Demanda'
|
||||
|
||||
if search and view_mode == 'virtual':
|
||||
s_low = search.lower()
|
||||
matches = (
|
||||
s_low in data['subject_name'].lower() or
|
||||
s_low in data['commission_code'].lower() or
|
||||
s_low in data['subject_code'].lower() or
|
||||
s_low in data['career_name'].lower()
|
||||
)
|
||||
if not matches:
|
||||
continue
|
||||
|
||||
virtual_rooms.append({
|
||||
'commission_id': data['commission_id'],
|
||||
'commission_code': data['commission_code'],
|
||||
'subject_name': data['subject_name'],
|
||||
'subject_code': data['subject_code'],
|
||||
'career_name': data['career_name'],
|
||||
'max_students': data['max_students'],
|
||||
'virtual_link': data['virtual_link'],
|
||||
'shift': shift,
|
||||
'day_count': day_cnt,
|
||||
'week_count': week_cnt,
|
||||
'month_count': month_cnt,
|
||||
'total_count': len(res_list),
|
||||
'reservations': sorted(res_list, key=lambda x: x.start_time)
|
||||
})
|
||||
|
||||
virtual_rooms.sort(key=lambda x: (x['subject_name'], x['commission_code']))
|
||||
|
||||
global_virtual_metrics = {
|
||||
'today_count': sum(1 for r in all_vres if today_start <= r.start_time <= today_end),
|
||||
'week_count': sum(1 for r in all_vres if week_start <= r.start_time <= week_end),
|
||||
'month_count': sum(1 for r in all_vres if month_start <= r.start_time <= month_end),
|
||||
'total_count': len(all_vres),
|
||||
'commissions_count': len(comm_dict)
|
||||
}
|
||||
|
||||
return render_template('classrooms/list.html',
|
||||
classrooms=classrooms,
|
||||
buildings=buildings,
|
||||
floors=floors,
|
||||
building_filter=building_filter,
|
||||
floor_filter=floor_filter,
|
||||
active_only=active_only,
|
||||
search=search,
|
||||
view_mode=view_mode,
|
||||
physical_count=physical_count,
|
||||
virtual_count=len(comm_dict),
|
||||
virtual_rooms=virtual_rooms,
|
||||
virtual_classroom_id=vc_id,
|
||||
global_virtual_metrics=global_virtual_metrics,
|
||||
occupancy_metrics=occupancy_metrics,
|
||||
date_filters=date_filters,
|
||||
title='Classrooms')
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Error loading classrooms: {str(e)}', 'error')
|
||||
return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
|
||||
|
||||
|
||||
@classrooms_bp.route('/virtual-room/<int:commission_id>/update-link', methods=['POST'])
|
||||
@login_required
|
||||
def update_virtual_link(commission_id):
|
||||
"""Actualizar enlace de videollamada para una comisión virtual"""
|
||||
from app.models.subject import Commission
|
||||
from flask_babel import gettext as _
|
||||
comm = Commission.query.get_or_404(commission_id)
|
||||
new_link = request.form.get('virtual_link', '').strip() or None
|
||||
comm.virtual_link = new_link
|
||||
db.session.commit()
|
||||
flash(_('Enlace de videollamada para %(code)s actualizado correctamente.', code=comm.get_full_code()), 'success')
|
||||
return redirect(request.referrer or url_for('classrooms.list_classrooms', view_mode='virtual'))
|
||||
|
||||
|
||||
@classrooms_bp.route('/<int:id>')
|
||||
@login_required
|
||||
def view_classroom(id):
|
||||
"""View classroom details"""
|
||||
try:
|
||||
classroom = Classroom.query.get_or_404(id)
|
||||
|
||||
# Get upcoming reservations for this classroom
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
@@ -118,50 +353,89 @@ def view_classroom(id):
|
||||
flash(f'Error loading classroom: {str(e)}', 'error')
|
||||
return redirect(url_for('classrooms.list_classrooms'))
|
||||
|
||||
|
||||
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_classroom(id):
|
||||
"""Edit classroom"""
|
||||
"""Edit classroom with decoupled building and floor"""
|
||||
from app.models.building import Building
|
||||
import re
|
||||
from sqlalchemy import func
|
||||
|
||||
classroom = Classroom.query.get_or_404(id)
|
||||
form = ClassroomForm(obj=classroom)
|
||||
|
||||
# TODO: Update when campus model is implemented
|
||||
# form.campus_id.choices = [(1, 'Main Campus')]
|
||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
|
||||
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
|
||||
|
||||
if request.method == 'GET':
|
||||
form.building_id.data = str(classroom.building_id or '')
|
||||
form.room_number.data = classroom.code
|
||||
form.floor.data = classroom.floor
|
||||
if classroom.capacity == 0 or classroom.is_virtual:
|
||||
form.capacity.data = '∞'
|
||||
else:
|
||||
form.capacity.data = str(classroom.capacity)
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check for duplicates (excluding current classroom)
|
||||
building = None
|
||||
if form.building_id.data == '__new__':
|
||||
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
|
||||
if not new_building_name:
|
||||
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
|
||||
|
||||
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
|
||||
if not building:
|
||||
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
|
||||
building = Building(name=new_building_name, code=code_slug, is_active=True)
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
else:
|
||||
building = Building.query.get_or_404(int(form.building_id.data))
|
||||
|
||||
room_code = form.room_number.data.strip()
|
||||
floor_val = str(form.floor.data).strip()
|
||||
|
||||
# Check duplicate excluding current
|
||||
existing_classroom = Classroom.query.filter(
|
||||
Classroom.building == form.building.data,
|
||||
Classroom.code == form.room_number.data,
|
||||
Classroom.floor == form.floor.data,
|
||||
Classroom.building_id == building.id,
|
||||
Classroom.code == room_code,
|
||||
Classroom.floor == floor_val,
|
||||
Classroom.id != id
|
||||
).first()
|
||||
|
||||
if existing_classroom:
|
||||
flash('Classroom with this building, room number, and floor already exists.', 'error')
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
|
||||
flash(f'Ya existe otra aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
|
||||
|
||||
# Update classroom
|
||||
# classroom.campus_id = form.campus_id.data
|
||||
classroom.building = form.building.data
|
||||
classroom.code = form.room_number.data
|
||||
classroom.floor = form.floor.data
|
||||
classroom.capacity = form.capacity.data
|
||||
cap_raw = str(form.capacity.data or '').strip()
|
||||
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||
cap_val = 0
|
||||
else:
|
||||
cap_val = int(cap_raw)
|
||||
|
||||
classroom.building_id = building.id
|
||||
classroom.building = building.name
|
||||
classroom.code = room_code
|
||||
classroom.floor = floor_val
|
||||
classroom.capacity = cap_val
|
||||
classroom.description = form.description.data
|
||||
classroom.is_active = form.is_active.data
|
||||
classroom.updated_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Classroom {classroom.code_display} has been updated successfully!', 'success')
|
||||
flash(f'Aula {classroom.location_display} actualizada correctamente.', 'success')
|
||||
return redirect(url_for('classrooms.view_classroom', id=classroom.id))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error updating classroom: {str(e)}', 'error')
|
||||
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
|
||||
|
||||
@classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
|
||||
@@ -249,7 +249,7 @@ genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
|
||||
@login_required
|
||||
def genetic_optimizer():
|
||||
"""Render the genetic algorithm optimizer interface"""
|
||||
if current_user.role not in ['ADMIN']:
|
||||
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||
return render_template('auth/403.html'), 403
|
||||
|
||||
return render_template('genetic_optimizer.html')
|
||||
@@ -113,8 +113,8 @@ def dashboard_stats_api():
|
||||
@login_required
|
||||
def genetic_optimizer():
|
||||
"""Serve the genetic algorithm optimization interface"""
|
||||
if current_user.role not in ['ADMIN']:
|
||||
flash('You do not have permission to access the optimization tool.', 'danger')
|
||||
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||
flash(_('No tienes permisos para acceder a la herramienta de optimización.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('genetic_optimizer.html')
|
||||
@@ -122,11 +122,37 @@ def genetic_optimizer():
|
||||
@main_bp.route('/set_language/<language>')
|
||||
def set_language(language=None):
|
||||
if language not in ['en', 'es']:
|
||||
language = 'en'
|
||||
language = 'es'
|
||||
|
||||
session['language'] = language
|
||||
|
||||
# Si se selecciona el idioma desde la pantalla de login, registrar la selección explícita
|
||||
if request.referrer and 'login' in request.referrer:
|
||||
session['login_lang_explicit'] = language
|
||||
|
||||
if current_user.is_authenticated:
|
||||
current_user.preferred_language = language
|
||||
db.session.commit()
|
||||
|
||||
return redirect(request.referrer or url_for('main.dashboard'))
|
||||
|
||||
|
||||
@main_bp.route('/api/milestone-types')
|
||||
def api_milestone_types():
|
||||
"""Retorna las tipificaciones de hitos activas en JSON para consumo de frontend/mobile"""
|
||||
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
|
||||
if MilestoneType.query.count() == 0:
|
||||
for item in BASE_MILESTONE_TYPES:
|
||||
mt = MilestoneType(
|
||||
name=item['name'],
|
||||
code=item['code'],
|
||||
color=item['color'],
|
||||
description=item['description'],
|
||||
sort_order=item['sort_order'],
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(mt)
|
||||
db.session.commit()
|
||||
|
||||
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
|
||||
return jsonify([t.to_dict() for t in types])␍
|
||||
@@ -14,21 +14,43 @@ schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule')
|
||||
@schedule_bp.route('/today')
|
||||
@login_required
|
||||
def today_schedule():
|
||||
"""Show today's schedule"""
|
||||
"""Show today's schedule with filters for virtual classrooms, floor, and shift"""
|
||||
today = datetime.utcnow().date()
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today_end = datetime.combine(today, datetime.max.time())
|
||||
|
||||
# Get reservations with eager loading
|
||||
reservations = Reservation.query.options(
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
floor_filter = request.args.get('floor', '').strip()
|
||||
shift_filter = request.args.get('shift', '').strip()
|
||||
|
||||
# Base query for today's reservations
|
||||
query = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission),
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject),
|
||||
joinedload(Reservation.user)
|
||||
).filter(
|
||||
).join(Reservation.classroom).filter(
|
||||
Reservation.start_time >= today_start,
|
||||
Reservation.start_time <= today_end,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
||||
).order_by(Reservation.start_time).all()
|
||||
)
|
||||
|
||||
if hide_virtual:
|
||||
query = query.filter(
|
||||
~Classroom.code.ilike('%VIRTUAL%'),
|
||||
~Classroom.building.ilike('%Virtual%')
|
||||
)
|
||||
|
||||
if floor_filter:
|
||||
flr = str(floor_filter).strip()
|
||||
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
||||
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
||||
else:
|
||||
query = query.filter(Classroom.floor == flr)
|
||||
|
||||
if shift_filter:
|
||||
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
||||
|
||||
reservations = query.order_by(Reservation.start_time).all()
|
||||
|
||||
# Create time blocks for timeline view
|
||||
time_blocks = {}
|
||||
@@ -49,11 +71,9 @@ def today_schedule():
|
||||
|
||||
# Calculate stats
|
||||
total_reservations = len(reservations)
|
||||
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value])
|
||||
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING])
|
||||
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value or str(r.status) == 'CONFIRMED'])
|
||||
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING or str(r.status) == 'PENDING'])
|
||||
|
||||
# Get total classrooms and calculate occupied ones
|
||||
from app.models.classroom import Classroom
|
||||
total_classrooms = Classroom.query.count()
|
||||
occupied_classrooms = len(set(r.classroom_id for r in reservations if r.classroom_id))
|
||||
|
||||
@@ -65,11 +85,18 @@ def today_schedule():
|
||||
'total_classrooms': total_classrooms
|
||||
}
|
||||
|
||||
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||
available_floors = [f[0] for f in floors]
|
||||
|
||||
return render_template('schedule/today.html',
|
||||
reservations=reservations,
|
||||
time_blocks=time_blocks,
|
||||
stats=stats,
|
||||
today=today)
|
||||
today=today,
|
||||
hide_virtual=hide_virtual,
|
||||
floor_filter=floor_filter,
|
||||
shift_filter=shift_filter,
|
||||
available_floors=available_floors)
|
||||
|
||||
@schedule_bp.route('/cancel/<int:id>', methods=['POST'])
|
||||
@login_required
|
||||
@@ -112,25 +139,353 @@ def view_reservation(id):
|
||||
flash(f'Error loading reservation details: {str(e)}', 'error')
|
||||
return redirect(url_for('schedule.today_schedule'))
|
||||
|
||||
@schedule_bp.route('/reservation/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_reservation(id):
|
||||
"""Edit existing reservation"""
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Commission
|
||||
from app.models.building import Building
|
||||
from flask_babel import gettext as _
|
||||
|
||||
reservation = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission),
|
||||
joinedload(Reservation.user)
|
||||
).get_or_404(id)
|
||||
|
||||
if not current_user.can_edit_reservation(reservation):
|
||||
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
|
||||
return redirect(url_for('schedule.view_reservation', id=reservation.id))
|
||||
|
||||
form = ReservationForm(is_edit=True)
|
||||
|
||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
commissions = Commission.query.filter_by(active=True).all()
|
||||
|
||||
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
|
||||
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
|
||||
|
||||
if request.method == 'GET':
|
||||
form.classroom_id.data = reservation.classroom_id
|
||||
form.commission_id.data = reservation.commission_id
|
||||
form.start_time.data = reservation.start_time
|
||||
form.end_time.data = reservation.end_time
|
||||
form.purpose.data = reservation.purpose
|
||||
form.expected_attendees.data = reservation.expected_attendees
|
||||
form.notes.data = reservation.notes or ''
|
||||
form.virtual_link.data = reservation.virtual_link or ''
|
||||
|
||||
elif request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Verificar colisión de horario si no es virtual
|
||||
target_room = Classroom.query.get(form.classroom_id.data)
|
||||
if target_room and not target_room.is_virtual:
|
||||
conflict = Reservation.query.filter(
|
||||
Reservation.id != reservation.id,
|
||||
Reservation.classroom_id == form.classroom_id.data,
|
||||
Reservation.status == ReservationStatus.CONFIRMED.value,
|
||||
Reservation.start_time < form.end_time.data,
|
||||
Reservation.end_time > form.start_time.data
|
||||
).first()
|
||||
|
||||
if conflict:
|
||||
flash(_('El aula seleccionada ya cuenta con una reserva confirmada en esa franja horaria.'), 'danger')
|
||||
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
||||
|
||||
# Asignar cambios
|
||||
reservation.classroom_id = form.classroom_id.data
|
||||
reservation.commission_id = form.commission_id.data
|
||||
reservation.start_time = form.start_time.data
|
||||
reservation.end_time = form.end_time.data
|
||||
reservation.purpose = form.purpose.data
|
||||
reservation.expected_attendees = form.expected_attendees.data
|
||||
reservation.notes = form.notes.data
|
||||
reservation.virtual_link = form.virtual_link.data.strip() if form.virtual_link.data else None
|
||||
|
||||
# Turno
|
||||
h = reservation.start_time.hour
|
||||
if h < 13:
|
||||
reservation.shift = 'Mañana'
|
||||
elif h < 18:
|
||||
reservation.shift = 'Tarde'
|
||||
elif h < 21:
|
||||
reservation.shift = 'Vespertino'
|
||||
else:
|
||||
reservation.shift = 'Noche'
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Reserva #%(id)d modificada exitosamente.', id=reservation.id), 'success')
|
||||
return redirect(url_for('schedule.view_reservation', id=reservation.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al modificar reserva: {str(e)}', 'danger')
|
||||
|
||||
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
||||
|
||||
@schedule_bp.route('/calendar')
|
||||
@login_required
|
||||
def calendar_view():
|
||||
"""Show calendar view"""
|
||||
return render_template('schedule/calendar.html', title='Calendar View')
|
||||
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
|
||||
from app.models.career import Career
|
||||
from app.models.building import Building
|
||||
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all()
|
||||
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
|
||||
careers = Career.query.order_by(Career.name.asc()).all()
|
||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||
|
||||
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||
available_floors = [f[0] for f in floors]
|
||||
|
||||
return render_template(
|
||||
'schedule/calendar.html',
|
||||
title='Calendar View',
|
||||
today_date=date.today().isoformat(),
|
||||
classrooms=classrooms,
|
||||
subjects=subjects,
|
||||
careers=careers,
|
||||
buildings=buildings,
|
||||
available_floors=available_floors
|
||||
)
|
||||
|
||||
def user_can_manage_commission_milestones(user, commission=None):
|
||||
"""Verifica si el usuario (Docente, Admin, Bedelía) puede gestionar hitos y links virtuales"""
|
||||
if user.is_admin():
|
||||
return True
|
||||
role_name = (user.role_obj.name if user.role_obj else user.role or '').lower()
|
||||
if role_name in ['admin', 'bedelia', 'bedelía', 'operador']:
|
||||
return True
|
||||
if role_name in ['docente', 'profesor']:
|
||||
return True
|
||||
return user.has_permission('schedule', 'read_write')
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions')
|
||||
@login_required
|
||||
def commissions_view():
|
||||
subjects = Subject.query.all()
|
||||
"""Vista y ABM de Comisiones con soporte de enlace virtual e hitos académicos"""
|
||||
from app.models.subject import Commission, Subject
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.user import User
|
||||
from app.models.milestone import MilestoneType
|
||||
|
||||
# Calculamos el total de capacidad sumando todas las comisiones de todas las materias
|
||||
subjects = Subject.query.order_by(Subject.code.asc()).all()
|
||||
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.code.asc()).all()
|
||||
teachers = User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
milestone_types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc()).all()
|
||||
|
||||
# Totales para indicadores superiores
|
||||
total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
|
||||
# Calculamos el total de comisiones
|
||||
total_commissions = sum(len(s.commissions) for s in subjects)
|
||||
|
||||
can_manage_milestones = user_can_manage_commission_milestones(current_user)
|
||||
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
||||
can_edit_commissions = current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')
|
||||
|
||||
return render_template('schedule/commissions.html',
|
||||
subjects=subjects,
|
||||
classrooms=classrooms,
|
||||
teachers=teachers,
|
||||
milestone_types=milestone_types,
|
||||
total_capacity=total_capacity,
|
||||
total_commissions=total_commissions)
|
||||
total_commissions=total_commissions,
|
||||
can_manage_milestones=can_manage_milestones,
|
||||
can_edit_commissions=can_edit_commissions)
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions/add', methods=['POST'])
|
||||
@login_required
|
||||
def commission_add():
|
||||
"""Crear una nueva comisión de cursada"""
|
||||
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
||||
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
|
||||
flash(_('No tienes permisos para crear comisiones.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
from app.models.subject import Commission
|
||||
subject_id = request.form.get('subject_id', type=int)
|
||||
code = request.form.get('code', '').strip()
|
||||
semester = request.form.get('semester', '1C').strip()
|
||||
year = request.form.get('year', datetime.now().year, type=int)
|
||||
teacher_id = request.form.get('teacher_id', type=int) or None
|
||||
max_students = request.form.get('max_students', 40, type=int)
|
||||
schedule = request.form.get('schedule', '').strip()
|
||||
shift = request.form.get('shift', 'Tarde').strip()
|
||||
virtual_link = request.form.get('virtual_link', '').strip()
|
||||
|
||||
if not subject_id or not code:
|
||||
flash(_('La asignatura y el código de comisión son obligatorios.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
# Verificar existencia duplicada
|
||||
existing = Commission.query.filter_by(subject_id=subject_id, code=code, semester=semester, year=year).first()
|
||||
if existing:
|
||||
flash(_('Ya existe una comisión %(code)s para el ciclo %(sem)s %(yr)d.', code=code, sem=semester, yr=year), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
new_comm = Commission(
|
||||
subject_id=subject_id,
|
||||
code=code,
|
||||
semester=semester,
|
||||
year=year,
|
||||
teacher_id=teacher_id,
|
||||
max_students=max_students,
|
||||
schedule=schedule,
|
||||
shift=shift,
|
||||
virtual_link=virtual_link or None,
|
||||
active=True
|
||||
)
|
||||
db.session.add(new_comm)
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Comisión %(code)s creada exitosamente.', code=new_comm.get_full_code()), 'success')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions/<int:id>/edit', methods=['POST'])
|
||||
@login_required
|
||||
def commission_edit(id):
|
||||
"""Editar datos de una comisión"""
|
||||
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
||||
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
|
||||
flash(_('No tienes permisos para modificar comisiones.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
from app.models.subject import Commission
|
||||
commission = Commission.query.get_or_404(id)
|
||||
|
||||
commission.code = request.form.get('code', commission.code).strip()
|
||||
commission.semester = request.form.get('semester', commission.semester).strip()
|
||||
commission.year = request.form.get('year', commission.year, type=int)
|
||||
commission.teacher_id = request.form.get('teacher_id', type=int) or None
|
||||
commission.max_students = request.form.get('max_students', commission.max_students, type=int)
|
||||
commission.schedule = request.form.get('schedule', commission.schedule).strip()
|
||||
commission.shift = request.form.get('shift', commission.shift).strip()
|
||||
commission.virtual_link = request.form.get('virtual_link', '').strip() or None
|
||||
commission.active = True if request.form.get('active') in ['1', 'true', 'on'] else False
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Comisión %(code)s actualizada con éxito.', code=commission.get_full_code()), 'success')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions/<int:id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def commission_delete(id):
|
||||
"""Eliminar una comisión"""
|
||||
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
|
||||
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
|
||||
flash(_('No tienes permisos para eliminar comisiones.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
from app.models.subject import Commission
|
||||
commission = Commission.query.get_or_404(id)
|
||||
full_code = commission.get_full_code()
|
||||
|
||||
db.session.delete(commission)
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Comisión %(code)s eliminada permanentemente.', code=full_code), 'success')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions/<int:id>/virtual-link', methods=['POST'])
|
||||
@login_required
|
||||
def commission_virtual_link(id):
|
||||
"""Actualizar enlace del aula virtual (Zoom, Meet, Teams, Moodle) - Profesores, Admin y Bedelía"""
|
||||
from app.models.subject import Commission
|
||||
commission = Commission.query.get_or_404(id)
|
||||
|
||||
if not user_can_manage_commission_milestones(current_user, commission):
|
||||
flash(_('No tienes permisos para actualizar el aula virtual de esta comisión.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
link = request.form.get('virtual_link', '').strip()
|
||||
commission.virtual_link = link or None
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Enlace del aula virtual actualizado para la comisión %(code)s.', code=commission.get_full_code()), 'success')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions/<int:id>/milestones/add', methods=['POST'])
|
||||
@login_required
|
||||
def commission_milestone_add(id):
|
||||
"""Agregar un hito académico (Parcial, TP, Recuperatorio) a una comisión - Profesores, Admin y Bedelía"""
|
||||
from app.models.subject import Commission
|
||||
from app.models.milestone import AcademicMilestone, MilestoneType
|
||||
commission = Commission.query.get_or_404(id)
|
||||
|
||||
if not user_can_manage_commission_milestones(current_user, commission):
|
||||
flash(_('No tienes permisos para agregar hitos a esta comisión.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
milestone_type_id = request.form.get('milestone_type_id', type=int)
|
||||
title = request.form.get('title', '').strip()
|
||||
date_str = request.form.get('date', '').strip()
|
||||
time_str = request.form.get('time', '18:00').strip()
|
||||
classroom = request.form.get('classroom', '').strip()
|
||||
link = request.form.get('link', '').strip() or commission.virtual_link or ''
|
||||
notes = request.form.get('notes', '').strip()
|
||||
|
||||
if not milestone_type_id or not date_str:
|
||||
flash(_('Debes especificar el tipo de hito y la fecha.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
m_type = MilestoneType.query.get(milestone_type_id)
|
||||
if not m_type:
|
||||
flash(_('Tipo de hito inválido.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
flash(_('Formato de fecha inválido.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
milestone = AcademicMilestone(
|
||||
title=title or m_type.name,
|
||||
materia_codigo=commission.subject.code if commission.subject else 'ASIG',
|
||||
subject_id=commission.subject_id,
|
||||
commission_id=commission.id,
|
||||
milestone_type_id=m_type.id,
|
||||
date=dt,
|
||||
classroom=classroom or 'Campus Virtual',
|
||||
link=link or None,
|
||||
notes=notes or None,
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(milestone)
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Hito "%(title)s" registrado correctamente en %(comm)s.', title=milestone.title, comm=commission.get_full_code()), 'success')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
|
||||
@schedule_bp.route('/commissions/<int:commission_id>/milestones/<int:milestone_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def commission_milestone_delete(commission_id, milestone_id):
|
||||
"""Eliminar un hito académico de una comisión"""
|
||||
from app.models.subject import Commission
|
||||
from app.models.milestone import AcademicMilestone
|
||||
commission = Commission.query.get_or_404(commission_id)
|
||||
|
||||
if not user_can_manage_commission_milestones(current_user, commission):
|
||||
flash(_('No tienes permisos para eliminar hitos de esta comisión.'), 'error')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=commission_id).first_or_404()
|
||||
title = milestone.title
|
||||
db.session.delete(milestone)
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Hito "%(title)s" eliminado de la comisión.', title=title), 'success')
|
||||
return redirect(url_for('schedule.commissions_view'))
|
||||
|
||||
@schedule_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@@ -138,23 +493,23 @@ def add_reservation():
|
||||
"""Add new reservation"""
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Commission
|
||||
from app.models.building import Building
|
||||
|
||||
form = ReservationForm()
|
||||
|
||||
# Get active classrooms for the dropdown
|
||||
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
|
||||
# Get active commissions for the dropdown
|
||||
# Get active buildings and classrooms
|
||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
commissions = Commission.query.filter_by(active=True).all()
|
||||
|
||||
# Populate form choices
|
||||
form.classroom_id.choices = [(c.id, f'{c.code_display} - {c.name} (Cap: {c.capacity})') for c in classrooms]
|
||||
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
|
||||
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
|
||||
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check for existing reservations at the same time
|
||||
# Check for existing reservations at the same time
|
||||
from app.models.reservation import ReservationStatus
|
||||
existing_reservation = Reservation.query.filter_by(
|
||||
classroom_id=form.classroom_id.data,
|
||||
@@ -166,7 +521,7 @@ def add_reservation():
|
||||
|
||||
if existing_reservation:
|
||||
flash('This classroom is already booked for the selected time slot.', 'danger')
|
||||
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
|
||||
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
||||
|
||||
# Create new reservation
|
||||
reservation = Reservation(
|
||||
@@ -177,6 +532,7 @@ def add_reservation():
|
||||
end_time=form.end_time.data,
|
||||
purpose=form.purpose.data,
|
||||
notes=form.notes.data,
|
||||
virtual_link=form.virtual_link.data.strip() if form.virtual_link.data else None,
|
||||
status=ReservationStatus.PENDING.value,
|
||||
expected_attendees=form.expected_attendees.data
|
||||
)
|
||||
@@ -189,16 +545,144 @@ def add_reservation():
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error creating reservation: {str(e)}")
|
||||
flash(f'Error creating reservation: {str(e)}', 'danger')
|
||||
|
||||
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
|
||||
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
||||
|
||||
@schedule_bp.route('/reservation/add', methods=['POST'])
|
||||
@login_required
|
||||
def quick_reservation_add():
|
||||
"""Endpoint AJAX para crear reserva rápida desde el modal de comisiones"""
|
||||
try:
|
||||
commission_id = request.form.get('commission_id', type=int)
|
||||
classroom_id = request.form.get('classroom_id', type=int)
|
||||
purpose = request.form.get('purpose', '').strip() or 'Clase de cursada'
|
||||
start_str = request.form.get('start_time', '').strip()
|
||||
end_str = request.form.get('end_time', '').strip()
|
||||
virtual_link = request.form.get('virtual_link', '').strip() or None
|
||||
|
||||
if not commission_id or not classroom_id or not start_str or not end_str:
|
||||
return jsonify({'error': 'Faltan campos obligatorios'}), 400
|
||||
|
||||
start_time = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
|
||||
end_time = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
|
||||
|
||||
from app.models.subject import Commission
|
||||
from app.models.classroom import Classroom
|
||||
comm = Commission.query.get_or_404(commission_id)
|
||||
|
||||
if not virtual_link and comm.virtual_link:
|
||||
virtual_link = comm.virtual_link
|
||||
|
||||
new_res = Reservation(
|
||||
classroom_id=classroom_id,
|
||||
commission_id=commission_id,
|
||||
user_id=comm.teacher_id or current_user.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
purpose=purpose,
|
||||
expected_attendees=comm.max_students or 30,
|
||||
shift=comm.shift or 'Tarde',
|
||||
virtual_link=virtual_link,
|
||||
status=ReservationStatus.CONFIRMED.value if current_user.is_admin() else ReservationStatus.PENDING.value
|
||||
)
|
||||
db.session.add(new_res)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'id': new_res.id})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@schedule_bp.route('/list')
|
||||
@login_required
|
||||
def list_reservations():
|
||||
"""List all reservations"""
|
||||
return redirect(url_for('schedule.today_schedule'))
|
||||
"""List all reservations with pagination, date filters, floor, shift, and virtual toggle"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
status_filter = request.args.get('status', 'all').strip()
|
||||
date_from = request.args.get('date_from', '').strip()
|
||||
date_to = request.args.get('date_to', '').strip()
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
floor_filter = request.args.get('floor', '').strip()
|
||||
shift_filter = request.args.get('shift', '').strip()
|
||||
classroom_id = request.args.get('classroom_id', type=int)
|
||||
search = request.args.get('search', '').strip()
|
||||
|
||||
query = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject),
|
||||
joinedload(Reservation.user)
|
||||
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
|
||||
|
||||
if classroom_id:
|
||||
query = query.filter(Reservation.classroom_id == classroom_id)
|
||||
|
||||
if status_filter and status_filter != 'all':
|
||||
query = query.filter(Reservation.status == status_filter.upper())
|
||||
|
||||
if date_from:
|
||||
try:
|
||||
d_from = datetime.strptime(date_from, '%Y-%m-%d')
|
||||
query = query.filter(Reservation.start_time >= d_from)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
d_to = datetime.strptime(date_to, '%Y-%m-%d') + timedelta(days=1)
|
||||
query = query.filter(Reservation.start_time < d_to)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if hide_virtual:
|
||||
query = query.filter(
|
||||
~Classroom.code.ilike('%VIRTUAL%'),
|
||||
~Classroom.building.ilike('%Virtual%')
|
||||
)
|
||||
|
||||
if floor_filter:
|
||||
flr = str(floor_filter).strip()
|
||||
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
||||
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
||||
else:
|
||||
query = query.filter(Classroom.floor == flr)
|
||||
|
||||
if shift_filter:
|
||||
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Subject.name.ilike(f'%{search}%'),
|
||||
Subject.code.ilike(f'%{search}%'),
|
||||
Commission.code.ilike(f'%{search}%'),
|
||||
Classroom.code.ilike(f'%{search}%'),
|
||||
Reservation.purpose.ilike(f'%{search}%'),
|
||||
Reservation.shift.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
reservations = query.order_by(Reservation.start_time.desc()).paginate(
|
||||
page=page, per_page=20, error_out=False
|
||||
)
|
||||
|
||||
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||
available_floors = [f[0] for f in floors]
|
||||
all_classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
|
||||
return render_template(
|
||||
'schedule/list.html',
|
||||
reservations=reservations,
|
||||
status_filter=status_filter,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
hide_virtual=hide_virtual,
|
||||
floor_filter=floor_filter,
|
||||
shift_filter=shift_filter,
|
||||
classroom_id=classroom_id,
|
||||
all_classrooms=all_classrooms,
|
||||
search=search,
|
||||
available_floors=available_floors
|
||||
)
|
||||
|
||||
|
||||
@schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST'])
|
||||
@@ -321,53 +805,128 @@ def classroom_availability_api():
|
||||
def calendar_data():
|
||||
"""Get calendar events data for FullCalendar"""
|
||||
try:
|
||||
# Get date range from query parameters
|
||||
start = request.args.get('start')
|
||||
end = request.args.get('end')
|
||||
classroom_id = request.args.get('classroom_id')
|
||||
subject_id = request.args.get('subject_id')
|
||||
career_id = request.args.get('career_id')
|
||||
shift = request.args.get('shift', '').strip()
|
||||
floor = request.args.get('floor', '').strip()
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
search = request.args.get('search', '').strip()
|
||||
|
||||
if start and end:
|
||||
from datetime import datetime
|
||||
start_date = datetime.fromisoformat(start)
|
||||
end_date = datetime.fromisoformat(end)
|
||||
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
|
||||
start_clean = start.replace('Z', '+00:00')
|
||||
end_clean = end.replace('Z', '+00:00')
|
||||
start_date = datetime.fromisoformat(start_clean).replace(tzinfo=None)
|
||||
end_date = datetime.fromisoformat(end_clean).replace(tzinfo=None)
|
||||
else:
|
||||
# Default to current month
|
||||
today = date.today()
|
||||
start_date = datetime.combine(today.replace(day=1), datetime.min.time())
|
||||
next_month = today.replace(day=28) + timedelta(days=4) # Next month
|
||||
next_month = today.replace(day=28) + timedelta(days=4)
|
||||
end_date = datetime.combine(next_month.replace(day=1), datetime.max.time())
|
||||
|
||||
# Get reservations in the date range
|
||||
reservations = Reservation.query.options(
|
||||
query = Reservation.query.join(Reservation.commission).join(Commission.subject).outerjoin(Reservation.classroom).options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission)
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
|
||||
).filter(
|
||||
Reservation.start_time >= start_date,
|
||||
Reservation.start_time <= end_date,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
||||
).order_by(Reservation.start_time).all()
|
||||
Reservation.start_time <= end_date
|
||||
)
|
||||
|
||||
if classroom_id and classroom_id.isdigit():
|
||||
query = query.filter(Reservation.classroom_id == int(classroom_id))
|
||||
|
||||
if subject_id and subject_id.isdigit():
|
||||
query = query.filter(Commission.subject_id == int(subject_id))
|
||||
|
||||
if career_id and career_id.isdigit():
|
||||
query = query.filter(Subject.career_id == int(career_id))
|
||||
|
||||
building = request.args.get('building', '').strip()
|
||||
if building:
|
||||
from app.models.building import Building
|
||||
query = query.filter(
|
||||
or_(
|
||||
Classroom.building.ilike(f'%{building}%'),
|
||||
Classroom.building_entity.has(Building.name.ilike(f'%{building}%'))
|
||||
)
|
||||
)
|
||||
|
||||
if shift:
|
||||
query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
|
||||
|
||||
if hide_virtual:
|
||||
query = query.filter(
|
||||
~Classroom.code.ilike('%VIRTUAL%'),
|
||||
~Classroom.building.ilike('%Virtual%')
|
||||
)
|
||||
|
||||
if floor:
|
||||
flr = str(floor).strip()
|
||||
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
||||
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
||||
else:
|
||||
query = query.filter(Classroom.floor == flr)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Subject.name.ilike(f'%{search}%'),
|
||||
Subject.code.ilike(f'%{search}%'),
|
||||
Classroom.code.ilike(f'%{search}%'),
|
||||
Reservation.purpose.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
reservations = query.order_by(Reservation.start_time).all()
|
||||
|
||||
events = []
|
||||
for reservation in reservations:
|
||||
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
|
||||
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
|
||||
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
|
||||
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
|
||||
classroom_floor = reservation.classroom.floor_display if reservation.classroom else None
|
||||
classroom_building = reservation.classroom.building_name if reservation.classroom else ''
|
||||
is_virtual = bool(reservation.classroom and reservation.classroom.is_virtual)
|
||||
career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '')
|
||||
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
|
||||
|
||||
events.append({
|
||||
'id': reservation.id,
|
||||
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}",
|
||||
'title': subject_name,
|
||||
'start': reservation.start_time.isoformat(),
|
||||
'end': reservation.end_time.isoformat(),
|
||||
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
|
||||
'className': f'fc-event-{reservation.status.value.lower()}',
|
||||
'className': f'fc-event-{status_val.lower()}',
|
||||
'extendedProps': {
|
||||
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown',
|
||||
'commission': reservation.commission.get_full_code() if reservation.commission else 'No Commission',
|
||||
'purpose': reservation.purpose or 'No purpose',
|
||||
'attendees': reservation.expected_attendees,
|
||||
'status': reservation.status.value,
|
||||
'notes': reservation.notes or ''
|
||||
'subject_code': subject_code,
|
||||
'subject_name': subject_name,
|
||||
'classroom': classroom_name,
|
||||
'building': classroom_building,
|
||||
'floor': classroom_floor,
|
||||
'is_virtual': is_virtual,
|
||||
'location_display': reservation.classroom.location_display if reservation.classroom else '',
|
||||
'career': career_name,
|
||||
'commission': reservation.commission.code if reservation.commission else '',
|
||||
'shift': reservation.shift or '',
|
||||
'status': status_val,
|
||||
'purpose': reservation.purpose or '',
|
||||
'attendees': reservation.expected_attendees or 0,
|
||||
'notes': reservation.notes or '',
|
||||
'teacher': reservation.user.name if reservation.user else '',
|
||||
'virtual_link': reservation.effective_virtual_link,
|
||||
'cancel_url': url_for('schedule.cancel_reservation', id=reservation.id)
|
||||
}
|
||||
})
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@schedule_bp.route('/api/today-events')
|
||||
@@ -380,26 +939,33 @@ def api_today_events():
|
||||
|
||||
reservations = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission)
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
|
||||
).filter(
|
||||
Reservation.start_time >= today_start,
|
||||
Reservation.start_time <= today_end,
|
||||
Reservation.status == ReservationStatus.CONFIRMED.value
|
||||
Reservation.start_time <= today_end
|
||||
).order_by(Reservation.start_time).all()
|
||||
|
||||
events = []
|
||||
for reservation in reservations:
|
||||
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
|
||||
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
|
||||
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
|
||||
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
|
||||
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
|
||||
|
||||
events.append({
|
||||
'id': reservation.id,
|
||||
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}",
|
||||
'title': f"{subject_name} ({subject_code}) - {classroom_name}",
|
||||
'start': reservation.start_time.isoformat(),
|
||||
'end': reservation.end_time.isoformat(),
|
||||
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
|
||||
'extendedProps': {
|
||||
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown',
|
||||
'subject_code': subject_code,
|
||||
'subject_name': subject_name,
|
||||
'classroom': classroom_name,
|
||||
'purpose': reservation.purpose or 'No purpose',
|
||||
'attendees': reservation.expected_attendees,
|
||||
'status': reservation.status.value
|
||||
'status': status_val
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .sheets_importer import GoogleSheetsImporter
|
||||
|
||||
__all__ = ['GoogleSheetsImporter']
|
||||
@@ -0,0 +1,413 @@
|
||||
import urllib.request
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from datetime import datetime, date, time, timedelta
|
||||
from app import db
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.career import Career
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.user import User
|
||||
|
||||
BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv'
|
||||
|
||||
SHEETS_CONFIG = [
|
||||
{ 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 },
|
||||
{ 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 },
|
||||
{ 'name': 'Miércoles', 'gid': '615483345', 'day_idx': 2 },
|
||||
{ 'name': 'Jueves', 'gid': '682416571', 'day_idx': 3 },
|
||||
{ 'name': 'Viernes', 'gid': '1162906711', 'day_idx': 4 },
|
||||
{ 'name': 'Sábado', 'gid': '752305601', 'day_idx': 5 },
|
||||
]
|
||||
|
||||
TIME_REGEX = re.compile(r'(\d{1,2})(?::(\d{2}))?\s*(?:a|-|to)\s*(\d{1,2})(?::(\d{2}))?', re.IGNORECASE)
|
||||
|
||||
class GoogleSheetsImporter:
|
||||
"""
|
||||
Service to fetch, parse, and synchronize academic schedules,
|
||||
classrooms, careers, and subjects from the official UniCABA Google Sheet.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url=None):
|
||||
self.base_url = base_url or BASE_CSV_URL
|
||||
|
||||
def fetch_sheet_csv(self, gid):
|
||||
"""Fetch CSV string for a specific sheet gid"""
|
||||
url = f"{self.base_url}&gid={gid}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return resp.read().decode('utf-8', errors='replace')
|
||||
|
||||
def parse_sheet_rows(self, csv_content, sheet_name):
|
||||
"""Parse raw CSV text into a structured list of class records"""
|
||||
reader = csv.reader(io.StringIO(csv_content))
|
||||
rows = list(reader)
|
||||
|
||||
header_idx = -1
|
||||
col_offset = 0
|
||||
|
||||
for r_idx, row in enumerate(rows):
|
||||
for c_idx, cell in enumerate(row):
|
||||
cell_clean = cell.lower().strip()
|
||||
if 'codigo' in cell_clean or 'código' in cell_clean:
|
||||
header_idx = r_idx
|
||||
col_offset = c_idx
|
||||
break
|
||||
if header_idx != -1:
|
||||
break
|
||||
|
||||
if header_idx == -1:
|
||||
return []
|
||||
|
||||
records = []
|
||||
current_shift = 'Mañana'
|
||||
for row in rows[header_idx + 1:]:
|
||||
if len(row) >= col_offset + 5:
|
||||
raw_cell = row[col_offset].strip().replace('\n', ' - ')
|
||||
row_full_text = ' '.join(row).upper()
|
||||
|
||||
# Detect shift delimiters
|
||||
if 'TURNO MAÑANA' in row_full_text or 'TURNO MANANA' in row_full_text:
|
||||
current_shift = 'Mañana'
|
||||
continue
|
||||
elif 'TURNO VESPERTINO' in row_full_text or 'TURNO TARDE' in row_full_text or 'TURNO NOCHE' in row_full_text:
|
||||
current_shift = 'Tarde'
|
||||
continue
|
||||
elif raw_cell.lower().startswith('turno'):
|
||||
if 'mañ' in raw_cell.lower() or 'man' in raw_cell.lower():
|
||||
current_shift = 'Mañana'
|
||||
else:
|
||||
current_shift = 'Tarde'
|
||||
continue
|
||||
|
||||
codigo = raw_cell
|
||||
asignatura = row[col_offset + 1].strip().replace('\n', ' ')
|
||||
aula = row[col_offset + 2].strip().replace('\n', ' ')
|
||||
carrera = row[col_offset + 3].strip().replace('\n', ' ')
|
||||
horario = row[col_offset + 4].strip().replace('\n', ' ')
|
||||
|
||||
# Check valid data row
|
||||
if codigo and asignatura and not 'codigo' in codigo.lower():
|
||||
# Double-check shift from start hour if possible
|
||||
record_shift = current_shift
|
||||
time_match = re.search(r'(\d{1,2}):(\d{2})', horario)
|
||||
if time_match:
|
||||
h = int(time_match.group(1))
|
||||
if h >= 14:
|
||||
record_shift = 'Tarde'
|
||||
elif h < 13:
|
||||
record_shift = 'Mañana'
|
||||
|
||||
records.append({
|
||||
'dia': sheet_name,
|
||||
'codigo': codigo,
|
||||
'asignatura': asignatura,
|
||||
'aula': aula,
|
||||
'carrera': carrera,
|
||||
'horario': horario,
|
||||
'turno': record_shift
|
||||
})
|
||||
return records
|
||||
|
||||
def normalize_room(self, aula_raw):
|
||||
"""
|
||||
Normalize room string into (building, room_number, floor, capacity)
|
||||
"""
|
||||
aula_upper = aula_raw.upper().strip()
|
||||
if not aula_upper or any(x in aula_upper for x in ['FERIADO', 'SIN ACTIVIDAD', 'CONSULTAR EN BEDELIA']):
|
||||
return None
|
||||
|
||||
if 'VIRTUAL' in aula_upper:
|
||||
return {
|
||||
'building': 'Campus Virtual',
|
||||
'code': 'VIRTUAL',
|
||||
'floor': 0,
|
||||
'capacity': 0,
|
||||
'description': 'Espacio áulico virtual UniCABA (Capacidad Ilimitada ∞)'
|
||||
}
|
||||
|
||||
if 'AUDITORIO' in aula_upper:
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': 'Auditorio',
|
||||
'floor': 0,
|
||||
'capacity': 150,
|
||||
'description': 'Auditorio principal UniCABA'
|
||||
}
|
||||
|
||||
if 'SALA DE REUNI' in aula_upper:
|
||||
floor = 2 if ('2DO' in aula_upper or '2°' in aula_upper) else 4
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': f'Sala Reunión {floor}°P',
|
||||
'floor': floor,
|
||||
'capacity': 15,
|
||||
'description': f'Sala de reunión piso {floor}'
|
||||
}
|
||||
|
||||
# Standard classrooms: "Aula 402", "AULA 203", "306 - 406"
|
||||
num_match = re.search(r'\d+', aula_raw)
|
||||
if num_match:
|
||||
num = num_match.group(0)
|
||||
floor = int(num[0]) if len(num) >= 3 else 1
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': f'Aula {num}',
|
||||
'floor': floor,
|
||||
'capacity': 35,
|
||||
'description': f'Aula física {num} - Piso {floor}'
|
||||
}
|
||||
|
||||
# Generic fallback
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': aula_raw,
|
||||
'floor': 1,
|
||||
'capacity': 30,
|
||||
'description': aula_raw
|
||||
}
|
||||
|
||||
def parse_time_range(self, horario_str):
|
||||
"""Parse '8:00 a 9:00hs' into (time(8, 0), time(9, 0))"""
|
||||
m = TIME_REGEX.search(horario_str)
|
||||
if not m:
|
||||
return None, None
|
||||
try:
|
||||
h1 = int(m.group(1))
|
||||
m1 = int(m.group(2) or 0)
|
||||
h2 = int(m.group(3))
|
||||
m2 = int(m.group(4) or 0)
|
||||
return time(h1, m1), time(h2, m2)
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
def sync(self):
|
||||
"""
|
||||
Main synchronization process:
|
||||
1. Fetches all 6 day sheets.
|
||||
2. Upserts Careers, Classrooms, Subjects, Commissions.
|
||||
3. Generates Reservations in the calendar for the semester.
|
||||
"""
|
||||
stats = {
|
||||
'total_rows_processed': 0,
|
||||
'careers_created': 0,
|
||||
'careers_updated': 0,
|
||||
'classrooms_created': 0,
|
||||
'classrooms_updated': 0,
|
||||
'subjects_created': 0,
|
||||
'subjects_updated': 0,
|
||||
'commissions_created': 0,
|
||||
'reservations_created': 0,
|
||||
'errors': [],
|
||||
'sample_records': []
|
||||
}
|
||||
|
||||
all_raw_records = []
|
||||
for sheet_cfg in SHEETS_CONFIG:
|
||||
try:
|
||||
csv_data = self.fetch_sheet_csv(sheet_cfg['gid'])
|
||||
rows = self.parse_sheet_rows(csv_data, sheet_cfg['name'])
|
||||
all_raw_records.extend([(r, sheet_cfg['day_idx']) for r in rows])
|
||||
except Exception as e:
|
||||
stats['errors'].append(f"Error al descargar o procesar la hoja de {sheet_cfg['name']}: {str(e)}")
|
||||
|
||||
stats['total_rows_processed'] = len(all_raw_records)
|
||||
if not all_raw_records:
|
||||
return stats
|
||||
|
||||
# Default admin user for reservations
|
||||
default_user = User.query.filter_by(role='ADMIN').first() or User.query.first()
|
||||
default_user_id = default_user.id if default_user else 1
|
||||
|
||||
# Ensure institutional buildings exist with official address
|
||||
from app.models.building import Building
|
||||
b_central = Building.query.filter_by(name='Edificio Central').first()
|
||||
if not b_central:
|
||||
b_central = Building(
|
||||
name='Edificio Central',
|
||||
code='CENTRAL',
|
||||
address='Tte. Gral. Juan Domingo Perón 802, CABA',
|
||||
floors=4,
|
||||
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
|
||||
)
|
||||
db.session.add(b_central)
|
||||
db.session.flush()
|
||||
else:
|
||||
b_central.address = 'Tte. Gral. Juan Domingo Perón 802, CABA'
|
||||
|
||||
b_virtual = Building.query.filter_by(name='Campus Virtual').first()
|
||||
if not b_virtual:
|
||||
b_virtual = Building(
|
||||
name='Campus Virtual',
|
||||
code='VIRTUAL',
|
||||
address='Entorno Digital / Microsoft Teams - Moodle',
|
||||
floors=0,
|
||||
description='Entorno de aulas virtuales y clases a distancia'
|
||||
)
|
||||
db.session.add(b_virtual)
|
||||
db.session.flush()
|
||||
|
||||
buildings_cache = {b.name.strip().upper(): b for b in Building.query.all()}
|
||||
careers_cache = {c.name.strip().upper(): c for c in Career.query.all()}
|
||||
classrooms_cache = {(c.building.strip().upper(), c.code.strip().upper()): c for c in Classroom.query.all()}
|
||||
subjects_cache = {s.code.strip().upper(): s for s in Subject.query.all()}
|
||||
|
||||
# Calculate current semester dates (anchored on academic year 2026)
|
||||
# Week starting Monday 2026-08-31 to Saturday 2026-09-05
|
||||
base_monday = date(2026, 8, 31)
|
||||
weeks_to_generate = 4 # Generate reservations for 4 consecutive weeks
|
||||
|
||||
for rec, day_idx in all_raw_records:
|
||||
try:
|
||||
codigo = rec['codigo']
|
||||
asignatura = rec['asignatura']
|
||||
aula_raw = rec['aula']
|
||||
carrera_name = rec['carrera'].strip()
|
||||
horario_raw = rec['horario']
|
||||
dia = rec['dia']
|
||||
turno = rec.get('turno', 'Mañana')
|
||||
|
||||
# 1. Upsert Career
|
||||
carrera_key = carrera_name.upper()
|
||||
career = careers_cache.get(carrera_key)
|
||||
if not career:
|
||||
career = Career(name=carrera_name)
|
||||
db.session.add(career)
|
||||
db.session.flush()
|
||||
careers_cache[carrera_key] = career
|
||||
stats['careers_created'] += 1
|
||||
|
||||
# 2. Upsert Classroom
|
||||
room_info = self.normalize_room(aula_raw)
|
||||
classroom = None
|
||||
if room_info:
|
||||
room_key = (room_info['building'].upper(), room_info['code'].upper())
|
||||
classroom = classrooms_cache.get(room_key)
|
||||
b_entity = buildings_cache.get(room_info['building'].upper())
|
||||
if not classroom:
|
||||
classroom = Classroom(
|
||||
building=room_info['building'],
|
||||
building_id=b_entity.id if b_entity else None,
|
||||
floor=room_info['floor'],
|
||||
capacity=room_info['capacity'],
|
||||
description=room_info['description'],
|
||||
is_active=True
|
||||
)
|
||||
classroom.code = room_info['code']
|
||||
db.session.add(classroom)
|
||||
db.session.flush()
|
||||
classrooms_cache[room_key] = classroom
|
||||
stats['classrooms_created'] += 1
|
||||
else:
|
||||
classroom.capacity = max(classroom.capacity, room_info['capacity'])
|
||||
if not classroom.building_id and b_entity:
|
||||
classroom.building_id = b_entity.id
|
||||
|
||||
# 3. Upsert Subject
|
||||
subject_key = codigo.upper()
|
||||
subject = subjects_cache.get(subject_key)
|
||||
if not subject:
|
||||
subject = Subject(
|
||||
code=codigo,
|
||||
name=asignatura,
|
||||
career_id=career.id,
|
||||
department=career.name,
|
||||
credits=4,
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(subject)
|
||||
db.session.flush()
|
||||
subjects_cache[subject_key] = subject
|
||||
stats['subjects_created'] += 1
|
||||
else:
|
||||
if subject.name != asignatura:
|
||||
subject.name = asignatura
|
||||
stats['subjects_updated'] += 1
|
||||
subject.career_id = career.id
|
||||
subject.department = career.name
|
||||
|
||||
# 4. Upsert Commission
|
||||
# Identify commission subcode e.g. from code suffix or default 'C1'
|
||||
comm_code = 'C1'
|
||||
if '-E' in codigo:
|
||||
comm_code = codigo.split('-')[-1]
|
||||
|
||||
commission = Commission.query.filter_by(
|
||||
subject_id=subject.id,
|
||||
code=comm_code,
|
||||
semester='2026-2',
|
||||
year=2026
|
||||
).first()
|
||||
|
||||
if not commission:
|
||||
commission = Commission(
|
||||
subject_id=subject.id,
|
||||
code=comm_code,
|
||||
semester='2026-2',
|
||||
year=2026,
|
||||
teacher_id=default_user_id,
|
||||
max_students=classroom.capacity if classroom else 35,
|
||||
current_students=0,
|
||||
schedule=f"{dia} {horario_raw}",
|
||||
shift=turno,
|
||||
active=True
|
||||
)
|
||||
db.session.add(commission)
|
||||
db.session.flush()
|
||||
stats['commissions_created'] += 1
|
||||
else:
|
||||
commission.schedule = f"{dia} {horario_raw}"
|
||||
commission.shift = turno
|
||||
|
||||
# 5. Generate Calendar Reservations
|
||||
t_start, t_end = self.parse_time_range(horario_raw)
|
||||
if classroom and t_start and t_end and day_idx is not None:
|
||||
for w in range(weeks_to_generate):
|
||||
class_date = base_monday + timedelta(days=day_idx, weeks=w)
|
||||
dt_start = datetime.combine(class_date, t_start)
|
||||
dt_end = datetime.combine(class_date, t_end)
|
||||
|
||||
existing_res = Reservation.query.filter_by(
|
||||
classroom_id=classroom.id,
|
||||
start_time=dt_start,
|
||||
end_time=dt_end
|
||||
).first()
|
||||
|
||||
if not existing_res:
|
||||
res = Reservation(
|
||||
classroom_id=classroom.id,
|
||||
commission_id=commission.id,
|
||||
user_id=default_user_id,
|
||||
start_time=dt_start,
|
||||
end_time=dt_end,
|
||||
purpose=f"{subject.code} - {subject.name}",
|
||||
status='CONFIRMED',
|
||||
expected_attendees=min(classroom.capacity, 30),
|
||||
shift=turno,
|
||||
notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
||||
)
|
||||
db.session.add(res)
|
||||
stats['reservations_created'] += 1
|
||||
else:
|
||||
existing_res.shift = turno
|
||||
existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
||||
|
||||
if len(stats['sample_records']) < 10:
|
||||
stats['sample_records'].append({
|
||||
'dia': dia,
|
||||
'codigo': codigo,
|
||||
'asignatura': asignatura,
|
||||
'carrera': career.name,
|
||||
'aula': classroom.code_display if classroom else aula_raw,
|
||||
'horario': horario_raw
|
||||
})
|
||||
|
||||
except Exception as row_err:
|
||||
stats['errors'].append(f"Error en fila '{rec.get('codigo', '?')}': {str(row_err)}")
|
||||
|
||||
db.session.commit()
|
||||
return stats
|
||||
@@ -1,97 +1,44 @@
|
||||
/* Custom CSS for Edu-Space Admin */
|
||||
|
||||
/* Dark Theme Enhancements */
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
/* Custom Enhancements for Edu-Space Admin */
|
||||
|
||||
/* Card Styles */
|
||||
.card {
|
||||
border-radius: 15px;
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
border-radius: 14px;
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.navbar {
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn {
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #5568d8 0%, #654198 100%);
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.form-control, .form-select {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
background-color: #2a2a2a;
|
||||
color: #fff;
|
||||
transition: border-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
background-color: #2a2a2a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.table {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.table {
|
||||
background-color: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Table Hover */
|
||||
.table:hover tbody tr:hover {
|
||||
background-color: rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
/* Badge Styles */
|
||||
.badge {
|
||||
font-size: 0.75em;
|
||||
padding: 0.5em 0.8em;
|
||||
background-color: rgba(var(--brand-primary-rgb), 0.04);
|
||||
}
|
||||
|
||||
/* Status Badge Colors */
|
||||
.bg-confirmed-custom {
|
||||
background-color: #28a745;
|
||||
background-color: #10B981;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.bg-pending-custom {
|
||||
background-color: #ffc107;
|
||||
color: #000;
|
||||
background-color: #F59E0B;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.bg-cancelled-custom {
|
||||
background-color: #dc3545;
|
||||
background-color: #EF4444;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
@@ -99,9 +46,9 @@ body {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border: 3px solid rgba(var(--brand-primary-rgb), 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #667eea;
|
||||
border-top-color: var(--brand-primary);
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -111,28 +58,25 @@ body {
|
||||
|
||||
/* Calendar Customizations */
|
||||
.fc-event {
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
border: none !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 12px !important;
|
||||
margin: 1px 0 !important;
|
||||
}
|
||||
|
||||
.fc-day-sat, .fc-day-sun {
|
||||
background-color: rgba(255, 255, 255, 0.02) !important;
|
||||
}
|
||||
|
||||
/* Dashboard Stat Cards */
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 15px;
|
||||
background: var(--gradient-primary);
|
||||
color: #FFFFFF;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 20px rgba(var(--brand-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.stat-card-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
.stat-card-success { background: linear-gradient(135deg, #28a745 0%, #20c997 100%); }
|
||||
.stat-card-info { background: linear-gradient(135deg, #17a2b8 0%, #138496 100%); }
|
||||
.stat-card-warning { background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%); }
|
||||
.stat-card-primary { background: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%); }
|
||||
.stat-card-success { background: linear-gradient(135deg, #059669 0%, #10B981 100%); }
|
||||
.stat-card-info { background: linear-gradient(135deg, #682CAD 0%, #8B5CF6 100%); }
|
||||
.stat-card-warning { background: linear-gradient(135deg, #D97706 0%, #F59E0B 100%); }
|
||||
|
||||
/* Quick Action Buttons */
|
||||
.quick-action-btn {
|
||||
@@ -144,14 +88,14 @@ body {
|
||||
}
|
||||
|
||||
.quick-action-btn:hover {
|
||||
transform: translateX(5px);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Empty State Styles */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #6c757d;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
@@ -160,30 +104,43 @@ body {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Notification Toastenhancements */
|
||||
/* Notification Toast enhancements */
|
||||
.toast {
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Pagination Styles */
|
||||
.pagination .page-link {
|
||||
border-radius: 8px;
|
||||
margin: 0 2px;
|
||||
border: 1px solid #444;
|
||||
background-color: #2a2a2a;
|
||||
color: #fff;
|
||||
margin: 0 3px;
|
||||
border: 1px solid var(--card-border);
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pagination .page-link:hover {
|
||||
background-color: #667eea;
|
||||
border-color: #667eea;
|
||||
color: white;
|
||||
background-color: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background-color: #667eea;
|
||||
border-color: #667eea;
|
||||
background: var(--gradient-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Dashboard Stat Shortcuts */
|
||||
.dashboard-stat-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
.dashboard-stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 0.5rem 1.25rem rgba(0, 0, 0, 0.1) !important;
|
||||
border-color: rgba(30, 77, 140, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness */
|
||||
@@ -208,13 +165,13 @@ body {
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #ddd !important;
|
||||
border: 1px solid #DDD !important;
|
||||
box-shadow: none !important;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: white !important;
|
||||
color: black !important;
|
||||
background-color: #FFFFFF !important;
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
/**
|
||||
* Sistema de Diseño & Tokens de Color - Edu-Space Admin
|
||||
* Paleta Institucional Magenta / Uva y Soporte Claro/Oscuro
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* --- Brand Primary (Magenta / Uva) --- */
|
||||
--brand-primary: #B43E8E;
|
||||
--brand-primary-rgb: 180, 62, 142;
|
||||
--brand-primary-shade: #9E367D;
|
||||
--brand-primary-tint: #BC5199;
|
||||
--brand-primary-contrast: #FFFFFF;
|
||||
|
||||
/* --- Accent Colors (Modo Claro) --- */
|
||||
--accent-purple: #682CAD;
|
||||
--accent-purple-rgb: 104, 44, 173;
|
||||
--secondary-magenta: #7B2049;
|
||||
--secondary-magenta-rgb: 123, 32, 73;
|
||||
|
||||
/* --- Global Surface & Text (Modo Claro) --- */
|
||||
--app-bg: #F8FAFC;
|
||||
--card-bg: #FFFFFF;
|
||||
--card-border: rgba(0, 0, 0, 0.08);
|
||||
--card-hover-border: rgba(180, 62, 142, 0.3);
|
||||
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
--card-hover-shadow: 0 10px 25px rgba(180, 62, 142, 0.12);
|
||||
|
||||
--text-primary: #1E293B;
|
||||
--text-secondary: #64748B;
|
||||
--text-muted: #94A3B8;
|
||||
|
||||
--navbar-bg: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
|
||||
--navbar-text: #FFFFFF;
|
||||
--navbar-border: transparent;
|
||||
--navbar-shadow: 0 4px 20px rgba(180, 62, 142, 0.25);
|
||||
|
||||
--input-bg: #FFFFFF;
|
||||
--input-border: #CBD5E1;
|
||||
--input-text: #1E293B;
|
||||
--input-focus-border: #B43E8E;
|
||||
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.25);
|
||||
|
||||
/* --- Distinctive Gradient --- */
|
||||
--gradient-primary: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
|
||||
--gradient-primary-shadow: 0 8px 24px rgba(180, 62, 142, 0.25);
|
||||
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.08) 0%, rgba(104, 44, 173, 0.12) 100%);
|
||||
|
||||
/* --- Badges (Modo Claro) --- */
|
||||
--badge-primary-bg: #B43E8E;
|
||||
--badge-primary-text: #FFFFFF;
|
||||
--badge-accent-bg: #682CAD;
|
||||
--badge-accent-text: #FFFFFF;
|
||||
--badge-magenta-bg: #7B2049;
|
||||
--badge-magenta-text: #FFFFFF;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
MODO OSCURO ([data-bs-theme="dark"])
|
||||
======================================================= */
|
||||
[data-bs-theme="dark"] {
|
||||
/* --- Brand Tweaks for Dark Theme --- */
|
||||
--brand-primary: #B43E8E;
|
||||
--brand-primary-tint: #C95FA6;
|
||||
|
||||
/* --- Accent Colors (Modo Oscuro) --- */
|
||||
--accent-purple: #AE7FFF;
|
||||
--secondary-magenta: #FF8AB6;
|
||||
|
||||
/* --- Surfaces & Contrast in Dark Mode --- */
|
||||
--app-bg: #121622;
|
||||
--card-bg: #1E2442;
|
||||
--card-border: rgba(255, 255, 255, 0.08);
|
||||
--card-hover-border: rgba(180, 62, 142, 0.5);
|
||||
--card-shadow: 0 8px 25px rgba(0, 0, 0, 0.4);
|
||||
--card-hover-shadow: 0 12px 30px rgba(180, 62, 142, 0.25);
|
||||
|
||||
--text-primary: #E2E8F0;
|
||||
--text-secondary: #94A3B8;
|
||||
--text-muted: #64748B;
|
||||
|
||||
--navbar-bg: linear-gradient(135deg, #24142D 0%, #161A2E 100%);
|
||||
--navbar-text: #F8FAFC;
|
||||
--navbar-border: 1px solid rgba(180, 62, 142, 0.25);
|
||||
--navbar-shadow: 0 4px 25px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--input-bg: #161B30;
|
||||
--input-border: #2D3748;
|
||||
--input-text: #E2E8F0;
|
||||
--input-focus-border: #B43E8E;
|
||||
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.35);
|
||||
|
||||
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.18) 0%, rgba(30, 36, 66, 0.8) 100%);
|
||||
|
||||
/* --- Badges & Chips (Translucent in Dark Mode) --- */
|
||||
--badge-primary-bg: rgba(180, 62, 142, 0.25);
|
||||
--badge-primary-text: #FF8AB6;
|
||||
--badge-primary-border: 1px solid rgba(180, 62, 142, 0.45);
|
||||
|
||||
--badge-accent-bg: rgba(104, 44, 173, 0.25);
|
||||
--badge-accent-text: #AE7FFF;
|
||||
--badge-accent-border: 1px solid rgba(174, 127, 255, 0.45);
|
||||
|
||||
--badge-magenta-bg: rgba(123, 32, 73, 0.25);
|
||||
--badge-magenta-text: #FF96B4;
|
||||
--badge-magenta-border: 1px solid rgba(255, 138, 182, 0.4);
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
ELEMENTOS GLOBALES & TRANSICIONES
|
||||
======================================================= */
|
||||
body {
|
||||
background-color: var(--app-bg) !important;
|
||||
color: var(--text-primary) !important;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
transition: background-color 0.25s ease, color 0.25s ease;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar-app {
|
||||
background: var(--navbar-bg) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: var(--navbar-border);
|
||||
box-shadow: var(--navbar-shadow);
|
||||
padding: 0.65rem 1.25rem;
|
||||
transition: background 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.navbar-app .navbar-brand {
|
||||
color: var(--navbar-text) !important;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-app .nav-link {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
font-weight: 500;
|
||||
transition: color 0.15s ease, transform 0.15s ease;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.85rem !important;
|
||||
}
|
||||
|
||||
.navbar-app .nav-link:hover,
|
||||
.navbar-app .nav-link:focus {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.navbar-app .nav-link.active {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.navbar-logo {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
transition: filter 0.25s ease;
|
||||
}
|
||||
|
||||
/* En Modo Claro el navbar es magenta (#B43E8E), aplicamos color blanco al isotipo */
|
||||
[data-bs-theme="light"] .navbar-logo,
|
||||
:root:not([data-bs-theme="dark"]) .navbar-logo {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* En Modo Oscuro se mantiene el isotipo con su tono natural */
|
||||
[data-bs-theme="dark"] .navbar-logo {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* User Avatar Component */
|
||||
.user-avatar-small {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #B43E8E, #682CAD);
|
||||
color: #FFFFFF;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
/* Theme Toggle Button */
|
||||
.theme-toggle-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #FFFFFF;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
transform: rotate(15deg) scale(1.05);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Language Switcher Button (Alto Contraste en Navbar y Modo Claro) */
|
||||
.navbar-app .language-switcher .btn {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.16) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.32) !important;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.navbar-app .language-switcher .btn:hover,
|
||||
.navbar-app .language-switcher .btn:focus,
|
||||
.navbar-app .language-switcher .dropdown-toggle.show {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.28) !important;
|
||||
border-color: rgba(255, 255, 255, 0.5) !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.navbar-app .language-switcher .dropdown-menu {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--card-border);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Dropdown Item Active (Gradiente Magenta Institucional en lugar de Azul) */
|
||||
.dropdown-item {
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:active {
|
||||
background: var(--gradient-primary) !important;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.dropdown-item.active i,
|
||||
.dropdown-item:active i {
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
CARDS & CONTENEDORES
|
||||
======================================================= */
|
||||
.card {
|
||||
background-color: var(--card-bg) !important;
|
||||
border: 1px solid var(--card-border) !important;
|
||||
color: var(--text-primary) !important;
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 14px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--card-hover-border) !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: transparent !important;
|
||||
border-bottom: 1px solid var(--card-border) !important;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background-color: rgba(0, 0, 0, 0.02) !important;
|
||||
border-top: 1px solid var(--card-border) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .card-footer {
|
||||
background-color: rgba(255, 255, 255, 0.02) !important;
|
||||
}
|
||||
|
||||
/* Hero Banners */
|
||||
.hero-banner {
|
||||
background: var(--gradient-hero) !important;
|
||||
border: 1px solid var(--card-border) !important;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
BOTONES Y ACCIONES
|
||||
======================================================= */
|
||||
.btn {
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-primary) !important;
|
||||
border: none !important;
|
||||
color: var(--brand-primary-contrast) !important;
|
||||
box-shadow: 0 4px 14px rgba(var(--brand-primary-rgb), 0.35);
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus {
|
||||
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
|
||||
box-shadow: 0 6px 18px rgba(var(--brand-primary-rgb), 0.45);
|
||||
transform: translateY(-1px);
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover,
|
||||
.btn-outline-primary:focus {
|
||||
background: var(--gradient-primary) !important;
|
||||
border-color: var(--brand-primary-shade) !important;
|
||||
color: #FFFFFF !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(var(--brand-primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
/* Distinctive Text Accent */
|
||||
.text-primary,
|
||||
.text-brand {
|
||||
color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .text-primary,
|
||||
[data-bs-theme="dark"] .text-brand {
|
||||
color: var(--brand-primary-tint) !important;
|
||||
}
|
||||
|
||||
.text-accent-purple {
|
||||
color: var(--accent-purple) !important;
|
||||
}
|
||||
|
||||
.text-secondary-magenta {
|
||||
color: var(--secondary-magenta) !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
BADGES & CHIPS
|
||||
======================================================= */
|
||||
.badge {
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
padding: 0.4em 0.75em;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.badge-brand {
|
||||
background-color: var(--badge-primary-bg) !important;
|
||||
color: var(--badge-primary-text) !important;
|
||||
border: var(--badge-primary-border, none);
|
||||
}
|
||||
|
||||
.badge-approved {
|
||||
background-color: var(--badge-accent-bg) !important;
|
||||
color: var(--badge-accent-text) !important;
|
||||
border: var(--badge-accent-border, none);
|
||||
}
|
||||
|
||||
.badge-schedule {
|
||||
background-color: var(--badge-magenta-bg) !important;
|
||||
color: var(--badge-magenta-text) !important;
|
||||
border: var(--badge-magenta-border, none);
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
FORMULARIOS & INPUTS
|
||||
======================================================= */
|
||||
.form-control,
|
||||
.form-select {
|
||||
background-color: var(--input-bg) !important;
|
||||
border-color: var(--input-border) !important;
|
||||
color: var(--input-text) !important;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--input-focus-border) !important;
|
||||
box-shadow: var(--input-focus-shadow) !important;
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
TARJETA DE LOGIN (.auth-card)
|
||||
======================================================= */
|
||||
.auth-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: var(--app-bg);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background-color: var(--card-bg) !important;
|
||||
border: 1px solid var(--card-border) !important;
|
||||
border-left: 6px solid var(--brand-primary) !important;
|
||||
border-radius: 18px !important;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .auth-card {
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5), 0 0 25px rgba(180, 62, 142, 0.15);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
padding: 2.25rem 2rem 1.25rem;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
max-height: 52px;
|
||||
max-width: 220px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
margin-bottom: 1rem;
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.auth-controls {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Controles de Login en Modo Claro (Tarjeta Blanca) */
|
||||
[data-bs-theme="light"] .auth-controls .language-switcher .btn,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .language-switcher .btn {
|
||||
color: var(--brand-primary) !important;
|
||||
background: rgba(var(--brand-primary-rgb), 0.08) !important;
|
||||
border: 1px solid rgba(var(--brand-primary-rgb), 0.25) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] .auth-controls .language-switcher .btn:hover,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .language-switcher .btn:hover {
|
||||
color: #FFFFFF !important;
|
||||
background: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] .auth-controls .theme-toggle-btn,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .theme-toggle-btn {
|
||||
color: var(--brand-primary) !important;
|
||||
background: rgba(var(--brand-primary-rgb), 0.08) !important;
|
||||
border: 1px solid rgba(var(--brand-primary-rgb), 0.25) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] .auth-controls .theme-toggle-btn:hover,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .theme-toggle-btn:hover {
|
||||
color: #FFFFFF !important;
|
||||
background: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================
|
||||
FULLCALENDAR OVERRIDES
|
||||
======================================================= */
|
||||
.fc {
|
||||
--fc-border-color: var(--card-border);
|
||||
--fc-page-bg-color: transparent;
|
||||
--fc-neutral-bg-color: rgba(0, 0, 0, 0.02);
|
||||
--fc-today-bg-color: rgba(var(--brand-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fc {
|
||||
--fc-neutral-bg-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.fc-event {
|
||||
border-radius: 8px !important;
|
||||
border: none !important;
|
||||
padding: 3px 8px !important;
|
||||
font-size: 0.8rem !important;
|
||||
font-weight: 500 !important;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.fc-event:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.fc-button-primary {
|
||||
background: var(--gradient-primary) !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 2px 6px rgba(var(--brand-primary-rgb), 0.3) !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:hover {
|
||||
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:disabled {
|
||||
background: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
FORTIGATE-STYLE RBAC PERMISSIONS MATRIX
|
||||
======================================================= */
|
||||
.fg-permission-card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--card-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fg-permission-card .card-header {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
padding: 0.9rem 1.25rem;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-permission-card .card-header {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.fg-table {
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fg-table th {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
padding: 0.85rem 1.25rem;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-table th {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.fg-table td {
|
||||
padding: 0.85rem 1.25rem;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fg-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.fg-table tbody tr:hover {
|
||||
background-color: rgba(180, 62, 142, 0.04);
|
||||
}
|
||||
|
||||
/* FortiGate Segmented Control */
|
||||
.fg-segmented-control {
|
||||
display: inline-flex;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-segmented-control {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: #1A1F36;
|
||||
}
|
||||
|
||||
.fg-seg-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border-right: 1px solid #ced4da;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-seg-btn {
|
||||
color: #CBD5E1;
|
||||
border-right-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.fg-seg-btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.fg-seg-btn:hover:not(.active) {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-seg-btn:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Estado Activo FortiGate (Verde Esmeralda #2D9F6F) */
|
||||
.fg-seg-btn.active {
|
||||
background-color: #2D9F6F !important;
|
||||
color: #FFFFFF !important;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.fg-seg-btn.active i {
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* Set All Dropdown Button */
|
||||
.btn-fg-set-all {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ced4da;
|
||||
background: #FFFFFF;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .btn-fg-set-all {
|
||||
background: #1E2442;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: #E2E8F0;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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');
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,252 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Gestión de Ciclo Lectivo{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-calendar-range text-primary me-2"></i>{% trans %}Gestión de Ciclo Lectivo y Períodos Académicos{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
{% trans %}Administración de cuatrimestres, años académicos, fechas de inicio/fin y selección del ciclo lectivo activo.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddTerm">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nuevo Ciclo Lectivo{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Ver Cursadas{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Academic Terms Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Código{% endtrans %}</th>
|
||||
<th>{% trans %}Nombre del Ciclo Lectivo{% endtrans %}</th>
|
||||
<th>{% trans %}Año{% endtrans %}</th>
|
||||
<th>{% trans %}Período / Modalidad{% endtrans %}</th>
|
||||
<th>{% trans %}Fechas del Período{% endtrans %}</th>
|
||||
<th>{% trans %}Comisiones{% endtrans %}</th>
|
||||
<th>{% trans %}Estado Institucional{% endtrans %}</th>
|
||||
{% if can_edit %}
|
||||
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for term in terms %}
|
||||
{% set t_stat = stats.get(term.id, {'commissions_count': 0}) %}
|
||||
<tr class="{% if term.is_current %}table-primary-subtle{% endif %}">
|
||||
<td>
|
||||
<span class="badge font-monospace bg-primary-subtle text-primary border fs-6">{{ term.code }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold text-body">{{ term.name }}</div>
|
||||
<small class="text-muted">ID: #{{ term.id }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="fw-semibold text-body">{{ term.year }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary-subtle text-secondary border">
|
||||
{{ term.period_type }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if term.start_date and term.end_date %}
|
||||
<small class="text-body font-monospace">
|
||||
<i class="bi bi-calendar3 me-1 text-primary"></i>{{ term.start_date.strftime('%d/%m/%Y') }} - {{ term.end_date.strftime('%d/%m/%Y') }}
|
||||
</small>
|
||||
{% else %}
|
||||
<small class="text-muted">{% trans %}Sin fechas fijadas{% endtrans %}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info-subtle text-info border">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{{ t_stat.commissions_count }} {% trans %}comisiones{% endtrans %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if term.is_current %}
|
||||
<span class="badge bg-success text-white px-2 py-1 shadow-sm">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>{% trans %}Ciclo Activo Actual{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border">
|
||||
{% if term.is_active %}{% trans %}Habilitado{% endtrans %}{% else %}{% trans %}Archivado{% endtrans %}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if can_edit %}
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if not term.is_current %}
|
||||
<form method="POST" action="{{ url_for('admin.academic_term_set_current', id=term.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('{% trans %}¿Desea establecer este ciclo lectivo como el Activo para todo el sistema?{% endtrans %}');">
|
||||
<button type="submit" class="btn btn-outline-success py-0 px-2" title="{% trans %}Establecer como Ciclo Activo{% endtrans %}">
|
||||
<i class="bi bi-check2-circle me-1"></i>{% trans %}Activar{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-outline-secondary py-0 px-2"
|
||||
data-bs-toggle="modal" data-bs-target="#modalEditTerm{{ term.id }}"
|
||||
title="{% trans %}Editar Ciclo Lectivo{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Editar Ciclo Lectivo -->
|
||||
<div class="modal fade text-start" id="modalEditTerm{{ term.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.academic_term_edit', id=term.id) }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Ciclo Lectivo{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ term.code }}" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">{% trans %}Año Calendario{% endtrans %} *</label>
|
||||
<input type="number" name="year" class="form-control form-control-sm" value="{{ term.year }}" min="2020" max="2050" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre Descriptivo{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" value="{{ term.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Tipo de Período{% endtrans %}</label>
|
||||
<select name="period_type" class="form-select form-select-sm">
|
||||
<option value="Primer Cuatrimestre" {% if term.period_type == 'Primer Cuatrimestre' %}selected{% endif %}>Primer Cuatrimestre</option>
|
||||
<option value="Segundo Cuatrimestre" {% if term.period_type == 'Segundo Cuatrimestre' %}selected{% endif %}>Segundo Cuatrimestre</option>
|
||||
<option value="Anual" {% if term.period_type == 'Anual' %}selected{% endif %}>Anual</option>
|
||||
<option value="Curso de Verano" {% if term.period_type == 'Curso de Verano' %}selected{% endif %}>Curso de Verano</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">{% trans %}Fecha Inicio{% endtrans %}</label>
|
||||
<input type="date" name="start_date" class="form-control form-control-sm" value="{{ term.start_date.strftime('%Y-%m-%d') if term.start_date else '' }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">{% trans %}Fecha Fin{% endtrans %}</label>
|
||||
<input type="date" name="end_date" class="form-control form-control-sm" value="{{ term.end_date.strftime('%Y-%m-%d') if term.end_date else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="is_current" id="isCurr{{ term.id }}" {% if term.is_current %}checked{% endif %}>
|
||||
<label class="form-check-label small fw-semibold text-primary" for="isCurr{{ term.id }}">
|
||||
{% trans %}Establecer como Ciclo Lectivo Activo Actual{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="isAct{{ term.id }}" {% if term.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label small" for="isAct{{ term.id }}">{% trans %}Habilitado en el Sistema{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-calendar-range fs-1 d-block mb-2"></i>
|
||||
{% trans %}No se encontraron ciclos lectivos registrados.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Nuevo Ciclo Lectivo -->
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="modalAddTerm" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.academic_term_add') }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}Nuevo Ciclo Lectivo / Período Académico{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" placeholder="ej. 2027-1" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">{% trans %}Año Calendario{% endtrans %} *</label>
|
||||
<input type="number" name="year" class="form-control form-control-sm" value="2026" min="2020" max="2050" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre Descriptivo{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" placeholder="ej. Ciclo Lectivo 2027 - 1º Cuatrimestre" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Tipo de Período{% endtrans %}</label>
|
||||
<select name="period_type" class="form-select form-select-sm">
|
||||
<option value="Primer Cuatrimestre">Primer Cuatrimestre</option>
|
||||
<option value="Segundo Cuatrimestre" selected>Segundo Cuatrimestre</option>
|
||||
<option value="Anual">Anual</option>
|
||||
<option value="Curso de Verano">Curso de Verano</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">{% trans %}Fecha Inicio{% endtrans %}</label>
|
||||
<input type="date" name="start_date" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">{% trans %}Fecha Fin{% endtrans %}</label>
|
||||
<input type="date" name="end_date" class="form-control form-control-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_current" id="addIsCurr">
|
||||
<label class="form-check-label small fw-semibold text-primary" for="addIsCurr">
|
||||
{% trans %}Establecer de inmediato como Ciclo Lectivo Activo Actual{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Ciclo Lectivo{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,208 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Gestión de Carreras{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-mortarboard text-primary me-2"></i>{% trans %}Gestión de Carreras y Titulaciones{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
{% trans %}Administración de programas académicos, tecnicaturas, licenciaturas y planes de estudio.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddCareer">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Carrera{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-book me-1"></i>{% trans %}Ver Asignaturas{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Filter Bar -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="GET" class="row g-2 align-items-center">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control"
|
||||
placeholder="{% trans %}Buscar carrera por nombre, código o descripción...{% endtrans %}"
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<button type="submit" class="btn btn-sm btn-primary me-1">
|
||||
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('admin.careers_list') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Careers Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Carrera / Titulación{% endtrans %}</th>
|
||||
<th>{% trans %}Código{% endtrans %}</th>
|
||||
<th>{% trans %}Asignaturas{% endtrans %}</th>
|
||||
<th>{% trans %}Comisiones Activas{% endtrans %}</th>
|
||||
<th>{% trans %}Descripción{% endtrans %}</th>
|
||||
{% if can_edit %}
|
||||
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for career in careers %}
|
||||
{% set c_stat = stats.get(career.id, {'subjects_count': 0, 'commissions_count': 0}) %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle p-2 bg-primary-subtle text-primary me-3">
|
||||
<i class="bi bi-mortarboard-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<span class="fw-bold text-body">{{ career.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if career.code %}
|
||||
<span class="badge font-monospace bg-secondary-subtle text-secondary border">{{ career.code }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted small">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.subjects_list', career_id=career.id) }}" class="badge bg-info-subtle text-info border text-decoration-none">
|
||||
<i class="bi bi-book me-1"></i>{{ c_stat.subjects_count }} {% trans %}asignaturas{% endtrans %}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary-subtle text-primary border">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{{ c_stat.commissions_count }} {% trans %}comisiones{% endtrans %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="small text-muted text-truncate d-inline-block" style="max-width: 250px;">
|
||||
{{ career.description or '-' }}
|
||||
</span>
|
||||
</td>
|
||||
{% if can_edit %}
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-secondary py-0 px-2"
|
||||
data-bs-toggle="modal" data-bs-target="#modalEditCareer{{ career.id }}"
|
||||
title="{% trans %}Editar Carrera{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
{% if c_stat.subjects_count == 0 %}
|
||||
<form method="POST" action="{{ url_for('admin.career_delete', id=career.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('{% trans %}¿Confirma que desea eliminar esta carrera permanentemente?{% endtrans %}');">
|
||||
<button type="submit" class="btn btn-outline-danger py-0 px-2" title="{% trans %}Eliminar{% endtrans %}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Modal Editar Carrera -->
|
||||
<div class="modal fade text-start" id="modalEditCareer{{ career.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.career_edit', id=career.id) }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Carrera{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre de la Carrera{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control" value="{{ career.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código / Sigla{% endtrans %}</label>
|
||||
<input type="text" name="code" class="form-control" value="{{ career.code or '' }}" placeholder="ej. LIC-SIS, PUEP">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea name="description" class="form-control" rows="3">{{ career.description or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-mortarboard fs-1 d-block mb-2"></i>
|
||||
{% trans %}No se encontraron carreras registradas.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Nueva Carrera -->
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="modalAddCareer" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.career_add') }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-mortarboard text-primary me-2"></i>{% trans %}Nueva Carrera / Titulación{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre de la Carrera{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control" placeholder="ej. Licenciatura en Tecnologías Digitales" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código / Sigla{% endtrans %}</label>
|
||||
<input type="text" name="code" class="form-control" placeholder="ej. TEC-DIG">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea name="description" class="form-control" rows="3" placeholder="Objetivos y alcance de la titulación..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Carrera{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,242 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Import from Google Sheets{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-file-earmark-spreadsheet text-success me-2"></i>{% trans %}Google Sheets Academic Sync{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Extract and synchronize classrooms, careers, subjects, and weekly schedules directly from the official Google Sheet.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Dashboard{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-primary mb-1"><i class="bi bi-mortarboard fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_careers }}</div>
|
||||
<small class="text-muted">{% trans %}Careers{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-info mb-1"><i class="bi bi-book fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_subjects }}</div>
|
||||
<small class="text-muted">{% trans %}Subjects{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-success mb-1"><i class="bi bi-door-open fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_classrooms }}</div>
|
||||
<small class="text-muted">{% trans %}Classrooms{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-warning mb-1"><i class="bi bi-people fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_commissions }}</div>
|
||||
<small class="text-muted">{% trans %}Commissions{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-danger mb-1"><i class="bi bi-calendar-check fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_reservations }}</div>
|
||||
<small class="text-muted">{% trans %}Calendar Classes{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Action Banner -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-lg-8 mb-3 mb-lg-0">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-1 me-2 fw-semibold">
|
||||
<i class="bi bi-cloud-check me-1"></i>Google Sheets Live Integration
|
||||
</span>
|
||||
<span class="badge bg-light text-muted border">UniCABA 2026</span>
|
||||
</div>
|
||||
<h4 class="h5 mb-2 fw-bold">{% trans %}Official Schedule Board (Cartelera de Aulas){% endtrans %}</h4>
|
||||
<p class="text-muted small mb-2">
|
||||
{% trans %}Reads published CSV sheets for all 6 days of the week, automatically creating or updating academic careers, classrooms, subject codes, commissions, and weekly calendar entries without duplicating records.{% endtrans %}
|
||||
</p>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for s in metrics.sheets %}
|
||||
<span class="badge bg-body-tertiary text-secondary border small">
|
||||
<i class="bi bi-calendar-event me-1 text-primary"></i>{{ s.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 text-lg-end">
|
||||
<form method="POST" id="syncForm" onsubmit="startSync()">
|
||||
<button type="submit" id="syncBtn" class="btn btn-success btn-lg px-4 py-2 shadow fw-semibold">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>{% trans %}Sync Data Now{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
<div id="syncSpinner" class="d-none mt-2 text-muted small">
|
||||
<div class="spinner-border spinner-border-sm text-success me-1" role="status"></div>
|
||||
{% trans %}Connecting to Google Sheets and processing records...{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Results (if just completed) -->
|
||||
{% if stats %}
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-success bg-opacity-10 text-success py-3">
|
||||
<h5 class="card-title h6 mb-0 fw-bold">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>{% trans %}Synchronization Report{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 text-center mb-3">
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5">{{ stats.total_rows_processed }}</div>
|
||||
<small class="text-muted">{% trans %}Rows Processed{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-primary">+{{ stats.careers_created }}</div>
|
||||
<small class="text-muted">{% trans %}Careers Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-success">+{{ stats.classrooms_created }}</div>
|
||||
<small class="text-muted">{% trans %}Classrooms Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-info">+{{ stats.subjects_created }}</div>
|
||||
<small class="text-muted">{% trans %}Subjects Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-warning">+{{ stats.commissions_created }}</div>
|
||||
<small class="text-muted">{% trans %}Commissions Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-danger">+{{ stats.reservations_created }}</div>
|
||||
<small class="text-muted">{% trans %}Reservations Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if stats.errors %}
|
||||
<div class="alert alert-warning mb-0 small">
|
||||
<h6 class="alert-heading fw-bold mb-1">{% trans %}Warnings / Errors detected:{% endtrans %}</h6>
|
||||
<ul class="mb-0 ps-3">
|
||||
{% for err in stats.errors %}
|
||||
<li>{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Database Preview Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary d-flex justify-content-between align-items-center py-3">
|
||||
<h5 class="card-title h6 mb-0">
|
||||
<i class="bi bi-table text-primary me-2"></i>{% trans %}Recently Synchronized Subjects & Comissions{% endtrans %}
|
||||
</h5>
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-calendar3 me-1"></i>{% trans %}View Calendar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-body-tertiary">
|
||||
<tr>
|
||||
<th class="ps-4">{% trans %}Code{% endtrans %}</th>
|
||||
<th>{% trans %}Subject Name{% endtrans %}</th>
|
||||
<th>{% trans %}Career{% endtrans %}</th>
|
||||
<th>{% trans %}Commissions{% endtrans %}</th>
|
||||
<th>{% trans %}Status{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in recent_subjects %}
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border">
|
||||
{{ s.code }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ s.name }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-brand small">
|
||||
{{ s.career_obj.name if s.career_obj else (s.department or _('General')) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="small text-muted">
|
||||
{{ s.commissions|length }} {% trans %}commission(s){% endtrans %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if s.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle small">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary small">{% trans %}Inactive{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-cloud-arrow-down fs-1 d-block mb-2 text-secondary"></i>
|
||||
{% trans %}No subjects in database yet. Click "Sync Data Now" to import.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function startSync() {
|
||||
const btn = document.getElementById('syncBtn');
|
||||
const spinner = document.getElementById('syncSpinner');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Sincronizando...';
|
||||
}
|
||||
if (spinner) {
|
||||
spinner.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,343 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Gestión de Ocupación y Métricas{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.kpi-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.07);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
.kpi-icon-circle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.metric-bar-container {
|
||||
height: 10px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.metric-bar-fill {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-graph-up-arrow text-primary me-2"></i>{% trans %}Gestión de Ocupación y Métricas de Espacios{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
{% trans %}Analítica institucional de aprovechamiento de aulas físicas, demanda en Campus Virtual y distribución horaria.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-buildings me-1"></i>{% trans %}Aulas Físicas{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-globe2 me-1"></i>{% trans %}Campus Virtual (∞){% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver Calendario{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. Fila de KPIs Globales de Ocupación -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Ocupación Hoy -->
|
||||
<div class="col-xl-3 col-sm-6">
|
||||
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Tasa de Ocupación Hoy{% endtrans %}</span>
|
||||
<h2 class="h3 fw-bold mb-0 text-primary mt-1">{{ avg_day_pct }}%</h2>
|
||||
</div>
|
||||
<div class="kpi-icon-circle bg-primary-subtle text-primary">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-bar-container mt-3">
|
||||
<div class="metric-bar-fill bg-primary" data-width="{{ avg_day_pct }}%"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
|
||||
{% trans %}Promedio sobre 15 hs operativas ({{ physical_rooms_count }} aulas presenciales){% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ocupación Semana -->
|
||||
<div class="col-xl-3 col-sm-6">
|
||||
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Ocupación Semanal{% endtrans %}</span>
|
||||
<h2 class="h3 fw-bold mb-0 text-info mt-1">{{ avg_week_pct }}%</h2>
|
||||
</div>
|
||||
<div class="kpi-icon-circle bg-info-subtle text-info">
|
||||
<i class="bi bi-calendar-week"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-bar-container mt-3">
|
||||
<div class="metric-bar-fill bg-info" data-width="{{ avg_week_pct }}%"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
|
||||
{% trans %}Semana en curso (Lunes a Sábado){% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ocupación Mensual -->
|
||||
<div class="col-xl-3 col-sm-6">
|
||||
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Ocupación del Mes{% endtrans %}</span>
|
||||
<h2 class="h3 fw-bold mb-0 text-success mt-1">{{ avg_month_pct }}%</h2>
|
||||
</div>
|
||||
<div class="kpi-icon-circle bg-success-subtle text-success">
|
||||
<i class="bi bi-calendar3"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-bar-container mt-3">
|
||||
<div class="metric-bar-fill bg-success" data-width="{{ avg_month_pct }}%"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
|
||||
{% trans %}Total mes calendario activo{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clases Totales Dictadas -->
|
||||
<div class="col-xl-3 col-sm-6">
|
||||
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Total Clases en el Mes{% endtrans %}</span>
|
||||
<h2 class="h3 fw-bold mb-0 text-warning mt-1">{{ total_month_classes }}</h2>
|
||||
</div>
|
||||
<div class="kpi-icon-circle bg-warning-subtle text-warning">
|
||||
<i class="bi bi-mortarboard"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 small font-monospace">
|
||||
<span class="badge bg-primary-subtle text-primary border">Físicas: {{ total_month_classes - v_month }}</span>
|
||||
<span class="badge bg-info-subtle text-info border">Virtuales: {{ v_month }}</span>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
|
||||
{% trans %}Reservas y sesiones registradas en el período{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Detalle y Distribución Espacial -->
|
||||
<div class="row g-4 mb-4">
|
||||
<!-- Ocupación por Piso / Nivel -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title h6 fw-bold mb-0">
|
||||
<i class="bi bi-layers text-primary me-2"></i>{% trans %}Tasa de Ocupación por Piso / Nivel{% endtrans %}
|
||||
</h5>
|
||||
<span class="badge bg-secondary-subtle text-secondary border">Presencial</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
{% for fs in floor_summary %}
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="fw-semibold text-body small">
|
||||
{{ fs.floor }} ({{ fs.count }} {% trans %}aulas{% endtrans %})
|
||||
</span>
|
||||
<span class="fw-bold font-monospace small {% if fs.avg_pct > 50 %}text-danger{% elif fs.avg_pct > 25 %}text-warning{% else %}text-success{% endif %}">
|
||||
{{ fs.avg_pct }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="metric-bar-container">
|
||||
<div class="metric-bar-fill {% if fs.avg_pct > 50 %}bg-danger{% elif fs.avg_pct > 25 %}bg-warning{% else %}bg-success{% endif %}"
|
||||
data-width="{{ fs.avg_pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Distribución por Turno -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title h6 fw-bold mb-0">
|
||||
<i class="bi bi-brightness-high text-warning me-2"></i>{% trans %}Distribución de Clases por Turno{% endtrans %}
|
||||
</h5>
|
||||
<span class="badge bg-secondary-subtle text-secondary border">Mes Completo</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 text-center mb-3">
|
||||
<div class="col-3">
|
||||
<div class="p-2 rounded bg-body-tertiary border">
|
||||
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Mañana{% endtrans %}</div>
|
||||
<div class="h5 fw-bold text-primary mb-0">{{ shift_counts.Mañana }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="p-2 rounded bg-body-tertiary border">
|
||||
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Tarde{% endtrans %}</div>
|
||||
<div class="h5 fw-bold text-warning mb-0">{{ shift_counts.Tarde }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="p-2 rounded bg-body-tertiary border">
|
||||
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Vespertino{% endtrans %}</div>
|
||||
<div class="h5 fw-bold text-info mb-0">{{ shift_counts.Vespertino }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="p-2 rounded bg-body-tertiary border">
|
||||
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Noche{% endtrans %}</div>
|
||||
<div class="h5 fw-bold text-danger mb-0">{{ shift_counts.Noche }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campus Virtual Box -->
|
||||
<div class="p-3 rounded bg-primary-subtle border border-primary-subtle">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="fw-bold text-primary mb-1"><i class="bi bi-globe2 me-2"></i>{% trans %}Campus Virtual (A Demanda · ∞){% endtrans %}</h6>
|
||||
<p class="small text-muted mb-0">{% trans %}Salas simultáneas sin límite físico de cupo ni colisión.{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="badge bg-primary text-white fs-6">{{ v_month }} clases mes</div>
|
||||
<small class="d-block text-muted mt-1">{{ v_today }} hoy · {{ v_week }} semana</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Top Aulas de Mayor Demanda vs Menor Demanda -->
|
||||
<div class="row g-4">
|
||||
<!-- Mayor Demanda -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="card-title h6 fw-bold mb-0 text-success">
|
||||
<i class="bi bi-arrow-up-circle-fill me-2"></i>{% trans %}Top Aulas con Mayor Demanda y Ocupación{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 small">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Aula{% endtrans %}</th>
|
||||
<th>{% trans %}Piso{% endtrans %}</th>
|
||||
<th>{% trans %}Capacidad{% endtrans %}</th>
|
||||
<th>{% trans %}Ocup. Mes{% endtrans %}</th>
|
||||
<th class="text-end">{% trans %}Acción{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rm in top_rooms %}
|
||||
<tr>
|
||||
<td class="fw-bold text-primary">{{ rm.classroom.code }}</td>
|
||||
<td>{{ rm.classroom.floor_display }}</td>
|
||||
<td>{{ rm.classroom.capacity }} asientos</td>
|
||||
<td>
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle font-monospace">
|
||||
{{ rm.month_pct }}% ({{ rm.month_count }} cl.)
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('schedule.list_reservations', classroom_id=rm.classroom.id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}"
|
||||
class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 0.72rem;">
|
||||
{% trans %}Ver Reservas{% endtrans %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menor Demanda / Oportunidades -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="card-title h6 fw-bold mb-0 text-secondary">
|
||||
<i class="bi bi-arrow-down-circle-fill me-2"></i>{% trans %}Aulas con Mayor Disponibilidad (Menor Uso){% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 small">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Aula{% endtrans %}</th>
|
||||
<th>{% trans %}Piso{% endtrans %}</th>
|
||||
<th>{% trans %}Capacidad{% endtrans %}</th>
|
||||
<th>{% trans %}Ocup. Mes{% endtrans %}</th>
|
||||
<th class="text-end">{% trans %}Acción{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rm in low_rooms %}
|
||||
<tr>
|
||||
<td class="fw-bold text-body">{{ rm.classroom.code }}</td>
|
||||
<td>{{ rm.classroom.floor_display }}</td>
|
||||
<td>{{ rm.classroom.capacity }} asientos</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
|
||||
{{ rm.month_pct }}% ({{ rm.month_count }} cl.)
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?classroom_id={{ rm.classroom.id }}"
|
||||
class="btn btn-sm btn-outline-success py-0 px-2" style="font-size: 0.72rem;">
|
||||
{% trans %}Reservar{% endtrans %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.metric-bar-fill[data-width]').forEach(function(bar) {
|
||||
bar.style.width = bar.getAttribute('data-width');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,404 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Tipificaciones de Hitos y Categorías{% endtrans %} - Edu-Space{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1 small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">{% trans %}Dashboard{% endtrans %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% trans %}Tipificaciones{% endtrans %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-tags text-warning me-2"></i>{% trans %}Tipificaciones de Hitos Académicos{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
{% trans %}Catálogo dinámico y configurable de tipos de evaluaciones, parciales, TPs y eventos para el calendario institucional.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddType">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Tipificación{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert / Help Info -->
|
||||
<div class="alert alert-info border-0 shadow-sm d-flex align-items-center mb-4 py-2 px-3">
|
||||
<i class="bi bi-info-circle-fill text-info fs-5 me-3"></i>
|
||||
<div class="small">
|
||||
{% trans %}Las tipificaciones activas aparecen de forma inmediata en el selector de tipo de evento/hito tanto en el portal web como en la app móvil. Puedes desactivar una tipificación sin eliminar registros históricos asociados.{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Card -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-body-tertiary">
|
||||
<tr>
|
||||
<th class="ps-4">{% trans %}Tipificación e Insignia{% endtrans %}</th>
|
||||
<th>{% trans %}Código / Slug{% endtrans %}</th>
|
||||
<th>{% trans %}Color Hex{% endtrans %}</th>
|
||||
<th>{% trans %}Descripción{% endtrans %}</th>
|
||||
<th class="text-center">{% trans %}Orden{% endtrans %}</th>
|
||||
<th class="text-center">{% trans %}Estado{% endtrans %}</th>
|
||||
<th class="text-end pe-4">{% trans %}Acciones{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in types %}
|
||||
<tr id="row-{{ item.id }}">
|
||||
<td class="ps-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="rounded-circle shadow-xs me-3 flex-shrink-0 border"
|
||||
style="width: 22px; height: 22px; background-color: {{ item.color or '#0d6efd' }};"
|
||||
title="{{ item.color }}"></span>
|
||||
<div>
|
||||
<span class="fw-bold text-dark-emphasis">{{ item.name }}</span>
|
||||
<span class="badge rounded-pill text-white ms-2 shadow-xs" style="background-color: {{ item.color or '#0d6efd' }}; font-size: 0.70rem;">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-primary fw-semibold">{{ item.code }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-monospace small text-muted">{{ item.color or '#0d6efd' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small">{{ item.description or '—' }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-body-tertiary text-secondary border font-monospace">#{{ item.sort_order }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if can_edit %}
|
||||
<form action="{{ url_for('admin.milestone_type_toggle', id=item.id) }}" method="POST" class="d-inline">
|
||||
<div class="form-check form-switch d-inline-block">
|
||||
<input class="form-check-input status-toggle" type="checkbox" role="switch"
|
||||
onchange="this.form.submit()"
|
||||
{% if item.is_active %}checked{% endif %}
|
||||
title="{% trans %}Cambiar estado{% endtrans %}">
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
{% if item.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">{% trans %}Activo{% endtrans %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">{% trans %}Inactivo{% endtrans %}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-outline-primary btn-edit"
|
||||
data-id="{{ item.id }}"
|
||||
data-name="{{ item.name }}"
|
||||
data-code="{{ item.code }}"
|
||||
data-color="{{ item.color or '#0d6efd' }}"
|
||||
data-description="{{ item.description or '' }}"
|
||||
data-sort-order="{{ item.sort_order }}"
|
||||
data-is-active="{{ 'true' if item.is_active else 'false' }}"
|
||||
title="{% trans %}Editar Tipificación{% endtrans %}">
|
||||
<i class="bi bi-pencil-square"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-delete-type"
|
||||
data-id="{{ item.id }}"
|
||||
data-name="{{ item.name }}"
|
||||
data-action-url="{{ url_for('admin.milestone_type_delete', id=item.id) }}"
|
||||
title="{% trans %}Eliminar Tipificación{% endtrans %}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-muted small"><i class="bi bi-lock me-1"></i>{% trans %}Solo lectura{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
|
||||
{% trans %}No se han encontrado tipificaciones registradas.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Nueva Tipificación -->
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="modalAddType" tabindex="-1" aria-labelledby="modalAddTypeLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header bg-body-tertiary">
|
||||
<h5 class="modal-title fw-bold" id="modalAddTypeLabel">
|
||||
<i class="bi bi-plus-circle text-primary me-2"></i>{% trans %}Nueva Tipificación de Hito{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="{{ url_for('admin.milestone_type_add') }}" method="POST">
|
||||
<div class="modal-body p-4">
|
||||
<div class="mb-3">
|
||||
<label for="add_name" class="form-label fw-semibold">{% trans %}Nombre Visible{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="add_name" name="name" placeholder="Ej: Parcial 3, TP Integrador" required>
|
||||
<div class="form-text small">{% trans %}Texto que se mostrará en los selectores y tarjetas.{% endtrans %}</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-7">
|
||||
<label for="add_code" class="form-label fw-semibold">{% trans %}Código / Identificador{% endtrans %}</label>
|
||||
<input type="text" class="form-control font-monospace" id="add_code" name="code" placeholder="ej. parcial_3">
|
||||
<div class="form-text small">{% trans %}Slug único (se autogenera si se deja vacío).{% endtrans %}</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label for="add_color" class="form-label fw-semibold">{% trans %}Color Distintivo{% endtrans %}</label>
|
||||
<input type="color" class="form-control form-control-color w-100" id="add_color" name="color" value="#B43E8E" title="Elegir color">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paleta rápida de colores UniCABA -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted mb-1">{% trans %}Paleta Institucional Rápida{% endtrans %}:</label>
|
||||
<div class="d-flex flex-wrap gap-2" id="presetColorsAdd">
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #B43E8E;" data-color="#B43E8E" title="Magenta UniCABA"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #0d6efd;" data-color="#0d6efd" title="Azul Institucional"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #198754;" data-color="#198754" title="Verde TP"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #fd7e14;" data-color="#fd7e14" title="Naranja Recuperatorio"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #dc3545;" data-color="#dc3545" title="Rojo Final"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6f42c1;" data-color="#6f42c1" title="Púrpura Especial"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #20c997;" data-color="#20c997" title="Turquesa Cursada"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6c757d;" data-color="#6c757d" title="Gris Neutro"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="add_description" class="form-label fw-semibold">{% trans %}Descripción / Notas{% endtrans %}</label>
|
||||
<textarea class="form-control" id="add_description" name="description" rows="2" placeholder="Detalle o alcance de este tipo de evaluación..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-6">
|
||||
<label for="add_sort_order" class="form-label fw-semibold">{% trans %}Orden de Visualización{% endtrans %}</label>
|
||||
<input type="number" class="form-control" id="add_sort_order" name="sort_order" value="10" min="0">
|
||||
</div>
|
||||
<div class="col-6 d-flex align-items-center pt-3">
|
||||
<div class="form-check form-switch pt-2">
|
||||
<input class="form-check-input" type="checkbox" id="add_is_active" name="is_active" value="1" checked>
|
||||
<label class="form-check-label fw-semibold" for="add_is_active">{% trans %}Activo de inmediato{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer bg-body-tertiary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-save me-1"></i>{% trans %}Crear Tipificación{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Editar Tipificación -->
|
||||
<div class="modal fade" id="modalEditType" tabindex="-1" aria-labelledby="modalEditTypeLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header bg-body-tertiary">
|
||||
<h5 class="modal-title fw-bold" id="modalEditTypeLabel">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Tipificación{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form id="formEditType" method="POST">
|
||||
<div class="modal-body p-4">
|
||||
<div class="mb-3">
|
||||
<label for="edit_name" class="form-label fw-semibold">{% trans %}Nombre Visible{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="edit_name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-7">
|
||||
<label for="edit_code" class="form-label fw-semibold">{% trans %}Código / Slug{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control font-monospace" id="edit_code" name="code" required>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label for="edit_color" class="form-label fw-semibold">{% trans %}Color Distintivo{% endtrans %}</label>
|
||||
<input type="color" class="form-control form-control-color w-100" id="edit_color" name="color" title="Elegir color">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paleta rápida de colores en Edición -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted mb-1">{% trans %}Paleta Institucional Rápida{% endtrans %}:</label>
|
||||
<div class="d-flex flex-wrap gap-2" id="presetColorsEdit">
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #B43E8E;" data-color="#B43E8E" title="Magenta UniCABA"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #0d6efd;" data-color="#0d6efd" title="Azul Institucional"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #198754;" data-color="#198754" title="Verde TP"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #fd7e14;" data-color="#fd7e14" title="Naranja Recuperatorio"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #dc3545;" data-color="#dc3545" title="Rojo Final"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6f42c1;" data-color="#6f42c1" title="Púrpura Especial"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #20c997;" data-color="#20c997" title="Turquesa Cursada"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6c757d;" data-color="#6c757d" title="Gris Neutro"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="edit_description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea class="form-control" id="edit_description" name="description" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-6">
|
||||
<label for="edit_sort_order" class="form-label fw-semibold">{% trans %}Orden{% endtrans %}</label>
|
||||
<input type="number" class="form-control" id="edit_sort_order" name="sort_order" min="0">
|
||||
</div>
|
||||
<div class="col-6 d-flex align-items-center pt-3">
|
||||
<div class="form-check form-switch pt-2">
|
||||
<input class="form-check-input" type="checkbox" id="edit_is_active" name="is_active" value="1">
|
||||
<label class="form-check-label fw-semibold" for="edit_is_active">{% trans %}Activo{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer bg-body-tertiary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Guardar Cambios{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Global de Eliminación de Tipificación (fuera de la tabla) -->
|
||||
<div class="modal fade" id="deleteMilestoneTypeModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
|
||||
</div>
|
||||
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación{% endtrans %}</h5>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-start py-3">
|
||||
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar la tipificación{% endtrans %} <strong id="deleteMilestoneTypeName" class="text-primary"></strong>?</p>
|
||||
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>{% trans %}Si existen hitos vinculados a este tipo, el sistema lo desactivará automáticamente para preservar el historial.{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<form id="deleteMilestoneTypeForm" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger px-3">
|
||||
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Tipificación{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Autogenerate code slug in Add Modal
|
||||
var addNameInput = document.getElementById('add_name');
|
||||
var addCodeInput = document.getElementById('add_code');
|
||||
if (addNameInput && addCodeInput) {
|
||||
addNameInput.addEventListener('input', function() {
|
||||
if (!addCodeInput.dataset.touched) {
|
||||
var slug = addNameInput.value.toLowerCase()
|
||||
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
addCodeInput.value = slug;
|
||||
}
|
||||
});
|
||||
addCodeInput.addEventListener('input', function() {
|
||||
addCodeInput.dataset.touched = "true";
|
||||
});
|
||||
}
|
||||
|
||||
// Color preset clicks for Add Modal
|
||||
document.querySelectorAll('#presetColorsAdd button').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var color = this.getAttribute('data-color');
|
||||
var input = document.getElementById('add_color');
|
||||
if (input) input.value = color;
|
||||
});
|
||||
});
|
||||
|
||||
// Color preset clicks for Edit Modal
|
||||
document.querySelectorAll('#presetColorsEdit button').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var color = this.getAttribute('data-color');
|
||||
var input = document.getElementById('edit_color');
|
||||
if (input) input.value = color;
|
||||
});
|
||||
});
|
||||
|
||||
// Edit modal bindings
|
||||
var modalEditEl = document.getElementById('modalEditType');
|
||||
var modalEdit = modalEditEl ? new bootstrap.Modal(modalEditEl) : null;
|
||||
var formEdit = document.getElementById('formEditType');
|
||||
|
||||
document.querySelectorAll('.btn-edit').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var id = this.getAttribute('data-id');
|
||||
var name = this.getAttribute('data-name');
|
||||
var code = this.getAttribute('data-code');
|
||||
var color = this.getAttribute('data-color');
|
||||
var description = this.getAttribute('data-description');
|
||||
var sortOrder = this.getAttribute('data-sort-order');
|
||||
var isActive = this.getAttribute('data-is-active') === 'true';
|
||||
|
||||
if (formEdit) {
|
||||
formEdit.action = "{{ url_for('admin.milestone_types_list') }}/edit/" + id;
|
||||
document.getElementById('edit_name').value = name;
|
||||
document.getElementById('edit_code').value = code;
|
||||
document.getElementById('edit_color').value = color;
|
||||
document.getElementById('edit_description').value = description;
|
||||
document.getElementById('edit_sort_order').value = sortOrder;
|
||||
document.getElementById('edit_is_active').checked = isActive;
|
||||
if (modalEdit) modalEdit.show();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delete modal bindings
|
||||
var deleteModalEl = document.getElementById('deleteMilestoneTypeModal');
|
||||
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
|
||||
var deleteNameEl = document.getElementById('deleteMilestoneTypeName');
|
||||
var deleteFormEl = document.getElementById('deleteMilestoneTypeForm');
|
||||
|
||||
document.querySelectorAll('.btn-delete-type').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var name = this.getAttribute('data-name');
|
||||
var actionUrl = this.getAttribute('data-action-url');
|
||||
if (deleteNameEl) deleteNameEl.textContent = name;
|
||||
if (deleteFormEl) deleteFormEl.action = actionUrl;
|
||||
if (deleteModal) deleteModal.show();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,191 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if role %}{% trans %}Editar Rol{% endtrans %}: {{ role.name }}{% else %}{% trans %}Nuevo Rol{% endtrans %}{% endif %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-shield-lock text-primary me-2"></i>
|
||||
{% if role %}{% trans %}Editar Rol y Permisos de Acceso{% endtrans %}{% else %}{% trans %}Crear Rol y Permisos de Acceso{% endtrans %}{% endif %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Configure permisos granulares por módulo con la matriz de acceso estilo FortiGate.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.roles_list') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver a Roles{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
<div class="row">
|
||||
<!-- Left Column: Role Details -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary py-3">
|
||||
<h5 class="card-title h6 mb-0">
|
||||
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}Información del Rol{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label fw-semibold">{% trans %}Nombre del Rol{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="name" name="name"
|
||||
value="{{ role.name if role else '' }}" required
|
||||
placeholder="{% trans %}ej. Docente, Operador, Bedelía{% endtrans %}"
|
||||
{% if role and role.is_system %}readonly title="{% trans %}El nombre del rol del sistema no se puede modificar{% endtrans %}"{% endif %}>
|
||||
{% if role and role.is_system %}
|
||||
<small class="text-muted d-block mt-1">
|
||||
<i class="bi bi-info-circle me-1"></i>{% trans %}Rol del sistema protegido.{% endtrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3"
|
||||
placeholder="{% trans %}Describa las responsabilidades o alcance de este rol...{% endtrans %}">{{ role.description if role else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary py-2 fw-semibold">
|
||||
<i class="bi bi-check-lg me-1"></i>{% trans %}Guardar Rol y Permisos{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('admin.roles_list') }}" class="btn btn-outline-secondary">
|
||||
{% trans %}Cancelar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: FortiGate Permissions Matrix -->
|
||||
<div class="col-lg-8 mb-4">
|
||||
<div class="fg-permission-card">
|
||||
<!-- Matrix Header -->
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold text-primary">
|
||||
<i class="bi bi-grid-3x3-gap-fill me-2"></i>{% trans %}Permisos de Acceso{% endtrans %}
|
||||
</span>
|
||||
|
||||
<!-- Set All Action Dropdown (FortiGate style) -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-fg-set-all dropdown-toggle" type="button" id="setAllDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-lightning-charge me-1"></i>{% trans %}Establecer Todos{% endtrans %}
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="setAllDropdown">
|
||||
<li>
|
||||
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('none')">
|
||||
<i class="bi bi-slash-circle text-danger me-2"></i>{% trans %}Establecer Todos en Ninguno{% endtrans %}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('read')">
|
||||
<i class="bi bi-eye text-info me-2"></i>{% trans %}Establecer Todos en Solo Lectura{% endtrans %}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('read_write')">
|
||||
<i class="bi bi-pencil text-success me-2"></i>{% trans %}Establecer Todos en Lectura y Escritura{% endtrans %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Matrix Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="fg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 48%;">{% trans %}Módulo del Sistema{% endtrans %}</th>
|
||||
<th style="width: 52%;" class="text-end pe-4">{% trans %}Nivel de Permiso{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for mod in modules %}
|
||||
{% set cur_level = current_perms.get(mod.id, 'none') %}
|
||||
<tr data-module-row="{{ mod.id }}">
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary bg-opacity-10 text-primary rounded p-2 me-3 d-inline-flex align-items-center justify-content-center" style="width: 36px; height: 36px;">
|
||||
<i class="bi {{ mod.icon }} fs-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold">{{ mod.name }}</div>
|
||||
<small class="text-muted d-block">{{ mod.description }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<!-- Hidden Input to submit via POST -->
|
||||
<input type="hidden" name="perm_{{ mod.id }}" id="input_perm_{{ mod.id }}" value="{{ cur_level }}">
|
||||
|
||||
<!-- FortiGate Segmented Control -->
|
||||
<div class="fg-segmented-control" role="group" aria-label="Nivel de permiso para {{ mod.name }}">
|
||||
<button type="button"
|
||||
class="fg-seg-btn {% if cur_level == 'none' %}active{% endif %}"
|
||||
data-val="none"
|
||||
data-target="input_perm_{{ mod.id }}"
|
||||
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'none')">
|
||||
<i class="bi bi-slash-circle"></i> {% trans %}Ninguno{% endtrans %}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="fg-seg-btn {% if cur_level == 'read' %}active{% endif %}"
|
||||
data-val="read"
|
||||
data-target="input_perm_{{ mod.id }}"
|
||||
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'read')">
|
||||
<i class="bi bi-eye"></i> {% trans %}Lectura{% endtrans %}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="fg-seg-btn {% if cur_level == 'read_write' %}active{% endif %}"
|
||||
data-val="read_write"
|
||||
data-target="input_perm_{{ mod.id }}"
|
||||
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'read_write')">
|
||||
<i class="bi bi-pencil"></i> {% trans %}Lectura/Escritura{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function selectPerm(btn, inputId, value) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) {
|
||||
input.value = value;
|
||||
}
|
||||
|
||||
// Toggle active state in this segmented control
|
||||
const parentControl = btn.closest('.fg-segmented-control');
|
||||
if (parentControl) {
|
||||
parentControl.querySelectorAll('.fg-seg-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function setAllPermissions(level) {
|
||||
document.querySelectorAll('.fg-segmented-control').forEach(control => {
|
||||
const targetBtn = control.querySelector(`button[data-val="${level}"]`);
|
||||
if (targetBtn) {
|
||||
targetBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,153 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Roles y Grupos de Acceso{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}Roles y Grupos de Acceso{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Administración de perfiles de usuario y matriz granular de permisos por módulo del sistema.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.role_add') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nuevo Rol{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles Grid / Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-body-tertiary">
|
||||
<tr>
|
||||
<th class="ps-4">{% trans %}Nombre del Rol{% endtrans %}</th>
|
||||
<th>{% trans %}Descripción{% endtrans %}</th>
|
||||
<th>{% trans %}Integrantes{% endtrans %}</th>
|
||||
<th>{% trans %}Resumen de Permisos{% endtrans %}</th>
|
||||
<th class="text-end pe-4">{% trans %}Acciones{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for role in roles %}
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="badge-brand rounded-circle p-2 me-3 d-inline-flex align-items-center justify-content-center" style="width: 38px; height: 38px;">
|
||||
<i class="bi bi-shield-check fs-6 text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold text-primary">{{ role.name }}</div>
|
||||
{% if role.is_system %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border small">{% trans %}Protegido del Sistema{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small">{{ role.description or _('Sin descripción provista.') }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary-subtle text-primary border px-2 py-1">
|
||||
<i class="bi bi-people me-1"></i>{{ role.users|length }} {% trans %}usuarios{% endtrans %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for mod in modules %}
|
||||
{% set p_level = role.get_permission(mod.id) %}
|
||||
{% if p_level == 'read_write' %}
|
||||
<span class="badge bg-success-subtle text-success border small" title="{{ mod.name }}: {% trans %}Lectura y Escritura{% endtrans %}">
|
||||
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: RW
|
||||
</span>
|
||||
{% elif p_level == 'read' %}
|
||||
<span class="badge bg-info-subtle text-info border small" title="{{ mod.name }}: {% trans %}Solo Lectura{% endtrans %}">
|
||||
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: R
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-muted border small opacity-75" title="{{ mod.name }}: {% trans %}Sin Acceso{% endtrans %}">
|
||||
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: -
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('admin.role_edit', id=role.id) }}" class="btn btn-outline-primary" title="{% trans %}Configurar Permisos{% endtrans %}">
|
||||
<i class="bi bi-pencil-square me-1"></i>{% trans %}Permisos{% endtrans %}
|
||||
</a>
|
||||
{% if not role.is_system %}
|
||||
<button type="button" class="btn btn-outline-danger btn-delete-role"
|
||||
data-role-id="{{ role.id }}"
|
||||
data-role-name="{{ role.name }}"
|
||||
data-action-url="{{ url_for('admin.role_delete', id=role.id) }}"
|
||||
title="{% trans %}Eliminar Rol{% endtrans %}"
|
||||
{% if role.users|length > 0 %}disabled{% endif %}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Global de Eliminación fuera de la tabla (evita desplazamientos visuales) -->
|
||||
<div class="modal fade" id="deleteRoleModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
|
||||
</div>
|
||||
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación de Rol{% endtrans %}</h5>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-start py-3">
|
||||
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar el rol{% endtrans %} <strong id="deleteRoleName" class="text-primary"></strong>?</p>
|
||||
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>{% trans %}Esta acción no se puede deshacer.{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<form id="deleteRoleForm" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger px-3">
|
||||
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Rol{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var deleteModalEl = document.getElementById('deleteRoleModal');
|
||||
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
|
||||
var deleteRoleName = document.getElementById('deleteRoleName');
|
||||
var deleteRoleForm = document.getElementById('deleteRoleForm');
|
||||
|
||||
document.querySelectorAll('.btn-delete-role').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var roleName = this.getAttribute('data-role-name');
|
||||
var actionUrl = this.getAttribute('data-action-url');
|
||||
if (deleteRoleName) deleteRoleName.textContent = roleName;
|
||||
if (deleteRoleForm) deleteRoleForm.action = actionUrl;
|
||||
if (deleteModal) deleteModal.show();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,306 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Gestión de Asignaturas{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-book text-primary me-2"></i>{% trans %}Gestión de Asignaturas y Materias{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
{% trans %}Catálogo institucional de asignaturas, códigos, créditos académicos y carreras vinculadas.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddSubject">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Asignatura{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Ver Comisiones{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Bar -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="GET" class="row g-2 align-items-center">
|
||||
<!-- Buscador -->
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control"
|
||||
placeholder="{% trans %}Buscar por código, nombre o departamento...{% endtrans %}"
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtro Carrera -->
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-mortarboard text-primary"></i></span>
|
||||
<select name="career_id" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">{% trans %}Todas las carreras{% endtrans %}</option>
|
||||
{% for c in careers %}
|
||||
<option value="{{ c.id }}" {% if career_filter == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtro Estado -->
|
||||
<div class="col-md-2">
|
||||
<select name="active" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">{% trans %}Todos los estados{% endtrans %}</option>
|
||||
<option value="true" {% if active_filter == 'true' %}selected{% endif %}>{% trans %}Solo activas{% endtrans %}</option>
|
||||
<option value="false" {% if active_filter == 'false' %}selected{% endif %}>{% trans %}Solo inactivas{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 text-md-end">
|
||||
<button type="submit" class="btn btn-sm btn-primary me-1">
|
||||
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subjects Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Código{% endtrans %}</th>
|
||||
<th>{% trans %}Nombre de la Asignatura{% endtrans %}</th>
|
||||
<th>{% trans %}Carrera{% endtrans %}</th>
|
||||
<th>{% trans %}Departamento{% endtrans %}</th>
|
||||
<th>{% trans %}Créditos{% endtrans %}</th>
|
||||
<th>{% trans %}Comisiones{% endtrans %}</th>
|
||||
<th>{% trans %}Estado{% endtrans %}</th>
|
||||
{% if can_edit %}
|
||||
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in subjects %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge font-monospace bg-primary-subtle text-primary border">{{ s.code }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="fw-bold text-body">{{ s.name }}</span>
|
||||
{% if s.description %}
|
||||
<small class="text-muted d-block text-truncate" style="max-width: 300px;">{{ s.description }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if s.career_obj %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border">
|
||||
<i class="bi bi-mortarboard me-1"></i>{{ s.career_obj.name }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted small">General</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="small text-muted">{{ s.department or '-' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border">{{ s.credits }} cr.</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info-subtle text-info border">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{{ s.commissions|length if s.commissions else 0 }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge rounded-pill {% if s.is_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %}">
|
||||
{% if s.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
{% if can_edit %}
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-secondary py-0 px-2"
|
||||
data-bs-toggle="modal" data-bs-target="#modalEditSubject{{ s.id }}"
|
||||
title="{% trans %}Editar Asignatura{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<form method="POST" action="{{ url_for('admin.subject_toggle', id=s.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-warning py-0 px-2"
|
||||
title="{% if s.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
|
||||
<i class="bi {% if s.is_active %}bi-pause{% else %}bi-play{% endif %}"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Modal Editar Asignatura -->
|
||||
<div class="modal fade text-start" id="modalEditSubject{{ s.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.subject_edit', id=s.id) }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Asignatura{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ s.code }}" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">{% trans %}Créditos{% endtrans %}</label>
|
||||
<input type="number" name="credits" class="form-control form-control-sm" value="{{ s.credits }}" min="1" max="20">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre de la Asignatura{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" value="{{ s.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Carrera Vinculada{% endtrans %}</label>
|
||||
<select name="career_id" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Sin carrera específica (General){% endtrans %}</option>
|
||||
{% for c in careers %}
|
||||
<option value="{{ c.id }}" {% if s.career_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Departamento / Área{% endtrans %}</label>
|
||||
<input type="text" name="department" class="form-control form-control-sm" value="{{ s.department or '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Descripción / Objetivos{% endtrans %}</label>
|
||||
<textarea name="description" class="form-control form-control-sm" rows="2">{{ s.description or '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="activeSubj{{ s.id }}" {% if s.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label small" for="activeSubj{{ s.id }}">{% trans %}Asignatura Activa{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-book fs-1 d-block mb-2"></i>
|
||||
{% trans %}No se encontraron asignaturas con los criterios indicados.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Paginación -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="card-footer bg-body-tertiary border-0 py-2">
|
||||
<nav aria-label="Subjects pagination">
|
||||
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.subjects_list', page=pagination.prev_num, search=search, career_id=career_filter, active=active_filter) }}">{% trans %}Anterior{% endtrans %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for p in pagination.iter_pages(left_edge=2, right_edge=2, left_current=2, right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.subjects_list', page=p, search=search, career_id=career_filter, active=active_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.subjects_list', page=pagination.next_num, search=search, career_id=career_filter, active=active_filter) }}">{% trans %}Siguiente{% endtrans %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Nueva Asignatura -->
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="modalAddSubject" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.subject_add') }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-book text-primary me-2"></i>{% trans %}Nueva Asignatura{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" placeholder="ej. ASIG0015" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">{% trans %}Créditos{% endtrans %}</label>
|
||||
<input type="number" name="credits" class="form-control form-control-sm" value="4" min="1" max="20">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre de la Asignatura{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" placeholder="ej. Álgebra Lineal" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Carrera Vinculada{% endtrans %}</label>
|
||||
<select name="career_id" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Sin carrera específica (General){% endtrans %}</option>
|
||||
{% for c in careers %}
|
||||
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Departamento / Área{% endtrans %}</label>
|
||||
<input type="text" name="department" class="form-control form-control-sm" placeholder="ej. Ciencias Exactas, Informática">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea name="description" class="form-control form-control-sm" rows="2" placeholder="Programa de la materia..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Asignatura{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,107 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if user %}{% trans %}Edit User{% endtrans %}: {{ user.name }}{% else %}{% trans %}New User{% endtrans %}{% endif %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-person-gear text-primary me-2"></i>
|
||||
{% if user %}{% trans %}Edit User Account{% endtrans %}{% else %}{% trans %}Create User Account{% endtrans %}{% endif %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Configure user credentials, profile information, and role assignment.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Users{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6 mx-auto">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary py-3">
|
||||
<h5 class="card-title h6 mb-0">
|
||||
<i class="bi bi-person-vcard text-primary me-2"></i>{% trans %}Account Details{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label fw-semibold">{% trans %}Full Name{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="name" name="name"
|
||||
value="{{ user.name if user else '' }}" required
|
||||
placeholder="{% trans %}e.g. María González{% endtrans %}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label fw-semibold">{% trans %}Email Address{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
value="{{ user.email if user else '' }}" required
|
||||
placeholder="{% trans %}e.g. mgonzalez@edu-space.com{% endtrans %}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label fw-semibold">
|
||||
{% trans %}Password{% endtrans %} {% if not user %}<span class="text-danger">*</span>{% endif %}
|
||||
</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
{% if not user %}required{% endif %}
|
||||
placeholder="{% if user %}{% trans %}Leave empty to keep current password{% endtrans %}{% else %}{% trans %}At least 6 characters{% endtrans %}{% endif %}">
|
||||
{% if user %}
|
||||
<small class="text-muted d-block mt-1">
|
||||
{% trans %}Only fill this in if you want to reset this user's password.{% endtrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="role_id" class="form-label fw-semibold">{% trans %}Role / Access Group{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="role_id" name="role_id" required>
|
||||
<option value="" disabled {% if not user or not user.role_id %}selected{% endif %}>{% trans %}Select a role...{% endtrans %}</option>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r.id }}" {% if user and user.role_id == r.id %}selected{% endif %}>
|
||||
{{ r.name }} - {{ r.description or '' }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="text-muted d-block mt-1">
|
||||
<a href="{{ url_for('admin.roles_list') }}" target="_blank" class="text-decoration-none">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}View or manage roles and permissions{% endtrans %}
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="is_active" name="is_active"
|
||||
{% if not user or user.is_active %}checked{% endif %}
|
||||
{% if user and user.id == current_user.id %}disabled{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="is_active">
|
||||
{% trans %}Active Account{% endtrans %}
|
||||
</label>
|
||||
<small class="text-muted d-block">
|
||||
{% trans %}Inactive users are prevented from signing in to the platform.{% endtrans %}
|
||||
</small>
|
||||
{% if user and user.id == current_user.id %}
|
||||
<input type="hidden" name="is_active" value="on">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center pt-2">
|
||||
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
|
||||
{% trans %}Cancel{% endtrans %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary px-4 fw-semibold">
|
||||
<i class="bi bi-check-lg me-1"></i>{% trans %}Save User{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,247 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}User Management{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-people text-primary me-2"></i>{% trans %}User Management{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Manage user accounts, assign roles, and control active status.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.user_add') }}" class="btn btn-primary">
|
||||
<i class="bi bi-person-plus me-1"></i>{% trans %}New User{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-3">
|
||||
<form method="GET" class="row g-2 align-items-center">
|
||||
<div class="col-md-5">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary border-end-0">
|
||||
<i class="bi bi-search text-muted"></i>
|
||||
</span>
|
||||
<input type="text" name="search" class="form-control border-start-0"
|
||||
placeholder="{% trans %}Search by name or email...{% endtrans %}"
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="role" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}All Roles{% endtrans %}</option>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r.id }}" {% if role_filter == (r.id|string) %}selected{% endif %}>{{ r.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}All Statuses{% endtrans %}</option>
|
||||
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>{% trans %}Active{% endtrans %}</option>
|
||||
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>{% trans %}Inactive{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||
<i class="bi bi-filter me-1"></i>{% trans %}Filter{% endtrans %}
|
||||
</button>
|
||||
{% if search or role_filter or status_filter %}
|
||||
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Clear filters{% endtrans %}">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-body-tertiary">
|
||||
<tr>
|
||||
<th class="ps-4">{% trans %}User{% endtrans %}</th>
|
||||
<th>{% trans %}Email{% endtrans %}</th>
|
||||
<th>{% trans %}Role / Group{% endtrans %}</th>
|
||||
<th>{% trans %}Status{% endtrans %}</th>
|
||||
<th>{% trans %}Last Login{% endtrans %}</th>
|
||||
<th class="text-end pe-4">{% trans %}Actions{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="user-avatar-small me-2">
|
||||
{{ (u.first_name[0] if u.first_name else 'U')|upper }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">{{ u.name }}</div>
|
||||
<small class="text-muted">ID: #{{ u.id }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-monospace small">{{ u.email }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if u.role_obj %}
|
||||
<span class="badge badge-brand">
|
||||
<i class="bi bi-shield-check me-1"></i>{{ u.role_obj.name }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
{{ u.role or _('No Role') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
|
||||
<i class="bi bi-dash-circle me-1"></i>{% trans %}Inactive{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{% if u.last_login %}
|
||||
{{ format_datetime(u.last_login, format='short') }}
|
||||
{% else %}
|
||||
{% trans %}Never{% endtrans %}
|
||||
{% endif %}
|
||||
</small>
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('admin.user_edit', id=u.id) }}" class="btn btn-outline-primary" title="{% trans %}Edit User{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% if u.id != current_user.id %}
|
||||
<form action="{{ url_for('admin.user_toggle', id=u.id) }}" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn {% if u.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
|
||||
title="{% if u.is_active %}{% trans %}Deactivate User{% endtrans %}{% else %}{% trans %}Activate User{% endtrans %}{% endif %}">
|
||||
<i class="bi {% if u.is_active %}bi-person-x{% else %}bi-person-check{% endif %}"></i>
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-outline-danger btn-delete-user"
|
||||
data-user-id="{{ u.id }}"
|
||||
data-user-name="{{ u.name }}"
|
||||
data-user-email="{{ u.email }}"
|
||||
data-action-url="{{ url_for('admin.user_delete', id=u.id) }}"
|
||||
title="{% trans %}Eliminar Usuario{% endtrans %}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-people fs-1 d-block mb-2 text-secondary"></i>
|
||||
{% trans %}No users found matching the criteria.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="card-footer bg-body-tertiary d-flex justify-content-between align-items-center py-2 px-4">
|
||||
<small class="text-muted">
|
||||
{% trans %}Showing page{% endtrans %} {{ pagination.page }} {% trans %}of{% endtrans %} {{ pagination.pages }} ({{ pagination.total }} {% trans %}total{% endtrans %})
|
||||
</small>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.prev_num, search=search, role=role_filter, status=status_filter) }}">«</a></li>
|
||||
{% endif %}
|
||||
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.users_list', page=p, search=search, role=role_filter, status=status_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.next_num, search=search, role=role_filter, status=status_filter) }}">»</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Global de Eliminación de Usuario (fuera de la tabla) -->
|
||||
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
|
||||
<i class="bi bi-person-x-fill fs-5"></i>
|
||||
</div>
|
||||
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación de Usuario{% endtrans %}</h5>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-start py-3">
|
||||
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar permanentemente al siguiente usuario?{% endtrans %}</p>
|
||||
<div class="p-2 rounded bg-body-tertiary mb-2">
|
||||
<div class="fw-bold text-primary" id="deleteUserName"></div>
|
||||
<div class="small text-muted font-monospace" id="deleteUserEmail"></div>
|
||||
</div>
|
||||
<p class="text-muted small mb-0"><i class="bi bi-exclamation-triangle me-1 text-warning"></i>{% trans %}Esta acción desvinculará sus comisiones y reservas asociadas. No se puede deshacer.{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<form id="deleteUserForm" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger px-3">
|
||||
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Usuario{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var deleteModalEl = document.getElementById('deleteUserModal');
|
||||
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
|
||||
var nameEl = document.getElementById('deleteUserName');
|
||||
var emailEl = document.getElementById('deleteUserEmail');
|
||||
var formEl = document.getElementById('deleteUserForm');
|
||||
|
||||
document.querySelectorAll('.btn-delete-user').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var name = this.getAttribute('data-user-name');
|
||||
var email = this.getAttribute('data-user-email');
|
||||
var actionUrl = this.getAttribute('data-action-url');
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
if (emailEl) emailEl.textContent = email;
|
||||
if (formEl) formEl.action = actionUrl;
|
||||
if (deleteModal) deleteModal.show();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,114 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ session.language or request.accept_languages.best_match(['es', 'en']) or 'es' }}">
|
||||
<html lang="{{ current_lang }}" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% trans %}Login - Edu-Space Admin{% endtrans %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
|
||||
|
||||
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
|
||||
<script>
|
||||
(function() {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
const theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.login-card {
|
||||
background: rgba(30, 41, 59, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5);
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
color: #f8fafc;
|
||||
}
|
||||
.logo-section {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: white;
|
||||
position: relative;
|
||||
}
|
||||
.lang-switch {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
</style>
|
||||
<!-- Design System Theme -->
|
||||
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<div class="logo-section">
|
||||
<div class="lang-switch">
|
||||
{% include 'partials/language_switcher.html' %}
|
||||
</div>
|
||||
<i class="bi bi-building" style="font-size: 3rem;"></i>
|
||||
<h3 class="mt-3 mb-1 fw-bold">Edu-Space Admin</h3>
|
||||
<p class="mb-0 text-white-50 small">{% trans %}Classroom Management System{% endtrans %}</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label small text-secondary">{{ form.email.label.text }}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-envelope"></i></span>
|
||||
<input type="email" class="form-control bg-dark border-secondary text-white" id="email" name="email" value="{{ form.email.data or '' }}" required placeholder="{% trans %}Enter your email{% endtrans %}">
|
||||
</div>
|
||||
{% for error in form.email.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label small text-secondary">{{ form.password.label.text }}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-lock"></i></span>
|
||||
<input type="password" class="form-control bg-dark border-secondary text-white" id="password" name="password" required placeholder="{% trans %}Enter your password{% endtrans %}">
|
||||
</div>
|
||||
{% for error in form.password.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
{{ form.remember_me(class="form-check-input") }}
|
||||
<label class="form-check-label text-secondary small" for="remember_me">{{ form.remember_me.label.text }}</label>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg fw-semibold">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i> {{ form.submit.label.text }}
|
||||
<div class="auth-wrapper">
|
||||
<div class="auth-card">
|
||||
<!-- Header con Controles Superiores & Logo Oficial -->
|
||||
<div class="auth-header">
|
||||
<div class="auth-controls">
|
||||
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
|
||||
<i class="bi bi-moon-stars-fill"></i>
|
||||
</button>
|
||||
{% include 'partials/language_switcher.html' %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<small class="text-secondary">
|
||||
<i class="bi bi-shield-lock me-1"></i>
|
||||
{% trans %}Secure Login • All access is logged{% endtrans %}
|
||||
</small>
|
||||
<img src="{{ url_for('static', filename='img/logo_unicaba.png') }}" alt="UniCABA" class="auth-logo">
|
||||
<h4 class="fw-bold mb-1">Edu-Space Admin</h4>
|
||||
<p class="text-muted small mb-0">{% trans %}Classroom Management System{% endtrans %}</p>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-4 pt-2">
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show rounded-3 py-2 small" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close py-2" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label small fw-semibold text-muted">{{ form.email.label.text }}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
|
||||
<i class="bi bi-envelope"></i>
|
||||
</span>
|
||||
<input type="email" class="form-control border-start-0 ps-0" id="email" name="email"
|
||||
value="{{ form.email.data or '' }}" required
|
||||
placeholder="{% trans %}Enter your email{% endtrans %}">
|
||||
</div>
|
||||
{% for error in form.email.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label small fw-semibold text-muted">{{ form.password.label.text }}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
|
||||
<i class="bi bi-lock"></i>
|
||||
</span>
|
||||
<input type="password" class="form-control border-start-0 ps-0" id="password" name="password"
|
||||
required placeholder="{% trans %}Enter your password{% endtrans %}">
|
||||
</div>
|
||||
{% for error in form.password.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4 form-check">
|
||||
{{ form.remember_me(class="form-check-input") }}
|
||||
<label class="form-check-label text-muted small" for="remember_me">
|
||||
{{ form.remember_me.label.text }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg fw-semibold py-2">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i> {{ form.submit.label.text }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<small class="text-muted d-inline-flex align-items-center">
|
||||
<i class="bi bi-shield-check text-primary me-1"></i>
|
||||
{% trans %}Secure Login • All access is logged{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -76,17 +76,19 @@
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Language Preference Section -->
|
||||
<!-- Preferences Section -->
|
||||
<form method="POST" action="{{ url_for('auth.profile') }}">
|
||||
<label class="form-label fw-bold mb-2">
|
||||
<i class="bi bi-translate text-primary me-2"></i>{% trans %}Language Preference{% endtrans %}
|
||||
</label>
|
||||
<p class="text-muted small mb-3">
|
||||
{% trans %}If set, this language overrides the browser configuration for all your sessions.{% endtrans %}
|
||||
</p>
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-sm-8">
|
||||
<select class="form-select" name="preferred_language">
|
||||
<h5 class="fw-bold mb-3">
|
||||
<i class="bi bi-sliders text-primary me-2"></i>{% trans %}User Preferences{% endtrans %}
|
||||
</h5>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Preferred Language -->
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="preferred_language" class="form-label fw-semibold">
|
||||
<i class="bi bi-translate text-primary me-1"></i>{% trans %}Interface Language{% endtrans %}
|
||||
</label>
|
||||
<select class="form-select" id="preferred_language" name="preferred_language">
|
||||
<option value="" {% if not current_user.preferred_language %}selected{% endif %}>
|
||||
{% trans %}Auto-detect from browser (Default){% endtrans %}
|
||||
</option>
|
||||
@@ -97,13 +99,38 @@
|
||||
English
|
||||
</option>
|
||||
</select>
|
||||
<small class="text-muted d-block mt-1">
|
||||
{% trans %}If set, this language overrides the browser configuration for all your sessions.{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<button type="submit" class="btn btn-outline-primary w-100">
|
||||
<i class="bi bi-check2 me-1"></i>{% trans %}Save Preference{% endtrans %}
|
||||
</button>
|
||||
|
||||
<!-- Theme Preference -->
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="theme_preference" class="form-label fw-semibold">
|
||||
<i class="bi bi-palette text-primary me-1"></i>{% trans %}Visual Theme{% endtrans %}
|
||||
</label>
|
||||
<select class="form-select" id="theme_preference" name="theme_preference">
|
||||
<option value="auto" {% if current_user.theme_preference == 'auto' or not current_user.theme_preference %}selected{% endif %}>
|
||||
{% trans %}Automatic (follow system){% endtrans %}
|
||||
</option>
|
||||
<option value="light" {% if current_user.theme_preference == 'light' %}selected{% endif %}>
|
||||
{% trans %}Light Mode{% endtrans %}
|
||||
</option>
|
||||
<option value="dark" {% if current_user.theme_preference == 'dark' %}selected{% endif %}>
|
||||
{% trans %}Dark Mode{% endtrans %}
|
||||
</option>
|
||||
</select>
|
||||
<small class="text-muted d-block mt-1">
|
||||
{% trans %}Choose your preferred visual theme.{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-end mt-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-1"></i>{% trans %}Save Preferences{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer bg-body-tertiary px-4 py-3 d-flex justify-content-between align-items-center">
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ session.language or request.accept_languages.best_match(['es', 'en']) or 'en' }}" data-bs-theme="dark">
|
||||
<html lang="{{ current_lang }}" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{% trans %}Edu-Space Admin{% endtrans %}{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
|
||||
|
||||
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
|
||||
<script>
|
||||
(function() {
|
||||
let userTheme = "{{ current_user.theme_preference if (current_user and current_user.is_authenticated) else '' }}";
|
||||
let theme = userTheme;
|
||||
if (!theme || theme === 'auto') {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
}
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<!-- Custom CSS -->
|
||||
<!-- Design System & Custom Theme -->
|
||||
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<body data-user-authenticated="{{ 'true' if (current_user and current_user.is_authenticated) else 'false' }}">
|
||||
{% if current_user.is_authenticated %}
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark navbar-app fixed-top">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-building"></i> Edu-Space Admin
|
||||
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('main.dashboard') }}">
|
||||
<img src="{{ url_for('static', filename='img/logo_unicaba_mini.png') }}" alt="Logo UniCABA" class="navbar-logo me-2">
|
||||
<span>Edu-Space Admin</span>
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
@@ -42,9 +60,15 @@
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}">
|
||||
<i class="bi bi-list"></i> {% trans %}All Classrooms{% endtrans %}
|
||||
</a></li>
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}">
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}">
|
||||
<i class="bi bi-buildings"></i> {% trans %}Buildings{% endtrans %}
|
||||
</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
@@ -58,37 +82,94 @@
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}">
|
||||
<i class="bi bi-list-check"></i> {% trans %}All Reservations{% endtrans %}
|
||||
</a></li>
|
||||
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}">
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}New Reservation{% endtrans %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}">
|
||||
<i class="bi bi-calendar-today"></i> {% trans %}Today's Schedule{% endtrans %}
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}">
|
||||
<i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}
|
||||
</a></li>
|
||||
{% if current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role|upper == 'ADMIN') %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}">
|
||||
<i class="bi bi-cpu text-info me-2"></i>{% trans %}Optimizador de Aulas (IA){% endtrans %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
{% if current_user.is_authenticated and current_user.can_manage() %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-shield-lock"></i> {% trans %}Gestión{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm" style="min-width: 260px;">
|
||||
<!-- Espacios & Infraestructura -->
|
||||
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Espacios e Infraestructura{% endtrans %}</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Gestión de Ocupación, Métricas{% endtrans %}</a></li>
|
||||
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<!-- Gestión Académica -->
|
||||
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Gestión Académica{% endtrans %}</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-calendar-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
|
||||
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<!-- Administración & Sistema -->
|
||||
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Administración & Seguridad{% endtrans %}</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Usuarios{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles y Permisos{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav align-items-center">
|
||||
<!-- Theme Mode Toggle Button -->
|
||||
<li class="nav-item me-2">
|
||||
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
|
||||
<i class="bi bi-moon-stars-fill"></i>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<ul class="navbar-nav">
|
||||
<!-- Language Switcher -->
|
||||
<li class="nav-item">
|
||||
<li class="nav-item me-2">
|
||||
{% include 'partials/language_switcher.html' %}
|
||||
</li>
|
||||
|
||||
<!-- User Dropdown with Avatar -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle"></i> {{ current_user.first_name }} {{ current_user.last_name }}
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center py-1" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<span class="user-avatar-small">{{ current_user.name[:1]|upper if current_user.name else 'U' }}</span>
|
||||
<span>{{ current_user.first_name }}</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
|
||||
<li>
|
||||
<div class="px-3 py-2 border-bottom">
|
||||
<small class="text-muted d-block">{% trans %}Signed in as{% endtrans %}</small>
|
||||
<span class="fw-bold small">{{ current_user.email }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
||||
<i class="bi bi-person"></i> {% trans %}Profile{% endtrans %}
|
||||
<i class="bi bi-person me-2"></i> {% trans %}Profile{% endtrans %}
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
|
||||
<i class="bi bi-key"></i> {% trans %}Change Password{% endtrans %}
|
||||
<i class="bi bi-key me-2"></i> {% trans %}Change Password{% endtrans %}
|
||||
</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right"></i> {% trans %}Logout{% endtrans %}
|
||||
<li><a class="dropdown-item text-danger" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right me-2"></i> {% trans %}Logout{% endtrans %}
|
||||
</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -99,11 +180,11 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0" role="alert">
|
||||
<div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0 shadow" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
@@ -123,6 +204,8 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- jQuery -->
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<!-- Theme Toggle JS -->
|
||||
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
|
||||
<!-- Custom JS -->
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{% if building %}{% trans %}Editar Edificio{% endtrans %} - {{ building.name }}{% else %}{% trans %}Nuevo Edificio{% endtrans %}{% endif %} - Edu-Space Admin
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-buildings text-primary me-2"></i>
|
||||
{% if building %}{% trans %}Editar Edificio{% endtrans %}{% else %}{% trans %}Nuevo Edificio{% endtrans %}{% endif %}
|
||||
</h1>
|
||||
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver a Edificios{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-info-circle text-primary me-2"></i>{% trans %}Información del Edificio / Sede{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
{{ form.name.label(class="form-label fw-semibold") }}
|
||||
{{ form.name(class="form-control" + (" is-invalid" if form.name.errors else ""), placeholder="ej. Edificio Central, Pabellón de Ciencias, Sede Norte") }}
|
||||
{% if form.name.errors %}
|
||||
<div class="invalid-feedback">{{ form.name.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
{{ form.code.label(class="form-label fw-semibold") }}
|
||||
{{ form.code(class="form-control" + (" is-invalid" if form.code.errors else ""), placeholder="ej. CENTRAL, VIRTUAL, PAB-A") }}
|
||||
{% if form.code.errors %}
|
||||
<div class="invalid-feedback">{{ form.code.errors[0] }}</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">{% trans %}Código identificador corto{% endtrans %}</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
{{ form.address.label(class="form-label fw-semibold") }}
|
||||
{{ form.address(class="form-control" + (" is-invalid" if form.address.errors else ""), placeholder="ej. Av. Paseo Colón 255, CABA") }}
|
||||
{% if form.address.errors %}
|
||||
<div class="invalid-feedback">{{ form.address.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
{{ form.floors.label(class="form-label fw-semibold") }}
|
||||
{{ form.floors(class="form-control" + (" is-invalid" if form.floors.errors else ""), min=1, max=100) }}
|
||||
{% if form.floors.errors %}
|
||||
<div class="invalid-feedback">{{ form.floors.errors[0] }}</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">{% trans %}Cantidad de plantas/pisos{% endtrans %}</small>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
{{ form.description.label(class="form-label fw-semibold") }}
|
||||
{{ form.description(class="form-control", rows=3, placeholder="Información relevante, accesos, comodidades o departamentos alojados...") }}
|
||||
{% if form.description.errors %}
|
||||
<div class="invalid-feedback">{{ form.description.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch pt-2">
|
||||
{{ form.is_active(class="form-check-input", role="switch") }}
|
||||
{{ form.is_active.label(class="form-check-label fw-semibold") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
|
||||
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary">{% trans %}Cancelar{% endtrans %}</a>
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
{% if building %}{% trans %}Guardar Cambios{% endtrans %}{% else %}{% trans %}Crear Edificio{% endtrans %}{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,223 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Gestión de Edificios{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col-md-7">
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-buildings text-primary me-2"></i>{% trans %}Gestión de Edificios{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Administra las sedes, edificios físicos y campus virtuales de UniCABA{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<a href="{{ url_for('buildings.add_building') }}" class="btn btn-primary me-2">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nuevo Edificio{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-door-open me-1"></i>{% trans %}Ver Aulas{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-3 p-3 bg-primary bg-opacity-10 text-primary me-3">
|
||||
<i class="bi bi-buildings fs-3"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small fw-semibold">{% trans %}Total Edificios{% endtrans %}</div>
|
||||
<h3 class="fw-bold mb-0">{{ buildings|length }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-3 p-3 bg-success bg-opacity-10 text-success me-3">
|
||||
<i class="bi bi-door-open fs-3"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small fw-semibold">{% trans %}Aulas Activas{% endtrans %}</div>
|
||||
<h3 class="fw-bold mb-0 text-success">{{ total_classrooms }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-3 p-3 bg-info bg-opacity-10 text-info me-3">
|
||||
<i class="bi bi-people fs-3"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small fw-semibold">{% trans %}Capacidad Total{% endtrans %}</div>
|
||||
<h3 class="fw-bold mb-0 text-info">{{ total_capacity }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-3 p-3 bg-warning bg-opacity-10 text-warning me-3">
|
||||
<i class="bi bi-layers fs-3"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small fw-semibold">{% trans %}Pisos Disponibles{% endtrans %}</div>
|
||||
<h3 class="fw-bold mb-0 text-warning">{{ buildings|sum(attribute='floors') }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter & Search Toolbar -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-3">
|
||||
<form method="GET" class="row g-2 align-items-center">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-body-tertiary border-end-0">
|
||||
<i class="bi bi-search text-muted"></i>
|
||||
</span>
|
||||
<input type="text" name="search" value="{{ search }}" class="form-control border-start-0" placeholder="{% trans %}Buscar por nombre, código o dirección...{% endtrans %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<select name="status" class="form-select" onchange="this.form.submit()">
|
||||
<option value="">{% trans %}Todos los estados{% endtrans %}</option>
|
||||
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>{% trans %}Activos únicamente{% endtrans %}</option>
|
||||
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>{% trans %}Inactivos{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary w-100">{% trans %}Buscar{% endtrans %}</button>
|
||||
{% if search or status_filter %}
|
||||
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary" title="{% trans %}Limpiar filtro{% endtrans %}">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buildings Cards List -->
|
||||
<div class="row g-3">
|
||||
{% for b in buildings %}
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100 position-relative">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border mb-1">
|
||||
{{ b.code or 'EDIF' }}
|
||||
</span>
|
||||
<h5 class="card-title fw-bold mb-0">
|
||||
<a href="{{ url_for('buildings.view_building', id=b.id) }}" class="text-decoration-none text-primary">
|
||||
{{ b.name }}
|
||||
</a>
|
||||
</h5>
|
||||
</div>
|
||||
<div>
|
||||
{% if b.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Activo{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
|
||||
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactivo{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if b.address %}
|
||||
<p class="text-muted small mb-3">
|
||||
<i class="bi bi-geo-alt-fill text-danger me-1"></i>{{ b.address }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if b.description %}
|
||||
<p class="small text-secondary mb-3">{{ b.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-2 text-center pt-2 border-top">
|
||||
<div class="col-4">
|
||||
<div class="p-2 rounded bg-body-tertiary">
|
||||
<small class="text-muted d-block">{% trans %}Pisos{% endtrans %}</small>
|
||||
<span class="fw-bold fs-6">{{ b.floors }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="p-2 rounded bg-body-tertiary">
|
||||
<small class="text-muted d-block">{% trans %}Aulas{% endtrans %}</small>
|
||||
<span class="fw-bold fs-6 text-primary">{{ b.classrooms_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="p-2 rounded bg-body-tertiary">
|
||||
<small class="text-muted d-block">{% trans %}Capacidad{% endtrans %}</small>
|
||||
<span class="fw-bold fs-6 text-success">{{ b.total_capacity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<a href="{{ url_for('buildings.view_building', id=b.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-eye me-1"></i>{% trans %}Ver Distribución de Aulas{% endtrans %}
|
||||
</a>
|
||||
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<div class="btn-group">
|
||||
<a href="{{ url_for('buildings.edit_building', id=b.id) }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Editar Edificio{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('buildings.toggle_building', id=b.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{% if b.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
|
||||
<i class="bi bi-power"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm p-5 text-center">
|
||||
<i class="bi bi-buildings text-muted fs-1 mb-3"></i>
|
||||
<h5 class="fw-bold">{% trans %}No se encontraron edificios{% endtrans %}</h5>
|
||||
<p class="text-muted mb-3">{% trans %}No hay edificios que coincidan con los criterios de búsqueda.{% endtrans %}</p>
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<div>
|
||||
<a href="{{ url_for('buildings.add_building') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Crear Nuevo Edificio{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,177 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ building.name }} - {% trans %}Detalle del Edificio{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Breadcrumb & Header -->
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col-md-7">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1 small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('buildings.list_buildings') }}">{% trans %}Edificios{% endtrans %}</a></li>
|
||||
<li class="breadcrumb-item active">{{ building.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 mb-0 d-flex align-items-center flex-wrap gap-2">
|
||||
<i class="bi bi-building text-primary"></i> {{ building.name }}
|
||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border fs-6">{{ building.code or 'EDIF' }}</span>
|
||||
{% if building.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle fs-6">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Activo{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fs-6">
|
||||
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactivo{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<a href="{{ url_for('buildings.edit_building', id=building.id) }}" class="btn btn-outline-secondary me-2">
|
||||
<i class="bi bi-pencil me-1"></i>{% trans %}Editar Edificio{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Aula{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="text-muted small fw-semibold">{% trans %}Ubicación / Dirección{% endtrans %}</div>
|
||||
<p class="h6 fw-bold mb-2">
|
||||
<i class="bi bi-geo-alt text-danger me-1"></i>{{ building.address or 'Sin dirección especificada' }}
|
||||
</p>
|
||||
{% if building.description %}
|
||||
<small class="text-secondary">{{ building.description }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm text-center p-3">
|
||||
<small class="text-muted text-uppercase fw-bold">{% trans %}Pisos{% endtrans %}</small>
|
||||
<span class="display-6 fw-bold text-primary">{{ building.floors }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm text-center p-3">
|
||||
<small class="text-muted text-uppercase fw-bold">{% trans %}Aulas Asociadas{% endtrans %}</small>
|
||||
<span class="display-6 fw-bold text-success">{{ classrooms|length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm text-center p-3">
|
||||
<small class="text-muted text-uppercase fw-bold">{% trans %}Capacidad Total{% endtrans %}</small>
|
||||
<span class="display-6 fw-bold text-info">{{ building.total_capacity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Classrooms by Floor -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<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-layers text-primary me-2"></i>{% trans %}Distribución de Aulas por Piso{% endtrans %}
|
||||
</h5>
|
||||
<span class="badge badge-brand">{{ classrooms|length }} {% trans %}aulas configuradas{% endtrans %}</span>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{% if classrooms_by_floor %}
|
||||
{% for floor_num in classrooms_by_floor|sort %}
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<span class="badge bg-dark bg-opacity-75 fs-6 px-3 py-2 me-2">
|
||||
<i class="bi bi-layer-forward me-1"></i>
|
||||
{% if floor_num == 0 %}Planta Baja{% else %}Piso {{ floor_num }}{% endif %}
|
||||
</span>
|
||||
<div class="border-bottom flex-grow-1"></div>
|
||||
<small class="text-muted ms-2">{{ classrooms_by_floor[floor_num]|length }} aulas</small>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for c in classrooms_by_floor[floor_num] %}
|
||||
<div class="col-md-6 col-xl-4">
|
||||
<div class="card border border-light-subtle shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h6 class="fw-bold mb-0">
|
||||
<a href="{{ url_for('classrooms.view_classroom', id=c.id) }}" class="text-decoration-none text-primary">
|
||||
<i class="bi bi-door-open me-1"></i>{{ c.code }}
|
||||
</a>
|
||||
</h6>
|
||||
{% if c.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle small">Activa</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle small">Inactiva</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="small text-muted mb-2">
|
||||
<i class="bi bi-people me-1"></i>Capacidad: <strong>{{ c.capacity }}</strong> estudiantes
|
||||
</div>
|
||||
|
||||
{% if c.description %}
|
||||
<p class="small text-secondary mb-2 text-truncate">{{ c.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center pt-2 border-top">
|
||||
<a href="{{ url_for('classrooms.view_classroom', id=c.id) }}" class="btn btn-outline-primary btn-sm py-0 px-2">
|
||||
<small>{% trans %}Ver Ficha{% endtrans %}</small>
|
||||
</a>
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<a href="{{ url_for('classrooms.edit_classroom', id=c.id) }}" class="btn btn-outline-secondary btn-sm py-0 px-2">
|
||||
<small>{% trans %}Editar{% endtrans %}</small>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-door-closed text-muted fs-1 mb-2"></i>
|
||||
<h6 class="fw-bold">{% trans %}No hay aulas asignadas a este edificio{% endtrans %}</h6>
|
||||
<p class="text-muted small mb-3">{% trans %}Puedes crear aulas nuevas o editar las existentes para vincularlas aquí.{% endtrans %}</p>
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Agregar Primera Aula{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Danger Zone (Delete) -->
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<div class="card border-danger border-opacity-25 shadow-sm mb-4">
|
||||
<div class="card-body p-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<h6 class="text-danger fw-bold mb-1">{% trans %}Eliminar Edificio{% endtrans %}</h6>
|
||||
<small class="text-muted">{% trans %}Solo es posible eliminar edificios que no tengan aulas asignadas.{% endtrans %}</small>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('buildings.delete_building', id=building.id) }}" onsubmit="return confirm('¿Estás seguro de que deseas eliminar este edificio?');">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" {% if classrooms|length > 0 %}disabled title="Tiene aulas asociadas"{% endif %}>
|
||||
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Edificio{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -7,11 +7,19 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3">
|
||||
<i class="bi bi-plus-circle text-primary"></i> {% trans %}Add Classroom{% endtrans %}
|
||||
</h1>
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1 small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('classrooms.list_classrooms') }}">{% trans %}Classrooms{% endtrans %}</a></li>
|
||||
<li class="breadcrumb-item active">{% trans %}Add Classroom{% endtrans %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-plus-circle text-primary me-2"></i>{% trans %}Add Classroom{% endtrans %}
|
||||
</h1>
|
||||
</div>
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> {% trans %}Back to Classrooms{% endtrans %}
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -22,49 +30,82 @@
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-door-open text-primary me-2"></i> {% trans %}Classroom Information{% endtrans %}
|
||||
<i class="bi bi-door-open text-primary me-2"></i>{% trans %}Classroom Information{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" id="classroom-form" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
{{ form.building.label(class="form-label") }}
|
||||
{{ form.building(class="form-control", placeholder="A") }}
|
||||
{% if form.building.errors %}
|
||||
<div class="row g-3 mb-3">
|
||||
<!-- Selector de Edificio / Sede -->
|
||||
<div class="col-md-6">
|
||||
<label for="building_id" class="form-label fw-semibold">
|
||||
<i class="bi bi-buildings text-primary me-1"></i>{% trans %}Edificio / Sede{% endtrans %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.building_id(class="form-select", id="building_id") }}
|
||||
{% if form.building_id.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.building.errors[0] }}
|
||||
{{ form.building_id.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Input emergente si selecciona "+ Crear nuevo edificio" -->
|
||||
<div id="newBuildingContainer" class="mt-2 p-2 rounded bg-body-tertiary border border-success-subtle" style="display: none;">
|
||||
<label for="new_building_name" class="form-label small fw-bold text-success mb-1">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nombre del Nuevo Edificio / Sede{% endtrans %}:
|
||||
</label>
|
||||
{{ form.new_building_name(class="form-control form-control-sm", id="new_building_name", placeholder="ej. Anexo Belgrano, Pabellón III") }}
|
||||
<div class="form-text small text-muted">{% trans %}Se registrará automáticamente en el catálogo de sedes.{% endtrans %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
{{ form.room_number.label(class="form-label") }}
|
||||
{{ form.room_number(class="form-control", placeholder="101") }}
|
||||
{% if form.room_number.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.room_number.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
{{ form.floor.label(class="form-label") }}
|
||||
{{ form.floor(class="form-control") }}
|
||||
<!-- Selector / Input de Piso con sugerencias -->
|
||||
<div class="col-md-3">
|
||||
<label for="floor" class="form-label fw-semibold">
|
||||
<i class="bi bi-layers text-secondary me-1"></i>{% trans %}Piso / Nivel{% endtrans %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.floor(class="form-control", id="floor", list="floorSuggestions", placeholder="ej. PB, 1, 2, 4°P") }}
|
||||
<datalist id="floorSuggestions">
|
||||
<!-- Poblado dinámicamente por JS según el edificio -->
|
||||
</datalist>
|
||||
<div class="form-text small text-muted">{% trans %}Sugerido o nuevo{% endtrans %}</div>
|
||||
{% if form.floor.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.floor.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Número / Nombre de Aula -->
|
||||
<div class="col-md-3">
|
||||
<label for="room_number" class="form-label fw-semibold">
|
||||
<i class="bi bi-door-open text-info me-1"></i>{% trans %}Aula / Sala{% endtrans %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.room_number(class="form-control", id="room_number", placeholder="ej. Aula 304, Lab 1") }}
|
||||
{% if form.room_number.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.room_number.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.capacity.label(class="form-label") }}
|
||||
{{ form.capacity(class="form-control", placeholder="30") }}
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label for="capacity" class="form-label fw-semibold mb-0">{% trans %}Capacidad de Alumnos{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinityAdd" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}">
|
||||
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
{{ form.capacity(class="form-control", id="capacity", placeholder="ej. 35 o ∞") }}
|
||||
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
|
||||
</div>
|
||||
<div class="form-text small text-muted mt-1">
|
||||
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o sin límite de aforo, ingrese <strong>∞</strong> o <strong>0</strong>.{% endtrans %}
|
||||
</div>
|
||||
{% if form.capacity.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.capacity.errors[0] }}
|
||||
@@ -72,18 +113,17 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label d-block"> </label>
|
||||
<div class="form-check pt-2">
|
||||
{{ form.is_active(class="form-check-input") }}
|
||||
{{ form.is_active.label(class="form-check-label") }}
|
||||
<div class="col-md-6 d-flex align-items-center pt-md-3">
|
||||
<div class="form-check form-switch pt-2">
|
||||
{{ form.is_active(class="form-check-input", id="is_active") }}
|
||||
<label class="form-check-label fw-semibold" for="is_active">{% trans %}Aula Habilitada para Reservas{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.description.label(class="form-label") }}
|
||||
{{ form.description(class="form-control", rows="3", placeholder=_("Optional: Add any additional details about this classroom...")) }}
|
||||
{{ form.description.label(class="form-label fw-semibold") }}
|
||||
{{ form.description(class="form-control", rows="3", placeholder=_("Opcional: Detalles adicionales de equipamiento, tomas de corriente, proyector...")) }}
|
||||
{% if form.description.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.description.errors[0] }}
|
||||
@@ -91,27 +131,36 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Preview Section -->
|
||||
<div class="alert alert-info">
|
||||
<h6 class="alert-heading">
|
||||
<i class="bi bi-info-circle"></i> {% trans %}Preview{% endtrans %}
|
||||
</h6>
|
||||
<div id="classroom-preview">
|
||||
<p class="mb-1">{% trans %}Fill in the form to see a preview of the classroom code:{% endtrans %}
|
||||
<span class="fw-bold" id="preview-code">-</span>
|
||||
</p>
|
||||
<p class="mb-0">{% trans %}Full location:{% endtrans %}
|
||||
<span class="fw-bold" id="preview-location">-</span>
|
||||
</p>
|
||||
<!-- Preview Card -->
|
||||
<div class="card bg-body-tertiary border-0 shadow-xs mb-4">
|
||||
<div class="card-body py-3 px-4">
|
||||
<h6 class="card-subtitle mb-2 text-muted small fw-bold">
|
||||
<i class="bi bi-eye me-1"></i>{% trans %}Vista Previa de Ubicación{% endtrans %}
|
||||
</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted d-block">🏢 {% trans %}Edificio{% endtrans %}</small>
|
||||
<strong id="preview-building" class="text-primary">-</strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted d-block">🪜 {% trans %}Piso{% endtrans %}</small>
|
||||
<strong id="preview-floor" class="text-secondary">-</strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted d-block">🚪 {% trans %}Aula / Código{% endtrans %}</small>
|
||||
<strong id="preview-room" class="text-success">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %}
|
||||
{% trans %}Cancelar{% endtrans %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-1"></i> {{ form.submit.label.text }}
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Crear Aula{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -119,83 +168,89 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resources Section -->
|
||||
<div class="row mt-4 mb-4">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-tools text-warning me-2"></i> {% trans %}Resources (Optional){% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-0">{% trans %}You can add classroom resources after creating the classroom.{% endtrans %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
function updatePreview() {
|
||||
const building = $('#building').val();
|
||||
const roomNumber = $('#room_number').val();
|
||||
const floor = $('#floor').val();
|
||||
const floorLabel = '{{ _("Floor") }}';
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Map of existing floors per building
|
||||
var buildingFloors = {{ building_floors|tojson }};
|
||||
|
||||
if (building && roomNumber && floor) {
|
||||
$('#preview-code').text(`${building}-${roomNumber}`);
|
||||
$('#preview-location').text(`${building} - ${floorLabel} ${floor}`);
|
||||
} else {
|
||||
$('#preview-code').text('-');
|
||||
$('#preview-location').text('-');
|
||||
var buildingSelect = document.getElementById('building_id');
|
||||
var newBuildingContainer = document.getElementById('newBuildingContainer');
|
||||
var newBuildingNameInput = document.getElementById('new_building_name');
|
||||
var floorInput = document.getElementById('floor');
|
||||
var floorDatalist = document.getElementById('floorSuggestions');
|
||||
var roomInput = document.getElementById('room_number');
|
||||
|
||||
var previewBuilding = document.getElementById('preview-building');
|
||||
var previewFloor = document.getElementById('preview-floor');
|
||||
var previewRoom = document.getElementById('preview-room');
|
||||
|
||||
function updateFloorsDatalist(bId) {
|
||||
if (!floorDatalist) return;
|
||||
floorDatalist.innerHTML = '';
|
||||
var floors = buildingFloors[bId] || [];
|
||||
floors.forEach(function(f) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = f;
|
||||
opt.textContent = (f === '0' || f === 'PB') ? 'Planta Baja' : 'Piso ' + f;
|
||||
floorDatalist.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
var bText = '-';
|
||||
if (buildingSelect) {
|
||||
if (buildingSelect.value === '__new__') {
|
||||
bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}';
|
||||
} else if (buildingSelect.selectedIndex >= 0) {
|
||||
bText = buildingSelect.options[buildingSelect.selectedIndex].text;
|
||||
}
|
||||
}
|
||||
var fText = floorInput ? (floorInput.value.trim() || '-') : '-';
|
||||
var rText = roomInput ? (roomInput.value.trim() || '-') : '-';
|
||||
|
||||
if (previewBuilding) previewBuilding.textContent = bText;
|
||||
if (previewFloor) previewFloor.textContent = (fText === '0' || fText.toUpperCase() === 'PB') ? 'Planta Baja' : fText;
|
||||
if (previewRoom) previewRoom.textContent = rText;
|
||||
}
|
||||
|
||||
if (buildingSelect) {
|
||||
buildingSelect.addEventListener('change', function() {
|
||||
if (this.value === '__new__') {
|
||||
newBuildingContainer.style.display = 'block';
|
||||
if (newBuildingNameInput) newBuildingNameInput.focus();
|
||||
} else {
|
||||
newBuildingContainer.style.display = 'none';
|
||||
updateFloorsDatalist(this.value);
|
||||
}
|
||||
updatePreview();
|
||||
});
|
||||
// Initial call
|
||||
if (buildingSelect.value === '__new__') {
|
||||
newBuildingContainer.style.display = 'block';
|
||||
} else if (buildingSelect.value) {
|
||||
updateFloorsDatalist(buildingSelect.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Update preview when inputs change
|
||||
$('#building, #room_number, #floor').on('input', updatePreview);
|
||||
if (newBuildingNameInput) newBuildingNameInput.addEventListener('input', updatePreview);
|
||||
if (floorInput) floorInput.addEventListener('input', updatePreview);
|
||||
if (roomInput) roomInput.addEventListener('input', updatePreview);
|
||||
|
||||
// Initialize preview
|
||||
updatePreview();
|
||||
|
||||
// Form validation
|
||||
$('#classroom-form').on('submit', function(e) {
|
||||
let isValid = true;
|
||||
|
||||
if (!$('#building').val().trim()) {
|
||||
$('#building').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#building').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
if (!$('#room_number').val().trim()) {
|
||||
$('#room_number').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#room_number').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
if (!$('#floor').val()) {
|
||||
$('#floor').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#floor').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
if (!$('#capacity').val() || $('#capacity').val() < 1) {
|
||||
$('#capacity').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#capacity').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
return isValid;
|
||||
});
|
||||
// Infinity button
|
||||
var btnInf = document.getElementById('btnInfinityAdd');
|
||||
var capInput = document.getElementById('capacity');
|
||||
if (btnInf && capInput) {
|
||||
btnInf.addEventListener('click', function() {
|
||||
capInput.value = '∞';
|
||||
capInput.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,247 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Edit Classroom{% endtrans %} - {{ classroom.code_display }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col-md-7">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1 small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('classrooms.list_classrooms') }}">{% trans %}Classrooms{% endtrans %}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}">{{ classroom.code_display }}</a></li>
|
||||
<li class="breadcrumb-item active">{% trans %}Edit{% endtrans %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Edit Classroom{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{{ classroom.location_display }}</p>
|
||||
</div>
|
||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-secondary me-2">
|
||||
<i class="bi bi-eye me-1"></i>{% trans %}Ver Detalle{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver al Listado{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<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-door-open text-primary me-2"></i>{% trans %}Classroom Information{% endtrans %}
|
||||
</h5>
|
||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border">ID #{{ classroom.id }}</span>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" id="classroom-form" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<!-- Edificio -->
|
||||
<div class="col-md-6">
|
||||
<label for="building_id" class="form-label fw-semibold">
|
||||
<i class="bi bi-buildings text-primary me-1"></i>{% trans %}Edificio / Sede{% endtrans %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.building_id(class="form-select", id="building_id") }}
|
||||
{% if form.building_id.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.building_id.errors[0] }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="newBuildingContainer" class="mt-2 p-2 rounded bg-body-tertiary border border-success-subtle" style="display: none;">
|
||||
<label for="new_building_name" class="form-label small fw-bold text-success mb-1">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nombre del Nuevo Edificio{% endtrans %}:
|
||||
</label>
|
||||
{{ form.new_building_name(class="form-control form-control-sm", id="new_building_name", placeholder="ej. Pabellón III") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Piso -->
|
||||
<div class="col-md-3">
|
||||
<label for="floor" class="form-label fw-semibold">
|
||||
<i class="bi bi-layers text-secondary me-1"></i>{% trans %}Piso / Nivel{% endtrans %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.floor(class="form-control", id="floor", list="floorSuggestions", placeholder="ej. PB, 1, 2, 4°P") }}
|
||||
<datalist id="floorSuggestions">
|
||||
<!-- sugerencias dinámicas -->
|
||||
</datalist>
|
||||
<div class="form-text small text-muted">{% trans %}Sugerido o nuevo{% endtrans %}</div>
|
||||
{% if form.floor.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.floor.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Aula -->
|
||||
<div class="col-md-3">
|
||||
<label for="room_number" class="form-label fw-semibold">
|
||||
<i class="bi bi-door-open text-info me-1"></i>{% trans %}Aula / Sala{% endtrans %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.room_number(class="form-control", id="room_number", placeholder="ej. Aula 304, Lab 1") }}
|
||||
{% if form.room_number.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.room_number.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label for="capacityInput" class="form-label fw-semibold mb-0">{% trans %}Capacidad de Alumnos{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinity" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}">
|
||||
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
{{ form.capacity(class="form-control" + (" is-invalid" if form.capacity.errors else ""), id="capacityInput", placeholder="ej. 35 o ∞") }}
|
||||
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
|
||||
</div>
|
||||
<div class="form-text small text-muted mt-1">
|
||||
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o eventos sin aforo restringido, ingrese <strong>∞</strong> o <strong>0</strong>.{% endtrans %}
|
||||
</div>
|
||||
{% if form.capacity.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.capacity.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 d-flex align-items-center pt-md-3">
|
||||
<div class="form-check form-switch pt-2">
|
||||
{{ form.is_active(class="form-check-input", id="is_active") }}
|
||||
<label class="form-check-label fw-semibold" for="is_active">
|
||||
{% trans %}Aula Habilitada para Reservas{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.description.label(class="form-label fw-semibold") }}
|
||||
{{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), rows="3", placeholder=_("Opcional: Detalles adicionales del equipamiento...")) }}
|
||||
{% if form.description.errors %}
|
||||
<div class="invalid-feedback">{{ form.description.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Preview Card -->
|
||||
<div class="card bg-body-tertiary border-0 shadow-xs mb-4">
|
||||
<div class="card-body py-3 px-4">
|
||||
<h6 class="card-subtitle mb-2 text-muted small fw-bold">
|
||||
<i class="bi bi-eye me-1"></i>{% trans %}Vista Previa de Ubicación{% endtrans %}
|
||||
</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted d-block">🏢 {% trans %}Edificio{% endtrans %}</small>
|
||||
<strong id="preview-building" class="text-primary">{{ classroom.building_name }}</strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted d-block">🪜 {% trans %}Piso{% endtrans %}</small>
|
||||
<strong id="preview-floor" class="text-secondary">{{ classroom.floor_display }}</strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted d-block">🚪 {% trans %}Aula / Código{% endtrans %}</small>
|
||||
<strong id="preview-room" class="text-success">{{ classroom.code }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-secondary">
|
||||
{% trans %}Cancelar{% endtrans %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Guardar Cambios{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var buildingFloors = {{ building_floors|tojson }};
|
||||
|
||||
var buildingSelect = document.getElementById('building_id');
|
||||
var newBuildingContainer = document.getElementById('newBuildingContainer');
|
||||
var newBuildingNameInput = document.getElementById('new_building_name');
|
||||
var floorInput = document.getElementById('floor');
|
||||
var floorDatalist = document.getElementById('floorSuggestions');
|
||||
var roomInput = document.getElementById('room_number');
|
||||
|
||||
var previewBuilding = document.getElementById('preview-building');
|
||||
var previewFloor = document.getElementById('preview-floor');
|
||||
var previewRoom = document.getElementById('preview-room');
|
||||
|
||||
function updateFloorsDatalist(bId) {
|
||||
if (!floorDatalist) return;
|
||||
floorDatalist.innerHTML = '';
|
||||
var floors = buildingFloors[bId] || [];
|
||||
floors.forEach(function(f) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = f;
|
||||
opt.textContent = (f === '0' || f === 'PB') ? 'Planta Baja' : 'Piso ' + f;
|
||||
floorDatalist.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
var bText = '-';
|
||||
if (buildingSelect) {
|
||||
if (buildingSelect.value === '__new__') {
|
||||
bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}';
|
||||
} else if (buildingSelect.selectedIndex >= 0) {
|
||||
bText = buildingSelect.options[buildingSelect.selectedIndex].text;
|
||||
}
|
||||
}
|
||||
var fText = floorInput ? (floorInput.value.trim() || '-') : '-';
|
||||
var rText = roomInput ? (roomInput.value.trim() || '-') : '-';
|
||||
|
||||
if (previewBuilding) previewBuilding.textContent = bText;
|
||||
if (previewFloor) previewFloor.textContent = (fText === '0' || fText.toUpperCase() === 'PB') ? 'Planta Baja' : fText;
|
||||
if (previewRoom) previewRoom.textContent = rText;
|
||||
}
|
||||
|
||||
if (buildingSelect) {
|
||||
buildingSelect.addEventListener('change', function() {
|
||||
if (this.value === '__new__') {
|
||||
newBuildingContainer.style.display = 'block';
|
||||
if (newBuildingNameInput) newBuildingNameInput.focus();
|
||||
} else {
|
||||
newBuildingContainer.style.display = 'none';
|
||||
updateFloorsDatalist(this.value);
|
||||
}
|
||||
updatePreview();
|
||||
});
|
||||
if (buildingSelect.value === '__new__') {
|
||||
newBuildingContainer.style.display = 'block';
|
||||
} else if (buildingSelect.value) {
|
||||
updateFloorsDatalist(buildingSelect.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (newBuildingNameInput) newBuildingNameInput.addEventListener('input', updatePreview);
|
||||
if (floorInput) floorInput.addEventListener('input', updatePreview);
|
||||
if (roomInput) roomInput.addEventListener('input', updatePreview);
|
||||
|
||||
updatePreview();
|
||||
|
||||
var btnInf = document.getElementById('btnInfinity');
|
||||
var capInput = document.getElementById('capacityInput');
|
||||
if (btnInf && capInput) {
|
||||
btnInf.addEventListener('click', function() {
|
||||
capInput.value = '∞';
|
||||
capInput.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,50 +1,250 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Classrooms{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
{% block title %}{% trans %}Aulas y Espacios{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.custom-space-pills .nav-link {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1.1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.custom-space-pills .nav-link.active {
|
||||
background-color: var(--bs-primary);
|
||||
color: #fff !important;
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 4px 12px rgba(180, 62, 142, 0.25);
|
||||
}
|
||||
.custom-space-pills .nav-link:hover:not(.active) {
|
||||
background-color: var(--bs-secondary-bg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.classroom-card, .virtual-room-card {
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
.classroom-card:hover, .virtual-room-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.virtual-banner {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(180, 62, 142, 0.08) 100%);
|
||||
border: 1px solid rgba(99, 102, 241, 0.25) !important;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
[data-bs-theme="dark"] .virtual-banner {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.16) 0%, rgba(180, 62, 142, 0.16) 100%);
|
||||
border: 1px solid rgba(99, 102, 241, 0.35) !important;
|
||||
}
|
||||
|
||||
.virtual-stat-box {
|
||||
background-color: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
text-decoration: none !important;
|
||||
display: block;
|
||||
}
|
||||
.virtual-stat-box:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--bs-primary) !important;
|
||||
}
|
||||
|
||||
.metric-pill {
|
||||
padding: 0.35rem 0.2rem;
|
||||
border-radius: 0.45rem;
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.metric-pill:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.metric-val {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Semantics light */
|
||||
.metric-high {
|
||||
background-color: #FEE2E2;
|
||||
border-color: #FCA5A5 !important;
|
||||
color: #991B1B !important;
|
||||
}
|
||||
.metric-medium {
|
||||
background-color: #FEF3C7;
|
||||
border-color: #FCD34D !important;
|
||||
color: #92400E !important;
|
||||
}
|
||||
.metric-low {
|
||||
background-color: #D1FAE5;
|
||||
border-color: #6EE7B7 !important;
|
||||
color: #065F46 !important;
|
||||
}
|
||||
.metric-zero {
|
||||
background-color: rgba(148, 163, 184, 0.1);
|
||||
border-color: rgba(148, 163, 184, 0.25) !important;
|
||||
color: #64748B !important;
|
||||
}
|
||||
|
||||
/* Semantics dark */
|
||||
[data-bs-theme="dark"] .metric-high {
|
||||
background-color: rgba(239, 68, 68, 0.18);
|
||||
border-color: rgba(239, 68, 68, 0.4) !important;
|
||||
color: #FCA5A5 !important;
|
||||
}
|
||||
[data-bs-theme="dark"] .metric-medium {
|
||||
background-color: rgba(245, 158, 11, 0.18);
|
||||
border-color: rgba(245, 158, 11, 0.4) !important;
|
||||
color: #FCD34D !important;
|
||||
}
|
||||
[data-bs-theme="dark"] .metric-low {
|
||||
background-color: rgba(16, 185, 129, 0.18);
|
||||
border-color: rgba(16, 185, 129, 0.4) !important;
|
||||
color: #6EE7B7 !important;
|
||||
}
|
||||
[data-bs-theme="dark"] .metric-zero {
|
||||
background-color: rgba(148, 163, 184, 0.08);
|
||||
border-color: rgba(148, 163, 184, 0.18) !important;
|
||||
color: #94A3B8 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="row">
|
||||
|
||||
<!-- 1. Encabezado y Selector de Entorno (Presencial vs Virtual) -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3">
|
||||
<i class="bi bi-door-open text-primary"></i> {% trans %}Classrooms{% endtrans %}
|
||||
</h1>
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
|
||||
<div>
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
|
||||
</a>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-door-open text-primary me-2"></i>{% trans %}Aulas y Espacios de Aprendizaje{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Administre las aulas físicas de sedes presenciales y las aulas virtuales a demanda del Campus Virtual.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
{% if view_mode == 'virtual' %}
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-success shadow-sm">
|
||||
<i class="bi bi-camera-video me-1"></i>{% trans %}Nueva Clase Virtual a Demanda{% endtrans %}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary shadow-sm">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Agregar Aula Física{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pestañas de Selección de Modalidad -->
|
||||
<div class="mt-3">
|
||||
<ul class="nav nav-pills custom-space-pills gap-2">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if view_mode == 'physical' %}active{% endif %}"
|
||||
href="{{ url_for('classrooms.list_classrooms', view_mode='physical', active_only='true' if active_only else 'false') }}">
|
||||
<i class="bi bi-buildings me-2"></i>{% trans %}Aulas Presenciales{% endtrans %}
|
||||
<span class="badge rounded-pill ms-2 {% if view_mode == 'physical' %}bg-light text-primary{% else %}bg-secondary-subtle text-secondary border{% endif %}">
|
||||
{{ physical_count }} {% trans %}aulas{% endtrans %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if view_mode == 'virtual' %}active{% endif %}"
|
||||
href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}">
|
||||
<i class="bi bi-globe2 me-2 text-info"></i>{% trans %}Campus Virtual & Aulas a Demanda{% endtrans %}
|
||||
<span class="badge rounded-pill ms-2 {% if view_mode == 'virtual' %}bg-light text-primary{% else %}bg-primary-subtle text-primary border{% endif %}">
|
||||
<i class="bi bi-infinity me-1"></i>{{ virtual_count }} {% trans %}salas activas{% endtrans %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="row">
|
||||
<!-- ========================================================================= -->
|
||||
<!-- VISTA 1: AULAS FÍSICAS (PRESENCIALES) -->
|
||||
<!-- ========================================================================= -->
|
||||
{% if view_mode == 'physical' %}
|
||||
|
||||
<!-- Filtros de Aulas Físicas -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="search" class="form-control"
|
||||
placeholder="{% trans %}Search by building, room, description...{% endtrans %}"
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<form method="GET" class="row g-2 align-items-center">
|
||||
<input type="hidden" name="view_mode" value="physical">
|
||||
<div class="col-md-3">
|
||||
<div class="form-check pt-2">
|
||||
<input class="form-check-input" type="checkbox" name="active_only"
|
||||
id="activeOnly" {% if active_only %}checked{% endif %}>
|
||||
<label class="form-check-label" for="activeOnly">
|
||||
{% trans %}Show active classrooms only{% endtrans %}
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control"
|
||||
placeholder="{% trans %}Buscar aula, descripción...{% endtrans %}"
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtro por Edificio Físico -->
|
||||
<div class="col-md-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-buildings text-primary"></i></span>
|
||||
<select name="building_id" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">{% trans %}Todos los edificios presenciales{% endtrans %}</option>
|
||||
{% for b in buildings %}
|
||||
<option value="{{ b.id }}" {% if building_filter == (b.id|string) %}selected{% endif %}>{{ b.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtro por Piso Real -->
|
||||
<div class="col-md-2">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-layers text-secondary"></i></span>
|
||||
<select name="floor" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
|
||||
{% for f in floors %}
|
||||
<option value="{{ f }}" {% if floor_filter == (f|string) %}selected{% endif %}>
|
||||
{% if f == '0' or f == 'PB' %}{% trans %}Planta Baja{% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<div class="form-check pt-1">
|
||||
<input class="form-check-input" type="checkbox" name="active_only" value="true"
|
||||
id="activeOnly" {% if active_only %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="form-check-label small" for="activeOnly">
|
||||
{% trans %}Solo activas{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
<i class="bi bi-search"></i> {% trans %}Search{% endtrans %}
|
||||
|
||||
<div class="col-md-2 text-md-end">
|
||||
<button type="submit" class="btn btn-sm btn-primary me-1">
|
||||
<i class="bi bi-filter"></i> {% trans %}Filtrar{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> {% trans %}Clear{% endtrans %}
|
||||
<a href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> {% trans %}Limpiar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
@@ -53,77 +253,109 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Classrooms Grid -->
|
||||
<!-- Grilla de Aulas Físicas -->
|
||||
{% if classrooms and classrooms.items %}
|
||||
<div class="row mt-4">
|
||||
<div class="row g-3 mt-1">
|
||||
{% for classroom in classrooms.items %}
|
||||
<div class="col-lg-4 col-md-6 mb-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">
|
||||
{{ classroom.code_display }}
|
||||
</h5>
|
||||
<p class="text-muted small mb-2">
|
||||
<i class="bi bi-geo-alt"></i> {{ classroom.get_full_location() }}
|
||||
</p>
|
||||
<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">
|
||||
<div class="text-truncate me-2">
|
||||
<h5 class="card-title mb-0 fw-bold text-primary text-truncate fs-6" title="{{ classroom.code }}">
|
||||
<i class="bi bi-door-open me-1"></i>{{ classroom.code }}
|
||||
</h5>
|
||||
</div>
|
||||
<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 %}" style="font-size: 0.68rem;">
|
||||
{% if classroom.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<span class="badge {% if classroom.is_active %}bg-success{% else %}bg-secondary{% endif %}">
|
||||
{% if classroom.is_active %}{% trans %}Active{% endtrans %}{% else %}{% trans %}Inactive{% endtrans %}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row small">
|
||||
<div class="col-6">
|
||||
<span class="text-muted">{% trans %}Capacity{% endtrans %}:</span>
|
||||
<strong>{{ classroom.capacity }}</strong>
|
||||
<!-- Ubicación: Edificio y Piso -->
|
||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.7rem;">
|
||||
<i class="bi bi-buildings me-1"></i>{{ classroom.building_name }}
|
||||
</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary border" style="font-size: 0.7rem;">
|
||||
<i class="bi bi-layers me-1"></i>{{ classroom.floor_display }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<span class="text-muted">{% trans %}Building{% endtrans %}:</span>
|
||||
<strong>{{ classroom.building }}</strong>
|
||||
|
||||
<!-- Capacidad y Recursos -->
|
||||
<div class="d-flex justify-content-between align-items-center small py-1 px-2 bg-body-tertiary rounded mb-2" style="font-size: 0.75rem;">
|
||||
<div>
|
||||
<span class="text-muted">{% trans %}Capacidad:{% endtrans %}</span>
|
||||
<strong class="text-body ms-1">{{ classroom.capacity }} {% trans %}asientos{% endtrans %}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted"><i class="bi bi-tools me-1"></i>{{ classroom.resources|length if classroom.resources else 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barra de Métricas de Ocupación FÍSICA (%) -->
|
||||
{% 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}) %}
|
||||
<div class="classroom-occupancy-bar p-2 rounded bg-body-tertiary border mb-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="small fw-semibold text-muted" style="font-size: 0.72rem;">
|
||||
<i class="bi bi-graph-up me-1 text-primary"></i>{% trans %}Ocupación{% endtrans %}
|
||||
</span>
|
||||
<span class="text-muted" style="font-size: 0.65rem;" title="{% trans %}Haz clic en cualquier métrica para ver las reservas de ese período{% endtrans %}">
|
||||
<i class="bi bi-info-circle me-1"></i>{% trans %}Ver reservas{% endtrans %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row g-1 text-center">
|
||||
<!-- Día -->
|
||||
<div class="col-4">
|
||||
<a href="{{ url_for('schedule.list_reservations', classroom_id=classroom.id, date_from=date_filters.today, date_to=date_filters.today) }}"
|
||||
class="d-block p-1 text-decoration-none border metric-pill metric-{% if metrics.day_pct > 80 %}high{% elif metrics.day_pct > 40 %}medium{% elif metrics.day_pct > 0 %}low{% else %}zero{% endif %}"
|
||||
title="{% trans %}Ocupación Hoy{% endtrans %}: {{ metrics.day_count }} {% trans %}clases registradas{% endtrans %}">
|
||||
<div class="metric-label">{% trans %}Hoy{% endtrans %}</div>
|
||||
<div class="metric-val">{{ metrics.day_pct }}%</div>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Semana -->
|
||||
<div class="col-4">
|
||||
<a href="{{ url_for('schedule.list_reservations', classroom_id=classroom.id, date_from=date_filters.week_start, date_to=date_filters.week_end) }}"
|
||||
class="d-block p-1 text-decoration-none border metric-pill metric-{% if metrics.week_pct > 80 %}high{% elif metrics.week_pct > 40 %}medium{% elif metrics.week_pct > 0 %}low{% else %}zero{% endif %}"
|
||||
title="{% trans %}Ocupación Semana{% endtrans %}: {{ metrics.week_count }} {% trans %}clases registradas{% endtrans %}">
|
||||
<div class="metric-label">{% trans %}Semana{% endtrans %}</div>
|
||||
<div class="metric-val">{{ metrics.week_pct }}%</div>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Mes -->
|
||||
<div class="col-4">
|
||||
<a href="{{ url_for('schedule.list_reservations', classroom_id=classroom.id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}"
|
||||
class="d-block p-1 text-decoration-none border metric-pill metric-{% if metrics.month_pct > 80 %}high{% elif metrics.month_pct > 40 %}medium{% elif metrics.month_pct > 0 %}low{% else %}zero{% endif %}"
|
||||
title="{% trans %}Ocupación Mes{% endtrans %}: {{ metrics.month_count }} {% trans %}clases registradas{% endtrans %}">
|
||||
<div class="metric-label">{% trans %}Mes{% endtrans %}</div>
|
||||
<div class="metric-val">{{ metrics.month_pct }}%</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if classroom.description %}
|
||||
<p class="card-text small text-muted mt-2">
|
||||
{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Resources indicator -->
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-tools"></i>
|
||||
{% if classroom.resources %}
|
||||
{{ classroom.resources|length }} {% trans %}resources{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}No resources registered{% endtrans %}
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-body-tertiary border-0">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<!-- Footer con Botones Compactos -->
|
||||
<div class="pt-2 border-top d-flex justify-content-between align-items-center">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> {% trans %}View{% endtrans %}
|
||||
class="btn btn-outline-primary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}" style="font-size: 0.75rem;">
|
||||
<i class="bi bi-eye me-1"></i>{% trans %}Ficha{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i> {% trans %}Edit{% endtrans %}
|
||||
class="btn btn-outline-secondary py-0 px-2" title="{% trans %}Editar Aula{% endtrans %}" style="font-size: 0.75rem;">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}"
|
||||
class="d-inline">
|
||||
<button type="submit" class="btn btn-sm
|
||||
{% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
|
||||
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}" class="d-inline mb-0">
|
||||
<button type="submit" class="btn btn-sm py-0 px-2 {% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
|
||||
title="{% if classroom.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}" style="font-size: 0.75rem;">
|
||||
{% if classroom.is_active %}
|
||||
<i class="bi bi-pause-circle"></i> {% trans %}Deactivate{% endtrans %}
|
||||
<i class="bi bi-pause"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-play-circle"></i> {% trans %}Activate{% endtrans %}
|
||||
<i class="bi bi-play"></i>
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
@@ -134,14 +366,14 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<!-- Paginación Aulas Físicas -->
|
||||
{% if classrooms.pages > 1 %}
|
||||
<nav aria-label="Classroom pagination">
|
||||
<ul class="pagination justify-content-center">
|
||||
<nav aria-label="Classroom pagination" class="mt-4">
|
||||
<ul class="pagination pagination-sm justify-content-center">
|
||||
{% if classrooms.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}">
|
||||
{% trans %}Previous{% endtrans %}
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=classrooms.prev_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
|
||||
{% trans %}Anterior{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -150,7 +382,7 @@
|
||||
{% if page_num %}
|
||||
{% if page_num != classrooms.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=page_num, search=search, active_only=active_only) }}">
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=page_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
</li>
|
||||
@@ -168,8 +400,8 @@
|
||||
|
||||
{% if classrooms.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}">
|
||||
{% trans %}Next{% endtrans %}
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=classrooms.next_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
|
||||
{% trans %}Siguiente{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -182,20 +414,437 @@
|
||||
<div class="col-12">
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="mt-3 text-muted">{% trans %}No classrooms found{% endtrans %}</h4>
|
||||
<h4 class="mt-3 text-muted">{% trans %}No se encontraron aulas físicas{% endtrans %}</h4>
|
||||
<p class="text-muted">
|
||||
{% if search %}
|
||||
{% trans %}No classrooms match your search criteria.{% endtrans %}
|
||||
{% trans %}Ningún aula coincide con los criterios de búsqueda.{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}No classrooms have been registered yet.{% endtrans %}
|
||||
{% trans %}No hay aulas físicas registradas en esta sede.{% endtrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}Add First Classroom{% endtrans %}
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}Agregar Primera Aula{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ========================================================================= -->
|
||||
<!-- VISTA 2: CAMPUS VIRTUAL (AULAS A DEMANDA · INFINITAS) -->
|
||||
<!-- ========================================================================= -->
|
||||
{% else %}
|
||||
|
||||
<!-- Banner de Infraestructura Cloud y Concurrencia Ilimitada -->
|
||||
<div class="card virtual-banner p-3 mb-4 shadow-sm">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-primary text-white px-2 py-1"><i class="bi bi-cloud-check-fill me-1"></i>Cloud Infrastructure</span>
|
||||
<h5 class="mb-0 fw-bold text-primary">
|
||||
{% trans %}Campus Virtual UniCABA · Aulas a Demanda{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<p class="small text-muted mb-0 mt-1">
|
||||
{% trans %}A diferencia de los espacios físicos, las aulas virtuales se crean y escalan de manera <strong>infinita y concurrente</strong> según la necesidad pedagógica de cada comisión.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-2 fs-6">
|
||||
<i class="bi bi-infinity me-1"></i>{% trans %}Capacidad Ilimitada{% endtrans %}
|
||||
</span>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar Clase Virtual{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Indicadores de Demanda Global -->
|
||||
<div class="row g-2">
|
||||
<div class="col-lg-3 col-sm-6">
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="virtual-stat-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones Hoy{% endtrans %}</span>
|
||||
<h4 class="mb-0 fw-bold text-primary">{{ global_virtual_metrics.today_count }}</h4>
|
||||
</div>
|
||||
<div class="text-primary fs-4"><i class="bi bi-calendar-event"></i></div>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Clases virtuales programadas hoy{% endtrans %}</small>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-sm-6">
|
||||
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, date_from=date_filters.week_start, date_to=date_filters.week_end) }}" class="virtual-stat-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones Semana{% endtrans %}</span>
|
||||
<h4 class="mb-0 fw-bold text-info">{{ global_virtual_metrics.week_count }}</h4>
|
||||
</div>
|
||||
<div class="text-info fs-4"><i class="bi bi-calendar-week"></i></div>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Clases virtuales esta semana{% endtrans %}</small>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-sm-6">
|
||||
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}" class="virtual-stat-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones del Mes{% endtrans %}</span>
|
||||
<h4 class="mb-0 fw-bold text-success">{{ global_virtual_metrics.month_count }}</h4>
|
||||
</div>
|
||||
<div class="text-success fs-4"><i class="bi bi-calendar3"></i></div>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Total clases remotas en el mes{% endtrans %}</small>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-sm-6">
|
||||
<div class="virtual-stat-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Salas Activas{% endtrans %}</span>
|
||||
<h4 class="mb-0 fw-bold text-warning">{{ global_virtual_metrics.commissions_count }}</h4>
|
||||
</div>
|
||||
<div class="text-warning fs-4"><i class="bi bi-diagram-3"></i></div>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Comisiones con entorno virtual{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barra de Búsqueda de Salas Virtuales -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body py-2">
|
||||
<form method="GET" class="row g-2 align-items-center">
|
||||
<input type="hidden" name="view_mode" value="virtual">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control"
|
||||
placeholder="{% trans %}Buscar sala virtual por asignatura, comisión o carrera...{% endtrans %}"
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<button type="submit" class="btn btn-sm btn-primary me-1">
|
||||
<i class="bi bi-funnel me-1"></i>{% trans %}Buscar Salas{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Catálogo de Aulas Virtuales por Comisión -->
|
||||
{% if virtual_rooms %}
|
||||
<div class="row g-3">
|
||||
{% for room in virtual_rooms %}
|
||||
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
|
||||
<div class="card virtual-room-card border shadow-sm h-100">
|
||||
<div class="card-body p-3 d-flex flex-column justify-content-between">
|
||||
<div>
|
||||
<!-- Encabezado de la Sala Virtual -->
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div class="text-truncate me-2">
|
||||
<h5 class="card-title mb-0 fw-bold text-primary text-truncate fs-6" title="{{ room.subject_name }}">
|
||||
<i class="bi bi-laptop me-1 text-info"></i>{{ room.subject_name }}
|
||||
</h5>
|
||||
</div>
|
||||
<span class="badge rounded-pill bg-info-subtle text-info border border-info-subtle" style="font-size: 0.68rem;">
|
||||
<i class="bi bi-infinity me-1"></i>A Demanda
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Badges: Comisión, Carrera y Turno -->
|
||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.7rem;">
|
||||
<i class="bi bi-diagram-3 me-1"></i>Com. {{ room.commission_code }}
|
||||
</span>
|
||||
{% if room.subject_code %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border" style="font-size: 0.7rem;">
|
||||
{{ room.subject_code }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge bg-light text-dark border" style="font-size: 0.7rem;">
|
||||
{{ room.shift }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Cupo e Información Pedagógica -->
|
||||
<div class="d-flex justify-content-between align-items-center small py-1 px-2 bg-body-tertiary rounded mb-2" style="font-size: 0.75rem;">
|
||||
<div>
|
||||
<span class="text-muted">{% trans %}Alumnos:{% endtrans %}</span>
|
||||
<strong class="text-body ms-1">{{ room.max_students }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted">{% trans %}Modalidad:{% endtrans %}</span>
|
||||
<strong class="text-info ms-1">Remoto ∞</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botón de Videollamada (Zoom / Meet / Teams) -->
|
||||
<div class="mb-2">
|
||||
{% if room.virtual_link %}
|
||||
<a href="{{ room.virtual_link }}" target="_blank"
|
||||
class="btn btn-sm btn-outline-success w-100 py-1 d-flex align-items-center justify-content-center"
|
||||
style="font-size: 0.75rem;" title="{% trans %}Abrir enlace de videollamada{% endtrans %}">
|
||||
<i class="bi bi-camera-video-fill me-1"></i>{% trans %}Unirse a Videollamada{% endtrans %}
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="p-1 rounded bg-body-tertiary border text-center small text-muted" style="font-size: 0.72rem;">
|
||||
<i class="bi bi-link-45deg me-1"></i>{% trans %}Enlace a demanda por clase{% endtrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Barra de Demanda de la Sala Virtual (Hoy, Semana, Mes) -->
|
||||
<div class="p-2 rounded bg-body-tertiary border mb-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="small fw-semibold text-muted" style="font-size: 0.72rem;">
|
||||
<i class="bi bi-calendar3 me-1 text-primary"></i>{% trans %}Clases a Demanda{% endtrans %}
|
||||
</span>
|
||||
<span class="text-muted" style="font-size: 0.65rem;">
|
||||
{{ room.total_count }} {% trans %}totales{% endtrans %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row g-1 text-center">
|
||||
<!-- Hoy -->
|
||||
<div class="col-4">
|
||||
<div class="p-1 border metric-pill {% if room.day_count > 0 %}metric-low{% else %}metric-zero{% endif %}"
|
||||
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
|
||||
role="button" title="{% trans %}Ver vista previa de clases de hoy{% endtrans %}">
|
||||
<div class="metric-label">{% trans %}Hoy{% endtrans %}</div>
|
||||
<div class="metric-val">{{ room.day_count }} cl.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Semana -->
|
||||
<div class="col-4">
|
||||
<div class="p-1 border metric-pill {% if room.week_count > 0 %}metric-medium{% else %}metric-zero{% endif %}"
|
||||
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
|
||||
role="button" title="{% trans %}Ver vista previa de clases de la semana{% endtrans %}">
|
||||
<div class="metric-label">{% trans %}Semana{% endtrans %}</div>
|
||||
<div class="metric-val">{{ room.week_count }} cl.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mes -->
|
||||
<div class="col-4">
|
||||
<div class="p-1 border metric-pill {% if room.month_count > 0 %}metric-high{% else %}metric-zero{% endif %}"
|
||||
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
|
||||
role="button" title="{% trans %}Ver vista previa de clases del mes{% endtrans %}">
|
||||
<div class="metric-label">{% trans %}Mes{% endtrans %}</div>
|
||||
<div class="metric-val">{{ room.month_count }} cl.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer con Botones -->
|
||||
<div class="pt-2 border-top d-flex justify-content-between align-items-center gap-1">
|
||||
<button type="button" class="btn btn-sm btn-primary py-1 px-2 d-flex align-items-center"
|
||||
data-bs-toggle="modal" data-bs-target="#modalPreviewVirtual{{ room.commission_id }}"
|
||||
style="font-size: 0.75rem;" title="{% trans %}Vista previa y modificar sesiones{% endtrans %}">
|
||||
<i class="bi bi-eye me-1"></i>{% trans %}Vista Previa{% endtrans %}
|
||||
<span class="badge bg-white text-primary ms-1" style="font-size: 0.65rem;">{{ room.total_count }}</span>
|
||||
</button>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?commission_id={{ room.commission_id }}&is_virtual=1"
|
||||
class="btn btn-sm btn-outline-success py-1 px-2" style="font-size: 0.75rem;" title="{% trans %}Programar clase en esta sala virtual{% endtrans %}">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Vista Previa y Modificación de la Sala Virtual -->
|
||||
<div class="modal fade text-start" id="modalPreviewVirtual{{ room.commission_id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title h6 fw-bold mb-1">
|
||||
<i class="bi bi-laptop text-primary me-2"></i>{{ room.subject_name }}
|
||||
</h5>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.7rem;">
|
||||
<i class="bi bi-diagram-3 me-1"></i>Comisión {{ room.commission_code }}
|
||||
</span>
|
||||
{% if room.subject_code %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border font-monospace" style="font-size: 0.7rem;">
|
||||
{{ room.subject_code }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge bg-info-subtle text-info border" style="font-size: 0.7rem;">
|
||||
<i class="bi bi-infinity me-1"></i>{% trans %}A Demanda · Capacidad Ilimitada{% endtrans %}
|
||||
</span>
|
||||
<span class="badge bg-light text-dark border" style="font-size: 0.7rem;">
|
||||
{{ room.shift }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- 1. Enlace de Videollamada de la Comisión (Edición directa) -->
|
||||
<div class="card bg-body-tertiary border mb-3">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="fw-semibold small text-body">
|
||||
<i class="bi bi-camera-video text-primary me-1"></i>{% trans %}Enlace de Videollamada (Zoom / Meet / Teams / Moodle){% endtrans %}
|
||||
</span>
|
||||
{% if room.virtual_link %}
|
||||
<a href="{{ room.virtual_link }}" target="_blank" class="btn btn-sm btn-success py-0 px-2" style="font-size: 0.75rem;">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Abrir Reunión{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('classrooms.update_virtual_link', commission_id=room.commission_id) }}">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body border"><i class="bi bi-link-45deg"></i></span>
|
||||
<input type="url" name="virtual_link" class="form-control"
|
||||
placeholder="{% trans %}Ingresar URL de Zoom, Google Meet, Teams, etc.{% endtrans %}"
|
||||
value="{{ room.virtual_link or '' }}">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-1"></i>{% trans %}Guardar Enlace{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1" style="font-size: 0.7rem;">
|
||||
{% trans %}Este enlace se aplicará a todas las clases y cursadas virtuales de esta comisión.{% endtrans %}
|
||||
</small>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Tabla de Clases / Sesiones Programadas -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="fw-bold mb-0 text-body small">
|
||||
<i class="bi bi-calendar3 me-1 text-primary"></i>{% trans %}Sesiones Programadas en el Campus Virtual{% endtrans %} ({{ room.reservations|length }})
|
||||
</h6>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?commission_id={{ room.commission_id }}&is_virtual=1" class="btn btn-sm btn-outline-success py-0 px-2" style="font-size: 0.72rem;">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Clase{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if room.reservations %}
|
||||
<div class="table-responsive border rounded mb-2">
|
||||
<table class="table table-sm table-hover align-middle mb-0 small" style="font-size: 0.78rem;">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#ID</th>
|
||||
<th>{% trans %}Fecha y Horario{% endtrans %}</th>
|
||||
<th>{% trans %}Turno / Propósito{% endtrans %}</th>
|
||||
<th>{% trans %}Docente{% endtrans %}</th>
|
||||
<th>{% trans %}Videollamada{% endtrans %}</th>
|
||||
<th>{% trans %}Estado{% endtrans %}</th>
|
||||
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in room.reservations %}
|
||||
<tr>
|
||||
<td class="text-muted">#{{ r.id }}</td>
|
||||
<td>
|
||||
<span class="fw-bold text-body">{{ r.start_time.strftime('%d/%m/%Y') }}</span>
|
||||
<span class="text-muted d-block font-monospace" style="font-size: 0.72rem;">
|
||||
{{ r.start_time.strftime('%H:%M') }} - {{ r.end_time.strftime('%H:%M') }} hs
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border">{{ r.shift or 'Tarde' }}</span>
|
||||
<small class="text-muted d-block text-truncate" style="max-width: 130px;">{{ r.purpose or 'Clase regular' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-body">{{ r.user.name if r.user else 'Docente Asignado' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if r.effective_virtual_link %}
|
||||
<a href="{{ r.effective_virtual_link }}" target="_blank" class="badge bg-success-subtle text-success border border-success-subtle text-decoration-none" title="{{ r.effective_virtual_link }}">
|
||||
<i class="bi bi-camera-video me-1"></i>{% trans %}Link Activo{% endtrans %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted small">{% trans %}Hereda comisión{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle" style="font-size: 0.68rem;">
|
||||
{{ r.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('schedule.edit_reservation', id=r.id) }}"
|
||||
class="btn btn-sm btn-primary py-0 px-2" style="font-size: 0.72rem;"
|
||||
title="{% trans %}Modificar Fecha, Horario, Aula o Detalles{% endtrans %}">
|
||||
<i class="bi bi-pencil me-1"></i>{% trans %}Modificar{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.view_reservation', id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary py-0 px-2" style="font-size: 0.72rem;"
|
||||
title="{% trans %}Ver Ficha Completa{% endtrans %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info py-2 small mb-2">
|
||||
<i class="bi bi-info-circle me-1"></i>{% trans %}No hay clases programadas para esta sala virtual en el período actual.{% endtrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer d-flex justify-content-between">
|
||||
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, search=room.subject_code or room.subject_name) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-list-check me-1"></i>{% trans %}Ver en Listado de Reservas (Filtrado){% endtrans %}
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cerrar{% endtrans %}</button>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?commission_id={{ room.commission_id }}&is_virtual=1" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar Nueva Clase{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-globe text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="mt-3 text-muted">{% trans %}No se encontraron salas virtuales{% endtrans %}</h4>
|
||||
<p class="text-muted">
|
||||
{% if search %}
|
||||
{% trans %}Ninguna sala virtual coincide con los términos de búsqueda.{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}No hay clases virtuales programadas en el Campus Virtual.{% endtrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-primary">
|
||||
<i class="bi bi-camera-video me-1"></i>{% trans %}Crear Primera Reserva Virtual{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,173 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ classroom.code_display }} - {% trans %}Classroom Details{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Breadcrumb & Header -->
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col-md-7">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1 small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('classrooms.list_classrooms') }}">{% trans %}Classrooms{% endtrans %}</a></li>
|
||||
<li class="breadcrumb-item active">{{ classroom.code_display }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 mb-0 d-flex align-items-center flex-wrap gap-2">
|
||||
<i class="bi bi-door-open text-primary"></i> {{ classroom.code_display }}
|
||||
{% if classroom.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle fs-6">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fs-6">
|
||||
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactive{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}" class="btn btn-primary me-2">
|
||||
<i class="bi bi-pencil me-1"></i>{% trans %}Edit Classroom{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Classroom Details Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-info-circle text-primary me-2"></i>{% trans %}Ficha Técnica{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||
<span class="text-muted"><i class="bi bi-building me-2"></i>{% trans %}Edificio / Sede{% endtrans %}</span>
|
||||
<span class="fw-bold">
|
||||
{% if classroom.building_entity %}
|
||||
<a href="{{ url_for('buildings.view_building', id=classroom.building_entity.id) }}" class="text-decoration-none">
|
||||
{{ classroom.building_entity.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ classroom.building }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||
<span class="text-muted"><i class="bi bi-door-closed me-2"></i>{% trans %}Número / Nombre{% endtrans %}</span>
|
||||
<span class="fw-bold font-monospace">{{ classroom.code }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||
<span class="text-muted"><i class="bi bi-layers me-2"></i>{% trans %}Piso / Planta{% endtrans %}</span>
|
||||
<span class="fw-bold">{% if classroom.floor == 0 %}Planta Baja{% else %}Piso {{ classroom.floor }}{% endif %}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||
<span class="text-muted"><i class="bi bi-people me-2"></i>{% trans %}Capacidad Máxima{% endtrans %}</span>
|
||||
{% if classroom.is_unlimited %}
|
||||
<span class="badge bg-primary-subtle text-primary border px-2 py-1 fs-6">
|
||||
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="fw-bold text-success fs-6">{{ classroom.capacity }} {% trans %}estudiantes{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{% if classroom.description %}
|
||||
<div class="mt-3 pt-3 border-top">
|
||||
<small class="text-muted fw-bold d-block mb-1">{% trans %}Descripción / Notas{% endtrans %}:</small>
|
||||
<p class="small text-secondary mb-0">{{ classroom.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<!-- Upcoming Schedule / Reservations -->
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<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-calendar-check text-primary me-2"></i>{% trans %}Próximas Clases y Reservas{% endtrans %}
|
||||
</h5>
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver en Calendario{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if upcoming_reservations %}
|
||||
<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 %}Materia / Motivo{% endtrans %}</th>
|
||||
<th>{% trans %}Comisión{% endtrans %}</th>
|
||||
<th>{% trans %}Asistentes{% endtrans %}</th>
|
||||
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for res in upcoming_reservations %}
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<div class="fw-bold font-monospace text-primary fs-6 mb-1">
|
||||
<i class="bi bi-calendar-event text-primary me-1"></i>{{ res.start_time.strftime('%d/%m/%Y') }}
|
||||
</div>
|
||||
<div class="small font-monospace text-body-secondary">
|
||||
<i class="bi bi-clock text-warning me-1"></i>{{ res.start_time.strftime('%H:%M') }} - {{ res.end_time.strftime('%H:%M') }} hs
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold text-primary">{{ res.subject_name }}</div>
|
||||
<small class="badge bg-secondary-subtle text-secondary font-monospace">{{ res.subject_code }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info-subtle text-info border">
|
||||
{{ res.commission.code if res.commission else 'S/C' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="small">{{ res.expected_attendees }} / {{ classroom.capacity }}</span>
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
{% if res.effective_virtual_link %}
|
||||
<a href="{{ res.effective_virtual_link }}" target="_blank" class="btn btn-outline-success btn-sm py-0 px-2 me-1" title="{% trans %}Unirse a Clase Virtual (Zoom / Meet){% endtrans %}">
|
||||
<i class="bi bi-camera-video"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.view_reservation', id=res.id) }}" class="btn btn-outline-secondary btn-sm py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-calendar-x text-muted fs-1 mb-2"></i>
|
||||
<h6 class="fw-bold">{% trans %}Sin reservas programadas próximamente{% endtrans %}</h6>
|
||||
<p class="text-muted small mb-3">{% trans %}Esta aula no tiene clases asignadas en las próximas fechas.{% endtrans %}</p>
|
||||
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Asignar Nueva Reserva{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -8,77 +8,105 @@
|
||||
{% endif %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-speedometer2 text-primary"></i> {% trans %}Dashboard{% endtrans %}
|
||||
<small class="text-muted ms-2">{% trans %}Overview{% endtrans %}</small>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-speedometer2 text-primary me-2"></i>{% trans %}Panel de Control{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Resumen general de espacios, materias y reservas académicas{% endtrans %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">{% trans %}Total Classrooms{% endtrans %}</h6>
|
||||
<h2 class="mb-0">{{ stats.total_classrooms }}</h2>
|
||||
<!-- Statistics Cards with Direct Shortcuts -->
|
||||
<div class="row mb-4 g-3">
|
||||
<!-- 1. Total Aulas -->
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="text-decoration-none text-reset d-block h-100">
|
||||
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Aulas{% endtrans %}</span>
|
||||
<h2 class="mb-0 fw-bold mt-1 text-primary">{{ stats.total_classrooms }}</h2>
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3 text-primary">
|
||||
<i class="bi bi-door-open fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-door-open text-primary fs-4"></i>
|
||||
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-primary small fw-semibold">
|
||||
<span>{% trans %}Ver todas las aulas{% endtrans %}</span>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">{% trans %}Total Subjects{% endtrans %}</h6>
|
||||
<h2 class="mb-0">{{ stats.total_subjects }}</h2>
|
||||
<!-- 2. Total Materias -->
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<a href="{{ url_for('schedule.commissions_view') }}" class="text-decoration-none text-reset d-block h-100">
|
||||
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Materias{% endtrans %}</span>
|
||||
<h2 class="mb-0 fw-bold mt-1 text-success">{{ stats.total_subjects }}</h2>
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 rounded-circle p-3 text-success">
|
||||
<i class="bi bi-book fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-book text-success fs-4"></i>
|
||||
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-success small fw-semibold">
|
||||
<span>{% trans %}Gestionar materias{% endtrans %}</span>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">{% trans %}Today's Reservations{% endtrans %}</h6>
|
||||
<h2 class="mb-0">{{ stats.today_reservations }}</h2>
|
||||
<!-- 3. Reservas de Hoy -->
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="text-decoration-none text-reset d-block h-100">
|
||||
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Reservas de Hoy{% endtrans %}</span>
|
||||
<h2 class="mb-0 fw-bold mt-1 text-info">{{ stats.today_reservations }}</h2>
|
||||
</div>
|
||||
<div class="bg-info bg-opacity-10 rounded-circle p-3 text-info">
|
||||
<i class="bi bi-calendar-check fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-info bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-calendar-today text-info fs-4"></i>
|
||||
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-info small fw-semibold">
|
||||
<span>{% trans %}Ver cartelera de hoy{% endtrans %}</span>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">{% trans %}This Week{% endtrans %}</h6>
|
||||
<h2 class="mb-0">{{ stats.week_reservations }}</h2>
|
||||
<!-- 4. Esta Semana -->
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="text-decoration-none text-reset d-block h-100">
|
||||
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Esta Semana{% endtrans %}</span>
|
||||
<h2 class="mb-0 fw-bold mt-1 text-warning">{{ stats.week_reservations }}</h2>
|
||||
</div>
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle p-3 text-warning">
|
||||
<i class="bi bi-calendar-week fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-calendar-week text-warning fs-4"></i>
|
||||
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-warning small fw-semibold">
|
||||
<span>{% trans %}Abrir calendario semanal{% endtrans %}</span>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,9 +114,9 @@
|
||||
<!-- Monthly Chart -->
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-graph-up text-primary"></i> {% trans %}Monthly Reservations{% endtrans %}
|
||||
<div class="card-header border-0 py-3 bg-body-tertiary">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-graph-up text-primary me-2"></i>{% trans %}Reservas Mensuales{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -100,31 +128,40 @@
|
||||
<!-- Quick Actions -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-lightning text-warning"></i> {% trans %}Quick Actions{% endtrans %}
|
||||
<div class="card-header border-0 py-3 bg-body-tertiary">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-lightning text-warning me-2"></i>{% trans %}Accesos Rápidos{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary text-start">
|
||||
<i class="bi bi-calendar3 me-2"></i>{% trans %}Calendario de Reservas{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-warning">
|
||||
<i class="bi bi-people"></i> {% trans %}Manage Commissions{% endtrans %}
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info text-start">
|
||||
<i class="bi bi-calendar-day me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-outline-success">
|
||||
<i class="bi bi-calendar-plus"></i> {% trans %}New Reservation{% endtrans %}
|
||||
{% if current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role|upper == 'ADMIN') %}
|
||||
<a href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}" class="btn btn-outline-info text-start">
|
||||
<i class="bi bi-cpu me-2"></i>{% trans %}Optimizador de Aulas (IA){% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-calendar-today"></i> {% trans %}Today's Schedule{% endtrans %}
|
||||
{% endif %}
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary text-start">
|
||||
<i class="bi bi-door-open me-2"></i>{% trans %}Listado de Aulas{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}" class="btn btn-info text-white">
|
||||
<i class="bi bi-cpu"></i> {% trans %}AI Room Optimizer{% endtrans %}
|
||||
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary text-start">
|
||||
<i class="bi bi-buildings me-2"></i>{% trans %}Gestión de Edificios{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-calendar3"></i> {% trans %}View Calendar{% endtrans %}
|
||||
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-outline-success text-start">
|
||||
<i class="bi bi-calendar-plus me-2"></i>{% trans %}Nueva Reserva{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-outline-primary text-start">
|
||||
<i class="bi bi-plus-circle me-2"></i>{% trans %}Crear Nueva Aula{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,46 +172,66 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-clock-history text-info"></i> {% trans %}Upcoming Reservations (Next 7 Days){% endtrans %}
|
||||
</h5>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-sm btn-outline-primary">
|
||||
{% trans %}View All{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-header border-0 py-3 bg-body-tertiary d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-clock-history text-info me-2"></i>{% trans %}Próximas Reservas (Próximos 7 Días){% endtrans %}
|
||||
</h5>
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-sm btn-outline-primary">
|
||||
{% trans %}Ver en Calendario{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body p-0">
|
||||
{% if upcoming_reservations %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light small text-uppercase">
|
||||
<tr>
|
||||
<th>{% trans %}Date & Time{% endtrans %}</th>
|
||||
<th>{% trans %}Classroom{% endtrans %}</th>
|
||||
<th>{% trans %}Subject{% endtrans %}</th>
|
||||
<th>{% trans %}Teacher{% endtrans %}</th>
|
||||
<th>{% trans %}Status{% endtrans %}</th>
|
||||
<th>{% trans %}Actions{% endtrans %}</th>
|
||||
<th class="ps-3">{% trans %}Horario{% endtrans %}</th>
|
||||
<th>{% trans %}Aula{% endtrans %}</th>
|
||||
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
|
||||
<th>{% trans %}Turno / Comisión{% endtrans %}</th>
|
||||
<th>{% trans %}Estado{% endtrans %}</th>
|
||||
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reservation in upcoming_reservations %}
|
||||
<tr>
|
||||
<td>{{ reservation.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>{{ reservation.classroom.code }}</td>
|
||||
<td>{{ reservation.commission.get_full_code() }}</td>
|
||||
<td>{{ reservation.commission.teacher_name }}</td>
|
||||
<td class="ps-3">
|
||||
<div class="fw-bold font-monospace text-primary fs-6">
|
||||
<i class="bi bi-clock text-warning me-1"></i>{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if reservation.status.value == 'CONFIRMED' else 'warning' }}">
|
||||
{{ reservation.status.value }}
|
||||
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
|
||||
{{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> {% trans %}View{% endtrans %}
|
||||
<div class="fw-semibold text-primary">{{ reservation.subject_name }}</div>
|
||||
<small class="text-muted 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>
|
||||
{% 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>
|
||||
{% else %}
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
|
||||
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -183,9 +240,10 @@
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-calendar-x text-muted" style="font-size: 3rem;"></i>
|
||||
<p class="text-muted mt-3">{% trans %}No upcoming reservations in the next 7 days{% endtrans %}</p>
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-calendar-x text-muted fs-1 d-block mb-2"></i>
|
||||
<h6 class="fw-bold">{% trans %}No hay reservas para los próximos 7 días{% endtrans %}</h6>
|
||||
<p class="text-muted small">{% trans %}Puedes programar nuevas reservas desde el calendario.{% endtrans %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
{% if config.LANGUAGES|length > 1 %}
|
||||
<div class="dropdown language-switcher">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="languageDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle d-flex align-items-center" type="button" id="languageDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-globe me-1"></i>
|
||||
{% if session.language == 'es' or (session.language is none and request.accept_languages.best_match(['es', 'en']) == 'es') %}
|
||||
<span>
|
||||
{% if current_lang == 'es' %}
|
||||
Español
|
||||
{% else %}
|
||||
English
|
||||
{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="languageDropdown">
|
||||
{% set current_lang = session.language or request.accept_languages.best_match(['es', 'en']) or 'en' %}
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="languageDropdown">
|
||||
<li>
|
||||
<a class="dropdown-item {% if current_lang == 'en' %}active{% endif %}"
|
||||
href="{{ url_for('main.set_language', language='en') }}">
|
||||
<i class="bi bi-check-circle-fill text-primary me-2"></i>English
|
||||
<a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'es' %}active{% endif %}"
|
||||
href="{{ url_for('main.set_language', language='es') }}">
|
||||
<span>Español</span>
|
||||
{% if current_lang == 'es' %}
|
||||
<i class="bi bi-check-lg ms-2 text-white"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {% if current_lang == 'es' %}active{% endif %}"
|
||||
href="{{ url_for('main.set_language', language='es') }}">
|
||||
<i class="bi bi-check-circle-fill text-primary me-2"></i>Español
|
||||
<a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'en' %}active{% endif %}"
|
||||
href="{{ url_for('main.set_language', language='en') }}">
|
||||
<span>English</span>
|
||||
{% if current_lang == 'en' %}
|
||||
<i class="bi bi-check-lg ms-2 text-white"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,86 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Reservation - Edu-Space Admin{% endblock %}
|
||||
{% block title %}{% trans %}New Reservation{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3">
|
||||
<i class="bi bi-calendar-plus text-success"></i> New Reservation
|
||||
</h1>
|
||||
<div>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}New Reservation{% endtrans %}
|
||||
</h1>
|
||||
</div>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Reservations
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Reservations{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="col-lg-10 mx-auto">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-calendar3 text-success"></i> Reservation Details
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-calendar3 text-primary me-2"></i>{% trans %}Detalles de la Reserva y Espacio Físico{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" id="reservation-form" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.classroom_id.label(class="form-label") }}
|
||||
{{ form.classroom_id(class="form-select", id="classroom-select") }}
|
||||
{% if form.classroom_id.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.classroom_id.errors[0] }}
|
||||
<!-- 1. SELECTORES EN CASCADA: Edificio -> Piso -> Aula -->
|
||||
<div class="mb-4">
|
||||
<div class="p-3 rounded bg-body-tertiary border">
|
||||
<label class="form-label fw-bold text-primary mb-2">
|
||||
<i class="bi bi-geo-alt-fill me-1"></i>{% trans %}Selección de Espacio Físico / Modalidad{% endtrans %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="row g-3">
|
||||
<!-- Edificio / Sede -->
|
||||
<div class="col-md-4">
|
||||
<label for="cascade-building" class="form-label small fw-semibold text-muted mb-1">
|
||||
<i class="bi bi-buildings text-primary me-1"></i>1. {% trans %}Edificio / Sede{% endtrans %}
|
||||
</label>
|
||||
<select id="cascade-building" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}-- Selecciona Edificio --{% endtrans %}</option>
|
||||
{% for b in buildings %}
|
||||
<option value="{{ b.id }}" data-virtual="{{ 'true' if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) else 'false' }}">
|
||||
{% if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) %}🌐{% else %}🏢{% endif %} {{ b.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text small text-muted">{% trans %}Sede física o Campus Virtual{% endtrans %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">Select a classroom for your reservation</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.commission_id.label(class="form-label") }}
|
||||
<!-- Piso / Nivel -->
|
||||
<div class="col-md-4">
|
||||
<label for="cascade-floor" class="form-label small fw-semibold text-muted mb-1">
|
||||
<i class="bi bi-layers text-secondary me-1"></i>2. {% trans %}Piso / Nivel{% endtrans %}
|
||||
</label>
|
||||
<select id="cascade-floor" class="form-select form-select-sm" disabled>
|
||||
<option value="">{% trans %}-- Selecciona Piso --{% endtrans %}</option>
|
||||
</select>
|
||||
<div class="form-text small text-muted" id="floorHelpText">{% trans %}Filtra por planta o nivel{% endtrans %}</div>
|
||||
</div>
|
||||
|
||||
<!-- Aula / Espacio (Campo del Formulario) -->
|
||||
<div class="col-md-4">
|
||||
<label for="classroom-select" class="form-label small fw-semibold text-muted mb-1">
|
||||
<i class="bi bi-door-open text-success me-1"></i>3. {% trans %}Aula / Espacio{% endtrans %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.classroom_id(class="form-select form-select-sm", id="classroom-select") }}
|
||||
{% if form.classroom_id.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.classroom_id.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-text small text-muted">{% trans %}Capacidad y disponibilidad{% endtrans %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Comisión y Materia -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12">
|
||||
{{ form.commission_id.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
|
||||
{{ form.commission_id(class="form-select", id="commission-select") }}
|
||||
{% if form.commission_id.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.commission_id.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">Select the subject/commission</small>
|
||||
<div class="form-text small text-muted">{% trans %}Materia, código de comisión y cantidad de alumnos inscriptos{% endtrans %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.start_time.label(class="form-label") }}
|
||||
<!-- 3. Horario de Inicio y Fin -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
{{ form.start_time.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
|
||||
{{ form.start_time(class="form-control", id="start-time") }}
|
||||
{% if form.start_time.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.start_time.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">When will the class start?</small>
|
||||
<small class="text-muted">{% trans %}¿Cuándo comenzará la clase?{% endtrans %}</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.end_time.label(class="form-label") }}
|
||||
<div class="col-md-6">
|
||||
{{ form.end_time.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
|
||||
{{ form.end_time(class="form-control", id="end-time") }}
|
||||
{% if form.end_time.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.end_time.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">When will the class end?</small>
|
||||
<small class="text-muted">{% trans %}¿Cuándo finalizará la clase?{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
{{ form.purpose.label(class="form-label") }}
|
||||
{{ form.purpose(class="form-control", placeholder="Regular class, exam, study session, etc.") }}
|
||||
<!-- 4. Propósito y Asistentes -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-8">
|
||||
{{ form.purpose.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
|
||||
{{ form.purpose(class="form-control", placeholder=_("Clase regular, examen, sesión de estudio, etc.")) }}
|
||||
{% if form.purpose.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.purpose.errors[0] }}
|
||||
@@ -88,21 +133,22 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
{{ form.expected_attendees.label(class="form-label") }}
|
||||
{{ form.expected_attendees(class="form-control", placeholder="30") }}
|
||||
<div class="col-md-4">
|
||||
{{ form.expected_attendees.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
|
||||
{{ form.expected_attendees(class="form-control", id="expected_attendees", placeholder="30") }}
|
||||
{% if form.expected_attendees.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.expected_attendees.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">Number of expected attendees</small>
|
||||
<small class="text-muted">{% trans %}Cantidad de asistentes esperados{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.notes.label(class="form-label") }}
|
||||
{{ form.notes(class="form-control", rows="3", placeholder="Any additional notes or special requirements...") }}
|
||||
<!-- 5. Notas -->
|
||||
<div class="mb-3">
|
||||
{{ form.notes.label(class="form-label fw-semibold") }}
|
||||
{{ form.notes(class="form-control", rows="2", placeholder=_("Cualquier nota adicional o requerimientos especiales...")) }}
|
||||
{% if form.notes.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.notes.errors[0] }}
|
||||
@@ -110,108 +156,375 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Summary Section -->
|
||||
<div class="alert alert-info" id="reservation-summary">
|
||||
<h6 class="alert-heading">
|
||||
<i class="bi bi-info-circle"></i> Reservation Summary
|
||||
</h6>
|
||||
<div id="summary-content">
|
||||
<p class="mb-2">
|
||||
<strong>Selected Classroom:</strong>
|
||||
<span id="summary-classroom">Not selected</span>
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
<strong>Duration:</strong>
|
||||
<span id="summary-duration">Not calculated</span>
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>Commission:</strong>
|
||||
<span id="summary-commission">Not selected</span>
|
||||
</p>
|
||||
<!-- 6. Enlace de Aula Virtual (Zoom, Meet, Teams, Moodle) -->
|
||||
<div class="mb-4" id="virtual-link-container">
|
||||
<label for="virtual_link" class="form-label fw-semibold">
|
||||
<i class="bi bi-camera-video text-primary me-1"></i>{% trans %}Enlace de Aula Virtual (Zoom, Google Meet, Teams, Moodle){% endtrans %}
|
||||
</label>
|
||||
{{ form.virtual_link(class="form-control", id="virtual_link", placeholder="https://zoom.us/j/... o https://meet.google.com/...") }}
|
||||
{% if form.virtual_link.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.virtual_link.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-text small text-muted">
|
||||
<i class="bi bi-info-circle me-1"></i>{% trans %}Obligatorio para Campus Virtual o clases remotas/híbridas. Se autocompleta con el enlace de la comisión seleccionada.{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. TARJETA DE RESUMEN DESGLOSADA: Edificio, Piso, Aula, Modalidad -->
|
||||
<div class="card bg-body-tertiary border-0 shadow-xs mb-4" id="reservation-summary">
|
||||
<div class="card-body p-3">
|
||||
<h6 class="card-subtitle mb-2 text-muted small fw-bold">
|
||||
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Resumen de la Reserva{% endtrans %}
|
||||
</h6>
|
||||
|
||||
<!-- Badges desglosados de Ubicación -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="p-2 rounded bg-body border">
|
||||
<small class="text-muted d-block">🏢 {% trans %}Edificio / Sede{% endtrans %}</small>
|
||||
<strong id="summary-building" class="text-primary">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="p-2 rounded bg-body border">
|
||||
<small class="text-muted d-block">🪜 {% trans %}Piso / Nivel{% endtrans %}</small>
|
||||
<strong id="summary-floor" class="text-secondary">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="p-2 rounded bg-body border">
|
||||
<small class="text-muted d-block">🚪 {% trans %}Aula Seleccionada{% endtrans %}</small>
|
||||
<strong id="summary-classroom" class="text-success">{% trans %}No seleccionada{% endtrans %}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fila de Enlace Virtual -->
|
||||
<div class="row g-2 mb-3" id="summary-virtual-row">
|
||||
<div class="col-12">
|
||||
<div class="p-2 rounded bg-body border d-flex align-items-center justify-content-between">
|
||||
<div class="text-truncate me-2">
|
||||
<small class="text-muted d-block">🎥 {% trans %}Enlace Virtual (Videoconferencia){% endtrans %}</small>
|
||||
<span id="summary-virtual-link" class="small font-monospace text-primary text-break">{% trans %}Sin enlace configurado{% endtrans %}</span>
|
||||
</div>
|
||||
<span class="badge bg-success-subtle text-success border" id="summary-virtual-badge" style="display:none;">
|
||||
<i class="bi bi-camera-video me-1"></i>Virtual
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duración y Comisión -->
|
||||
<div class="row g-2 small">
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">⏱️ {% trans %}Duración:{% endtrans %}</span>
|
||||
<strong id="summary-duration" class="text-body ms-1">{% trans %}No calculada{% endtrans %}</strong>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<span class="text-muted">📚 {% trans %}Comisión:{% endtrans %}</span>
|
||||
<strong id="summary-commission" class="text-body ms-1">{% trans %}No seleccionada{% endtrans %}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botones -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i>{% trans %}Cancelar{% endtrans %}
|
||||
</a>
|
||||
{{ form.submit(class="btn btn-success") }}
|
||||
<button type="submit" class="btn btn-success px-4">
|
||||
<i class="bi bi-calendar-check me-1"></i>{% trans %}Realizar Reserva{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions Card -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-question-circle text-info"></i> Reservation Guidelines
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-primary">📅 Booking Rules</h6>
|
||||
<ul class="small">
|
||||
<li>Reservations can be made up to 3 months in advance</li>
|
||||
<li>Cannot book in the past</li>
|
||||
<li>Maximum duration: 8 hours per reservation</li>
|
||||
<li>Cancellations must be 24 hours before start time</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-warning">🚨 Important Notes</h6>
|
||||
<ul class="small">
|
||||
<li>Check classroom capacity before booking</li>
|
||||
<li>Pending reservations require approval</li>
|
||||
<li>Time slots must not overlap</li>
|
||||
<li>System automatically detects conflicts</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script type="application/json" id="classroom-data">
|
||||
{
|
||||
{% for c in classroom_list %}
|
||||
"{{ c.id }}": {
|
||||
"id": {{ c.id }},
|
||||
"building_id": {{ (c.building_id or 0)|tojson }},
|
||||
"building_name": {{ c.building_name|tojson }},
|
||||
"floor": {{ c.floor|tojson }},
|
||||
"floor_display": {{ c.floor_display|tojson }},
|
||||
"code": {{ c.code|tojson }},
|
||||
"code_display": {{ c.code_display|tojson }},
|
||||
"location_display": {{ c.location_display|tojson }},
|
||||
"capacity": {{ c.capacity_display|tojson }},
|
||||
"capacity_num": {{ c.capacity }},
|
||||
"is_virtual": {{ c.is_virtual|tojson }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="commission-data">
|
||||
{
|
||||
{% for c in commission_list %}
|
||||
"{{ c.id }}": {
|
||||
"id": {{ c.id }},
|
||||
"code": {{ c.code|tojson }},
|
||||
"virtual_link": {{ (c.virtual_link or '')|tojson }},
|
||||
"max_students": {{ c.max_students }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="i18n-strings">
|
||||
{
|
||||
"not_selected": "{% trans %}No seleccionada{% endtrans %}",
|
||||
"not_calculated": "{% trans %}No calculada{% endtrans %}",
|
||||
"invalid_time": "{% trans %}Rango horario inválido{% endtrans %}",
|
||||
"hour": "{% trans %}hora{% endtrans %}",
|
||||
"hours": "{% trans %}horas{% endtrans %}",
|
||||
"minute": "{% trans %}minuto{% endtrans %}",
|
||||
"minutes": "{% trans %}minutos{% endtrans %}",
|
||||
"capacity": "{% trans %}Capacidad{% endtrans %}",
|
||||
"capacity_exceeded": "{% trans %}Los asistentes esperados superan la capacidad del aula{% endtrans %}",
|
||||
"no_virtual_link": "{% trans %}Sin enlace configurado (Clase presencial){% endtrans %}"
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Classroom data for capacity checking
|
||||
const classroomData = {};
|
||||
{% for classroom in classroom_list %}
|
||||
classroomData[{{ classroom.id }}] = {
|
||||
capacity: {{ classroom.capacity }},
|
||||
code_display: "{{ classroom.code_display }}",
|
||||
name: "{{ classroom.name }}"
|
||||
};
|
||||
{% endfor %}
|
||||
let classroomData = {};
|
||||
try {
|
||||
const rawData = document.getElementById('classroom-data')?.textContent;
|
||||
if (rawData) classroomData = JSON.parse(rawData);
|
||||
} catch (err) {
|
||||
console.error('Error parsing classroomData:', err);
|
||||
}
|
||||
|
||||
let commissionData = {};
|
||||
try {
|
||||
const rawComm = document.getElementById('commission-data')?.textContent;
|
||||
if (rawComm) commissionData = JSON.parse(rawComm);
|
||||
} catch (err) {
|
||||
console.error('Error parsing commissionData:', err);
|
||||
}
|
||||
|
||||
let i18n = {};
|
||||
try {
|
||||
const rawI18n = document.getElementById('i18n-strings')?.textContent;
|
||||
if (rawI18n) i18n = JSON.parse(rawI18n);
|
||||
} catch (err) {
|
||||
console.error('Error parsing i18n:', err);
|
||||
}
|
||||
|
||||
const $building = $('#cascade-building');
|
||||
const $floor = $('#cascade-floor');
|
||||
const $classroom = $('#classroom-select');
|
||||
const $floorHelp = $('#floorHelpText');
|
||||
|
||||
// Guardar todas las opciones originales de aulas
|
||||
const originalClassroomOptions = [];
|
||||
$classroom.find('option').each(function() {
|
||||
originalClassroomOptions.push({
|
||||
value: $(this).val(),
|
||||
text: $(this).text()
|
||||
});
|
||||
});
|
||||
|
||||
// 1. Manejo del cambio de Edificio
|
||||
$building.on('change', function() {
|
||||
const selectedBuildingId = $(this).val();
|
||||
const isVirtual = $(this).find(':selected').data('virtual') === true || $(this).find(':selected').data('virtual') === 'true';
|
||||
|
||||
$floor.empty().append('<option value="">-- ' + (isVirtual ? 'No aplica (Campus Virtual)' : 'Selecciona Piso') + ' --</option>');
|
||||
$classroom.empty().append('<option value="">-- Selecciona Aula --</option>');
|
||||
|
||||
if (!selectedBuildingId) {
|
||||
$floor.prop('disabled', true);
|
||||
$classroom.prop('disabled', false);
|
||||
// Restaurar todas
|
||||
originalClassroomOptions.forEach(opt => {
|
||||
if (opt.value) $classroom.append($('<option>', { value: opt.value, text: opt.text }));
|
||||
});
|
||||
updateSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVirtual) {
|
||||
$floor.prop('disabled', true);
|
||||
$floorHelp.text('Modalidad 100% remota sin piso asignado');
|
||||
|
||||
// Filtrar solo aulas virtuales
|
||||
let virtualCount = 0;
|
||||
let lastVirtualId = null;
|
||||
Object.values(classroomData).forEach(c => {
|
||||
if (c.is_virtual || c.building_id == selectedBuildingId) {
|
||||
$classroom.append($('<option>', {
|
||||
value: c.id,
|
||||
text: `${c.code_display} (Cap: ${c.capacity})`
|
||||
}));
|
||||
lastVirtualId = c.id;
|
||||
virtualCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (virtualCount === 1 && lastVirtualId) {
|
||||
$classroom.val(lastVirtualId);
|
||||
}
|
||||
updateSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
// Edificio Físico
|
||||
$floor.prop('disabled', false);
|
||||
$floorHelp.text('Filtra por planta o nivel');
|
||||
|
||||
// Extraer pisos únicos de este edificio
|
||||
const buildingFloors = [];
|
||||
Object.values(classroomData).forEach(c => {
|
||||
if (c.building_id == selectedBuildingId && c.floor) {
|
||||
if (!buildingFloors.some(f => f.floor === c.floor)) {
|
||||
buildingFloors.push({
|
||||
floor: c.floor,
|
||||
floor_display: c.floor_display
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ordenar pisos: PB primero, luego numérico
|
||||
buildingFloors.sort((a, b) => {
|
||||
if (a.floor === '0' || a.floor.toUpperCase() === 'PB') return -1;
|
||||
if (b.floor === '0' || b.floor.toUpperCase() === 'PB') return 1;
|
||||
return a.floor.localeCompare(b.floor, undefined, { numeric: true });
|
||||
});
|
||||
|
||||
buildingFloors.forEach(f => {
|
||||
$floor.append($('<option>', {
|
||||
value: f.floor,
|
||||
text: f.floor_display
|
||||
}));
|
||||
});
|
||||
|
||||
// Poblar aulas de este edificio inicialmente
|
||||
Object.values(classroomData).forEach(c => {
|
||||
if (c.building_id == selectedBuildingId) {
|
||||
$classroom.append($('<option>', {
|
||||
value: c.id,
|
||||
text: `${c.code} · ${c.floor_display} (Cap: ${c.capacity})`
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
updateSummary();
|
||||
});
|
||||
|
||||
// 2. Manejo del cambio de Piso
|
||||
$floor.on('change', function() {
|
||||
const selectedBuildingId = $building.val();
|
||||
const selectedFloor = $(this).val();
|
||||
|
||||
$classroom.empty().append('<option value="">-- Selecciona Aula --</option>');
|
||||
|
||||
let count = 0;
|
||||
let lastId = null;
|
||||
|
||||
Object.values(classroomData).forEach(c => {
|
||||
const matchesBuilding = !selectedBuildingId || c.building_id == selectedBuildingId;
|
||||
const matchesFloor = !selectedFloor || c.floor == selectedFloor;
|
||||
|
||||
if (matchesBuilding && matchesFloor) {
|
||||
$classroom.append($('<option>', {
|
||||
value: c.id,
|
||||
text: `${c.code} (Cap: ${c.capacity})`
|
||||
}));
|
||||
lastId = c.id;
|
||||
count++;
|
||||
}
|
||||
});
|
||||
|
||||
if (count === 1 && lastId) {
|
||||
$classroom.val(lastId);
|
||||
}
|
||||
|
||||
updateSummary();
|
||||
});
|
||||
|
||||
// 3. Manejo del cambio de Aula directo
|
||||
$classroom.on('change', function() {
|
||||
const cId = $(this).val();
|
||||
if (cId && classroomData[cId]) {
|
||||
const c = classroomData[cId];
|
||||
// Sincronizar selectores en cascada si no coinciden
|
||||
if ($building.val() != c.building_id) {
|
||||
$building.val(c.building_id);
|
||||
// Si no es virtual, sincronizar piso
|
||||
if (!c.is_virtual) {
|
||||
$floor.prop('disabled', false);
|
||||
$floor.val(c.floor);
|
||||
}
|
||||
} else if (!c.is_virtual && $floor.val() != c.floor) {
|
||||
$floor.val(c.floor);
|
||||
}
|
||||
}
|
||||
updateSummary();
|
||||
});
|
||||
|
||||
// Inicializar cascada si ya hay un aula seleccionada (ej. post-error o query string)
|
||||
const initialClassroomId = $classroom.val();
|
||||
if (initialClassroomId && classroomData[initialClassroomId]) {
|
||||
const c = classroomData[initialClassroomId];
|
||||
$building.val(c.building_id).trigger('change');
|
||||
if (!c.is_virtual) {
|
||||
$floor.val(c.floor).trigger('change');
|
||||
}
|
||||
$classroom.val(initialClassroomId);
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
const classroomId = $('#classroom-select').val();
|
||||
const classroomId = $classroom.val();
|
||||
const commissionId = $('#commission-select').val();
|
||||
const startTime = $('#start-time').val();
|
||||
const endTime = $('#end-time').val();
|
||||
|
||||
// Update classroom summary
|
||||
// Update classroom badges
|
||||
if (classroomId && classroomData[classroomId]) {
|
||||
$('#summary-classroom').text(`${classroomData[classroomId].code_display} - ${classroomData[classroomId].name} (Capacity: ${classroomData[classroomId].capacity})`);
|
||||
const c = classroomData[classroomId];
|
||||
$('#summary-building').text(c.building_name);
|
||||
$('#summary-floor').text(c.is_virtual ? 'Remoto (Virtual)' : c.floor_display);
|
||||
$('#summary-classroom').text(`${c.code} (Cap: ${c.capacity})`);
|
||||
} else {
|
||||
$('#summary-classroom').text('Not selected');
|
||||
$('#summary-building').text('-');
|
||||
$('#summary-floor').text('-');
|
||||
$('#summary-classroom').text(i18n.not_selected || 'No seleccionada');
|
||||
}
|
||||
|
||||
// Update commission summary
|
||||
// Update commission summary and autofill virtual link if available
|
||||
if (commissionId) {
|
||||
const commissionText = $('#commission-select option:selected').text();
|
||||
$('#summary-commission').text(commissionText);
|
||||
} else {
|
||||
$('#summary-commission').text('Not selected');
|
||||
$('#summary-commission').text(i18n.not_selected || 'No seleccionada');
|
||||
}
|
||||
|
||||
// Update virtual link in summary
|
||||
const vLink = ($('#virtual_link').val() || '').trim();
|
||||
const isVirtual = (classroomId && classroomData[classroomId] && classroomData[classroomId].is_virtual);
|
||||
|
||||
if (vLink) {
|
||||
$('#summary-virtual-link').text(vLink);
|
||||
$('#summary-virtual-badge').show();
|
||||
} else if (isVirtual) {
|
||||
$('#summary-virtual-link').html('<span class="text-warning fw-semibold"><i class="bi bi-exclamation-triangle me-1"></i>Campus Virtual (Recomendado agregar enlace Zoom/Meet)</span>');
|
||||
$('#summary-virtual-badge').hide();
|
||||
} else {
|
||||
$('#summary-virtual-link').text(i18n.no_virtual_link || 'Sin enlace configurado (Clase presencial)');
|
||||
$('#summary-virtual-badge').hide();
|
||||
}
|
||||
|
||||
// Calculate duration
|
||||
@@ -226,37 +539,48 @@ $(document).ready(function() {
|
||||
|
||||
let durationText = '';
|
||||
if (diffHours > 0) {
|
||||
durationText += `${diffHours} hour${diffHours > 1 ? 's' : ''}`;
|
||||
const hLabel = diffHours > 1 ? (i18n.hours || 'horas') : (i18n.hour || 'hora');
|
||||
durationText += `${diffHours} ${hLabel}`;
|
||||
}
|
||||
if (diffMins > 0) {
|
||||
if (durationText) durationText += ' ';
|
||||
durationText += `${diffMins} minute${diffMins > 1 ? 's' : ''}`;
|
||||
const mLabel = diffMins > 1 ? (i18n.minutes || 'minutos') : (i18n.minute || 'minuto');
|
||||
durationText += `${diffMins} ${mLabel}`;
|
||||
}
|
||||
|
||||
$('#summary-duration').text(durationText);
|
||||
$('#summary-duration').text(durationText || '0 min');
|
||||
} else {
|
||||
$('#summary-duration').text('Invalid time range');
|
||||
$('#summary-duration').text(i18n.invalid_time || 'Rango horario inválido');
|
||||
}
|
||||
} else {
|
||||
$('#summary-duration').text('Not calculated');
|
||||
$('#summary-duration').text(i18n.not_calculated || 'No calculada');
|
||||
}
|
||||
}
|
||||
|
||||
// Check capacity vs expected attendees
|
||||
function checkCapacity() {
|
||||
const classroomId = $('#classroom-select').val();
|
||||
const classroomId = $classroom.val();
|
||||
const attendees = parseInt($('#expected_attendees').val()) || 0;
|
||||
|
||||
if (classroomId && classroomData[classroomId] && attendees > 0) {
|
||||
const capacity = classroomData[classroomId].capacity;
|
||||
const c = classroomData[classroomId];
|
||||
// No validar si es virtual o capacidad ilimitada
|
||||
if (c.is_virtual || c.capacity === '∞' || c.capacity_num === 0) {
|
||||
$('#expected_attendees').removeClass('is-invalid');
|
||||
$('#expected_attendees').siblings('.invalid-feedback').remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (attendees > capacity) {
|
||||
if (attendees > c.capacity_num) {
|
||||
$('#expected_attendees').addClass('is-invalid');
|
||||
$('#expected_attendees').after(`
|
||||
<div class="invalid-feedback">
|
||||
Expected attendees (${attendees}) exceeds classroom capacity (${capacity})
|
||||
</div>
|
||||
`);
|
||||
const msg = i18n.capacity_exceeded || 'Los asistentes esperados superan la capacidad del aula';
|
||||
if ($('#expected_attendees').siblings('.invalid-feedback').length === 0) {
|
||||
$('#expected_attendees').after(`
|
||||
<div class="invalid-feedback">
|
||||
${msg} (${attendees} > ${c.capacity_num})
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
} else {
|
||||
$('#expected_attendees').removeClass('is-invalid');
|
||||
$('#expected_attendees').siblings('.invalid-feedback').remove();
|
||||
@@ -264,23 +588,35 @@ $(document).ready(function() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update on change
|
||||
$('#classroom-select, #commission-select, #start-time, #end-time, #expected_attendees').on('change', updateSummary);
|
||||
$('#expected_attendees').on('input', checkCapacity);
|
||||
$('#commission-select').on('change', function() {
|
||||
const cId = $(this).val();
|
||||
if (cId && commissionData[cId]) {
|
||||
const comm = commissionData[cId];
|
||||
if (comm.virtual_link && !$('#virtual_link').val().trim()) {
|
||||
$('#virtual_link').val(comm.virtual_link);
|
||||
}
|
||||
}
|
||||
updateSummary();
|
||||
});
|
||||
|
||||
$('#virtual_link').on('input change', updateSummary);
|
||||
$('#start-time, #end-time').on('change', updateSummary);
|
||||
$('#expected_attendees').on('input', function() {
|
||||
checkCapacity();
|
||||
updateSummary();
|
||||
});
|
||||
|
||||
// Initialize summary
|
||||
updateSummary();
|
||||
|
||||
// Form validation
|
||||
$('#reservation-form').on('submit', function(e) {
|
||||
let isValid = true;
|
||||
|
||||
// Check required fields
|
||||
if (!$('#classroom-select').val()) {
|
||||
$('#classroom-select').addClass('is-invalid');
|
||||
if (!$classroom.val()) {
|
||||
$classroom.addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#classroom-select').removeClass('is-invalid');
|
||||
$classroom.removeClass('is-invalid');
|
||||
}
|
||||
|
||||
if (!$('#commission-select').val()) {
|
||||
@@ -304,7 +640,6 @@ $(document).ready(function() {
|
||||
$('#end-time').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
// Validate time range
|
||||
const startTime = new Date($('#start-time').val());
|
||||
const endTime = new Date($('#end-time').val());
|
||||
|
||||
@@ -313,7 +648,6 @@ $(document).ready(function() {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Check capacity
|
||||
checkCapacity();
|
||||
if ($('#expected_attendees').hasClass('is-invalid')) {
|
||||
isValid = false;
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Modificar Reserva{% endtrans %} #{{ reservation.id }} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1 small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('schedule.today_schedule') }}">{% trans %}Schedule{% endtrans %}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('schedule.list_reservations') }}">{% trans %}Reservas{% endtrans %}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}">#{{ reservation.id }}</a></li>
|
||||
<li class="breadcrumb-item active">{% trans %}Modificar{% endtrans %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-pencil-square text-warning me-2"></i>{% trans %}Modificar Reserva{% endtrans %} #{{ reservation.id }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver al Detalle{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-10 mx-auto">
|
||||
<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-calendar3 text-warning me-2"></i>{% trans %}Editar Parámetros de la Reserva{% endtrans %}
|
||||
</h5>
|
||||
<span class="badge {% if reservation.status == 'CONFIRMED' %}bg-success{% elif reservation.status == 'PENDING' %}bg-warning{% else %}bg-secondary{% endif %}">
|
||||
{{ reservation.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" id="reservation-form" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<!-- 1. SELECTORES EN CASCADA: Edificio -> Piso -> Aula -->
|
||||
<div class="mb-4">
|
||||
<div class="p-3 rounded bg-body-tertiary border">
|
||||
<label class="form-label fw-bold text-primary mb-2">
|
||||
<i class="bi bi-geo-alt-fill me-1"></i>{% trans %}Espacio Físico / Modalidad{% endtrans %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="row g-3">
|
||||
<!-- Edificio / Sede -->
|
||||
<div class="col-md-4">
|
||||
<label for="cascade-building" class="form-label small fw-semibold text-muted mb-1">
|
||||
<i class="bi bi-buildings text-primary me-1"></i>1. {% trans %}Edificio / Sede{% endtrans %}
|
||||
</label>
|
||||
<select id="cascade-building" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}-- Selecciona Edificio --{% endtrans %}</option>
|
||||
{% for b in buildings %}
|
||||
<option value="{{ b.id }}" data-virtual="{{ 'true' if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) else 'false' }}">
|
||||
{% if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) %}🌐{% else %}🏢{% endif %} {{ b.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Piso / Nivel -->
|
||||
<div class="col-md-4">
|
||||
<label for="cascade-floor" class="form-label small fw-semibold text-muted mb-1">
|
||||
<i class="bi bi-layers text-secondary me-1"></i>2. {% trans %}Piso / Nivel{% endtrans %}
|
||||
</label>
|
||||
<select id="cascade-floor" class="form-select form-select-sm" disabled>
|
||||
<option value="">{% trans %}-- Selecciona Piso --{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Aula / Espacio -->
|
||||
<div class="col-md-4">
|
||||
<label for="classroom_id" class="form-label small fw-semibold text-muted mb-1">
|
||||
<i class="bi bi-door-open text-success me-1"></i>3. {% trans %}Aula Final{% endtrans %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.classroom_id(class="form-select form-select-sm", id="classroom_id") }}
|
||||
{% if form.classroom_id.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.classroom_id.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card de Info Dinámica del Aula -->
|
||||
<div id="classroom-info-card" class="mt-3 p-2 rounded bg-body border" style="display: none;">
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div>
|
||||
<span class="badge bg-primary me-2" id="info-room-code">-</span>
|
||||
<small class="text-muted" id="info-room-location">-</small>
|
||||
</div>
|
||||
<div>
|
||||
<span class="badge bg-info-subtle text-info border me-1" id="info-room-capacity">Cap: -</span>
|
||||
<span class="badge bg-success-subtle text-success border" id="info-room-type">Presencial</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. DATOS ACADÉMICOS Y HORARIOS -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label for="commission_id" class="form-label fw-bold text-primary mb-1">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Comisión Académica{% endtrans %}
|
||||
</label>
|
||||
{{ form.commission_id(class="form-select", id="commission_id") }}
|
||||
<div class="form-text small text-muted">{% trans %}Comisión y asignatura vinculada a esta reserva.{% endtrans %}</div>
|
||||
{% if form.commission_id.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.commission_id.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label for="start_time" class="form-label fw-bold text-primary mb-1">
|
||||
<i class="bi bi-clock me-1"></i>{% trans %}Inicio{% endtrans %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.start_time(class="form-control", id="start_time", type="datetime-local") }}
|
||||
{% if form.start_time.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.start_time.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label for="end_time" class="form-label fw-bold text-primary mb-1">
|
||||
<i class="bi bi-clock-fill me-1"></i>{% trans %}Fin{% endtrans %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.end_time(class="form-control", id="end_time", type="datetime-local") }}
|
||||
{% if form.end_time.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.end_time.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. PROPÓSITO Y ASISTENTES -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-8">
|
||||
<label for="purpose" class="form-label fw-bold text-primary mb-1">
|
||||
<i class="bi bi-journal-text me-1"></i>{% trans %}Propósito / Motivo{% endtrans %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.purpose(class="form-control", id="purpose", placeholder="ej. Cursada regular, Examen Parcial, Clase Especial") }}
|
||||
{% if form.purpose.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.purpose.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="expected_attendees" class="form-label fw-bold text-primary mb-1">
|
||||
<i class="bi bi-people me-1"></i>{% trans %}Asistentes Estimados{% endtrans %}
|
||||
</label>
|
||||
{{ form.expected_attendees(class="form-control", id="expected_attendees", type="number", min="0") }}
|
||||
{% if form.expected_attendees.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.expected_attendees.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. ENLACE DE AULA VIRTUAL (Zoom, Meet, Teams, etc) -->
|
||||
<div class="mb-4">
|
||||
<div class="p-3 rounded bg-primary-subtle border border-primary-subtle">
|
||||
<label for="virtual_link" class="form-label fw-bold text-primary mb-1">
|
||||
<i class="bi bi-camera-video-fill me-1"></i>{% trans %}Enlace de Aula Virtual / Videoconferencia{% endtrans %}
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-body text-primary"><i class="bi bi-link-45deg"></i></span>
|
||||
{{ form.virtual_link(class="form-control font-monospace", id="virtual_link", placeholder="https://zoom.us/j/... o https://meet.google.com/...") }}
|
||||
<button type="button" class="btn btn-outline-primary" id="btn-test-link" title="{% trans %}Probar enlace en nueva pestaña{% endtrans %}">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Probar{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text small text-muted">
|
||||
{% trans %}Enlace directo a Zoom, Google Meet, Teams o Campus Virtual. Si se ingresa, se habilitará el botón de acceso directo en cartelera y cronograma.{% endtrans %}
|
||||
</div>
|
||||
{% if form.virtual_link.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.virtual_link.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. NOTAS ADICIONALES -->
|
||||
<div class="mb-4">
|
||||
<label for="notes" class="form-label fw-bold text-primary mb-1">
|
||||
<i class="bi bi-card-text me-1"></i>{% trans %}Observaciones / Notas Adicionales{% endtrans %}
|
||||
</label>
|
||||
{{ form.notes(class="form-control", id="notes", rows="3", placeholder="Observaciones sobre equipamiento, requerimientos o modalidad...") }}
|
||||
{% if form.notes.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.notes.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 6. RESUMEN DINÁMICO DE MODIFICACIÓN -->
|
||||
<div class="card bg-body-tertiary border mb-4 shadow-none">
|
||||
<div class="card-header bg-transparent py-2 border-bottom">
|
||||
<h6 class="card-title mb-0 small fw-bold text-primary">
|
||||
<i class="bi bi-check2-circle me-1"></i>{% trans %}Resumen de Cambios{% endtrans %}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<div class="row g-2 small mb-2">
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">🏢 {% trans %}Edificio:{% endtrans %}</span>
|
||||
<strong id="summary-building" class="text-body ms-1">-</strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">📍 {% trans %}Piso / Nivel:{% endtrans %}</span>
|
||||
<strong id="summary-floor" class="text-body ms-1">-</strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">🚪 {% trans %}Aula Seleccionada:{% endtrans %}</span>
|
||||
<strong id="summary-classroom" class="text-primary ms-1">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 small mb-2">
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">⏱️ {% trans %}Duración:{% endtrans %}</span>
|
||||
<strong id="summary-duration" class="text-body ms-1">-</strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">👥 {% trans %}Capacidad:{% endtrans %}</span>
|
||||
<strong id="summary-capacity" class="text-body ms-1">-</strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">📚 {% trans %}Comisión:{% endtrans %}</span>
|
||||
<strong id="summary-commission" class="text-body ms-1">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 rounded bg-body border d-flex align-items-center justify-content-between">
|
||||
<div class="text-truncate me-2">
|
||||
<small class="text-muted d-block">🎥 {% trans %}Enlace de Aula Virtual:{% endtrans %}</small>
|
||||
<span id="summary-virtual-link" class="small font-monospace text-primary text-break">{% trans %}Sin enlace configurado{% endtrans %}</span>
|
||||
</div>
|
||||
<span class="badge bg-success-subtle text-success border" id="summary-virtual-badge" style="display:none;">
|
||||
<i class="bi bi-camera-video me-1"></i>Virtual
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botones -->
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i>{% trans %}Cancelar{% endtrans %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-warning px-4 fw-bold">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Guardar Modificaciones{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script type="application/json" id="classroom-data">
|
||||
{
|
||||
{% for c in classroom_list %}
|
||||
"{{ c.id }}": {
|
||||
"id": {{ c.id }},
|
||||
"building_id": {{ (c.building_id or 0)|tojson }},
|
||||
"building_name": {{ c.building_name|tojson }},
|
||||
"floor": {{ c.floor|tojson }},
|
||||
"floor_display": {{ c.floor_display|tojson }},
|
||||
"code": {{ c.code|tojson }},
|
||||
"code_display": {{ c.code_display|tojson }},
|
||||
"location_display": {{ c.location_display|tojson }},
|
||||
"capacity": {{ c.capacity_display|tojson }},
|
||||
"capacity_num": {{ c.capacity }},
|
||||
"is_virtual": {{ c.is_virtual|tojson }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="commission-data">
|
||||
{
|
||||
{% for c in commission_list %}
|
||||
"{{ c.id }}": {
|
||||
"id": {{ c.id }},
|
||||
"code": {{ c.code|tojson }},
|
||||
"virtual_link": {{ (c.virtual_link or '')|tojson }},
|
||||
"max_students": {{ c.max_students }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
let classroomData = {};
|
||||
try {
|
||||
const rawData = document.getElementById('classroom-data')?.textContent;
|
||||
if (rawData) classroomData = JSON.parse(rawData);
|
||||
} catch (err) {
|
||||
console.error('Error parsing classroomData:', err);
|
||||
}
|
||||
|
||||
let commissionData = {};
|
||||
try {
|
||||
const rawComm = document.getElementById('commission-data')?.textContent;
|
||||
if (rawComm) commissionData = JSON.parse(rawComm);
|
||||
} catch (err) {
|
||||
console.error('Error parsing commissionData:', err);
|
||||
}
|
||||
|
||||
const $bldgSelect = $('#cascade-building');
|
||||
const $floorSelect = $('#cascade-floor');
|
||||
const $roomSelect = $('#classroom_id');
|
||||
const $commSelect = $('#commission_id');
|
||||
const $virtualLinkInput = $('#virtual_link');
|
||||
const $btnTestLink = $('#btn-test-link');
|
||||
|
||||
const initialRoomId = "{{ reservation.classroom_id }}";
|
||||
|
||||
// 1. Población de Pisos al cambiar Edificio
|
||||
$bldgSelect.on('change', function() {
|
||||
const selectedBldg = $(this).val();
|
||||
$floorSelect.empty().append('<option value="">-- Selecciona Piso --</option>');
|
||||
$roomSelect.empty().append('<option value="">-- Selecciona Aula --</option>');
|
||||
|
||||
if (!selectedBldg) {
|
||||
$floorSelect.prop('disabled', true);
|
||||
$roomSelect.prop('disabled', true);
|
||||
updateSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
const floors = new Set();
|
||||
Object.values(classroomData).forEach(function(room) {
|
||||
if (String(room.building_id) === String(selectedBldg)) {
|
||||
floors.add(room.floor);
|
||||
}
|
||||
});
|
||||
|
||||
const sortedFloors = Array.from(floors).sort(function(a, b) {
|
||||
if (a === '0' || a === 'PB') return -1;
|
||||
if (b === '0' || b === 'PB') return 1;
|
||||
return String(a).localeCompare(String(b), undefined, {numeric: true});
|
||||
});
|
||||
|
||||
sortedFloors.forEach(function(fl) {
|
||||
let label = (fl === '0' || fl === 'PB') ? 'Planta Baja' : ('Piso ' + fl);
|
||||
$floorSelect.append($('<option>', { value: fl, text: label }));
|
||||
});
|
||||
|
||||
$floorSelect.prop('disabled', false);
|
||||
$roomSelect.prop('disabled', true);
|
||||
updateSummary();
|
||||
});
|
||||
|
||||
// 2. Población de Aulas al cambiar Piso
|
||||
$floorSelect.on('change', function() {
|
||||
const selectedBldg = $bldgSelect.val();
|
||||
const selectedFloor = $(this).val();
|
||||
$roomSelect.empty().append('<option value="">-- Selecciona Aula --</option>');
|
||||
|
||||
if (!selectedFloor) {
|
||||
$roomSelect.prop('disabled', true);
|
||||
updateSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingRooms = Object.values(classroomData).filter(function(room) {
|
||||
return String(room.building_id) === String(selectedBldg) && String(room.floor) === String(selectedFloor);
|
||||
});
|
||||
|
||||
matchingRooms.sort(function(a, b) { return a.code.localeCompare(b.code); });
|
||||
|
||||
matchingRooms.forEach(function(room) {
|
||||
const capText = room.is_virtual ? '∞ Remoto' : ('Cap: ' + room.capacity);
|
||||
$roomSelect.append($('<option>', {
|
||||
value: room.id,
|
||||
text: room.code + ' (' + capText + ')'
|
||||
}));
|
||||
});
|
||||
|
||||
$roomSelect.prop('disabled', false);
|
||||
updateSummary();
|
||||
});
|
||||
|
||||
// 3. Cambio de Aula
|
||||
$roomSelect.on('change', function() {
|
||||
updateClassroomInfo();
|
||||
updateSummary();
|
||||
});
|
||||
|
||||
// 4. Preselección inicial de Aula actual
|
||||
if (initialRoomId && classroomData[initialRoomId]) {
|
||||
const initRoom = classroomData[initialRoomId];
|
||||
$bldgSelect.val(initRoom.building_id).trigger('change');
|
||||
$floorSelect.val(initRoom.floor).trigger('change');
|
||||
$roomSelect.val(initRoom.id).trigger('change');
|
||||
}
|
||||
|
||||
function updateClassroomInfo() {
|
||||
const roomId = $roomSelect.val();
|
||||
const room = classroomData[roomId];
|
||||
const $card = $('#classroom-info-card');
|
||||
if (!room) {
|
||||
$card.slideUp(150);
|
||||
return;
|
||||
}
|
||||
$('#info-room-code').text(room.code);
|
||||
$('#info-room-location').text(room.location_display);
|
||||
$('#info-room-capacity').text('Capacidad: ' + room.capacity);
|
||||
if (room.is_virtual) {
|
||||
$('#info-room-type').removeClass('bg-success-subtle text-success').addClass('bg-primary-subtle text-primary').text('🌐 Campus Virtual');
|
||||
} else {
|
||||
$('#info-room-type').removeClass('bg-primary-subtle text-primary').addClass('bg-success-subtle text-success').text('🏢 Presencial');
|
||||
}
|
||||
$card.slideDown(150);
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
const bldgName = $bldgSelect.find('option:selected').text().trim();
|
||||
const floorName = $floorSelect.find('option:selected').text().trim();
|
||||
const roomId = $roomSelect.val();
|
||||
const room = classroomData[roomId];
|
||||
|
||||
$('#summary-building').text(bldgName && !$bldgSelect.val() ? '-' : (bldgName || '-'));
|
||||
$('#summary-floor').text(floorName && !$floorSelect.val() ? '-' : (floorName || '-'));
|
||||
$('#summary-classroom').text(room ? room.code : '-');
|
||||
$('#summary-capacity').text(room ? (room.is_virtual ? '∞ Ilimitada' : room.capacity) : '-');
|
||||
|
||||
const commText = $commSelect.find('option:selected').text().trim();
|
||||
$('#summary-commission').text(commText && $commSelect.val() ? commText : '-');
|
||||
|
||||
// Duración
|
||||
const st = $('#start_time').val();
|
||||
const et = $('#end_time').val();
|
||||
if (st && et) {
|
||||
const startDate = new Date(st);
|
||||
const endDate = new Date(et);
|
||||
const diffMs = endDate - startDate;
|
||||
if (diffMs > 0) {
|
||||
const diffHrs = Math.floor(diffMs / 3600000);
|
||||
const diffMins = Math.round((diffMs % 3600000) / 60000);
|
||||
let durText = '';
|
||||
if (diffHrs > 0) durText += diffHrs + ' hs ';
|
||||
if (diffMins > 0) durText += diffMins + ' min';
|
||||
$('#summary-duration').text(durText);
|
||||
} else {
|
||||
$('#summary-duration').text('Rango inválido');
|
||||
}
|
||||
}
|
||||
|
||||
// Enlace virtual
|
||||
const vLink = $virtualLinkInput.val() ? $virtualLinkInput.val().trim() : '';
|
||||
if (vLink) {
|
||||
$('#summary-virtual-link').html('<a href="' + vLink + '" target="_blank" class="text-decoration-none text-primary"><i class="bi bi-box-arrow-up-right me-1"></i>' + vLink + '</a>');
|
||||
$('#summary-virtual-badge').show();
|
||||
} else {
|
||||
$('#summary-virtual-link').text('Sin enlace configurado');
|
||||
$('#summary-virtual-badge').hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Eventos reactivos
|
||||
$('#start_time, #end_time, #commission_id, #virtual_link').on('input change', updateSummary);
|
||||
|
||||
// Botón Probar enlace
|
||||
$btnTestLink.on('click', function() {
|
||||
const link = $virtualLinkInput.val() ? $virtualLinkInput.val().trim() : '';
|
||||
if (link) {
|
||||
window.open(link, '_blank');
|
||||
} else {
|
||||
alert('Por favor ingresa primero una URL para probar.');
|
||||
}
|
||||
});
|
||||
|
||||
updateSummary();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,291 +1,286 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Reservations List - Edu-Space Admin{% endblock %}
|
||||
{% block title %}{% trans %}Listado de Reservas{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-calendar-list text-primary"></i> Reservations
|
||||
<small class="text-muted ms-2">Manage & View All Reservations</small>
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col-md-7">
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-calendar-check text-primary me-2"></i>{% trans %}Listado de Reservas{% endtrans %}
|
||||
</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-calendar3"></i> Calendar View
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-calendar-day"></i> Today
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add Reservation
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">{% trans %}Registro histórico y programación completa de clases y asignación de aulas{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success me-2">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Reserva{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary me-2">
|
||||
<i class="bi bi-calendar3 me-1"></i>{% trans %}Ver Calendario{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-calendar-day me-1"></i>{% trans %}Cartelera de Hoy{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="all" {% if status_filter == 'all' %}selected{% endif %}>All Status</option>
|
||||
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>Confirmed</option>
|
||||
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Filters Card -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-3">
|
||||
<form method="GET" action="{{ url_for('schedule.list_reservations') }}" class="row g-2 align-items-end">
|
||||
<!-- Search text -->
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-search me-1"></i>{% trans %}Buscar{% endtrans %}
|
||||
</label>
|
||||
<input type="text" name="search" class="form-control form-control-sm" placeholder="Materia, código o aula..." value="{{ search or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">From Date</label>
|
||||
<input type="date" name="date_from" class="form-control" value="{{ date_from or '' }}">
|
||||
</div>
|
||||
<!-- Status Filter -->
|
||||
<div class="col-lg-2 col-md-3">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-shield-check me-1"></i>{% trans %}Estado{% endtrans %}
|
||||
</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="all" {% if status_filter == 'all' or not status_filter %}selected{% endif %}>{% trans %}Todos los estados{% endtrans %}</option>
|
||||
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>{% trans %}Confirmadas{% endtrans %}</option>
|
||||
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>{% trans %}Pendientes{% endtrans %}</option>
|
||||
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>{% trans %}Canceladas{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">To Date</label>
|
||||
<input type="date" name="date_to" class="form-control" value="{{ date_to or '' }}">
|
||||
</div>
|
||||
<!-- Floor Filter -->
|
||||
<div class="col-lg-2 col-md-3">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-layers me-1"></i>{% trans %}Piso{% endtrans %}
|
||||
</label>
|
||||
<select name="floor" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
|
||||
{% for f in available_floors %}
|
||||
<option value="{{ f }}" {% if floor_filter == f|string %}selected{% endif %}>
|
||||
{% if f == 0 %}{% trans %}Planta Baja{% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label"> </label>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
<i class="bi bi-funnel"></i> Filter
|
||||
<!-- Aula Filter -->
|
||||
<div class="col-lg-3 col-md-4">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-door-open text-primary me-1"></i>{% trans %}Aula / Espacio{% endtrans %}
|
||||
</label>
|
||||
<select name="classroom_id" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Todas las aulas{% endtrans %}</option>
|
||||
{% for c in all_classrooms %}
|
||||
<option value="{{ c.id }}" {% if classroom_id == c.id %}selected{% endif %}>{{ c.building_name }} - {{ c.code }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Shift Filter -->
|
||||
<div class="col-lg-2 col-md-3">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
|
||||
</label>
|
||||
<select name="shift" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
|
||||
<option value="Mañana" {% if shift_filter == 'Mañana' %}selected{% endif %}>{% trans %}Turno Mañana{% endtrans %}</option>
|
||||
<option value="Tarde" {% if shift_filter == 'Tarde' %}selected{% endif %}>{% trans %}Turno Tarde{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date Range From -->
|
||||
<div class="col-lg-2 col-md-3">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-calendar-event me-1"></i>{% trans %}Desde Fecha{% endtrans %}
|
||||
</label>
|
||||
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from or '' }}">
|
||||
</div>
|
||||
|
||||
<!-- Date Range To -->
|
||||
<div class="col-lg-2 col-md-3">
|
||||
<label class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-calendar-event me-1"></i>{% trans %}Hasta Fecha{% endtrans %}
|
||||
</label>
|
||||
<input type="date" name="date_to" class="form-control form-control-sm" value="{{ date_to or '' }}">
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Hide Virtual, Submit, Clear -->
|
||||
<div class="col-lg-6 col-md-6 pt-2">
|
||||
<div class="form-check form-switch pt-1">
|
||||
<input class="form-check-input" type="checkbox" role="switch" name="hide_virtual" value="1" id="hideVirtualList" {% if hide_virtual %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold small text-dark" for="hideVirtualList">
|
||||
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-md-6 pt-2 text-md-end d-flex justify-content-md-end gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm px-3">
|
||||
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> Clear
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary btn-sm px-3">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reservations List -->
|
||||
{% if reservations and reservations.items %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">Classroom</th>
|
||||
<th class="border-0">Commission</th>
|
||||
<th class="border-0">Date & Time</th>
|
||||
<th class="border-0">Purpose</th>
|
||||
<th class="border-0">Attendees</th>
|
||||
<th class="border-0">Status</th>
|
||||
<th class="border-0">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reservation in reservations.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar avatar-sm bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2">
|
||||
<i class="bi bi-door-open"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">{{ reservation.classroom.code_display }}</div>
|
||||
<small class="text-muted">Capacity: {{ reservation.classroom.capacity }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div>
|
||||
<div class="fw-bold">
|
||||
{% if reservation.commission %}
|
||||
{{ reservation.commission.get_full_code() or 'N/A' }}
|
||||
{% else %}
|
||||
No Commission
|
||||
{% endif %}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{% if reservation.commission and reservation.commission.teacher_name %}
|
||||
{{ reservation.commission.teacher_name }}
|
||||
{% else %}
|
||||
No Teacher
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div>
|
||||
<div class="fw-bold">{{ reservation.start_time.strftime('%b %d, %Y') }}</div>
|
||||
<small class="text-muted">
|
||||
{{ reservation.start_time.strftime('%I:%M %p') }} - {{ reservation.end_time.strftime('%I:%M %p') }}
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span class="text-truncate" style="max-width: 150px; display: inline-block;">
|
||||
{{ reservation.purpose or 'No purpose specified' }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-people text-muted me-1"></i>
|
||||
{{ reservation.expected_attendees }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if reservation.status.value == 'confirmed' %}
|
||||
<span class="badge bg-success">Confirmed</span>
|
||||
{% elif reservation.status.value == 'pending' %}
|
||||
<span class="badge bg-warning">Pending</span>
|
||||
{% elif reservation.status.value == 'cancelled' %}
|
||||
<span class="badge bg-danger">Cancelled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ reservation.status.value }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if reservation.status.value == 'pending' %}
|
||||
<form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-outline-success btn-sm" title="Confirm">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if reservation.status.value in ['pending', 'confirmed'] %}
|
||||
<form method="POST" action="{{ url_for('schedule.cancel_reservation', id=reservation.id) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Cancel">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if reservation.notes %}
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
title="{{ reservation.notes }}" data-bs-toggle="tooltip">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if reservations.pages > 1 %}
|
||||
<div class="card-footer bg-white border-0">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="text-muted small">
|
||||
Showing {{ reservations.items|length }} of {{ reservations.total }} reservations
|
||||
</span>
|
||||
<!-- Reservations Table -->
|
||||
{% 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>
|
||||
<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>
|
||||
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if reservations.has_prev %}
|
||||
<!-- 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) }}">
|
||||
<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 page_num in reservations.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != reservations.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=page_num, status=status_filter, date_from=date_from, date_to=date_to) }}">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">…</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if reservations.has_next %}
|
||||
{% 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) }}">
|
||||
<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>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-calendar-x text-muted fs-1 d-block mb-3"></i>
|
||||
<h5 class="fw-bold">{% trans %}No se encontraron reservas{% endtrans %}</h5>
|
||||
<p class="text-muted small mb-3">{% trans %}No hay registros que coincidan con los criterios de búsqueda o filtros seleccionados.{% endtrans %}</p>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Restablecer Todos los Filtros{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-calendar-x display-1 text-muted"></i>
|
||||
</div>
|
||||
<h5 class="text-muted">No reservations found</h5>
|
||||
<p class="text-muted">
|
||||
{% if status_filter != 'all' or date_from or date_to %}
|
||||
No reservations match your current filters.
|
||||
<a href="{{ url_for('schedule.list_reservations') }}">Clear filters</a> to see all reservations.
|
||||
{% else %}
|
||||
There are no reservations in the system yet.
|
||||
<a href="{{ url_for('schedule.add_reservation') }}">Create your first reservation</a>.
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="d-flex justify-content-center gap-2 mt-3">
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add Reservation
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-calendar-day"></i> Today's Schedule
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Initialize tooltips
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
});
|
||||
|
||||
// Form submission confirmation
|
||||
document.querySelectorAll('form[action*="cancel"]').forEach(function(form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!confirm('Are you sure you want to cancel this reservation?')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('form[action*="confirm"]').forEach(function(form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!confirm('Are you sure you want to confirm this reservation?')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,403 +1,382 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Today's Schedule - Edu-Space Admin{% endblock %}
|
||||
{% block title %}{% trans %}Cartelera de Hoy{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3">
|
||||
<i class="bi bi-calendar-today text-info"></i> {% trans %}Today's Schedule{% endtrans %}
|
||||
<small class="text-muted ms-2">{{ today.strftime('%B %d, %Y') }}</small>
|
||||
<!-- Header -->
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col-md-7">
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-calendar-day text-info me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
|
||||
<small class="text-muted ms-2 fs-6">{{ format_date(today, format='full')|capitalize }}</small>
|
||||
</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}New Reservation{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary ms-2">
|
||||
<i class="bi bi-list"></i> {% trans %}All Reservations{% endtrans %}
|
||||
</a>
|
||||
{% if current_user and current_user.role in ['admin', 'teacher'] %}
|
||||
<a href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}" class="btn btn-info text-white ms-2">
|
||||
<i class="bi bi-cpu"></i> {% trans %}AI Room Optimizer{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-muted small mb-0">{% trans %}Cronograma de clases y asignación de aulas activas para el día de hoy{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success me-2">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Reserva{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary me-2">
|
||||
<i class="bi bi-calendar3 me-1"></i>{% trans %}Ver Calendario{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-list me-1"></i>{% trans %}Todas las Reservas{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<!-- Quick Stats Cards with Shortcuts -->
|
||||
<div class="row mb-4 g-3">
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">{% trans %}Total Reservations{% endtrans %}</h6>
|
||||
<h2 class="mb-0">{{ stats.total_reservations }}</h2>
|
||||
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Reservas{% endtrans %}</span>
|
||||
<h2 class="mb-0 fw-bold mt-1 text-info">{{ stats.total_reservations }}</h2>
|
||||
</div>
|
||||
<div class="bg-info bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-calendar3 text-info fs-4"></i>
|
||||
<div class="bg-info bg-opacity-10 rounded-circle p-3 text-info">
|
||||
<i class="bi bi-calendar3 fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Confirmadas{% endtrans %}</span>
|
||||
<h2 class="mb-0 fw-bold mt-1 text-success">{{ stats.confirmed }}</h2>
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 rounded-circle p-3 text-success">
|
||||
<i class="bi bi-check-circle fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Pendientes{% endtrans %}</span>
|
||||
<h2 class="mb-0 fw-bold mt-1 text-warning">{{ stats.pending }}</h2>
|
||||
</div>
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle p-3 text-warning">
|
||||
<i class="bi bi-clock fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Aulas Ocupadas{% endtrans %}</span>
|
||||
<h2 class="mb-0 fw-bold mt-1 text-primary">{{ stats.occupied_classrooms }}/{{ stats.total_classrooms }}</h2>
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3 text-primary">
|
||||
<i class="bi bi-door-open fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">{% trans %}Confirmed{% endtrans %}</h6>
|
||||
<h2 class="mb-0">{{ stats.confirmed }}</h2>
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-check-circle text-success fs-4"></i>
|
||||
<!-- Filters Toolbar (Piso, Turno y Ocultar Virtuales) -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-3">
|
||||
<form method="GET" action="{{ url_for('schedule.today_schedule') }}" class="row g-2 align-items-center">
|
||||
<!-- Filter by Floor -->
|
||||
<div class="col-lg-3 col-md-4">
|
||||
<label for="floorSelect" class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-layers text-secondary me-1"></i>{% trans %}Piso / Planta{% endtrans %}
|
||||
</label>
|
||||
<select name="floor" id="floorSelect" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
|
||||
{% for f in available_floors %}
|
||||
<option value="{{ f }}" {% if floor_filter == f|string %}selected{% endif %}>
|
||||
{% if f == 0 %}{% trans %}Planta Baja (Piso 0){% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Filter by Turno -->
|
||||
<div class="col-lg-3 col-md-4">
|
||||
<label for="shiftSelect" class="form-label small fw-bold text-muted mb-1">
|
||||
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
|
||||
</label>
|
||||
<select name="shift" id="shiftSelect" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
|
||||
<option value="Mañana" {% if shift_filter == 'Mañana' %}selected{% endif %}>{% trans %}Turno Mañana{% endtrans %}</option>
|
||||
<option value="Tarde" {% if shift_filter == 'Tarde' %}selected{% endif %}>{% trans %}Turno Tarde / Vespertino{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Hide Virtual Classrooms Toggle -->
|
||||
<div class="col-lg-4 col-md-4 pt-md-3">
|
||||
<div class="form-check form-switch pt-1">
|
||||
<input class="form-check-input" type="checkbox" role="switch" name="hide_virtual" value="1" id="hideVirtualToday" {% if hide_virtual %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="form-check-label fw-semibold small text-dark" for="hideVirtualToday">
|
||||
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="col-lg-2 col-md-12 pt-md-3 text-lg-end d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm flex-fill">
|
||||
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Limpiar Filtros{% endtrans %}">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">{% trans %}Pending{% endtrans %}</h6>
|
||||
<h2 class="mb-0">{{ stats.pending }}</h2>
|
||||
</div>
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-clock text-warning fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">{% trans %}Active Classrooms{% endtrans %}</h6>
|
||||
<h2 class="mb-0">{{ stats.occupied_classrooms }}/{{ stats.total_classrooms }}</h2>
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-door-open text-primary fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Schedule -->
|
||||
{% if time_blocks %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-clock-history text-info"></i> Timeline View
|
||||
</h5>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary" id="refresh-btn">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1" id="toggle-view-btn">
|
||||
<i class="bi bi-grid"></i> Grid View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="timeline-view">
|
||||
{% for hour in range(7, 22) %}
|
||||
<div class="row border-bottom py-3 align-items-start">
|
||||
<div class="col-md-2 col-3">
|
||||
<div class="text-center">
|
||||
<strong>{{ "%02d:00"|format(hour) }}</strong>
|
||||
<br>
|
||||
<small class="text-muted">{{ hour+1 }}:00</small>
|
||||
</div>
|
||||
<!-- Today's Schedule -->
|
||||
{% if time_blocks %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 py-3 bg-body-tertiary">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-clock-history text-info me-2"></i>{% trans %}Vista Cronológica por Horario{% endtrans %}
|
||||
</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" id="refresh-btn">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>{% trans %}Actualizar{% endtrans %}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="toggle-view-btn">
|
||||
<i class="bi bi-grid me-1"></i>{% trans %}Vista en Tabla{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-10 col-9">
|
||||
{% if time_blocks[hour] %}
|
||||
<div class="row">
|
||||
{% for item in time_blocks[hour] %}
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h6 class="card-title mb-1">
|
||||
{{ item.commission.get_full_code() }}
|
||||
</h6>
|
||||
<p class="text-muted small mb-2 mb-md-0">
|
||||
<i class="bi bi-geo-alt"></i> {{ item.classroom.code }}
|
||||
<br class="d-md-none">
|
||||
<i class="bi bi-person ms-md-2"></i> {{ item.user.first_name }} {{ item.user.last_name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<!-- Timeline View -->
|
||||
<div id="timeline-view">
|
||||
{% for hour in range(7, 22) %}
|
||||
<div class="row border-bottom py-3 align-items-start {% if time_blocks[hour] %}bg-light-subtle{% endif %}">
|
||||
<div class="col-md-2 col-3">
|
||||
<div class="text-center p-2 rounded bg-body-tertiary border">
|
||||
<strong class="h5 text-primary mb-0 font-monospace">{{ "%02d:00"|format(hour) }}</strong>
|
||||
<div class="small text-muted font-monospace">{{ hour+1 }}:00 hs</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-10 col-9">
|
||||
{% if time_blocks[hour] %}
|
||||
<div class="row g-2">
|
||||
{% for item in time_blocks[hour] %}
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100 border-start border-4 border-primary">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h6 class="card-title fw-bold mb-1 text-primary">
|
||||
{{ item.reservation.subject_name }}
|
||||
</h6>
|
||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border me-1">
|
||||
{{ item.reservation.subject_code }}
|
||||
</span>
|
||||
<span class="badge bg-info-subtle text-info border">
|
||||
{{ item.reservation.shift or 'Tarde' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="badge bg-{% if item.reservation.status == 'CONFIRMED' or item.reservation.status.value == 'CONFIRMED' %}success-subtle text-success border border-success-subtle{% else %}warning-subtle text-warning border border-warning-subtle{% endif %}">
|
||||
{% if item.reservation.status == 'CONFIRMED' or item.reservation.status.value == 'CONFIRMED' %}
|
||||
{% trans %}Confirmada{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}Pendiente{% endtrans %}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<span class="badge bg-{% if item.reservation.status.value == 'CONFIRMED' %}success{% elif item.reservation.status.value == 'PENDING' %}warning{% else %}secondary{% endif %}">
|
||||
{{ item.reservation.status.value }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row small">
|
||||
<div class="col-md-6">
|
||||
<i class="bi bi-clock"></i>
|
||||
{{ item.reservation.start_time.strftime('%H:%M') }} - {{ item.reservation.end_time.strftime('%H:%M') }}
|
||||
<div class="small text-muted mb-2">
|
||||
<span class="fw-semibold text-dark me-2">
|
||||
<i class="bi bi-door-open me-1 text-success"></i>{{ item.classroom.code }}
|
||||
</span>
|
||||
<span class="me-2">
|
||||
<i class="bi bi-layers me-1"></i>{% if item.classroom.floor == 0 %}PB{% else %}Piso {{ item.classroom.floor }}{% endif %}
|
||||
</span>
|
||||
<span>
|
||||
<i class="bi bi-clock me-1 text-warning"></i>{{ item.reservation.start_time.strftime('%H:%M') }} - {{ item.reservation.end_time.strftime('%H:%M') }} hs
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<i class="bi bi-people"></i>
|
||||
{{ item.reservation.expected_attendees }} attendees
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center pt-2 border-top">
|
||||
<span class="small text-muted">
|
||||
<i class="bi bi-people me-1"></i>{{ item.reservation.expected_attendees }} {% trans %}estudiantes{% endtrans %}
|
||||
</span>
|
||||
<div class="d-flex gap-1">
|
||||
{% if item.reservation.effective_virtual_link %}
|
||||
<a href="{{ item.reservation.effective_virtual_link }}" target="_blank" class="btn btn-sm btn-outline-success py-0 px-2 d-inline-flex align-items-center" title="{% trans %}Unirse a Videollamada (Zoom / Meet){% endtrans %}">
|
||||
<i class="bi bi-camera-video me-1"></i>Zoom/Meet
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.can_edit_reservation(item.reservation) %}
|
||||
<a href="{{ url_for('schedule.edit_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-warning py-0 px-2" title="{% trans %}Modificar Reserva{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha Detallada{% endtrans %}">
|
||||
<i class="bi bi-eye me-1"></i>{% trans %}Detalles{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if item.reservation.purpose %}
|
||||
<p class="card-text small text-muted mt-2">
|
||||
{{ item.reservation.purpose }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> Details
|
||||
</a>
|
||||
{% if item.reservation.status.value == 'PENDING' %}
|
||||
<a href="{{ url_for('schedule.confirm_reservation', id=item.reservation.id) }}"
|
||||
class="btn btn-sm btn-outline-success ms-1"
|
||||
onclick="return confirm('Confirm this reservation?')">
|
||||
<i class="bi bi-check-circle"></i> Confirm
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-3">
|
||||
<i class="bi bi-circle"></i> No reservations scheduled
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Grid View (hidden by default) -->
|
||||
<div id="grid-view" style="display: none;">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Classroom</th>
|
||||
<th>Subject</th>
|
||||
<th>Teacher</th>
|
||||
<th>Purpose</th>
|
||||
<th>Attendees</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reservation in reservations %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ reservation.start_time.strftime('%H:%M') }} -
|
||||
{{ reservation.end_time.strftime('%H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
<i class="bi bi-geo-alt"></i>
|
||||
{{ reservation.classroom.code }}
|
||||
</td>
|
||||
<td>{{ reservation.commission.get_full_code() }}</td>
|
||||
<td>{{ reservation.user.first_name }} {{ reservation.user.last_name }}</td>
|
||||
<td>{{ reservation.purpose }}</td>
|
||||
<td>{{ reservation.expected_attendees }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if reservation.status.value == 'CONFIRMED' %}success{% elif reservation.status.value == 'PENDING' %}warning{% else %}secondary{% endif %}">
|
||||
{{ reservation.status.value }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{% if reservation.status.value == 'PENDING' %}
|
||||
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"
|
||||
class="btn btn-sm btn-outline-success ms-1"
|
||||
onclick="return confirm('Confirm this reservation?')">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Summary -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-graph-up text-warning"></i> Daily Analytics
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="mb-2">
|
||||
<i class="bi bi-door-open-fill text-success" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
<h6>Most Active Hours</h6>
|
||||
<!-- Calculate peak hours from data -->
|
||||
{% set peak_hours = [] %}
|
||||
{% for item in reservations %}
|
||||
{% set slot = item.start_time.hour %}
|
||||
{% if slot not in peak_hours %}
|
||||
{% set _ = peak_hours.append(slot) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<p class="text-muted">
|
||||
{% if peak_hours %}
|
||||
{% for hour in peak_hours[:3]|sort %}
|
||||
{{ "%02d:00"|format(hour) }}{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
No data available
|
||||
<div class="text-muted py-2 small ps-2">
|
||||
<i class="bi bi-dash-circle me-1"></i>{% trans %}Sin clases programadas en este horario{% endtrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="mb-2">
|
||||
<i class="bi bi-building text-info" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
<h6>Busiest Classrooms</h6>
|
||||
<p class="text-muted">
|
||||
{% if reservations %}
|
||||
{% set classroom_codes = [] %}
|
||||
{% for item in reservations %}
|
||||
{% if item.classroom.code not in classroom_codes %}
|
||||
{% set _ = classroom_codes.append(item.classroom.code) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for code in classroom_codes[:3] %}
|
||||
{{ code }}{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
No data available
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="mb-2">
|
||||
<i class="bi bi-clock-fill text-primary" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
<h6>Total Hours Booked</h6>
|
||||
<p class="text-muted">
|
||||
{% set total_hours = 0 %}
|
||||
{% for item in reservations %}
|
||||
{% set duration = (item.end_time - item.start_time).total_seconds() / 3600 %}
|
||||
{% set total_hours = (total_hours + duration) %}
|
||||
{% endfor %}
|
||||
{{ total_hours|round(1) }} hours total
|
||||
</p>
|
||||
|
||||
<!-- Table / Grid View (hidden by default) -->
|
||||
<div id="grid-view" style="display: none;">
|
||||
<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 %}Horario{% endtrans %}</th>
|
||||
<th>{% trans %}Aula / Piso{% endtrans %}</th>
|
||||
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
|
||||
<th>{% trans %}Turno{% endtrans %}</th>
|
||||
<th>{% trans %}Asistentes{% endtrans %}</th>
|
||||
<th>{% trans %}Estado{% endtrans %}</th>
|
||||
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reservation in reservations %}
|
||||
<tr>
|
||||
<td class="ps-3 font-monospace fw-bold">
|
||||
{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border">
|
||||
{{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
|
||||
</span>
|
||||
<small class="text-muted d-block">
|
||||
{% if reservation.classroom and reservation.classroom.floor == 0 %}Planta Baja{% elif reservation.classroom %}Piso {{ reservation.classroom.floor }}{% endif %}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold text-primary">{{ reservation.subject_name }}</div>
|
||||
<small class="text-muted font-monospace">{{ reservation.subject_code }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info-subtle text-info border">
|
||||
{{ reservation.shift or 'Tarde' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="small">{{ reservation.expected_attendees }} estudiantes</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>
|
||||
{% else %}
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
|
||||
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% 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>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-calendar-x text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="mt-3 text-muted">{% trans %}No reservations scheduled for today{% endtrans %}</h4>
|
||||
<p class="text-muted">
|
||||
{% trans %}There are no classroom reservations for today.{% endtrans %}
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}Schedule First Class{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary ms-2">
|
||||
<i class="bi bi-calendar-week"></i> {% trans %}View Week Schedule{% endtrans %}
|
||||
</a>
|
||||
{% if current_user and current_user.role in ['admin', 'teacher'] %}
|
||||
<a href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}" class="btn btn-info text-white ms-2">
|
||||
<i class="bi bi-cpu"></i> {% trans %}AI Room Optimizer{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-calendar-x text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="mt-3 text-muted">{% trans %}No hay reservas programadas para hoy{% endtrans %}</h4>
|
||||
<p class="text-muted">
|
||||
{% trans %}No se encontraron clases para los filtros seleccionados.{% endtrans %}
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
{% if current_user.has_permission('reservations', 'read_write') %}
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Crear Primera Reserva{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary ms-2">
|
||||
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver Calendario Semanal{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Toggle between timeline and grid view
|
||||
$('#toggle-view-btn').on('click', function() {
|
||||
const timelineView = $('#timeline-view');
|
||||
const gridView = $('#grid-view');
|
||||
const btn = $(this);
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var toggleBtn = document.getElementById('toggle-view-btn');
|
||||
var timelineView = document.getElementById('timeline-view');
|
||||
var gridView = document.getElementById('grid-view');
|
||||
|
||||
if (timelineView.is(':visible')) {
|
||||
timelineView.hide();
|
||||
gridView.show();
|
||||
btn.html('<i class="bi bi-list"></i> Timeline View');
|
||||
} else {
|
||||
timelineView.show();
|
||||
gridView.hide();
|
||||
btn.html('<i class="bi bi-grid"></i> Grid View');
|
||||
}
|
||||
});
|
||||
if (toggleBtn && timelineView && gridView) {
|
||||
toggleBtn.addEventListener('click', function() {
|
||||
if (timelineView.style.display === 'none') {
|
||||
timelineView.style.display = 'block';
|
||||
gridView.style.display = 'none';
|
||||
toggleBtn.innerHTML = '<i class="bi bi-grid me-1"></i>{% trans %}Vista en Tabla{% endtrans %}';
|
||||
} else {
|
||||
timelineView.style.display = 'none';
|
||||
gridView.style.display = 'block';
|
||||
toggleBtn.innerHTML = '<i class="bi bi-clock-history me-1"></i>{% trans %}Vista Cronológica{% endtrans %}';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh the page
|
||||
$('#refresh-btn').on('click', function() {
|
||||
location.reload();
|
||||
});
|
||||
|
||||
// Auto-refresh every 5 minutes
|
||||
setInterval(function() {
|
||||
location.reload();
|
||||
}, 300000); // 5 minutes = 300000 milliseconds
|
||||
|
||||
// Highlight current time
|
||||
const currentHour = new Date().getHours();
|
||||
if (currentHour >= 7 && currentHour < 22) {
|
||||
const timeBlockIndex = currentHour - 7;
|
||||
$('.row.border-bottom').eq(timeBlockIndex).addClass('bg-primary bg-opacity-10');
|
||||
var refreshBtn = document.getElementById('refresh-btn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', function() {
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -10,9 +10,14 @@
|
||||
<h1 class="h3">
|
||||
<i class="bi bi-calendar-check text-info"></i> Reservation Details
|
||||
</h1>
|
||||
<div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if current_user.can_edit_reservation(reservation) %}
|
||||
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}" class="btn btn-warning">
|
||||
<i class="bi bi-pencil-square me-1"></i>{% trans %}Modificar Reserva{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to List
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to List{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,7 +28,7 @@
|
||||
<!-- Main Reservation Info -->
|
||||
<div class="col-lg-8 mb-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<div class="card-header border-0 py-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-info-circle text-primary"></i> Reservation Information
|
||||
@@ -54,7 +59,7 @@
|
||||
<label class="text-muted small">Date & Time</label>
|
||||
<p class="mb-0">
|
||||
<i class="bi bi-clock"></i>
|
||||
{{ reservation.start_time.strftime('%B %d, %Y') }}<br>
|
||||
{{ format_date(reservation.start_time, format='long')|capitalize }}<br>
|
||||
{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -86,35 +91,70 @@
|
||||
|
||||
<!-- Classroom Details -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-building text-info"></i> Classroom Information
|
||||
<div class="card-header border-0 py-3 bg-body-tertiary">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-geo-alt text-primary me-2"></i>{% trans %}Ubicación y Espacio Asignado{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body p-4">
|
||||
{% if reservation.classroom %}
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="text-muted small">Classroom</label>
|
||||
<p class="mb-0"><strong>{{ reservation.classroom.code_display }}</strong></p>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="p-3 rounded bg-body-tertiary border">
|
||||
<small class="text-muted d-block mb-1">🏢 {% trans %}Edificio / Sede{% endtrans %}</small>
|
||||
<h6 class="mb-0 fw-bold text-primary">{{ reservation.classroom.building_name }}</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="text-muted small">Building</label>
|
||||
<p class="mb-0">{{ reservation.classroom.building }}</p>
|
||||
<div class="col-md-4">
|
||||
<div class="p-3 rounded bg-body-tertiary border">
|
||||
<small class="text-muted d-block mb-1">🪜 {% trans %}Piso / Nivel{% endtrans %}</small>
|
||||
<h6 class="mb-0 fw-bold text-secondary">
|
||||
{% if reservation.classroom.is_virtual %}
|
||||
<span class="badge bg-primary-subtle text-primary border">Campus Virtual</span>
|
||||
{% else %}
|
||||
{{ reservation.classroom.floor_display }}
|
||||
{% endif %}
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="text-muted small">Capacity</label>
|
||||
<p class="mb-0">{{ reservation.classroom.capacity }} people</p>
|
||||
<div class="col-md-4">
|
||||
<div class="p-3 rounded bg-body-tertiary border">
|
||||
<small class="text-muted d-block mb-1">🚪 {% trans %}Aula{% endtrans %}</small>
|
||||
<h6 class="mb-0 fw-bold text-success">{{ reservation.classroom.code }}</h6>
|
||||
<small class="text-muted">{% trans %}Capacidad:{% endtrans %} <strong>{{ reservation.classroom.capacity_display }}</strong></small>
|
||||
</div>
|
||||
</div>
|
||||
{% if reservation.classroom.description %}
|
||||
<div class="col-12">
|
||||
<label class="text-muted small">Description</label>
|
||||
<p class="mb-0">{{ reservation.classroom.description }}</p>
|
||||
</div>
|
||||
|
||||
{% if reservation.classroom.description %}
|
||||
<div class="mt-2">
|
||||
<label class="text-muted small">{% trans %}Descripción del Aula{% endtrans %}</label>
|
||||
<p class="mb-0 small">{{ reservation.classroom.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Banner de Aula Virtual (Zoom, Meet, Teams) -->
|
||||
{% if reservation.effective_virtual_link or (reservation.classroom and reservation.classroom.is_virtual) %}
|
||||
<div class="mt-3 p-3 rounded bg-primary bg-opacity-10 border border-primary border-opacity-25 d-flex flex-column flex-sm-row justify-content-between align-items-sm-center gap-3">
|
||||
<div>
|
||||
<div class="fw-bold text-primary mb-1">
|
||||
<i class="bi bi-camera-video me-1"></i>{% trans %}Aula Virtual / Videoconferencia{% endtrans %}
|
||||
</div>
|
||||
{% if reservation.effective_virtual_link %}
|
||||
<small class="text-secondary text-break font-monospace d-block">{{ reservation.effective_virtual_link }}</small>
|
||||
{% else %}
|
||||
<small class="text-muted fst-italic">{% trans %}Modalidad remota sin enlace específico registrado{% endtrans %}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if reservation.effective_virtual_link %}
|
||||
<a href="{{ reservation.effective_virtual_link }}" target="_blank" class="btn btn-success btn-sm px-3 text-nowrap">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Unirse a Clase (Zoom / Meet){% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Classroom information not available</p>
|
||||
<p class="text-muted mb-0">{% trans %}Classroom information not available{% endtrans %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,7 +164,7 @@
|
||||
<div class="col-lg-4">
|
||||
<!-- Subject & Commission -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0">
|
||||
<div class="card-header border-0 py-3">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-book text-warning"></i> Subject & Commission
|
||||
</h5>
|
||||
@@ -149,7 +189,7 @@
|
||||
|
||||
<!-- Attendees -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0">
|
||||
<div class="card-header border-0 py-3">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-people text-success"></i> Attendance
|
||||
</h5>
|
||||
@@ -159,14 +199,23 @@
|
||||
<h3 class="mb-1">{{ reservation.expected_attendees }}</h3>
|
||||
<p class="text-muted small">Expected Attendees</p>
|
||||
{% if reservation.classroom %}
|
||||
<div class="progress mt-3" style="height: 8px;">
|
||||
{% set percentage = (reservation.expected_attendees / reservation.classroom.capacity * 100) %}
|
||||
<div class="progress-bar bg-{% if percentage > 90 %}danger{% elif percentage > 75 %}warning{% else %}success{% endif %}"
|
||||
style="width: {{ percentage }}%"></div>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} capacity
|
||||
</small>
|
||||
{% if reservation.classroom.capacity and reservation.classroom.capacity > 0 %}
|
||||
<div class="progress mt-3" style="height: 8px;">
|
||||
{% set percentage = (reservation.expected_attendees / reservation.classroom.capacity * 100) %}
|
||||
<div class="progress-bar bg-{% if percentage > 90 %}danger{% elif percentage > 75 %}warning{% else %}success{% endif %}"
|
||||
role="progressbar"
|
||||
style="{{ 'width: ' ~ ([percentage|round(1), 100]|min|string) ~ '%;' }}"></div>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} {% trans %}capacidad{% endtrans %}
|
||||
</small>
|
||||
{% else %}
|
||||
<div class="mt-2">
|
||||
<span class="badge bg-primary-subtle text-primary border">
|
||||
{% if reservation.classroom.is_virtual %}🌐 Campus Virtual (Sin límite físico){% else %}{% trans %}Capacidad no definida{% endtrans %}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,13 +223,19 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<div class="card-header border-0 py-3">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-gear text-secondary"></i> Actions
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
{% if current_user.can_edit_reservation(reservation) %}
|
||||
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}" class="btn btn-warning">
|
||||
<i class="bi bi-pencil-square me-1"></i>{% trans %}Modificar Reserva{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if reservation.status.value == 'PENDING' %}
|
||||
<form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}">
|
||||
<button type="submit" class="btn btn-success" onclick="return confirm('Confirm this reservation?')">
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .decorators import permission_required, admin_required
|
||||
|
||||
__all__ = ['permission_required', 'admin_required']
|
||||
@@ -0,0 +1,64 @@
|
||||
from functools import wraps
|
||||
from flask import abort, redirect, url_for, request, flash
|
||||
from flask_login import current_user
|
||||
from flask_babel import gettext as _
|
||||
|
||||
def permission_required(module, min_level='read'):
|
||||
"""
|
||||
Decorator to restrict view access based on RBAC permissions.
|
||||
Hierarchy: 'read_write' > 'read' > 'none'.
|
||||
Administrators have implicit access to all modules.
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('auth.login', next=request.url))
|
||||
|
||||
if not current_user.has_permission(module, min_level):
|
||||
flash(_('No tienes los permisos necesarios para acceder a esta sección.'), 'error')
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""
|
||||
Decorator requiring administrative privileges or 'users' management access.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('auth.login', next=request.url))
|
||||
|
||||
if not current_user.can_manage():
|
||||
flash(_('Esta sección requiere privilegios de administrador.'), 'error')
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def can_edit_reservation_required(f):
|
||||
"""
|
||||
Decorator requiring permission to modify a specific reservation.
|
||||
Allows administrators, Bedelía, or the creator/teacher of the reservation.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('auth.login', next=request.url))
|
||||
|
||||
res_id = kwargs.get('id')
|
||||
if res_id:
|
||||
from app.models.reservation import Reservation
|
||||
reservation = Reservation.query.get_or_404(res_id)
|
||||
if not current_user.can_edit_reservation(reservation):
|
||||
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# backup_db.sh — Admin Edu-Space (UniCABA)
|
||||
# Generador de backups de PostgreSQL y actualización de classrooms_db.sql
|
||||
# Uso: bash backup_db.sh
|
||||
# ==============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
|
||||
ok() { echo -e "${GREEN} ✔ $*${RESET}"; }
|
||||
info() { echo -e "${CYAN} ▸ $*${RESET}"; }
|
||||
warn() { echo -e "${YELLOW} ⚠ $*${RESET}"; }
|
||||
err() { echo -e "${RED} ✘ $*${RESET}" >&2; exit 1; }
|
||||
hdr() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}\n"; }
|
||||
|
||||
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKUP_DIR="${APP_DIR}/backups"
|
||||
TIMESTAMP="$(date +'%Y%m%d_%H%M%S')"
|
||||
BACKUP_FILE="${BACKUP_DIR}/classrooms_db_${TIMESTAMP}.sql"
|
||||
ROOT_BACKUP="${APP_DIR}/classrooms_db.sql"
|
||||
APP_ENV="${APP_DIR}/.env"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
hdr "Iniciando Backup de la Base de Datos"
|
||||
|
||||
# 1. Obtener parámetros de conexión
|
||||
DB_NAME="classrooms_db"
|
||||
DB_USER="eduspace_user"
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="5432"
|
||||
DB_PASSWORD=""
|
||||
|
||||
if [[ -f "$APP_ENV" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source <(grep -v '^#' "$APP_ENV" | grep '=')
|
||||
set +a
|
||||
if [[ -n "${DATABASE_URL:-}" ]]; then
|
||||
# Extraer credenciales desde DATABASE_URL si existe
|
||||
# Formato: postgresql://usuario:pass@host:port/dbname
|
||||
DB_USER=$(echo "$DATABASE_URL" | sed -E 's|.*://([^:]+):?([^@]*)@.*|\1|')
|
||||
DB_PASSWORD=$(echo "$DATABASE_URL" | sed -E 's|.*://[^:]+:([^@]*)@.*|\1|')
|
||||
DB_HOST=$(echo "$DATABASE_URL" | sed -E 's|.*@([^:/]+):?.*|\1|')
|
||||
DB_PORT=$(echo "$DATABASE_URL" | sed -E 's|.*:([0-9]+)/.*|\1|')
|
||||
DB_NAME=$(echo "$DATABASE_URL" | sed -E 's|.*/([^?]+).*|\1|')
|
||||
fi
|
||||
fi
|
||||
|
||||
DB_USER="${DB_USER:-postgres}"
|
||||
DB_PASSWORD="${DB_PASSWORD:-password}"
|
||||
DB_NAME="${DB_NAME:-classrooms_db}"
|
||||
DB_HOST="${DB_HOST:-localhost}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
|
||||
info "Base de datos: ${DB_NAME}"
|
||||
info "Usuario: ${DB_USER}"
|
||||
info "Host: ${DB_HOST}:${DB_PORT}"
|
||||
|
||||
# 2. Ejecutar pg_dump
|
||||
info "Generando volcado SQL..."
|
||||
|
||||
export PGPASSWORD="${DB_PASSWORD:-}"
|
||||
|
||||
if command -v pg_dump >/dev/null 2>&1; then
|
||||
# Intentar como usuario configurado
|
||||
if pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
|
||||
--no-owner --no-privileges --clean --if-exists > "$BACKUP_FILE" 2>/dev/null; then
|
||||
ok "Volcado generado con éxito usando pg_dump"
|
||||
elif [[ $EUID -eq 0 ]] || sudo -n true 2>/dev/null; then
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -u postgres pg_dump -d "$DB_NAME" --no-owner --no-privileges > "$BACKUP_FILE"
|
||||
elif command -v runuser >/dev/null 2>&1; then
|
||||
runuser -u postgres -- pg_dump -d "$DB_NAME" --no-owner --no-privileges > "$BACKUP_FILE"
|
||||
else
|
||||
su - postgres -c "pg_dump -d \"$DB_NAME\" --no-owner --no-privileges" > "$BACKUP_FILE"
|
||||
fi
|
||||
ok "Volcado generado como usuario postgres"
|
||||
else
|
||||
err "No se pudo realizar el backup. Verifica tus permisos y credenciales."
|
||||
fi
|
||||
else
|
||||
err "No se encontró la herramienta 'pg_dump'. Instala postgresql-client."
|
||||
fi
|
||||
|
||||
# 3. Limpiar comandos de versiones específicas (ej: \restrict de PG 18) para máxima compatibilidad
|
||||
sed -i '/^\\restrict/d' "$BACKUP_FILE" 2>/dev/null || true
|
||||
|
||||
# 4. Actualizar copia en raíz para despliegues automáticos
|
||||
cp "$BACKUP_FILE" "$ROOT_BACKUP"
|
||||
|
||||
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | awk '{print $1}')
|
||||
|
||||
hdr "Resumen del Backup"
|
||||
ok "Archivo histórico: ${BACKUP_FILE} (${BACKUP_SIZE})"
|
||||
ok "Copia para deploy: ${ROOT_BACKUP} (${BACKUP_SIZE})"
|
||||
info "Para restaurar en otro servidor:"
|
||||
echo -e " ${CYAN}sudo -u postgres psql -d ${DB_NAME} < ${ROOT_BACKUP}${RESET}"
|
||||
echo ""
|
||||
@@ -1,10 +1,16 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
basedir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||
load_dotenv(os.path.join(basedir, '.env'))
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost:5432/classrooms_db'
|
||||
|
||||
_db_url = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost:5432/classrooms_db'
|
||||
if _db_url.startswith('postgres://'):
|
||||
_db_url = _db_url.replace('postgres://', 'postgresql://', 1)
|
||||
SQLALCHEMY_DATABASE_URI = _db_url
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
API_BASE_URL = os.environ.get('API_BASE_URL') or 'http://localhost:8080/api'
|
||||
|
||||
@@ -17,7 +23,7 @@ class Config:
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||||
|
||||
# Internationalization
|
||||
LANGUAGES = ['en', 'es']
|
||||
BABEL_DEFAULT_LOCALE = 'en'
|
||||
LANGUAGES = ['es', 'en']
|
||||
BABEL_DEFAULT_LOCALE = 'es'
|
||||
BABEL_DEFAULT_TIMEZONE = 'UTC'
|
||||
BABEL_TRANSLATION_DIRECTORIES = os.path.join(basedir, 'translations')
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () => import('./home/home.module').then( m => m.HomePageModule)
|
||||
},
|
||||
{
|
||||
path: 'perfil',
|
||||
loadChildren: () => import('./perfil/perfil.module').then( m => m.PerfilPageModule)
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'home',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
|
||||
],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
@@ -0,0 +1,3 @@
|
||||
<ion-app>
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
</ion-app>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [AppComponent],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Component, OnInit, NgZone } from '@angular/core';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { BackgroundFetch } from '@transistorsoft/capacitor-background-fetch';
|
||||
import { Network } from '@capacitor/network';
|
||||
import { DataService } from './services/data';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
constructor(
|
||||
private dataService: DataService,
|
||||
private router: Router,
|
||||
private zone: NgZone
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.setupBackgroundFetch();
|
||||
this.setupNotificationAction();
|
||||
}
|
||||
|
||||
private async setupBackgroundFetch() {
|
||||
const status = await Network.getStatus();
|
||||
// WiFi: 15 min, Mobile: 60 min
|
||||
const interval = status.connectionType === 'wifi' ? 15 : 60;
|
||||
|
||||
BackgroundFetch.configure({
|
||||
minimumFetchInterval: interval,
|
||||
stopOnTerminate: false,
|
||||
startOnBoot: true,
|
||||
enableHeadless: true
|
||||
}, async (taskId: string) => {
|
||||
console.log('[BackgroundFetch] Event received: ', taskId);
|
||||
await this.performSync();
|
||||
BackgroundFetch.finish(taskId);
|
||||
}, (error: string) => {
|
||||
console.error('[BackgroundFetch] FAILED: ', error);
|
||||
});
|
||||
}
|
||||
|
||||
private async performSync() {
|
||||
try {
|
||||
// 1. Obtener favoritos
|
||||
const favs = await this.dataService.getFavorites();
|
||||
// 2. Fetch y Sync (esto ya dispara notificaciones si hay cambios)
|
||||
this.dataService.getHorarios().subscribe(async (newData) => {
|
||||
await this.dataService.syncWithCache(newData, favs || []);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[BackgroundSync] Error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private setupNotificationAction() {
|
||||
LocalNotifications.addListener('localNotificationActionPerformed', (notification) => {
|
||||
const codigo = notification.notification.extra?.codigo;
|
||||
if (codigo) {
|
||||
this.zone.run(() => {
|
||||
// Navegar a home y pasar el código para resaltar/scroll
|
||||
this.router.navigate(['/home'], { queryParams: { highlight: codigo } });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { RouteReuseStrategy } from '@angular/router';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule, HttpClientModule],
|
||||
providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule { }
|
||||
@@ -0,0 +1,48 @@
|
||||
<ion-header>
|
||||
<ion-toolbar color="primary">
|
||||
<ion-title>Agenda Académica</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">Cerrar</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding" color="light">
|
||||
<div class="materia-info">
|
||||
<h2>{{ materia.asignatura }}</h2>
|
||||
<p>Código: {{ materia.codigo }}</p>
|
||||
</div>
|
||||
|
||||
<ion-button expand="block" (click)="abrirEditor()" class="add-btn">
|
||||
<ion-icon name="add-circle-outline" slot="start"></ion-icon>
|
||||
Agendar Nuevo Evento
|
||||
</ion-button>
|
||||
|
||||
<div class="empty-state" *ngIf="eventos.length === 0">
|
||||
<ion-icon name="calendar-clear-outline"></ion-icon>
|
||||
<p>No hay eventos programados.</p>
|
||||
</div>
|
||||
|
||||
<ion-list *ngIf="eventos.length > 0" class="agenda-list">
|
||||
<ion-item *ngFor="let ev of eventos" button (click)="abrirEditor(ev)" lines="full" class="agenda-item">
|
||||
<div class="item-content">
|
||||
<div class="item-header">
|
||||
<ion-badge color="secondary">{{ ev.tipo }}</ion-badge>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<p class="fecha-hora">
|
||||
<ion-icon name="calendar-outline"></ion-icon> {{ formatFecha(ev.fecha) }}
|
||||
<ion-icon name="time-outline" class="icon-space"></ion-icon> {{ formatHora(ev.fecha) }} hs
|
||||
</p>
|
||||
<p class="aula" *ngIf="ev.aula">
|
||||
<ion-icon name="location-outline"></ion-icon> Aula: {{ ev.aula }}
|
||||
</p>
|
||||
<p class="notas" *ngIf="ev.notas">
|
||||
{{ ev.notas }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ion-icon name="chevron-forward-outline" slot="end" color="medium"></ion-icon>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,77 @@
|
||||
.materia-info {
|
||||
margin-bottom: 20px;
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--ion-color-medium);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
margin-bottom: 20px;
|
||||
--border-radius: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
color: var(--ion-color-medium);
|
||||
|
||||
ion-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.agenda-list {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agenda-item {
|
||||
--background: var(--ion-card-background, #ffffff);
|
||||
--border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
|
||||
.item-content {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
p {
|
||||
margin: 4px 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ion-text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
ion-icon {
|
||||
margin-right: 6px;
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
|
||||
&.notas {
|
||||
color: var(--ion-color-medium);
|
||||
font-style: italic;
|
||||
margin-top: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-space {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { AgendaModalComponent } from './agenda-modal.component';
|
||||
|
||||
describe('AgendaModalComponent', () => {
|
||||
let component: AgendaModalComponent;
|
||||
let fixture: ComponentFixture<AgendaModalComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AgendaModalComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AgendaModalComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { IonicModule, ModalController } from '@ionic/angular';
|
||||
import { EventoAcademico, ClaseHorario } from '../../services/data';
|
||||
import { EventosService } from '../../services/eventos.service';
|
||||
import { EventoModalComponent } from '../evento-modal/evento-modal.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-agenda-modal',
|
||||
templateUrl: './agenda-modal.component.html',
|
||||
styleUrls: ['./agenda-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, IonicModule]
|
||||
})
|
||||
export class AgendaModalComponent implements OnInit {
|
||||
@Input() materia!: ClaseHorario;
|
||||
eventos: EventoAcademico[] = [];
|
||||
|
||||
constructor(
|
||||
private modalCtrl: ModalController,
|
||||
private eventosService: EventosService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.cargarEventos();
|
||||
}
|
||||
|
||||
ionViewWillEnter() {
|
||||
this.cargarEventos();
|
||||
}
|
||||
|
||||
cargarEventos() {
|
||||
this.eventos = this.eventosService.getEventosByMateria(this.materia.codigo)
|
||||
.sort((a, b) => new Date(a.fecha).getTime() - new Date(b.fecha).getTime());
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss();
|
||||
}
|
||||
|
||||
formatFecha(fechaIso: string): string {
|
||||
const d = new Date(fechaIso);
|
||||
return d.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
formatHora(fechaIso: string): string {
|
||||
const d = new Date(fechaIso);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
async abrirEditor(evento?: EventoAcademico) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: EventoModalComponent,
|
||||
componentProps: {
|
||||
materiaCodigo: this.materia.codigo,
|
||||
materiaNombre: this.materia.asignatura,
|
||||
eventoExistente: evento
|
||||
}
|
||||
});
|
||||
|
||||
await modal.present();
|
||||
|
||||
const { role } = await modal.onWillDismiss();
|
||||
if (role === 'saved' || role === 'deleted') {
|
||||
this.cargarEventos();
|
||||
}
|
||||
}
|
||||
}
|
||||