feat: gestion RBAC granular, importacion sheets, filtrado de aulas virtuales y pisos, capacidad infinita y traduccion

This commit is contained in:
Carlos Tello
2026-09-03 03:08:49 -03:00
parent d1d4be0428
commit 5cc33cc7a0
138 changed files with 14419 additions and 1490 deletions
+205 -36
View File
@@ -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,234 @@
* **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`).
+1 -1
View File
@@ -3,5 +3,5 @@ from app import create_app
app = create_app()
if __name__ == '__main__':
# Server entrypoint with auto-reload (locale auto-detection & profile override)
# Server entrypoint with auto-reload (RBAC 8 modules, ∞ Infinite Capacity & Clean Class Hours active)
app.run(debug=True, host='0.0.0.0', port=5000)
+44 -8
View File
@@ -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
+28
View File
@@ -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'))
+20 -7
View File
@@ -1,10 +1,24 @@
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)
@@ -14,12 +28,11 @@ class ClassroomForm(FlaskForm):
Length(min=1, max=50)
])
floor = IntegerField(_l('Floor'), validators=[
DataRequired(),
NumberRange(min=1, max=50)
InputRequired(message=_l('El piso es obligatorio (0 para Planta Baja)')),
NumberRange(min=0, max=50, message=_l('El piso debe estar entre 0 (Planta Baja) y 50'))
])
capacity = IntegerField(_l('Capacity'), validators=[
DataRequired(),
NumberRange(min=1, max=500)
capacity = StringField(_l('Capacity'), validators=[
validate_capacity
])
description = TextAreaField(_l('Description'), validators=[
Optional(),
+9 -1
View File
@@ -1,17 +1,25 @@
from .user import User
from .building import Building
from .classroom import Classroom, ClassroomResource
from .career import Career
from .subject import Subject, Commission
from .reservation import Reservation, ReservationStatus
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
from .role import Role, Permission, SYSTEM_MODULES
__all__ = [
'User',
'Building',
'Classroom',
'ClassroomResource',
'Career',
'Subject',
'Commission',
'Reservation',
'ReservationStatus',
'GeneticAlgorithm',
'ReservationOptimizer'
'ReservationOptimizer',
'Role',
'Permission',
'SYSTEM_MODULES'
]
+45
View File
@@ -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}>'
+27
View File
@@ -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}>'
+12
View File
@@ -6,6 +6,7 @@ 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)
@@ -40,6 +41,17 @@ class Classroom(db.Model):
def code_display(self):
return f"{self.building}-{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.code and 'VIRTUAL' in self.code.upper()):
return '∞'
return str(self.capacity)
@property
def is_unlimited(self):
return self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper())
def to_dict(self):
"""Convert classroom to dictionary"""
return {
+48 -2
View File
@@ -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,9 @@ 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
notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
@@ -32,10 +57,31 @@ 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 ''
# 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'))
+142
View File
@@ -0,0 +1,142 @@
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, 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.'
}
]
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}>'
+7 -5
View File
@@ -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,7 @@ 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
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)
+21 -2
View File
@@ -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,22 @@ 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')
def update_last_login(self):
"""Update last login timestamp"""
@@ -55,7 +72,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):
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+3 -1
View File
@@ -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']
+342
View File
@@ -0,0 +1,342 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, abort
from flask_login import login_required, current_user
from flask_babel import gettext as _
from app import db
from app.models.user import User
from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS
from app.utils.decorators import admin_required, permission_required
from sqlalchemy import or_
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
# ---------------------------------------------------------
# USERS MANAGEMENT
# ---------------------------------------------------------
@admin_bp.route('/users')
@admin_required
def users_list():
"""List system users with filtering and role information"""
search = request.args.get('search', '').strip()
role_filter = request.args.get('role', '').strip()
status_filter = request.args.get('status', '').strip()
page = request.args.get('page', 1, type=int)
per_page = 15
query = User.query
if search:
query = query.filter(
or_(
User.name.ilike(f'%{search}%'),
User.email.ilike(f'%{search}%')
)
)
if role_filter:
if role_filter.isdigit():
query = query.filter(User.role_id == int(role_filter))
else:
query = query.filter(User.role.ilike(role_filter))
if status_filter in ['active', 'inactive']:
query = query.filter(User.is_active == (status_filter == 'active'))
pagination = query.order_by(User.id.asc()).paginate(page=page, per_page=per_page, error_out=False)
roles = Role.query.order_by(Role.name.asc()).all()
return render_template(
'admin/users/list.html',
users=pagination.items,
pagination=pagination,
roles=roles,
search=search,
role_filter=role_filter,
status_filter=status_filter
)
@admin_bp.route('/users/add', methods=['GET', 'POST'])
@admin_required
def user_add():
"""Create a new user and assign a role"""
roles = Role.query.order_by(Role.name.asc()).all()
if request.method == 'POST':
name = request.form.get('name', '').strip()
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '')
role_id = request.form.get('role_id', type=int)
is_active = request.form.get('is_active') == 'on'
errors = []
if not name:
errors.append(_('El nombre es requerido.'))
if not email:
errors.append(_('El correo electrónico es requerido.'))
elif User.query.filter_by(email=email).first():
errors.append(_('Ya existe una cuenta con este correo electrónico.'))
if not password or len(password) < 6:
errors.append(_('La contraseña debe tener al menos 6 caracteres.'))
role_obj = Role.query.get(role_id) if role_id else None
if not role_obj:
errors.append(_('Debes seleccionar un rol válido.'))
if errors:
for err in errors:
flash(err, 'error')
return render_template('admin/users/form.html', user=None, roles=roles)
user = User(
name=name,
email=email,
role_id=role_obj.id,
role=role_obj.name,
is_active=is_active
)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash(_('Usuario "%(name)s" creado exitosamente.', name=user.name), 'success')
return redirect(url_for('admin.users_list'))
return render_template('admin/users/form.html', user=None, roles=roles)
@admin_bp.route('/users/edit/<int:id>', methods=['GET', 'POST'])
@admin_required
def user_edit(id):
"""Edit user details, role and status"""
user = User.query.get_or_404(id)
roles = Role.query.order_by(Role.name.asc()).all()
if request.method == 'POST':
name = request.form.get('name', '').strip()
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '')
role_id = request.form.get('role_id', type=int)
is_active = request.form.get('is_active') == 'on'
errors = []
if not name:
errors.append(_('El nombre es requerido.'))
if not email:
errors.append(_('El correo electrónico es requerido.'))
else:
existing = User.query.filter_by(email=email).first()
if existing and existing.id != user.id:
errors.append(_('Ya existe otra cuenta con este correo electrónico.'))
role_obj = Role.query.get(role_id) if role_id else None
if not role_obj:
errors.append(_('Debes seleccionar un rol válido.'))
# Do not allow deactivating the currently logged in admin user
if user.id == current_user.id and not is_active:
errors.append(_('No puedes desactivar tu propia cuenta mientras estás en sesión.'))
if errors:
for err in errors:
flash(err, 'error')
return render_template('admin/users/form.html', user=user, roles=roles)
user.name = name
user.email = email
user.role_id = role_obj.id
user.role = role_obj.name
user.is_active = is_active
if password:
if len(password) < 6:
flash(_('La nueva contraseña debe tener al menos 6 caracteres.'), 'error')
return render_template('admin/users/form.html', user=user, roles=roles)
user.set_password(password)
db.session.commit()
flash(_('Usuario "%(name)s" actualizado correctamente.', name=user.name), 'success')
return redirect(url_for('admin.users_list'))
return render_template('admin/users/form.html', user=user, roles=roles)
@admin_bp.route('/users/toggle/<int:id>', methods=['POST'])
@admin_required
def user_toggle(id):
"""Toggle user active status"""
user = User.query.get_or_404(id)
if user.id == current_user.id:
flash(_('No puedes desactivar tu propia cuenta.'), 'error')
return redirect(url_for('admin.users_list'))
user.is_active = not user.is_active
db.session.commit()
status_str = _('activada') if user.is_active else _('desactivada')
flash(_('La cuenta de %(name)s ha sido %(status)s.', name=user.name, status=status_str), 'success')
return redirect(url_for('admin.users_list'))
# ---------------------------------------------------------
# ROLES & PERMISSIONS MANAGEMENT (FortiGate Matrix)
# ---------------------------------------------------------
@admin_bp.route('/roles')
@admin_required
def roles_list():
"""List all roles and permission summaries"""
roles = Role.query.order_by(Role.id.asc()).all()
return render_template(
'admin/roles/list.html',
roles=roles,
modules=SYSTEM_MODULES
)
@admin_bp.route('/roles/add', methods=['GET', 'POST'])
@admin_required
def role_add():
"""Create a new role with the FortiGate permission matrix"""
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre del rol es requerido.'), 'error')
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
if Role.query.filter_by(name=name).first():
flash(_('Ya existe un rol con ese nombre.'), 'error')
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
new_role = Role(name=name, description=description, is_system=False)
db.session.add(new_role)
db.session.flush() # to obtain new_role.id
# Save permissions from matrix
for mod in SYSTEM_MODULES:
mod_id = mod['id']
level = request.form.get(f'perm_{mod_id}', 'none')
if level not in ACCESS_LEVELS:
level = 'none'
new_role.set_permission(mod_id, level)
db.session.commit()
flash(_('Rol "%(name)s" creado exitosamente con sus permisos.', name=new_role.name), 'success')
return redirect(url_for('admin.roles_list'))
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
@admin_bp.route('/roles/edit/<int:id>', methods=['GET', 'POST'])
@admin_required
def role_edit(id):
"""Edit an existing role and its FortiGate permission matrix"""
role = Role.query.get_or_404(id)
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre del rol es requerido.'), 'error')
return render_template('admin/roles/edit.html', role=role, modules=SYSTEM_MODULES, current_perms={p.module: p.access_level for p in role.permissions})
existing = Role.query.filter_by(name=name).first()
if existing and existing.id != role.id:
flash(_('Ya existe otro rol con ese nombre.'), 'error')
return render_template('admin/roles/edit.html', role=role, modules=SYSTEM_MODULES, current_perms={p.module: p.access_level for p in role.permissions})
# If it's a system role, keep name unchanged or protected
if not role.is_system:
role.name = name
role.description = description
# Update permissions from the FortiGate matrix
for mod in SYSTEM_MODULES:
mod_id = mod['id']
level = request.form.get(f'perm_{mod_id}', 'none')
if level not in ACCESS_LEVELS:
level = 'none'
role.set_permission(mod_id, level)
db.session.commit()
flash(_('Rol "%(name)s" y permisos actualizados correctamente.', name=role.name), 'success')
return redirect(url_for('admin.roles_list'))
current_perms = {p.module: p.access_level for p in role.permissions}
return render_template(
'admin/roles/edit.html',
role=role,
modules=SYSTEM_MODULES,
current_perms=current_perms
)
@admin_bp.route('/roles/delete/<int:id>', methods=['POST'])
@admin_required
def role_delete(id):
"""Delete a non-system role if it has no assigned users"""
role = Role.query.get_or_404(id)
if role.is_system:
flash(_('No se pueden eliminar roles protegidos del sistema.'), 'error')
return redirect(url_for('admin.roles_list'))
user_count = len(role.users) if role.users else 0
if user_count > 0:
flash(_('No se puede eliminar el rol "%(name)s" porque tiene %(count)d usuario(s) asignado(s). Reasígnalos primero.', name=role.name, count=user_count), 'error')
return redirect(url_for('admin.roles_list'))
role_name = role.name
db.session.delete(role)
db.session.commit()
flash(_('El rol "%(name)s" ha sido eliminado.', name=role_name), 'success')
return redirect(url_for('admin.roles_list'))
# ---------------------------------------------------------
# GOOGLE SHEETS IMPORT & SYNCHRONIZATION
# ---------------------------------------------------------
@admin_bp.route('/import', methods=['GET', 'POST'])
@admin_required
def google_sheets_import():
"""Manual sync and status page for Google Sheets academic schedule data"""
from app.services.sheets_importer import GoogleSheetsImporter, SHEETS_CONFIG
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
stats = None
if request.method == 'POST':
try:
importer = GoogleSheetsImporter()
stats = importer.sync()
if stats['errors']:
flash(_('La sincronización se completó con algunas advertencias.'), 'warning')
else:
flash(_('Sincronización completada exitosamente. %(rows)d registros procesados.', rows=stats['total_rows_processed']), 'success')
except Exception as e:
flash(_('Error al sincronizar con Google Sheets: %(err)s', err=str(e)), 'error')
# Calculate current system metrics
metrics = {
'total_classrooms': Classroom.query.count(),
'total_careers': Career.query.count(),
'total_subjects': Subject.query.count(),
'total_commissions': Commission.query.count(),
'total_reservations': Reservation.query.count(),
'sheets': SHEETS_CONFIG
}
# Recent samples from DB if available
recent_subjects = Subject.query.order_by(Subject.id.desc()).limit(10).all()
return render_template(
'admin/import.html',
metrics=metrics,
stats=stats,
recent_subjects=recent_subjects
)
+38 -9
View File
@@ -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
+193
View File
@@ -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'))
+20 -3
View File
@@ -30,12 +30,18 @@ def add_classroom():
flash('Classroom with this building, room number, and floor already exists.', 'error')
return render_template('classrooms/add.html', form=form, 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,
capacity=cap_val,
description=form.description.data,
is_active=form.is_active.data
)
@@ -125,6 +131,12 @@ def edit_classroom(id):
classroom = Classroom.query.get_or_404(id)
form = ClassroomForm(obj=classroom)
if request.method == 'GET':
if classroom.capacity == 0 or (classroom.code and 'VIRTUAL' in classroom.code.upper()):
form.capacity.data = '∞'
else:
form.capacity.data = str(classroom.capacity)
# TODO: Update when campus model is implemented
# form.campus_id.choices = [(1, 'Main Campus')]
@@ -142,12 +154,17 @@ def edit_classroom(id):
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')
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)
# 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
classroom.capacity = cap_val
classroom.description = form.description.data
classroom.is_active = form.is_active.data
classroom.updated_at = datetime.utcnow()
+6 -1
View File
@@ -122,9 +122,14 @@ 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()
+214 -39
View File
@@ -14,21 +14,39 @@ 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 and floor_filter.isdigit():
query = query.filter(Classroom.floor == int(floor_filter))
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 +67,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 +81,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
@@ -115,8 +138,24 @@ def view_reservation(id):
@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
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()
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,
available_floors=available_floors
)
@schedule_bp.route('/commissions')
def commissions_view():
@@ -197,8 +236,80 @@ def add_reservation():
@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()
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 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 and floor_filter.isdigit():
query = query.filter(Classroom.floor == int(floor_filter))
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}%'),
Classroom.code.ilike(f'%{search}%'),
Reservation.purpose.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]
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,
search=search,
available_floors=available_floors
)
@schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST'])
@@ -321,46 +432,101 @@ 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))
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 and floor.isdigit():
query = query.filter(Classroom.floor == int(floor))
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 if reservation.classroom else None
is_virtual = bool(reservation.classroom and ('VIRTUAL' in (reservation.classroom.code or '').upper() or 'VIRTUAL' in (reservation.classroom.building or '').upper()))
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,
'subject_code': subject_code,
'subject_name': subject_name,
'classroom': classroom_name,
'building': reservation.classroom.building if reservation.classroom else '',
'floor': classroom_floor,
'is_virtual': is_virtual,
'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 ''
}
})
@@ -368,6 +534,8 @@ def calendar_data():
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 +548,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
}
})
+3
View File
@@ -0,0 +1,3 @@
from .sheets_importer import GoogleSheetsImporter
__all__ = ['GoogleSheetsImporter']
+413
View File
@@ -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
+54 -97
View File
@@ -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;
}
}
+668
View File
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+88
View File
@@ -0,0 +1,88 @@
/**
* Theme Toggle Manager - Edu-Space Admin
* Manejo de Modo Claro y Modo Oscuro con persistencia en localStorage y anti-flickering.
*/
(function() {
'use strict';
// 1. Obtener tema almacenado o preferencia del sistema
function getPreferredTheme() {
const storedTheme = localStorage.getItem('theme');
if (storedTheme) {
return storedTheme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// 2. Aplicar tema al elemento <html>
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
updateToggleButtons(theme);
}
// 3. Actualizar iconos de los botones de toggle
function updateToggleButtons(theme) {
const buttons = document.querySelectorAll('.theme-toggle-btn');
buttons.forEach(btn => {
const icon = btn.querySelector('i');
if (icon) {
if (theme === 'dark') {
icon.className = 'bi bi-sun-fill';
btn.setAttribute('aria-label', 'Cambiar a modo claro');
btn.setAttribute('title', 'Modo Claro');
} else {
icon.className = 'bi bi-moon-stars-fill';
btn.setAttribute('aria-label', 'Cambiar a modo oscuro');
btn.setAttribute('title', 'Modo Oscuro');
}
}
});
}
// 4. Función global para alternar tema
window.toggleTheme = function() {
const currentTheme = document.documentElement.getAttribute('data-bs-theme') || getPreferredTheme();
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', newTheme);
applyTheme(newTheme);
// Si el usuario está autenticado, sincronizar asíncronamente con su perfil en el backend
if (document.body && document.body.getAttribute('data-user-authenticated') === 'true') {
fetch('/api/user/theme', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ theme: newTheme })
}).catch(err => {
console.warn('No se pudo sincronizar el tema con el servidor:', err);
});
}
};
// 5. Inicializar tema inmediatamente (anti-flicker)
const initialTheme = getPreferredTheme();
applyTheme(initialTheme);
// 6. Al cargar el DOM, registrar eventos en los botones de toggle
document.addEventListener('DOMContentLoaded', () => {
updateToggleButtons(document.documentElement.getAttribute('data-bs-theme') || initialTheme);
document.querySelectorAll('.theme-toggle-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
window.toggleTheme();
});
});
});
// 7. Escuchar cambios de preferencia del sistema si el usuario no fijó una manual
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
})();
+242
View File
@@ -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 %}
+191
View File
@@ -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 %}
+124
View File
@@ -0,0 +1,124 @@
{% 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" data-bs-toggle="modal" data-bs-target="#deleteModal{{ role.id }}" title="{% trans %}Eliminar Rol{% endtrans %}" {% if role.users|length > 0 %}disabled{% endif %}>
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
{% if not role.is_system %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal{{ role.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0">
<h5 class="modal-title text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% trans %}Confirmar Eliminación de Rol{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
<p>{% trans %}¿Está seguro de que desea eliminar el rol{% endtrans %} <strong>{{ role.name }}</strong>?</p>
<p class="text-muted small mb-0">{% trans %}Esta acción no se puede deshacer.{% endtrans %}</p>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form action="{{ url_for('admin.role_delete', id=role.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger">{% trans %}Eliminar Rol{% endtrans %}</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+107
View File
@@ -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 %}
+182
View File
@@ -0,0 +1,182 @@
{% 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>
{% 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) }}">&laquo;</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) }}">&raquo;</a></li>
{% endif %}
</ul>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+96 -95
View File
@@ -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>
+41 -14
View File
@@ -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">
+73 -17
View File
@@ -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,67 @@
<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>
</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 %}Management{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% 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 & Groups{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2"></i>{% trans %}Buildings{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></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 %}Import from Google 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 +153,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 +177,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>
+95
View File
@@ -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 %}
+223
View File
@@ -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 %}
+177
View File
@@ -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 %}
+21 -4
View File
@@ -63,8 +63,19 @@
<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="d-flex justify-content-between align-items-center mb-1">
{{ form.capacity.label(class="form-label mb-0") }}
<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. 30 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> (Capacidad ilimitada).{% endtrans %}
</div>
{% if form.capacity.errors %}
<div class="text-danger small mt-1">
{{ form.capacity.errors[0] }}
@@ -180,14 +191,16 @@ $(document).ready(function() {
$('#room_number').removeClass('is-invalid');
}
if (!$('#floor').val()) {
var floorVal = $('#floor').val();
if (floorVal === '' || isNaN(floorVal)) {
$('#floor').addClass('is-invalid');
isValid = false;
} else {
$('#floor').removeClass('is-invalid');
}
if (!$('#capacity').val() || $('#capacity').val() < 1) {
var capVal = $('#capacity').val() ? $('#capacity').val().trim() : '';
if (!capVal || (capVal !== '∞' && capVal !== '0' && isNaN(capVal))) {
$('#capacity').addClass('is-invalid');
isValid = false;
} else {
@@ -196,6 +209,10 @@ $(document).ready(function() {
return isValid;
});
$('#btnInfinityAdd').on('click', function() {
$('#capacity').val('∞').focus();
});
});
</script>
{% endblock %}
+162
View File
@@ -0,0 +1,162 @@
{% extends "base.html" %}
{% block title %}{% trans %}Edit Classroom{% endtrans %} - {{ classroom.code_display }} - 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>
<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.building }} — {{ classroom.code }}</p>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-primary">
<i class="bi bi-eye me-1"></i>{% trans %}Ver Ficha{% endtrans %}
</a>
<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>
</div>
</div>
<div class="row">
<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 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">
<div class="col-md-4">
{{ form.building.label(class="form-label fw-semibold") }}
{{ form.building(class="form-control" + (" is-invalid" if form.building.errors else ""), placeholder="Edificio Central") }}
{% if form.building.errors %}
<div class="invalid-feedback">{{ form.building.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.room_number.label(class="form-label fw-semibold") }}
{{ form.room_number(class="form-control" + (" is-invalid" if form.room_number.errors else ""), placeholder="402") }}
{% if form.room_number.errors %}
<div class="invalid-feedback">{{ form.room_number.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.floor.label(class="form-label fw-semibold") }}
{{ form.floor(class="form-control" + (" is-invalid" if form.floor.errors else "")) }}
{% if form.floor.errors %}
<div class="invalid-feedback">{{ form.floor.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1">
{{ form.capacity.label(class="form-label fw-semibold mb-0") }}
<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> (Capacidad ilimitada).{% 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-4">
<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 class="col-12">
{{ form.description.label(class="form-label fw-semibold") }}
{{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), rows="3", placeholder="Detalles sobre equipamiento o uso preferente del aula...") }}
{% if form.description.errors %}
<div class="invalid-feedback">{{ form.description.errors[0] }}</div>
{% endif %}
</div>
</div>
<!-- Live Preview Card -->
<div class="alert alert-info border-0 mt-4 mb-0">
<h6 class="alert-heading fw-bold mb-2">
<i class="bi bi-info-circle me-1"></i>{% trans %}Vista Previa de Identificador{% endtrans %}
</h6>
<div id="classroom-preview">
<p class="mb-0">
{% trans %}Código de visualización{% endtrans %}:
<strong id="preview-code" class="text-primary font-monospace fs-6">{{ classroom.code_display }}</strong>
</p>
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
<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 buildingInput = document.getElementById('building');
var roomInput = document.getElementById('room_number');
var previewCode = document.getElementById('preview-code');
var capacityInput = document.getElementById('capacityInput');
var btnInfinity = document.getElementById('btnInfinity');
function updatePreview() {
var b = buildingInput ? buildingInput.value.trim() : '';
var r = roomInput ? roomInput.value.trim() : '';
if (b && r) {
previewCode.textContent = b + '-' + r;
} else if (r) {
previewCode.textContent = r;
} else {
previewCode.textContent = '---';
}
}
if (buildingInput) buildingInput.addEventListener('input', updatePreview);
if (roomInput) roomInput.addEventListener('input', updatePreview);
if (btnInfinity && capacityInput) {
btnInfinity.addEventListener('click', function() {
capacityInput.value = '∞';
capacityInput.focus();
});
}
});
</script>
{% endblock %}
+7 -1
View File
@@ -77,7 +77,13 @@
<div class="row small">
<div class="col-6">
<span class="text-muted">{% trans %}Capacity{% endtrans %}:</span>
<strong>{{ classroom.capacity }}</strong>
<strong>
{% if classroom.is_unlimited %}
<span class="badge bg-primary-subtle text-primary border"><i class="bi bi-infinity me-1"></i>∞</span>
{% else %}
{{ classroom.capacity }}
{% endif %}
</strong>
</div>
<div class="col-6">
<span class="text-muted">{% trans %}Building{% endtrans %}:</span>
+165
View File
@@ -0,0 +1,165 @@
{% 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 %}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">
<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">
<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 %}
+147 -94
View File
@@ -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,35 @@
<!-- 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 %}
<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('schedule.today_schedule') }}" class="btn btn-outline-info">
<i class="bi bi-calendar-today"></i> {% trans %}Today's Schedule{% 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('genetic_algorithm_web.genetic_optimizer') }}" class="btn btn-info text-white">
<i class="bi bi-cpu"></i> {% trans %}AI Room Optimizer{% 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>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-secondary">
<i class="bi bi-calendar3"></i> {% trans %}View Calendar{% endtrans %}
{% 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 +167,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 +235,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>
+17 -10
View File
@@ -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>
+95 -53
View File
@@ -1,6 +1,6 @@
{% 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">
@@ -8,10 +8,10 @@
<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
<i class="bi bi-calendar-plus text-success"></i> {% trans %}New Reservation{% endtrans %}
</h1>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Reservations
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> {% trans %}Back to Reservations{% endtrans %}
</a>
</div>
</div>
@@ -20,9 +20,9 @@
<div class="row">
<div class="col-lg-8 mx-auto">
<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-calendar3 text-success"></i> Reservation Details
<i class="bi bi-calendar3 text-success"></i> {% trans %}Reservation Details{% endtrans %}
</h5>
</div>
<div class="card-body">
@@ -38,7 +38,7 @@
{{ form.classroom_id.errors[0] }}
</div>
{% endif %}
<small class="text-muted">Select a classroom for your reservation</small>
<small class="text-muted">{% trans %}Select a classroom for your reservation{% endtrans %}</small>
</div>
<div class="col-md-6 mb-3">
@@ -49,7 +49,7 @@
{{ form.commission_id.errors[0] }}
</div>
{% endif %}
<small class="text-muted">Select the subject/commission</small>
<small class="text-muted">{% trans %}Select the subject/commission{% endtrans %}</small>
</div>
</div>
@@ -62,7 +62,7 @@
{{ form.start_time.errors[0] }}
</div>
{% endif %}
<small class="text-muted">When will the class start?</small>
<small class="text-muted">{% trans %}When will the class start?{% endtrans %}</small>
</div>
<div class="col-md-6 mb-3">
@@ -73,14 +73,14 @@
{{ form.end_time.errors[0] }}
</div>
{% endif %}
<small class="text-muted">When will the class end?</small>
<small class="text-muted">{% trans %}When will the class end?{% 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.") }}
{{ form.purpose(class="form-control", placeholder=_("Regular class, exam, study session, etc.")) }}
{% if form.purpose.errors %}
<div class="text-danger small mt-1">
{{ form.purpose.errors[0] }}
@@ -96,13 +96,13 @@
{{ form.expected_attendees.errors[0] }}
</div>
{% endif %}
<small class="text-muted">Number of expected attendees</small>
<small class="text-muted">{% trans %}Number of expected attendees{% 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...") }}
{{ form.notes(class="form-control", rows="3", placeholder=_("Any additional notes or special requirements...")) }}
{% if form.notes.errors %}
<div class="text-danger small mt-1">
{{ form.notes.errors[0] }}
@@ -113,27 +113,27 @@
<!-- Summary Section -->
<div class="alert alert-info" id="reservation-summary">
<h6 class="alert-heading">
<i class="bi bi-info-circle"></i> Reservation Summary
<i class="bi bi-info-circle"></i> {% trans %}Reservation Summary{% endtrans %}
</h6>
<div id="summary-content">
<p class="mb-2">
<strong>Selected Classroom:</strong>
<span id="summary-classroom">Not selected</span>
<strong>{% trans %}Selected Classroom:{% endtrans %}</strong>
<span id="summary-classroom">{% trans %}Not selected{% endtrans %}</span>
</p>
<p class="mb-2">
<strong>Duration:</strong>
<span id="summary-duration">Not calculated</span>
<strong>{% trans %}Duration:{% endtrans %}</strong>
<span id="summary-duration">{% trans %}Not calculated{% endtrans %}</span>
</p>
<p class="mb-0">
<strong>Commission:</strong>
<span id="summary-commission">Not selected</span>
<strong>{% trans %}Commission:{% endtrans %}</strong>
<span id="summary-commission">{% trans %}Not selected{% endtrans %}</span>
</p>
</div>
</div>
<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"></i> {% trans %}Cancel{% endtrans %}
</a>
{{ form.submit(class="btn btn-success") }}
</div>
@@ -147,29 +147,29 @@
<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">
<div class="card-header border-0 py-3">
<h5 class="mb-0">
<i class="bi bi-question-circle text-info"></i> Reservation Guidelines
<i class="bi bi-question-circle text-info"></i> {% trans %}Reservation Guidelines{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary">📅 Booking Rules</h6>
<h6 class="text-primary">📅 {% trans %}Booking Rules{% endtrans %}</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>
<li>{% trans %}Reservations can be made up to 3 months in advance{% endtrans %}</li>
<li>{% trans %}Cannot book in the past{% endtrans %}</li>
<li>{% trans %}Maximum duration: 8 hours per reservation{% endtrans %}</li>
<li>{% trans %}Cancellations must be 24 hours before start time{% endtrans %}</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-warning">🚨 Important Notes</h6>
<h6 class="text-warning">🚨 {% trans %}Important Notes{% endtrans %}</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>
<li>{% trans %}Check classroom capacity before booking{% endtrans %}</li>
<li>{% trans %}Pending reservations require approval{% endtrans %}</li>
<li>{% trans %}Time slots must not overlap{% endtrans %}</li>
<li>{% trans %}System automatically detects conflicts{% endtrans %}</li>
</ul>
</div>
</div>
@@ -181,17 +181,55 @@
{% endblock %}
{% block extra_js %}
<script type="application/json" id="classroom-data">
{
{% for classroom in classroom_list %}
"{{ classroom.id }}": {
"capacity": {{ classroom.capacity }},
"code_display": {{ classroom.code_display|tojson }},
"name": {{ classroom.name|tojson }}
}{% if not loop.last %},{% endif %}
{% endfor %}
}
</script>
<script type="application/json" id="i18n-strings">
{
"not_selected": "{% trans %}Not selected{% endtrans %}",
"not_calculated": "{% trans %}Not calculated{% endtrans %}",
"invalid_time": "{% trans %}Invalid time range{% endtrans %}",
"hour": "{% trans %}hour{% endtrans %}",
"hours": "{% trans %}hours{% endtrans %}",
"minute": "{% trans %}minute{% endtrans %}",
"minutes": "{% trans %}minutes{% endtrans %}",
"capacity": "{% trans %}Capacity{% endtrans %}",
"capacity_exceeded": "{% trans %}Expected attendees exceeds classroom capacity{% 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 %}
// Classroom data for capacity checking
let classroomData = {};
try {
const rawData = document.getElementById('classroom-data')?.textContent;
if (rawData) {
classroomData = JSON.parse(rawData);
}
} catch (err) {
console.error('Error parsing classroomData:', err);
}
// I18n strings
let i18n = {};
try {
const rawI18n = document.getElementById('i18n-strings')?.textContent;
if (rawI18n) {
i18n = JSON.parse(rawI18n);
}
} catch (err) {
console.error('Error parsing i18n:', err);
}
function updateSummary() {
const classroomId = $('#classroom-select').val();
@@ -199,11 +237,12 @@ $(document).ready(function() {
const startTime = $('#start-time').val();
const endTime = $('#end-time').val();
// Update classroom summary
// Update classroom summary
if (classroomId && classroomData[classroomId]) {
$('#summary-classroom').text(`${classroomData[classroomId].code_display} - ${classroomData[classroomId].name} (Capacity: ${classroomData[classroomId].capacity})`);
const capLabel = i18n.capacity || 'Capacidad';
$('#summary-classroom').text(`${classroomData[classroomId].code_display} - ${classroomData[classroomId].name} (${capLabel}: ${classroomData[classroomId].capacity})`);
} else {
$('#summary-classroom').text('Not selected');
$('#summary-classroom').text(i18n.not_selected || 'No seleccionada');
}
// Update commission summary
@@ -211,7 +250,7 @@ $(document).ready(function() {
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');
}
// Calculate duration
@@ -226,19 +265,21 @@ $(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');
}
}
@@ -252,9 +293,10 @@ $(document).ready(function() {
if (attendees > capacity) {
$('#expected_attendees').addClass('is-invalid');
const msg = i18n.capacity_exceeded || 'Los asistentes esperados superan la capacidad del aula';
$('#expected_attendees').after(`
<div class="invalid-feedback">
Expected attendees (${attendees}) exceeds classroom capacity (${capacity})
${msg} (${attendees} > ${capacity})
</div>
`);
} else {
+512 -103
View File
@@ -4,93 +4,374 @@
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row mb-4">
<div class="col-12">
<h1 class="h3 mb-0">
<i class="bi bi-calendar3 text-primary"></i> {% trans %}Reservations Calendar{% endtrans %}
<small class="text-muted ms-2">{% trans %}View & Manage Reservations{% endtrans %}</small>
</h1>
<!-- Header -->
<div class="row mb-4 align-items-center">
<div class="col-md-7">
<h1 class="h3 mb-1">
<i class="bi bi-calendar3 text-primary me-2"></i>{% trans %}Reservations Calendar{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}View & Manage Reservations{% endtrans %} — {% trans %}Horarios de cursada y asignación de aulas UniCABA{% 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-primary me-2">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Add Reservation{% endtrans %}
</a>
{% endif %}
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-list me-1"></i>{% trans %}List View{% endtrans %}
</a>
</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<div class="row align-items-center">
<div class="col-md-6">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-calendar-week text-info me-2"></i> {% trans %}Reservations Calendar{% endtrans %}
</h5>
<!-- Filters Toolbar (Classroom & Subject Filters to prevent overlapping) -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<div class="row g-2 align-items-center">
<!-- Filter by Classroom -->
<div class="col-lg-3 col-md-6">
<label for="filterClassroom" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-door-open text-success me-1"></i>{% trans %}Aula{% endtrans %}
</label>
<select id="filterClassroom" class="form-select form-select-sm">
<option value="">{% trans %}Todas las aulas{% endtrans %}</option>
{% for c in classrooms %}
<option value="{{ c.id }}">{{ c.code }} ({{ c.building }})</option>
{% endfor %}
</select>
</div>
<!-- Filter by Subject -->
<div class="col-lg-3 col-md-6">
<label for="filterSubject" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-book text-primary me-1"></i>{% trans %}Materia{% endtrans %}
</label>
<select id="filterSubject" class="form-select form-select-sm">
<option value="">{% trans %}Todas las materias{% endtrans %}</option>
{% for s in subjects %}
<option value="{{ s.id }}">{{ s.name }} [{{ s.code }}]</option>
{% endfor %}
</select>
</div>
<!-- Filter by Career -->
<div class="col-lg-3 col-md-6">
<label for="filterCareer" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-mortarboard text-info me-1"></i>{% trans %}Carrera{% endtrans %}
</label>
<select id="filterCareer" class="form-select form-select-sm">
<option value="">{% trans %}Todas las carreras{% endtrans %}</option>
{% for cr in careers %}
<option value="{{ cr.id }}">{{ cr.name }}</option>
{% endfor %}
</select>
</div>
<!-- Filter by Turno (Mañana / Tarde) -->
<div class="col-lg-3 col-md-6">
<label for="filterShift" 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 id="filterShift" class="form-select form-select-sm">
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
<option value="Mañana">{% trans %}Turno Mañana{% endtrans %}</option>
<option value="Tarde">{% trans %}Turno Tarde / Vespertino{% endtrans %}</option>
</select>
</div>
</div>
<div class="col-md-6 text-end">
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> {% trans %}Add Reservation{% endtrans %}
</a>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-list"></i> {% trans %}List View{% endtrans %}
</a>
<!-- Second row of filters: Floor & Hide Virtual -->
<div class="row g-2 align-items-center mt-2 pt-2 border-top">
<!-- Filter by Floor -->
<div class="col-lg-3 col-md-4">
<label for="filterFloor" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-layers text-secondary me-1"></i>{% trans %}Filtrar por Piso{% endtrans %}
</label>
<select id="filterFloor" class="form-select form-select-sm">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in available_floors %}
<option value="{{ f }}">{% if f == 0 %}{% trans %}Planta Baja (Piso 0){% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}</option>
{% endfor %}
</select>
</div>
<!-- Hide Virtual Classrooms Toggle -->
<div class="col-lg-6 col-md-5 pt-md-3">
<div class="form-check form-switch pt-1">
<input class="form-check-input" type="checkbox" role="switch" id="filterHideVirtual">
<label class="form-check-label fw-semibold small text-dark" for="filterHideVirtual">
<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>
<!-- Reset Filters Button -->
<div class="col-lg-3 col-md-3 text-md-end pt-md-3">
<button type="button" id="btnResetFilters" class="btn btn-outline-secondary btn-sm px-3" title="{% trans %}Restablecer filtros{% endtrans %}">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar Filtros{% endtrans %}
</button>
</div>
</div>
<!-- Active filter indicator & counter -->
<div class="d-flex align-items-center flex-wrap gap-2 mt-2 pt-2 border-top">
<small class="text-muted fw-semibold">
<i class="bi bi-funnel-fill text-secondary me-1"></i>{% trans %}Estado{% endtrans %}:
</small>
<span id="activeFilterBadge" class="badge bg-secondary-subtle text-secondary border small">
{% trans %}Mostrando todas las aulas y materias{% endtrans %}
</span>
<span class="badge badge-brand ms-auto small">
<i class="bi bi-calendar-check me-1"></i><span id="eventCountNumber">0</span> {% trans %}clases visibles{% endtrans %}
</span>
</div>
</div>
</div>
<div class="card-body">
<!-- Legend -->
<div class="mb-3">
<div class="d-flex gap-3 flex-wrap">
<div class="d-flex align-items-center">
<span class="badge bg-success me-2" style="width: 20px; height: 20px;"></span>
<small class="text-muted">{% trans %}Confirmed{% endtrans %}</small>
<!-- Calendar Card -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-3">
<span class="fw-bold small text-secondary text-uppercase tracking-wide">
<i class="bi bi-info-circle me-1"></i>{% trans %}Referencia{% endtrans %}:
</span>
<div class="d-flex align-items-center">
<span class="badge badge-brand me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
<small class="text-muted">{% trans %}Clase Confirmada{% endtrans %}</small>
</div>
<div class="d-flex align-items-center">
<span class="badge bg-warning me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
<small class="text-muted">{% trans %}Pendiente{% endtrans %}</small>
</div>
</div>
<div class="d-flex align-items-center">
<span class="badge bg-warning me-2" style="width: 20px; height: 20px;"></span>
<small class="text-muted">{% trans %}Pending{% endtrans %}</small>
<div class="text-muted small">
<i class="bi bi-lightning-charge-fill text-warning me-1"></i>{% trans %}Haz clic en cualquier clase para ver los detalles completos{% endtrans %}
</div>
</div>
</div>
<!-- Calendar -->
<div id="calendar"></div>
<div class="card-body p-3">
<!-- FullCalendar Container -->
<div id="calendar"></div>
</div>
</div>
</div>
<!-- Reservation Details Modal -->
<div class="modal fade" id="reservationModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans %}Reservation Details{% endtrans %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="reservationDetails">
<!-- Content will be loaded dynamically -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans %}Close{% endtrans %}</button>
<!-- Reservation Details Modal -->
<div class="modal fade" id="reservationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header border-0 pb-0">
<div>
<span id="modalCategoryBadge" class="badge badge-brand mb-1">Clase Académica</span>
<h4 class="modal-title fw-bold text-primary" id="modalSubjectTitle">Detalle de la Clase</h4>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-3" id="reservationDetails">
<div class="row g-3">
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-book text-primary me-1"></i>{% trans %}Materia y Código{% endtrans %}
</h6>
<p class="h6 fw-bold mb-1" id="modalSubjectName">-</p>
<p class="mb-2">
<span class="badge bg-dark bg-opacity-75 font-monospace text-white" id="modalSubjectCode">-</span>
</p>
<p class="small text-muted mb-0" id="modalCareerWrap">
<i class="bi bi-mortarboard me-1"></i><span id="modalCareer">-</span>
</p>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-door-open text-success me-1"></i>{% trans %}Espacio Asignado{% endtrans %}
</h6>
<p class="h5 fw-bold text-success mb-1" id="modalClassroom">-</p>
<p class="small text-muted mb-0" id="modalBuilding">-</p>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-clock text-warning me-1"></i>{% trans %}Horario Programado{% endtrans %}
</h6>
<p class="h6 fw-bold mb-0" id="modalTimeRange">-</p>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-shield-check text-info me-1"></i>{% trans %}Estado y Capacidad{% endtrans %}
</h6>
<p class="mb-1">
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1" id="modalStatus">-</span>
</p>
<small class="text-muted" id="modalAttendees">-</small>
</div>
</div>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Close{% endtrans %}</button>
<a href="#" id="modalFullLink" class="btn btn-primary">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Ver Ficha Completa{% endtrans %}
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.css" rel="stylesheet">
<style>
/* FullCalendar Modern Overrides */
#calendar {
margin-top: 20px;
font-family: inherit;
min-height: 700px;
}
.fc .fc-toolbar-title {
font-size: 1.25rem !important;
font-weight: 700 !important;
color: var(--text-primary);
}
.fc .fc-button-primary {
background: var(--gradient-primary, linear-gradient(135deg, #B43E8E 0%, #9E367D 100%)) !important;
border: none !important;
border-radius: 6px !important;
font-weight: 600 !important;
font-size: 0.85rem !important;
padding: 0.35rem 0.75rem !important;
box-shadow: 0 2px 4px rgba(180, 62, 142, 0.25) !important;
}
.fc .fc-button-primary:hover {
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
}
.fc .fc-button-primary:disabled {
background: #94A3B8 !important;
opacity: 0.6 !important;
}
.fc-theme-standard td, .fc-theme-standard th {
border-color: var(--card-border, #E2E8F0) !important;
}
.fc .fc-col-header-cell-cushion {
color: var(--text-primary);
font-weight: 600;
padding: 8px 4px !important;
text-decoration: none !important;
}
.fc .fc-timegrid-slot-label-cushion {
color: var(--text-secondary, #64748B);
font-size: 0.78rem;
}
/* Event Card Visual Hierarchy */
.fc-event {
cursor: pointer;
border: none !important;
padding: 2px 6px !important;
font-size: 12px !important;
border-radius: 8px !important;
overflow: hidden !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12) !important;
transition: transform 0.15s ease, box-shadow 0.15s ease !important;
}
.fc-event-pending {
background-color: #ffc107 !important;
.fc-event:hover {
transform: translateY(-2px) scale(1.01);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22) !important;
z-index: 10 !important;
}
.fc-event-confirmed {
background-color: #28a745 !important;
background: linear-gradient(135deg, #B43E8E 0%, #832765 100%) !important;
color: #FFFFFF !important;
}
.fc-daygrid-day.fc-day-today {
background-color: rgba(102, 126, 234, 0.1) !important;
.fc-event-pending {
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%) !important;
color: #FFFFFF !important;
}
.fc-event-custom-container {
padding: 5px 7px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.fc-event-time-pill {
font-size: 0.72rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
margin-bottom: 2px;
display: flex;
align-items: center;
}
/* High Visual Hierarchy for Subject Name */
.fc-event-subject-title {
font-size: 0.88rem !important;
font-weight: 700 !important;
line-height: 1.22 !important;
color: #FFFFFF !important;
letter-spacing: -0.15px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
margin-bottom: 4px;
}
/* Secondary Badges */
.fc-event-badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 3px;
margin-top: auto;
}
.fc-badge-code {
background-color: rgba(0, 0, 0, 0.3) !important;
color: #FFFFFF !important;
font-size: 0.70rem !important;
font-weight: 600 !important;
padding: 2px 5px !important;
border-radius: 4px !important;
}
.fc-badge-room {
background-color: rgba(255, 255, 255, 0.28) !important;
color: #FFFFFF !important;
font-size: 0.70rem !important;
font-weight: 700 !important;
padding: 2px 5px !important;
border-radius: 4px !important;
display: inline-flex;
align-items: center;
}
/* Month View Optimization */
.fc-daygrid-event .fc-event-custom-container {
padding: 2px 4px;
}
.fc-daygrid-event .fc-event-subject-title {
font-size: 0.78rem !important;
-webkit-line-clamp: 1;
}
</style>
{% endblock %}
@@ -102,10 +383,52 @@
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var currentLang = '{{ session.get("language", "es") }}';
var todayDate = '{{ today_date or "2026-09-03" }}';
var filterRoom = document.getElementById('filterClassroom');
var filterSubj = document.getElementById('filterSubject');
var filterCar = document.getElementById('filterCareer');
var filterShift = document.getElementById('filterShift');
var filterFloor = document.getElementById('filterFloor');
var filterHideVirtual = document.getElementById('filterHideVirtual');
var activeBadge = document.getElementById('activeFilterBadge');
var countEl = document.getElementById('eventCountNumber');
function updateFilterSummaryBadge() {
var parts = [];
if (filterRoom.value && filterRoom.selectedOptions[0]) {
parts.push('Aula: ' + filterRoom.selectedOptions[0].text.split('(')[0].trim());
}
if (filterSubj.value && filterSubj.selectedOptions[0]) {
parts.push('Materia: ' + filterSubj.selectedOptions[0].text.split('[')[0].trim());
}
if (filterCar.value && filterCar.selectedOptions[0]) {
parts.push('Carrera: ' + filterCar.selectedOptions[0].text.trim());
}
if (filterShift && filterShift.value) {
parts.push('Turno ' + filterShift.value);
}
if (filterFloor && filterFloor.value) {
parts.push(filterFloor.value === '0' ? 'Planta Baja' : 'Piso ' + filterFloor.value);
}
if (filterHideVirtual && filterHideVirtual.checked) {
parts.push('Sin Virtuales');
}
if (parts.length > 0) {
activeBadge.className = 'badge bg-primary text-white border small';
activeBadge.textContent = parts.join(' | ');
} else {
activeBadge.className = 'badge bg-secondary-subtle text-secondary border small';
activeBadge.textContent = 'Mostrando todas las aulas y materias';
}
}
var calendar = new FullCalendar.Calendar(calendarEl, {
locale: currentLang,
initialView: 'timeGridWeek',
initialDate: todayDate,
allDaySlot: false,
headerToolbar: {
left: 'prev,next today',
center: 'title',
@@ -116,61 +439,147 @@ document.addEventListener('DOMContentLoaded', function() {
minute: '2-digit',
hour12: false
},
slotMinTime: '07:00:00',
slotMaxTime: '22:00:00',
slotMinTime: '07:30:00',
slotMaxTime: '22:30:00',
height: 'auto',
eventClick: function(info) {
if (info.event.url) {
loadReservationDetails(info.event.url);
return false;
}
slotEventOverlap: true,
/* Dynamic Server-Side Filtered Events */
events: function(info, successCallback, failureCallback) {
var params = new URLSearchParams({
start: info.startStr,
end: info.endStr,
classroom_id: filterRoom.value || '',
subject_id: filterSubj.value || '',
career_id: filterCar.value || '',
shift: (filterShift ? filterShift.value : '') || '',
floor: (filterFloor ? filterFloor.value : '') || '',
hide_virtual: (filterHideVirtual && filterHideVirtual.checked) ? '1' : ''
});
updateFilterSummaryBadge();
fetch('{{ url_for("schedule.calendar_data") }}?' + params.toString())
.then(function(response) {
if (!response.ok) throw new Error('Network error');
return response.json();
})
.then(function(eventsData) {
if (countEl) {
countEl.textContent = eventsData.length;
}
successCallback(eventsData);
})
.catch(function(err) {
console.error('Error loading calendar events:', err);
failureCallback(err);
});
},
events: '{{ url_for("schedule.calendar_data") }}',
loading: function(bool) {
/* Custom Card Render with Visual Hierarchy */
eventContent: function(arg) {
var ext = arg.event.extendedProps || {};
var subjectName = ext.subject_name || arg.event.title || '';
var subjectCode = ext.subject_code || '';
var classroom = ext.classroom || '';
var timeText = arg.timeText || '';
var container = document.createElement('div');
container.className = 'fc-event-custom-container';
var timeHtml = timeText ? `<div class="fc-event-time-pill"><i class="bi bi-clock me-1"></i>${timeText}</div>` : '';
var titleHtml = `<div class="fc-event-subject-title" title="${subjectName}">${subjectName}</div>`;
var badgesHtml = `
<div class="fc-event-badges">
${subjectCode ? `<span class="fc-badge-code font-monospace">${subjectCode}</span>` : ''}
${classroom ? `<span class="fc-badge-room"><i class="bi bi-door-open me-1"></i>${classroom}</span>` : ''}
</div>`;
container.innerHTML = timeHtml + titleHtml + badgesHtml;
return { domNodes: [container] };
},
/* Interactive Instant Modal */
eventClick: function(info) {
info.jsEvent.preventDefault();
var ext = info.event.extendedProps || {};
var subjectName = ext.subject_name || info.event.title || '';
var subjectCode = ext.subject_code || '-';
var classroom = ext.classroom || 'Sin Aula';
var building = ext.building || 'Edificio Central';
var career = ext.career || 'General';
var status = ext.status || 'CONFIRMED';
var attendees = ext.attendees || 0;
var startStr = info.event.start ? info.event.start.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
var endStr = info.event.end ? info.event.end.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
var dateStr = info.event.start ? info.event.start.toLocaleDateString([], {weekday: 'long', day: 'numeric', month: 'long'}) : '';
document.getElementById('modalSubjectTitle').textContent = subjectName;
document.getElementById('modalSubjectName').textContent = subjectName;
document.getElementById('modalSubjectCode').textContent = subjectCode;
document.getElementById('modalCareer').textContent = career;
document.getElementById('modalClassroom').textContent = classroom;
document.getElementById('modalBuilding').textContent = building;
document.getElementById('modalTimeRange').textContent = `${dateStr} (${startStr} a ${endStr} hs)`;
document.getElementById('modalStatus').textContent = status;
document.getElementById('modalAttendees').textContent = `${attendees} estudiantes esperados`;
if (info.event.url) {
var btn = document.getElementById('modalFullLink');
btn.href = info.event.url;
btn.classList.remove('d-none');
} else {
document.getElementById('modalFullLink').classList.add('d-none');
}
var modal = new bootstrap.Modal(document.getElementById('reservationModal'));
modal.show();
}
});
calendar.render();
function loadReservationDetails(url) {
$.get(url)
.done(function(data) {
var isEs = currentLang === 'es';
var html = `
<div class="row">
<div class="col-md-6">
<h6 class="text-muted mb-2">${isEs ? 'Aula' : 'Classroom'}</h6>
<p class="fw-bold">${data.classroom.code} - ${data.classroom.name || ''}</p>
<p><i class="bi bi-building text-primary"></i> ${data.classroom.building} - ${isEs ? 'Piso' : 'Floor'} ${data.classroom.floor}</p>
</div>
<div class="col-md-6">
<h6 class="text-muted mb-2">${isEs ? 'Materia' : 'Subject'}</h6>
<p class="fw-bold">${data.commission.subject.code} - ${data.commission.subject.name}</p>
<p><i class="bi bi-person text-info"></i> ${data.commission.teacher_name}</p>
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
<h6 class="text-muted mb-2">${isEs ? 'Horario' : 'Schedule'}</h6>
<p><i class="bi bi-clock text-warning"></i> ${new Date(data.start_time).toLocaleString()}</p>
<p><i class="bi bi-clock text-success"></i> ${new Date(data.end_time).toLocaleString()}</p>
</div>
<div class="col-md-6">
<h6 class="text-muted mb-2">${isEs ? 'Detalles' : 'Details'}</h6>
<p><i class="bi bi-people text-primary"></i> ${data.expected_attendees} ${isEs ? 'asistentes' : 'attendees'}</p>
<p><span class="badge bg-${data.status === 'CONFIRMED' ? 'success' : 'warning'}">${data.status}</span></p>
</div>
</div>
${data.notes ? `<div class="row mt-3"><div class="col-12"><h6 class="text-muted mb-2">${isEs ? 'Notas' : 'Notes'}</h6><p>${data.notes}</p></div></div>` : ''}
`;
$('#reservationDetails').html(html);
$('#reservationModal').modal('show');
})
.fail(function() {
$('#reservationDetails').html('<p class="text-danger">${isEs ? "Error al cargar los detalles de la reserva." : "Error loading reservation details."}</p>');
$('#reservationModal').modal('show');
});
// Event listeners to trigger immediate calendar refetch on filter change
filterRoom.addEventListener('change', function() {
calendar.refetchEvents();
});
filterSubj.addEventListener('change', function() {
calendar.refetchEvents();
});
filterCar.addEventListener('change', function() {
calendar.refetchEvents();
});
if (filterShift) {
filterShift.addEventListener('change', function() {
calendar.refetchEvents();
});
}
if (filterFloor) {
filterFloor.addEventListener('change', function() {
calendar.refetchEvents();
});
}
if (filterHideVirtual) {
filterHideVirtual.addEventListener('change', function() {
calendar.refetchEvents();
});
}
document.getElementById('btnResetFilters').addEventListener('click', function() {
filterRoom.value = '';
filterSubj.value = '';
filterCar.value = '';
if (filterShift) filterShift.value = '';
if (filterFloor) filterFloor.value = '';
if (filterHideVirtual) filterHideVirtual.checked = false;
calendar.refetchEvents();
});
});
</script>
{% endblock %}
+1 -1
View File
@@ -65,7 +65,7 @@
{% for subject in subjects %}
<div class="col-lg-6 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-start">
<div>
<h5 class="mb-1">
+220 -258
View File
@@ -1,291 +1,253 @@
{% 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">&nbsp;</label>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-funnel"></i> Filter
<!-- Shift Filter -->
<div class="col-lg-2 col-md-4">
<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 -->
<div class="col-lg-3 col-md-4">
<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>
<!-- 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">
<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, 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, 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, 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 %}
+303 -348
View File
@@ -1,403 +1,358 @@
{% 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>
<a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-eye me-1"></i>{% trans %}Detalles{% endtrans %}
</a>
</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">
<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>
+8 -7
View File
@@ -23,7 +23,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 +54,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,7 +86,7 @@
<!-- Classroom Details -->
<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-building text-info"></i> Classroom Information
</h5>
@@ -124,7 +124,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 +149,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>
@@ -162,7 +162,8 @@
<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>
role="progressbar"
style="{{ 'width: ' ~ (percentage|round(1)|string) ~ '%;' }}"></div>
</div>
<small class="text-muted">
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} capacity
@@ -174,7 +175,7 @@
<!-- 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>
+3
View File
@@ -0,0 +1,3 @@
from .decorators import permission_required, admin_required
__all__ = ['permission_required', 'admin_required']
+41
View File
@@ -0,0 +1,41 @@
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
+2 -2
View File
@@ -17,7 +17,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();
}
}
}
@@ -0,0 +1,74 @@
<ion-header>
<ion-toolbar color="primary">
<ion-title>Credencial Digital</ion-title>
<ion-buttons slot="end">
<ion-button (click)="close()">Cerrar</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="credential-content">
<!-- Loading State -->
<div class="state-container" *ngIf="state === 'loading'">
<ion-spinner name="crescent" color="primary"></ion-spinner>
<div class="loading-wrapper ion-text-center">
<p class="loading-text">{{ loadingStatus }}</p>
<p class="loading-subtext" style="font-size: 0.8rem; color: var(--ion-color-medium); margin-top: 5px;">
Por favor no cierres la aplicación
</p>
</div>
</div>
<!-- Error State -->
<div class="state-container error-container" *ngIf="state === 'error'">
<ion-icon name="alert-circle-outline" color="danger"></ion-icon>
<p class="error-text">{{ errorMessage }}</p>
<ion-button color="primary" fill="outline" (click)="resetState()">Intentar nuevamente</ion-button>
</div>
<!-- Login State -->
<div class="state-container login-container" *ngIf="state === 'login'">
<div class="login-card">
<h2 class="ion-text-center">Mi Credencial</h2>
<p class="ion-text-center text-muted">Ingresa con tus datos del campus virtual para generar tu credencial digital. Los datos se procesan de forma segura en tu dispositivo.</p>
<ion-item>
<ion-label position="floating">DNI</ion-label>
<ion-input type="number" [(ngModel)]="dni"></ion-input>
</ion-item>
<ion-item>
<ion-label position="floating">Contraseña</ion-label>
<ion-input [type]="showPassword ? 'text' : 'password'" [(ngModel)]="password"></ion-input>
<ion-button fill="clear" slot="end" class="ion-align-self-end" (click)="showPassword = !showPassword" style="margin-top: 15px;">
<ion-icon slot="icon-only" [name]="showPassword ? 'eye-off-outline' : 'eye-outline'" color="medium"></ion-icon>
</ion-button>
</ion-item>
<ion-button expand="block" class="ion-margin-top" (click)="generate()" [disabled]="!dni || !password">
Generar / Ver
</ion-button>
</div>
</div>
<!-- Success Credential State -->
<div class="state-container credential-container" *ngIf="state === 'success'">
<div class="credential-wrapper">
<img [src]="'data:image/png;base64,' + credentialImage" class="credential-img" alt="Credencial UNICABA" />
</div>
<div class="credential-actions">
<ion-button expand="block" color="primary" fill="outline" (click)="generate(true)">
<ion-icon name="refresh-outline" slot="start"></ion-icon>
Actualizar Credencial
</ion-button>
<ion-button expand="block" color="danger" fill="clear" (click)="logout()">
<ion-icon name="log-out-outline" slot="start"></ion-icon>
Cerrar Sesión
</ion-button>
</div>
</div>
</ion-content>
@@ -0,0 +1,93 @@
.state-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 80vh;
padding: 20px 16px;
}
.loading-text {
margin-top: 20px;
color: var(--ion-color-medium);
font-size: 1.1rem;
text-align: center;
}
.error-container {
ion-icon {
font-size: 64px;
margin-bottom: 20px;
}
.error-text {
color: var(--ion-color-danger);
text-align: center;
margin-bottom: 30px;
font-weight: 500;
}
}
.login-container {
.login-card {
background: var(--ion-card-background, #ffffff);
color: var(--ion-text-color, #000000);
padding: 30px 20px;
border-radius: 15px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
width: 100%;
max-width: 400px;
h2 {
color: var(--ion-color-primary);
margin-bottom: 10px;
}
.text-muted {
color: var(--ion-color-medium);
font-size: 0.9rem;
margin-bottom: 20px;
}
ion-item {
--background: transparent;
margin-bottom: 10px;
}
}
}
.credential-container {
justify-content: flex-start;
min-height: auto;
padding: 8px 8px 24px 8px;
width: 100%;
.credential-wrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.credential-img {
width: 100%;
max-width: 450px;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
border: 1px solid #efefef;
margin-bottom: 18px;
display: block;
object-fit: contain;
}
.credential-actions {
width: 100%;
max-width: 450px;
padding: 0 4px;
ion-button {
margin-top: 6px;
font-weight: 600;
letter-spacing: 0.5px;
}
}
}
@@ -0,0 +1,132 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule, ModalController, ToastController } from '@ionic/angular';
import { FormsModule } from '@angular/forms';
import { CredencialService } from '../../services/credencial.service';
import { AuthService } from '../../services/auth.service';
import { ScreenBrightness } from '@capacitor-community/screen-brightness';
type CredencialState = 'loading' | 'login' | 'success' | 'error';
@Component({
selector: 'app-credencial-modal',
templateUrl: './credencial-modal.component.html',
styleUrls: ['./credencial-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule, FormsModule]
})
export class CredencialModalComponent implements OnInit, OnDestroy {
state: CredencialState = 'loading';
errorMessage: string = '';
loadingStatus: string = 'Iniciando...';
dni: string = '';
password: string = '';
credentialImage: string = '';
showPassword: boolean = false;
originalBrightness: number | null = null;
constructor(
private modalCtrl: ModalController,
private credencialService: CredencialService,
private authService: AuthService,
private toastCtrl: ToastController
) { }
async ngOnInit() {
try {
const { brightness } = await ScreenBrightness.getBrightness();
this.originalBrightness = brightness;
await ScreenBrightness.setBrightness({ brightness: 1 });
} catch (e) {
console.warn('Brightness not supported on the web platform', e);
}
await this.checkExistingCredential();
}
ngOnDestroy() {
if (this.originalBrightness !== null) {
ScreenBrightness.setBrightness({ brightness: this.originalBrightness }).catch(() => { });
}
}
async checkExistingCredential() {
this.state = 'loading';
this.loadingStatus = 'Comprobando caché...';
try {
// 1. Try to load cached image first for instant load
const cachedImg = await this.credencialService.getCachedImage();
if (cachedImg) {
this.credentialImage = cachedImg;
this.state = 'success';
// Even if we show cache, load credentials to fields in background in case they want to update
const stored = await this.authService.getMoodleCredentials();
this.dni = stored.dni || '';
this.password = stored.pass || '';
return;
}
// 2. If no cached image, check for credentials to auto-login
const stored = await this.authService.getMoodleCredentials();
if (stored.dni && stored.pass) {
this.dni = stored.dni;
this.password = stored.pass;
await this.generate(false);
} else {
// 3. Need user to enter credentials
this.state = 'login';
}
} catch (error) {
console.error('Error on init:', error);
this.state = 'login';
}
}
async generate(forceUpdate: boolean = false) {
if (!this.dni || !this.password) return;
this.state = 'loading';
try {
this.credentialImage = await this.credencialService.generateCredential(
this.dni,
this.password,
(status) => { this.loadingStatus = status; }
);
this.state = 'success';
if (forceUpdate) {
const toast = await this.toastCtrl.create({
message: 'Credencial actualizada correctamente',
duration: 2000,
color: 'success'
});
await toast.present();
}
} catch (err: any) {
console.error('Error generating credential:', err);
this.errorMessage = err.message || 'Error al conectar con el campus.';
this.state = 'error';
}
}
async logout() {
await this.authService.logoutMoodle();
this.dni = '';
this.password = '';
this.credentialImage = '';
this.state = 'login';
}
resetState() {
// Determine where to back out: usually to login
this.state = 'login';
}
close() {
this.modalCtrl.dismiss();
}
}
@@ -0,0 +1,101 @@
<ion-header>
<ion-toolbar color="primary">
<ion-title>{{ isEdit ? 'Editar' : 'Programar' }} Hito</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">Cerrar</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="materia-info">
<small>Materia:</small>
<h3>{{ materiaNombre }}</h3>
</div>
<ion-list lines="inset" class="compact-list">
<ion-item class="input-item">
<ion-label position="stacked">Tipo e Hito</ion-label>
<ion-select [(ngModel)]="tipo" interface="popover">
<ion-select-option value="Parcial 1">Parcial 1</ion-select-option>
<ion-select-option value="Parcial 2">Parcial 2</ion-select-option>
<ion-select-option value="TP 1">Trabajo Práctico 1</ion-select-option>
<ion-select-option value="TP 2">Trabajo Práctico 2</ion-select-option>
<ion-select-option value="TP 3">Trabajo Práctico 3</ion-select-option>
<ion-select-option value="Recu. P1">Recuperatorio Parcial 1</ion-select-option>
<ion-select-option value="Recu. P2">Recuperatorio Parcial 2</ion-select-option>
<ion-select-option value="Recu. TP 1">Recuperatorio TP 1</ion-select-option>
<ion-select-option value="Recu. TP 2">Recuperatorio TP 2</ion-select-option>
<ion-select-option value="Recu. TP 3">Recuperatorio TP 3</ion-select-option>
<ion-select-option value="Final">Final</ion-select-option>
<ion-select-option value="Otro">Otro</ion-select-option>
</ion-select>
</ion-item>
<ion-grid class="ion-no-padding">
<ion-row>
<ion-col size="6">
<ion-item class="input-item side-item">
<ion-label position="stacked">Día del Hito</ion-label>
<ion-datetime-button datetime="date-input"></ion-datetime-button>
<ion-popover [keepContentsMounted]="true">
<ng-template>
<ion-datetime id="date-input" [(ngModel)]="fecha" presentation="date" prefer-wheel="true" [showDefaultButtons]="true" doneText="OK" cancelText="Atrás"></ion-datetime>
</ng-template>
</ion-popover>
</ion-item>
</ion-col>
<ion-col size="6">
<ion-item class="input-item side-item">
<ion-label position="stacked">Hora aprox.</ion-label>
<ion-datetime-button datetime="time-input"></ion-datetime-button>
<ion-popover [keepContentsMounted]="true">
<ng-template>
<ion-datetime id="time-input" [(ngModel)]="fecha" presentation="time" prefer-wheel="true" [showDefaultButtons]="true" doneText="OK" cancelText="Atrás"></ion-datetime>
</ng-template>
</ion-popover>
</ion-item>
</ion-col>
</ion-row>
</ion-grid>
<ion-grid class="ion-no-padding">
<ion-row>
<ion-col size="6">
<ion-item class="input-item side-item">
<ion-label position="stacked">Aula / Zoom</ion-label>
<ion-input type="text" [(ngModel)]="aula" placeholder="Ej: 102"></ion-input>
</ion-item>
</ion-col>
<ion-col size="6">
<ion-item class="input-item side-item">
<ion-label position="stacked">Link</ion-label>
<ion-input type="url" [(ngModel)]="link" placeholder="https://"></ion-input>
</ion-item>
</ion-col>
</ion-row>
</ion-grid>
<ion-item class="input-item">
<ion-label position="stacked">Notas</ion-label>
<ion-textarea [(ngModel)]="notas" placeholder="Breve descripción..." rows="2"></ion-textarea>
</ion-item>
</ion-list>
<div class="actions-container">
<ion-button expand="block" color="secondary" (click)="guardar()" class="main-btn">
{{ isEdit ? 'Actualizar Agenda' : 'Agendar Hito' }}
</ion-button>
<ion-button expand="block" fill="outline" color="dark" (click)="exportarCalendario()" class="export-btn" style="margin-top: 10px;">
<ion-icon name="calendar-outline" slot="start"></ion-icon>
Añadir a Google Calendar
</ion-button>
<ion-button *ngIf="isEdit" expand="block" fill="clear" color="danger" (click)="eliminar()" class="delete-btn">
<ion-icon name="trash-outline" slot="start"></ion-icon>
Eliminar Evento
</ion-button>
</div>
</ion-content>
@@ -0,0 +1,52 @@
.materia-info {
margin-top: 10px;
h3 {
margin: 5px 0 20px;
font-size: 1.4rem;
font-weight: 700;
}
}
.input-item {
--padding-start: 10px;
--padding-end: 10px;
--padding-top: 5px;
--padding-bottom: 5px;
--min-height: 45px;
margin-bottom: 10px;
border-radius: 8px;
background: var(--ion-color-light);
ion-label {
font-weight: 600;
margin-bottom: 4px !important;
font-size: 0.85rem;
}
}
.side-item {
margin-left: 2px;
margin-right: 2px;
}
.compact-list {
padding-top: 0;
}
ion-datetime-button {
margin-top: 8px;
margin-bottom: 8px;
}
.actions-container {
margin-top: 30px;
}
.main-btn {
margin-bottom: 15px;
}
.delete-btn {
--color: var(--ion-color-danger);
}
@@ -0,0 +1,117 @@
import { Component, Input, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule, ModalController, AlertController, ToastController } from '@ionic/angular';
import { EventoAcademico } from '../../services/data';
import { EventosService } from '../../services/eventos.service';
import { CapacitorCalendar } from '@ebarooni/capacitor-calendar';
@Component({
selector: 'app-evento-modal',
templateUrl: './evento-modal.component.html',
styleUrls: ['./evento-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule, FormsModule]
})
export class EventoModalComponent implements OnInit {
@Input() materiaCodigo!: string;
@Input() materiaNombre!: string;
@Input() eventoExistente?: EventoAcademico;
tipo: 'Parcial' | 'TP' | 'Final' | 'Otro' = 'Parcial';
fecha: string = new Date().toISOString();
aula: string = '';
link: string = '';
notas: string = '';
isEdit: boolean = false;
constructor(
private modalCtrl: ModalController,
private eventosService: EventosService,
private alertCtrl: AlertController,
private toastCtrl: ToastController
) { }
ngOnInit() {
if (this.eventoExistente) {
this.isEdit = true;
this.tipo = this.eventoExistente.tipo;
this.fecha = this.eventoExistente.fecha;
this.aula = this.eventoExistente.aula || '';
this.link = this.eventoExistente.link || '';
this.notas = this.eventoExistente.notas || '';
}
}
dismiss() {
this.modalCtrl.dismiss();
}
async guardar() {
const nuevoEvento: EventoAcademico = {
id: this.isEdit ? this.eventoExistente!.id : Date.now().toString(),
materiaCodigo: this.materiaCodigo,
tipo: this.tipo,
fecha: this.fecha,
aula: this.aula,
link: this.link,
notas: this.notas
};
await this.eventosService.saveEvento(nuevoEvento);
this.modalCtrl.dismiss(nuevoEvento, 'saved');
}
async eliminar() {
if (this.eventoExistente) {
const alert = await this.alertCtrl.create({
header: 'Eliminar Evento',
message: '¿Estás seguro que querés eliminar este hito de tu agenda?',
buttons: [
{
text: 'Cancelar',
role: 'cancel'
},
{
text: 'Eliminar',
role: 'destructive',
handler: async () => {
await this.eventosService.deleteEvento(this.eventoExistente!.id);
this.modalCtrl.dismiss(null, 'deleted');
}
}
]
});
await alert.present();
}
}
async exportarCalendario() {
try {
const startDate = new Date(this.fecha);
const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // +2 horas por defecto
let descString = this.notas || '';
if (this.link) descString += `\n\nLink de cursada: ${this.link}`;
await CapacitorCalendar.createEventWithPrompt({
title: `${this.tipo} de ${this.materiaNombre}`,
description: descString,
location: this.aula ? `Aula ${this.aula}, Universidad de la Ciudad` : 'Universidad de la Ciudad',
startDate: startDate.getTime(),
endDate: endDate.getTime(),
isAllDay: false
});
} catch (e) {
console.error('Error abriendo enlace de Calendario:', e);
const toast = await this.toastCtrl.create({
message: 'No pudimos abrir tu Calendario. Verificá si tenés una app instalada.',
duration: 2500, color: 'warning'
});
toast.present();
}
}
}
@@ -0,0 +1,326 @@
<ion-header>
<ion-toolbar color="primary">
<ion-title>Aulas UNICABA</ion-title>
<ion-buttons slot="end">
<ion-button (click)="close()">Cerrar</ion-button>
</ion-buttons>
</ion-toolbar>
<ion-toolbar color="primary">
<ion-segment [(ngModel)]="selectedSegment" mode="md">
<ion-segment-button value="manual">
<ion-label>Manual</ion-label>
</ion-segment-button>
<ion-segment-button value="benefits">
<ion-label>Beneficios</ion-label>
</ion-segment-button>
<ion-segment-button value="privacy">
<ion-label>Privacidad</ion-label>
</ion-segment-button>
</ion-segment>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding privacy-content">
<!-- VISTA: MANUAL DE USUARIO -->
<div *ngIf="selectedSegment === 'manual'" class="fade-in">
<div class="logo-container ion-text-center">
<img src="assets/logo_unicaba_mini.png" class="info-logo" alt="UNICABA Logo">
<h2>Guía del Estudiante</h2>
<p class="subtitle">Aprendé a usar tu Ecosistema Digital</p>
</div>
<div class="info-body">
<section>
<h3>1. Mi Cartelera (Horarios y Aulas)</h3>
<p>Visualizá todas las materias organizadas por día. Usá la barra de búsqueda para filtrar rápido por nombre,
aula o código.</p>
<ul>
<li><strong>Favoritos:</strong> Toca la estrella ⭐ para activar notificaciones automáticas y recordatorios 30
min antes de clase.</li>
<li><strong>Sincronización:</strong> Desliza hacia abajo para traer los últimos datos oficiales.</li>
</ul>
</section>
<section>
<h3>2. Mi Carrera (Seguimiento)</h3>
<p>Accedé desde el botón inferior para ver tu plan de estudios.</p>
<ul>
<li><strong>Estados:</strong> Marcá materias como <em>Aprobadas</em> (Fucsia) o <em>En Curso</em> (Celeste).
</li>
<li><strong>Optativas:</strong> Elegí tus materias directamente desde el selector para completar tu progreso.
</li>
</ul>
</section>
<section>
<h3>3. Agenda y Exámenes</h3>
<p>En el menú de cada materia (`...`) podés agendar <strong>Parciales, Finales o TPs</strong>. Verás insignias
de colores avisándote la proximidad.</p>
</section>
<section>
<h3>4. Credencial Digital QR</h3>
<p>Tocá el logo arriba a la izquierda para abrir tu credencial. La app subirá el brillo automáticamente para
facilitar el escaneo.</p>
</section>
<section>
<h3>5. Mantené tus datos seguros</h3>
<p>Usa <strong>Perfil > Guardar Respaldo</strong> para exportar un archivo CSV. Si cambias de teléfono, podés
restaurarlo desde el mismo lugar.</p>
</section>
</div>
</div>
<!-- VISTA: PRIVACIDAD -->
<div *ngIf="selectedSegment === 'privacy'" class="fade-in">
<div class="logo-container ion-text-center">
<ion-icon name="shield-checkmark-outline" size="large" color="success"></ion-icon>
<h2>Privacidad Zero-Trust</h2>
<p class="subtitle">Tus datos son tuyos y de nadie más</p>
</div>
<div class="info-body">
<h3>Sin Intermediarios</h3>
<p>Aulas UNICABA no tiene servidores propios. Cada vez que sincronizás la cartelera o tu credencial, la conexión
viaja directo de tu teléfono al servidor de la Universidad.</p>
<h3>Tratamiento de Credenciales</h3>
<p>Tus claves de Moodle o SIU Guaraní se guardan de forma encriptada en el almacenamiento seguro de tu
dispositivo. Nunca salen de tu teléfono hacia terceros.</p>
<h3>Analíticas y Rastreos</h3>
<p>No usamos Google Analytics ni SDKs de seguimiento. No hay publicidad. Cero telemetría invasiva.</p>
<div class="footer-info ion-text-center">
<p><small>Aulas UNICABA v1.2.3 • Independiente y Offline-First</small></p>
</div>
</div>
</div>
<!-- VISTA: BENEFICIOS -->
<div *ngIf="selectedSegment === 'benefits'" class="fade-in">
<div class="logo-container ion-text-center">
<ion-icon name="gift-outline" size="large" color="tertiary"></ion-icon>
<h2>Comunidad de Beneficios</h2>
<p class="subtitle">Descuentos exclusivos para la Comunidad UdelaCiudad</p>
</div>
<div class="benefits-container">
<!-- ON FIT (DESTACADO) -->
<div class="benefit-card featured">
<div class="card-top">
<div class="brand-info">
<div class="brand-icon gym">
<ion-icon name="barbell-outline"></ion-icon>
</div>
<div class="title-wrap">
<h3>ON FIT</h3>
<p class="category">Gimnasio & Bienestar</p>
</div>
</div>
<ion-badge color="success" class="discount-badge">25% OFF</ion-badge>
</div>
<p class="benefit-desc">
25% de descuento sobre el valor de la membresía mensual con acceso libre a sus 20 sedes en todo el AMBA.
</p>
<div class="inclusion-box">
<h4>¿Qué incluye la membresía?</h4>
<ul>
<li>Instalaciones con máquinas Technogym de última generación.</li>
<li>Plan de entrenamiento personalizado, actualizado mensualmente.</li>
<li>Clases grupales: funcional, zumba, stretching y más.</li>
<li>Lockers gratuitos en todas las sedes.</li>
</ul>
</div>
<div class="meta-grid">
<div class="meta-row">
<ion-icon name="information-circle-outline"></ion-icon>
<span>Presentá tu Credencial Digital de la app para acreditar pertenencia a la Universidad.</span>
</div>
</div>
<ion-button expand="block" color="success" class="action-btn"
href="https://wa.me/5491158500830?text=Hola!%20Soy%20de%20la%20Universidad%20de%20la%20Ciudad%20y%20deseo%20acceder%20al%20beneficio%20ON%20FIT"
target="_blank">
<ion-icon name="logo-whatsapp" slot="start"></ion-icon>
Consultar por WhatsApp (+54 9 11 5850-0830)
</ion-button>
</div>
<!-- BERESHIT -->
<div class="benefit-card">
<div class="card-top">
<div class="brand-info">
<div class="brand-icon cafe">
<ion-icon name="cafe-outline"></ion-icon>
</div>
<div class="title-wrap">
<h3>BERESHIT</h3>
<p class="category">Cafetería</p>
</div>
</div>
<ion-badge color="warning" class="discount-badge">10% OFF</ion-badge>
</div>
<p class="benefit-desc">10% de descuento en productos (excluye promociones vigentes).</p>
<div class="meta-grid">
<div class="meta-row">
<ion-icon name="location-outline"></ion-icon>
<span>Tte. Gral. Perón 893, CABA <em>(a metros de la sede)</em></span>
</div>
<div class="meta-row">
<ion-icon name="time-outline"></ion-icon>
<span>Lun a Vie de 8 a 20 h • Sábados de 8 a 16 h</span>
</div>
</div>
</div>
<!-- CAFÉ & CHOCOLATE -->
<div class="benefit-card">
<div class="card-top">
<div class="brand-info">
<div class="brand-icon cafe">
<ion-icon name="restaurant-outline"></ion-icon>
</div>
<div class="title-wrap">
<h3>Café & Chocolate</h3>
<p class="category">Cafetería & Delicatessen</p>
</div>
</div>
<ion-badge color="warning" class="discount-badge">10% OFF</ion-badge>
</div>
<p class="benefit-desc">10% de descuento en productos (excluye promociones vigentes).</p>
<div class="meta-grid">
<div class="meta-row">
<ion-icon name="location-outline"></ion-icon>
<span>Bartolomé Mitre 807, CABA</span>
</div>
<div class="meta-row">
<ion-icon name="time-outline"></ion-icon>
<span>Lun a Vie de 8 a 19 h • Sábados de 8 a 14 h</span>
</div>
</div>
</div>
<!-- AL BUEN TALLARÍN DE PABLO -->
<div class="benefit-card">
<div class="card-top">
<div class="brand-info">
<div class="brand-icon pasta">
<ion-icon name="pizza-outline"></ion-icon>
</div>
<div class="title-wrap">
<h3>Al Buen Tallarín de Pablo</h3>
<p class="category">Fábrica de Pastas</p>
</div>
</div>
<ion-badge color="danger" class="discount-badge">10% OFF</ion-badge>
</div>
<p class="benefit-desc">10% de descuento en productos (excluye promociones vigentes).</p>
<div class="meta-grid">
<div class="meta-row">
<ion-icon name="location-outline"></ion-icon>
<span>Tucumán 1596, CABA</span>
</div>
<div class="meta-row">
<ion-icon name="time-outline"></ion-icon>
<span>Mar a Vie de 8 a 19 h • Sáb y Dom de 9:30 a 14:30 h</span>
</div>
</div>
</div>
<!-- ATLAS COMERCIAL -->
<div class="benefit-card">
<div class="card-top">
<div class="brand-info">
<div class="brand-icon libreria">
<ion-icon name="book-outline"></ion-icon>
</div>
<div class="title-wrap">
<h3>Atlas Comercial</h3>
<p class="category">Librería & Papelería</p>
</div>
</div>
<ion-badge color="primary" class="discount-badge">10% OFF</ion-badge>
</div>
<p class="benefit-desc">10% de descuento en productos (excluye promociones vigentes).</p>
<div class="meta-grid">
<div class="meta-row">
<ion-icon name="location-outline"></ion-icon>
<span>Bartolomé Mitre 839, CABA</span>
</div>
<div class="meta-row">
<ion-icon name="time-outline"></ion-icon>
<span>Lun a Vie de 9 a 18 h</span>
</div>
</div>
</div>
<!-- FLUIRE STUDIO -->
<div class="benefit-card">
<div class="card-top">
<div class="brand-info">
<div class="brand-icon pilates">
<ion-icon name="body-outline"></ion-icon>
</div>
<div class="title-wrap">
<h3>Fluire Studio</h3>
<p class="category">Pilates & Salud</p>
</div>
</div>
<ion-badge color="tertiary" class="discount-badge">Hasta 20% OFF</ion-badge>
</div>
<p class="benefit-desc">
10% de descuento abonando 1 mes de servicio y 20% de descuento abonando 4 meses de servicio.
</p>
<div class="meta-grid">
<div class="meta-row">
<ion-icon name="location-outline"></ion-icon>
<span>Esmeralda 135, piso 4 B, CABA</span>
</div>
<div class="meta-row">
<ion-icon name="mail-outline"></ion-icon>
<span>Contacto: fluire.studio.ba@gmail.com</span>
</div>
</div>
<ion-button expand="block" fill="outline" color="tertiary" class="action-btn"
href="mailto:fluire.studio.ba@gmail.com?subject=Consulta%20Beneficio%20Universidad%20de%20la%20Ciudad">
<ion-icon name="mail-outline" slot="start"></ion-icon>
Contactar por Email
</ion-button>
</div>
<!-- SOY ESTUDIANTE -->
<div class="benefit-card">
<div class="card-top">
<div class="brand-info">
<div class="brand-icon travel">
<ion-icon name="bus-outline"></ion-icon>
</div>
<div class="title-wrap">
<h3>SOY Estudiante</h3>
<p class="category">Transporte de Larga Distancia</p>
</div>
</div>
<ion-badge color="secondary" class="discount-badge">Descuento Nacional</ion-badge>
</div>
<p class="benefit-desc">
Obtené descuentos en pasajes de micro a todo el país a través del portal de Central de Pasajes.
</p>
<div class="meta-grid">
<div class="meta-row">
<ion-icon name="checkmark-circle-outline"></ion-icon>
<span>Entrá a <b>Perfil</b>, generá tu Certificado de Alumno Regular y subilo a <a href="https://soyestudiante.com.ar/" target="_blank">soyestudiante.com.ar</a>.</span>
</div>
</div>
</div>
</div>
<div class="footer-info ion-text-center">
<p><small>Presentá tu credencial digital de Aulas UNICABA en los comercios adheridos para acceder a los descuentos.</small></p>
</div>
</div>
</ion-content>
@@ -0,0 +1,238 @@
.privacy-content {
--background: var(--ion-color-light);
}
ion-segment {
--background: var(--ion-color-primary-shade);
border-radius: 0;
ion-segment-button {
--color: rgba(255,255,255,0.7);
--color-checked: white;
--indicator-color: white;
}
}
.logo-container {
padding: 10px 0 20px;
.info-logo {
height: 80px;
background: white;
padding: 10px;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
margin-bottom: 15px;
}
ion-icon {
font-size: 64px;
margin-bottom: 5px;
}
h2 {
font-weight: 800;
margin: 0;
color: var(--ion-color-primary);
}
.subtitle {
margin: 5px 0 0;
color: var(--ion-color-medium);
font-size: 0.9rem;
font-weight: 500;
}
}
.info-body {
background: white;
border-radius: 20px;
padding: 20px;
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
margin-bottom: 20px;
section {
margin-bottom: 25px;
&:last-child { margin-bottom: 0; }
}
h3 {
font-weight: 700;
font-size: 1rem;
color: var(--ion-color-dark);
margin: 0 0 10px;
}
p, li {
font-size: 0.9rem;
line-height: 1.5;
color: var(--ion-color-medium-shade);
}
ul {
padding-left: 20px;
margin: 10px 0;
li { margin-bottom: 8px; }
}
}
/* --- ESTILOS DE BENEFICIOS --- */
.benefits-container {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 25px;
}
.benefit-card {
background: white;
border-radius: 18px;
padding: 18px;
box-shadow: 0 4px 15px rgba(0,0,0,0.06);
border: 1px solid rgba(0,0,0,0.04);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&.featured {
border: 1.5px solid var(--ion-color-primary);
background: linear-gradient(180deg, #ffffff 0%, rgba(var(--ion-color-primary-rgb), 0.02) 100%);
box-shadow: 0 6px 20px rgba(var(--ion-color-primary-rgb), 0.12);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
.brand-info {
display: flex;
align-items: center;
gap: 12px;
.brand-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(var(--ion-color-primary-rgb), 0.1);
color: var(--ion-color-primary);
font-size: 24px;
flex-shrink: 0;
&.cafe { background: #fff3e0; color: #e65100; }
&.gym { background: #e8f5e9; color: #2e7d32; }
&.pilates { background: #ede7f6; color: #512da8; }
&.pasta { background: #fbe9e7; color: #d84315; }
&.libreria { background: #e1f5fe; color: #0288d1; }
&.travel { background: #e3f2fd; color: #1565c0; }
}
.title-wrap {
h3 {
margin: 0;
font-size: 1.05rem;
font-weight: 800;
color: var(--ion-color-dark);
}
.category {
margin: 2px 0 0;
font-size: 0.8rem;
color: var(--ion-color-medium);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
.discount-badge {
font-size: 0.78rem;
font-weight: 800;
padding: 5px 10px;
border-radius: 20px;
letter-spacing: 0.3px;
}
}
.benefit-desc {
font-size: 0.92rem;
color: var(--ion-color-dark-shade);
margin: 0 0 12px;
line-height: 1.45;
}
.meta-grid {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 10px;
border-top: 1px dashed #e0e0e0;
margin-bottom: 12px;
.meta-row {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 0.84rem;
color: var(--ion-color-medium-shade);
line-height: 1.4;
ion-icon {
font-size: 16px;
margin-top: 2px;
color: var(--ion-color-primary);
flex-shrink: 0;
}
}
}
.inclusion-box {
background: #f8fafc;
border-radius: 12px;
padding: 12px 14px;
margin: 10px 0;
border-left: 3px solid var(--ion-color-success);
h4 {
margin: 0 0 6px;
font-size: 0.85rem;
font-weight: 700;
color: var(--ion-color-dark);
}
ul {
margin: 0;
padding-left: 18px;
li {
font-size: 0.82rem;
margin-bottom: 4px;
color: #475569;
&:last-child { margin-bottom: 0; }
}
}
}
.action-btn {
--border-radius: 12px;
font-weight: 700;
font-size: 0.88rem;
margin-top: 6px;
letter-spacing: 0.3px;
}
}
.footer-info {
margin-top: 30px;
opacity: 0.6;
}
.fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -0,0 +1,21 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule, ModalController } from '@ionic/angular';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-info-modal',
templateUrl: './info-modal.component.html',
styleUrls: ['./info-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule, FormsModule]
})
export class InfoModalComponent {
selectedSegment: string = 'manual';
constructor(private modalCtrl: ModalController) {}
close() {
this.modalCtrl.dismiss();
}
}
@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomePage } from './home.page';
const routes: Routes = [
{
path: '',
component: HomePage,
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HomePageRoutingModule {}
@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { FormsModule } from '@angular/forms';
import { HomePage } from './home.page';
import { HomePageRoutingModule } from './home-routing.module';
import { CredencialModalComponent } from '../components/credencial-modal/credencial-modal.component';
import { EventoModalComponent } from '../components/evento-modal/evento-modal.component';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
HomePageRoutingModule,
CredencialModalComponent,
EventoModalComponent
],
declarations: [HomePage]
})
export class HomePageModule {}
@@ -0,0 +1,231 @@
<ion-header [translucent]="true">
<ion-toolbar color="secondary">
<div class="header-container-flex">
<div class="header-logo-section" (click)="openInfo()">
<img src="assets/logo_unicaba_mini.png" class="header-logo" alt="UNICABA Logo">
</div>
<ion-buttons slot="end">
<ion-button (click)="openCredencial()" title="Mi Credencial QR">
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
</ion-button>
<ion-button (click)="goToPerfil()" title="Mi Perfil">
<div class="user-avatar-small" *ngIf="authState?.userPhoto; else defaultIcon">
<img [src]="authState?.userPhoto" />
</div>
<ng-template #defaultIcon>
<ion-icon slot="icon-only" name="person-circle-outline"></ion-icon>
</ng-template>
</ion-button>
</ion-buttons>
</div>
</ion-toolbar>
<ion-toolbar color="light" class="ion-no-padding">
<div class="view-segment">
<ion-segment [(ngModel)]="viewMode" (ionChange)="onViewModeChange()" color="primary">
<ion-segment-button value="favoritos">Favoritos</ion-segment-button>
<ion-segment-button value="todas">Cartelera</ion-segment-button>
<ion-segment-button value="plan">Mi Carrera</ion-segment-button>
</ion-segment>
</div>
</ion-toolbar>
<ion-toolbar color="light" class="ion-no-padding" *ngIf="viewMode === 'todas'">
<div class="filter-section">
<ion-searchbar placeholder="Buscar materia, aula o código..." (ionInput)="onSearchChange($event)"
[debounce]="250"></ion-searchbar>
</div>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true" color="light" (swipe)="onSwipe($event)">
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="Desliza para actualizar" refreshingSpinner="crescent"
refreshingText="Actualizando..."></ion-refresher-content>
</ion-refresher>
<!-- Banner: Próxima Clase -->
<div class="proxima-clase-container" *ngIf="viewMode === 'favoritos' && proximaClase && !loading">
<ion-card class="proxima-clase-card" [class.urgente]="proximaClase.diffMs > 0 && proximaClase.diffMs <= 1800000">
<ion-card-content (click)="verAgendaMateria(proximaClase.item)">
<div class="proxima-header">
<div class="proxima-label">
<ion-icon name="time-outline"></ion-icon>
<span>{{ proximaClase.label }}</span>
</div>
<ion-chip [color]="countdownColor(proximaClase.diffMs)" class="countdown-chip"
[class.pulse-chip]="proximaClase.diffMs > 0 && proximaClase.diffMs <= 1800000">
<ion-icon name="alarm-outline"></ion-icon>
<ion-label>{{ formatCountdown(proximaClase.diffMs) }}</ion-label>
</ion-chip>
</div>
<p class="proxima-asignatura">{{ proximaClase.item.asignatura }}</p>
<div class="proxima-detalles">
<span class="proxima-aula">
<ion-icon name="location-outline"></ion-icon>
Aula {{ proximaClase.item.aula }}
</span>
<span class="proxima-hora">
<ion-icon name="calendar-outline"></ion-icon>
{{ proximaClase.item.dia }} {{ proximaClase.item.horario }}
</span>
</div>
</ion-card-content>
</ion-card>
</div>
<div class="loading-container" *ngIf="loading">
<ion-spinner name="crescent" color="primary"></ion-spinner>
<p>Cargando cartelera...</p>
</div>
<div class="error-state" *ngIf="!loading && errorMsg">
<p>{{ errorMsg }}</p>
</div>
<ion-list *ngIf="!loading && !errorMsg && horariosFiltrados.length > 0 && viewMode !== 'plan'">
<ion-card *ngFor="let item of horariosFiltrados" [class.highlight-card]="item.codigo === highlightedCodigo"
class="subject-card">
<ion-card-header>
<div class="card-header-flex">
<div class="header-titles">
<ion-card-subtitle color="primary">{{ item.carrera }}</ion-card-subtitle>
<ion-card-title>{{ item.asignatura }}</ion-card-title>
</div>
<div class="card-actions">
<!-- Compartir siempre visible -->
<ion-icon class="share-icon" name="share-social-outline" color="medium"
(click)="shareItem(item)"></ion-icon>
<!-- Icono condicional: Hamburguesa en favoritos, Estrella en cartelera -->
<ion-icon class="menu-icon"
[name]="viewMode === 'favoritos' ? 'menu-outline' : (isFavorito(item) ? 'star' : 'star-outline')"
[color]="(viewMode === 'todas' && isFavorito(item)) ? 'warning' : 'medium'"
(click)="openMateriaActions(item)"></ion-icon>
</div>
</div>
<!-- Indicador de Próximo Evento (Agenda) -->
<div class="evento-badge-container" *ngIf="getProximoEvento(item.codigo) as proximo">
<ion-badge [color]="isEventoUrgente(proximo.fecha) ? 'danger' : 'secondary'" class="evento-badge">
<ion-icon [name]="proximo.tipo === 'TP' ? 'document-text-outline' : 'calendar-outline'"></ion-icon>
{{ proximo.tipo }}: {{ formatFechaEvento(proximo.fecha) }}
</ion-badge>
</div>
</ion-card-header>
<ion-card-content (click)="verAgendaMateria(item)">
<p><strong>Código:</strong> {{ item.codigo }}</p>
<div class="aula-container">
<span class="aula-tag">{{ item.aula }}</span>
</div>
<p class="schedule-text">{{ item.dia }} - {{ item.horario }}</p>
<div *ngIf="item.link" class="link-container">
<ion-button fill="outline" color="primary" size="small" expand="block"
(click)="openLink(item.link!); $event.stopPropagation()">
<ion-icon slot="start" name="videocam-outline"></ion-icon>
Ingresar a Clase Virtual
</ion-button>
</div>
</ion-card-content>
</ion-card>
</ion-list>
<div class="empty-state" *ngIf="!loading && !errorMsg && horariosFiltrados.length === 0 && viewMode !== 'plan'">
<p>No se encontraron horarios para la selección actual.</p>
</div>
<!-- Vista: Mi Carrera (Plan de Estudios) -->
<div *ngIf="viewMode === 'plan'" class="ion-padding content-plan">
<ion-card class="carrera-selector-card">
<ion-card-content>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span class="selector-title" style="margin: 0;">Plan de Estudios / Recorrido</span>
<div style="display: flex; gap: 8px;">
</div>
</div>
<p class="selector-desc">Selecciona tu carrera para guardar tu avance y ver el recorrido sugerido, toca materias
para marcarlas como aprobadas o en curso.</p>
<ion-item lines="none" class="carrera-item">
<ion-icon name="school-outline" slot="start" color="primary"></ion-icon>
<ion-select placeholder="Elegí una carrera" [(ngModel)]="carreraSeleccionada" interface="popover">
<ion-select-option value="tecnologias">Lic. en Tecnologías Digitales</ion-select-option>
<ion-select-option value="datos">Lic. en Ciencia de Datos</ion-select-option>
<ion-select-option value="videojuegos">Lic. en Des. de Videojuegos</ion-select-option>
</ion-select>
</ion-item>
</ion-card-content>
</ion-card>
<div *ngIf="carreraSeleccionada" class="tabla-recorrido-container fade-in">
<table class="recorrido-table">
<thead>
<tr>
<th>1° Cuat.</th>
<th>2° Cuat.</th>
<th>3° Cuat.</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of getRecorridoData()">
<td>
<div class="materia-cell" [class.aprobada]="isAprobada(row.c1.name)"
[class.encurso]="isEnCurso(row.c1.name)" [class.optativa-cell]="row.c1.isOptativa"
(click)="handleCellClick(row.c1)">
<span class="m-name">{{ abreviarMateria(row.c1.name) }}</span>
<ion-badge *ngIf="getSiuMateriaParaCelda(row.c1.name) as siuMat"
[ngClass]="'custom-badge-' + getNotaColor(siuMat.nota)" class="siu-cell-badge">
{{ getSiuBadgeText(siuMat) }}
</ion-badge>
</div>
</td>
<td>
<div class="materia-cell" [class.aprobada]="isAprobada(row.c2.name)"
[class.encurso]="isEnCurso(row.c2.name)" [class.optativa-cell]="row.c2.isOptativa"
(click)="handleCellClick(row.c2)">
<span class="m-name">{{ abreviarMateria(row.c2.name) }}</span>
<ion-badge *ngIf="getSiuMateriaParaCelda(row.c2.name) as siuMat"
[ngClass]="'custom-badge-' + getNotaColor(siuMat.nota)" class="siu-cell-badge">
{{ getSiuBadgeText(siuMat) }}
</ion-badge>
</div>
</td>
<td>
<div class="materia-cell" [class.aprobada]="isAprobada(row.c3.name)"
[class.encurso]="isEnCurso(row.c3.name)" [class.optativa-cell]="row.c3.isOptativa"
(click)="handleCellClick(row.c3)">
<span class="m-name">{{ abreviarMateria(row.c3.name) }}</span>
<ion-badge *ngIf="getSiuMateriaParaCelda(row.c3.name) as siuMat"
[ngClass]="'custom-badge-' + getNotaColor(siuMat.nota)" class="siu-cell-badge">
{{ getSiuBadgeText(siuMat) }}
</ion-badge>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- The SIU Guaraní list at the bottom was removed per user request -->
</div>
</ion-content>
<ion-footer class="ion-no-border">
<ion-toolbar class="footer-toolbar">
<div class="modern-footer-btn" (click)="openMap()" title="Abrir en Mapas">
<ion-icon name="location" class="location-icon"></ion-icon>
<div class="footer-text-column">
<span class="footer-title">U de la Ciudad</span>
<span class="footer-address">Tte. Gral. J.D. Perón 802 • CABA</span>
</div>
<ion-icon name="navigate-circle" class="nav-icon"></ion-icon>
</div>
</ion-toolbar>
</ion-footer>
@@ -0,0 +1,743 @@
.filter-section {
background: var(--ion-background-color);
padding: 5px 0;
ion-item {
--background: transparent;
}
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 50px;
color: var(--ion-color-medium);
}
.empty-state {
text-align: center;
padding: 30px 20px;
color: var(--ion-color-medium);
}
.error-state {
text-align: center;
padding: 30px 20px;
color: var(--ion-color-danger);
font-weight: bold;
}
// Header styles
.header-container-flex {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
width: 100%;
}
.header-logo-section {
display: flex;
align-items: center;
cursor: pointer;
padding: 8px 0;
.header-logo {
height: 38px;
background: white;
padding: 4px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
}
.user-avatar-small {
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
border: 2px solid white;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
// Footer replica styles
.footer-toolbar {
--background: var(--ion-background-color);
--border-width: 0;
padding: 8px 14px;
padding-bottom: calc(8px + var(--ion-safe-area-bottom, 0px));
}
.modern-footer-btn {
display: flex;
align-items: center;
background: linear-gradient(135deg, var(--ion-color-primary), var(--ion-color-secondary));
border-radius: 16px;
padding: 12px 16px;
color: white;
box-shadow: 0 4px 15px rgba(var(--ion-color-primary-rgb), 0.3);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
&:active {
transform: scale(0.97);
box-shadow: 0 2px 8px rgba(var(--ion-color-primary-rgb), 0.2);
}
.location-icon {
font-size: 24px;
margin-right: 12px;
background: rgba(255, 255, 255, 0.2);
padding: 8px;
border-radius: 12px;
flex-shrink: 0;
}
.footer-text-column {
flex: 1;
display: flex;
flex-direction: column;
.footer-title {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.footer-address {
font-size: 12px;
opacity: 0.9;
}
}
.nav-icon {
font-size: 28px;
opacity: 0.95;
margin-left: 10px;
flex-shrink: 0;
}
}
.view-segment {
background: var(--ion-background-color);
padding: 10px;
}
.card-header-flex {
display: flex;
justify-content: space-between;
align-items: flex-start;
.header-titles {
flex: 1;
margin-right: 15px;
}
.fav-icon {
font-size: 28px;
cursor: pointer;
flex-shrink: 0;
}
}
.aula-container {
margin: 15px 0;
.aula-tag {
color: #682cad;
font-size: 1.3em;
font-weight: bold;
display: inline-block;
}
}
ion-card {
margin: 8px 10px;
ion-card-header {
padding: 10px 14px 0;
ion-card-title {
font-size: 1.15rem;
}
}
ion-card-content {
padding: 10px 14px 14px;
p {
margin-top: 0;
margin-bottom: 4px;
font-size: 0.9em;
}
.aula-container {
margin: 8px 0;
.aula-tag {
font-size: 1.2em;
}
}
}
}
ion-card.subject-card {
margin: 8px 12px;
position: relative;
overflow: hidden;
transition: transform 0.1s;
&:active {
transform: scale(0.98);
}
}
.card-actions {
display: flex;
align-items: center;
gap: 12px;
.share-icon,
.menu-icon {
font-size: 1.6rem;
cursor: pointer;
transition: transform 0.2s;
&:active {
transform: scale(0.9);
}
}
}
.link-container {
margin-top: 10px;
ion-button {
--border-radius: 8px;
font-weight: 600;
letter-spacing: 0.3px;
}
}
.schedule-text {
color: #7B2049;
/* Secondary Uva color */
font-size: 0.98rem !important;
font-weight: bold !important;
margin-top: 8px !important;
margin-bottom: 8px !important;
}
.highlight-card {
border: 2px solid var(--ion-color-secondary) !important;
animation: pulse-border 1s alternate infinite;
}
@keyframes pulse-border {
from {
box-shadow: 0 0 5px var(--ion-color-secondary);
}
to {
box-shadow: 0 0 20px var(--ion-color-secondary);
}
}
/* Dark mode overwrites for custom colored text */
@media (prefers-color-scheme: dark) {
.aula-container .aula-tag {
color: #ae7fff;
/* Lighter vivid purple for dark background */
}
.schedule-text {
color: #ff8ab6 !important;
/* Lighter magenta for dark background */
}
.highlight-card {
border-color: var(--ion-color-primary) !important;
animation: pulse-border-dark 1s alternate infinite;
}
@keyframes pulse-border-dark {
from {
box-shadow: 0 0 5px var(--ion-color-primary);
}
to {
box-shadow: 0 0 20px var(--ion-color-primary);
}
}
.proxima-clase-card {
background: linear-gradient(135deg, #1e2a4a, #1a1f3c) !important;
border-color: rgba(var(--ion-color-primary-rgb), 0.6) !important;
.proxima-label {
color: rgba(255, 255, 255, 0.6);
}
.proxima-asignatura {
color: #fff;
}
.proxima-detalles {
color: rgba(255, 255, 255, 0.75);
}
}
.evento-badge {
--background: rgba(var(--ion-color-secondary-rgb), 0.15);
color: var(--ion-color-secondary-contrast);
}
}
// ─── Hitos Académicos (Agenda) ──────────────────────────────────────
.evento-badge-container {
margin-top: 5px;
padding: 0 0 10px;
}
.evento-badge {
font-size: 0.8rem;
padding: 6px 10px;
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
letter-spacing: 0.3px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
ion-icon {
font-size: 0.95rem;
}
}
// ─── Banner: Próxima Clase ───────────────────────────────────────────
.proxima-clase-container {
padding: 8px 12px 0;
}
.proxima-clase-card {
margin: 0;
border-radius: 14px;
border: 1.5px solid rgba(var(--ion-color-primary-rgb), 0.35);
background: linear-gradient(135deg, #eef2ff, #f5f0ff);
box-shadow: 0 4px 18px rgba(var(--ion-color-primary-rgb), 0.15);
transition: border-color 0.3s, box-shadow 0.3s;
ion-card-content {
padding: 14px 16px;
}
&.urgente {
border-color: rgba(var(--ion-color-danger-rgb), 0.5);
background: linear-gradient(135deg, #fff0f3, #ffe8ed);
box-shadow: 0 4px 18px rgba(var(--ion-color-danger-rgb), 0.18);
animation: pulse-urgente 1.4s ease-in-out infinite;
}
}
@keyframes pulse-urgente {
0%,
100% {
box-shadow: 0 4px 18px rgba(var(--ion-color-danger-rgb), 0.15);
}
50% {
box-shadow: 0 4px 28px rgba(var(--ion-color-danger-rgb), 0.35);
}
}
.proxima-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.proxima-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--ion-color-medium);
ion-icon {
font-size: 1rem;
}
}
}
.countdown-chip {
font-weight: 700;
font-size: 0.82rem;
height: 28px;
--padding-start: 10px;
--padding-end: 10px;
ion-icon {
margin-right: 4px;
}
}
@keyframes pulse-chip {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.75;
transform: scale(1.05);
}
}
.pulse-chip {
animation: pulse-chip 1s ease-in-out infinite;
}
.proxima-asignatura {
font-size: 1.1rem;
font-weight: 700;
color: var(--ion-color-dark);
margin: 0 0 10px;
line-height: 1.3;
}
.proxima-detalles {
display: flex;
gap: 16px;
flex-wrap: wrap;
span {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.88rem;
color: var(--ion-color-medium-shade);
font-weight: 500;
ion-icon {
font-size: 1rem;
flex-shrink: 0;
}
}
}
// ─── Recorrido Académico (Plan de Estudios) ───────────────────────────
/* --- Vista: Mi Carrera (Plan de Estudios) --- */
.content-plan {
background: var(--ion-color-light);
}
.carrera-selector-card {
margin: 10px 0 20px 0;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
ion-card-content {
padding: 16px;
}
.selector-title {
display: block;
font-size: 1.15rem;
font-weight: 700;
color: var(--ion-color-dark);
margin-bottom: 6px;
}
.selector-desc {
font-size: 0.88rem;
color: var(--ion-color-medium);
margin-bottom: 16px;
line-height: 1.4;
}
.carrera-item {
--background: var(--ion-color-light);
border-radius: 8px;
--padding-start: 10px;
--inner-padding-end: 10px;
ion-select {
font-weight: 600;
color: var(--ion-color-primary);
width: 100%;
}
}
}
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.recorrido-table {
width: 100%;
border-collapse: collapse;
font-size: 0.70rem;
/* Fuente más chica para nombres largos */
th {
background: var(--ion-color-primary);
color: white;
padding: 8px 4px;
text-align: center;
font-weight: 600;
border-radius: 4px 4px 0 0;
}
td {
padding: 4px;
vertical-align: top;
width: 33.33%;
}
.materia-cell {
padding: 6px 4px;
width: 100%;
aspect-ratio: 1 / 0.65;
/* Reducimos 15% el alto respecto al ancho */
background: var(--ion-color-light);
border-radius: 6px;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1.5px solid transparent;
.m-name {
display: flex;
flex: 1;
align-items: center;
}
.siu-cell-badge {
margin-top: 4px;
font-size: 0.75rem;
padding: 3px 8px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
&.custom-badge-success {
--background: rgba(255, 255, 255, 0.95);
color: #a31c62;
/* Rosa oscuro legible sobre blanco */
font-weight: 800;
letter-spacing: 0.5px;
}
&.custom-badge-warning {
--background: rgba(255, 255, 255, 0.95);
color: #0b5f7e;
/* Azul/Cyan oscuro legible sobre blanco */
font-weight: 800;
letter-spacing: 0.5px;
}
&.custom-badge-danger {
--background: rgba(var(--ion-color-danger-rgb), 0.15);
color: var(--ion-color-danger);
}
}
&:active {
transform: scale(0.95);
}
&.aprobada {
background: #682cad;
color: white;
font-weight: 600;
box-shadow: 0 2px 6px rgba(104, 44, 173, 0.3);
border-color: rgba(0, 0, 0, 0.1);
}
&.encurso {
background: rgba(var(--ion-color-secondary-rgb), 0.15); // Subtono Cyan/Celeste
color: var(--ion-color-secondary);
font-weight: 600;
border: 1.5px solid var(--ion-color-secondary);
}
&.optativa-cell:not(.aprobada):not(.encurso) {
border: 1.5px dashed var(--ion-color-medium);
color: var(--ion-color-primary);
background: transparent;
font-weight: 600;
}
}
}
.instruccion-marcar {
text-align: center;
color: var(--ion-color-medium);
margin-top: 12px;
font-style: italic;
}
// ─── SIU Guaraní ───────────────────────────────────────────
.siu-container {
padding: 10px 0;
.siu-header {
padding: 0 10px;
}
.ultima-sync {
padding: 0 10px;
}
}
.siu-card {
margin: 10px 0;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
ion-card-header {
padding: 12px 16px 4px;
ion-card-title {
color: var(--ion-color-dark);
font-weight: 700;
}
}
.siu-card-content {
padding: 10px 16px 16px;
ion-badge {
font-size: 0.9rem;
padding: 6px 10px;
border-radius: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
.siu-login-modal {
--border-radius: 16px 16px 0 0;
.siu-logo-container {
padding-top: 20px;
h2 {
font-weight: 800;
color: var(--ion-color-primary);
}
}
.siu-form-list {
background: transparent;
ion-item {
--background: rgba(var(--ion-color-light-rgb), 0.5);
--border-radius: 8px;
margin-bottom: 10px;
--padding-start: 12px;
ion-input {
margin-left: 10px;
font-weight: 500;
}
}
}
ion-button[expand="block"] {
margin-top: 20px;
height: 48px;
--border-radius: 12px;
font-weight: bold;
font-size: 1.05rem;
letter-spacing: 0.5px;
}
}
@media (prefers-color-scheme: dark) {
.siu-card {
background: var(--ion-color-step-100);
}
.siu-login-modal {
.siu-form-list ion-item {
--background: var(--ion-color-step-100);
}
.siu-info {
background: var(--ion-color-step-100) !important;
}
}
/* Mejorar el contraste de los botones de Optativa en modo oscuro */
.recorrido-table .materia-cell.optativa-cell:not(.aprobada):not(.encurso) {
border-color: rgba(255, 255, 255, 0.45);
color: #ff8ab6;
/* Lighter magenta for dark background */
}
/* Mejorar íconos y bordes de encabezado (botones SIU/Guardar) */
ion-button.header-btn {
--border-width: 1.5px;
}
ion-button.header-btn[color="primary"] {
--color: #ff8ab6 !important;
--border-color: #ff8ab6 !important;
}
ion-button.header-btn[color="tertiary"] {
--color: #ae7fff !important;
--border-color: #ae7fff !important;
}
ion-button.header-btn[color="success"] {
--color: #68f4a1 !important;
--border-color: #68f4a1 !important;
}
}
@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { HomePage } from './home.page';
describe('HomePage', () => {
let component: HomePage;
let fixture: ComponentFixture<HomePage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomePage],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(HomePage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More