feat: gestion RBAC granular, importacion sheets, filtrado de aulas virtuales y pisos, capacidad infinita y traduccion
@@ -30,36 +30,65 @@ def create_app(config_class=Config):
|
||||
|
||||
# Import models to ensure they are registered
|
||||
from app.models import user
|
||||
|
||||
# Register blueprints
|
||||
from app.routes import auth_bp, classrooms_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp
|
||||
# Register blueprints
|
||||
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix="/")
|
||||
app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
|
||||
app.register_blueprint(buildings_bp)
|
||||
app.register_blueprint(main_bp, url_prefix="/")
|
||||
app.register_blueprint(schedule_bp, url_prefix="/schedule")
|
||||
app.register_blueprint(genetic_bp)
|
||||
app.register_blueprint(genetic_web_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# Language selector function
|
||||
def get_locale():
|
||||
# 1. Check if language is explicitly set in URL parameters
|
||||
# 1. Parámetro explícito en la URL (?lang=es o ?lang=en)
|
||||
lang = request.args.get('lang')
|
||||
if lang and lang in app.config['LANGUAGES']:
|
||||
session['language'] = lang
|
||||
return lang
|
||||
|
||||
# 2. Check if user is authenticated and has a configured language in profile
|
||||
# 2. Si el usuario está autenticado y tiene idioma configurado en su perfil, prevalece en toda la sesión
|
||||
from flask_login import current_user
|
||||
if current_user.is_authenticated and getattr(current_user, 'preferred_language', None):
|
||||
if current_user.preferred_language in app.config['LANGUAGES']:
|
||||
return current_user.preferred_language
|
||||
|
||||
# 3. Check if language is stored in session (e.g. manual switcher)
|
||||
# 3. En la pantalla de Login (usuario no autenticado):
|
||||
# El idioma predeterminado del login debe ser español o respetar el idioma del navegador.
|
||||
# No debe verse afectado por cookies o sesiones residuales de otros usuarios.
|
||||
is_login = (request.endpoint == 'auth.login' or request.path.rstrip('/') == '/login')
|
||||
if is_login:
|
||||
# Solo si el usuario cambió el idioma explícitamente en el selector del login
|
||||
if session.get('login_lang_explicit') in app.config['LANGUAGES']:
|
||||
return session['login_lang_explicit']
|
||||
|
||||
# Detectar según el navegador del usuario (soporta es-419, es-AR, es-ES, etc.)
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
return primary
|
||||
|
||||
# Idioma predeterminado por defecto del login: Español
|
||||
return 'es'
|
||||
|
||||
# 4. Para otras rutas: verificar si hay idioma en sesión (ej. selector manual)
|
||||
if 'language' in session and session['language'] in app.config['LANGUAGES']:
|
||||
return session['language']
|
||||
|
||||
# 4. Check browser language preference by default (especially on login/guest)
|
||||
return request.accept_languages.best_match(app.config['LANGUAGES']) or app.config['BABEL_DEFAULT_LOCALE']
|
||||
# 5. Detección automática del navegador por defecto (soporta subtags regionales)
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
return primary
|
||||
|
||||
# Fallback del sistema: Español
|
||||
return app.config['BABEL_DEFAULT_LOCALE']
|
||||
|
||||
|
||||
# Babel context processor
|
||||
@app.context_processor
|
||||
@@ -73,4 +102,11 @@ def create_app(config_class=Config):
|
||||
# Configure Babel
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
# Register localized date and datetime formatters for Jinja2 templates
|
||||
from flask_babel import format_date, format_datetime
|
||||
app.jinja_env.globals['format_date'] = format_date
|
||||
app.jinja_env.globals['format_datetime'] = format_datetime
|
||||
app.jinja_env.filters['format_date'] = format_date
|
||||
app.jinja_env.filters['format_datetime'] = format_datetime
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,28 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
class BuildingForm(FlaskForm):
|
||||
name = StringField(_l('Building Name'), validators=[
|
||||
DataRequired(),
|
||||
Length(min=2, max=100)
|
||||
])
|
||||
code = StringField(_l('Code'), validators=[
|
||||
Optional(),
|
||||
Length(max=50)
|
||||
])
|
||||
address = StringField(_l('Address'), validators=[
|
||||
Optional(),
|
||||
Length(max=255)
|
||||
])
|
||||
floors = IntegerField(_l('Number of Floors'), default=1, validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=100)
|
||||
])
|
||||
description = TextAreaField(_l('Description'), validators=[
|
||||
Optional(),
|
||||
Length(max=500)
|
||||
])
|
||||
is_active = BooleanField(_l('Active'), default=True)
|
||||
submit = SubmitField(_l('Save Building'))
|
||||
@@ -1,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(),
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Building(db.Model):
|
||||
__tablename__ = 'buildings'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=True)
|
||||
address = db.Column(db.String(255), nullable=True)
|
||||
floors = db.Column(db.Integer, default=1, nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
|
||||
|
||||
@property
|
||||
def total_capacity(self):
|
||||
"""Total student capacity across active classrooms in this building"""
|
||||
return sum(c.capacity for c in self.classrooms if c.is_active)
|
||||
|
||||
@property
|
||||
def classrooms_count(self):
|
||||
"""Number of active classrooms"""
|
||||
return len([c for c in self.classrooms if c.is_active])
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'address': self.address,
|
||||
'floors': self.floors,
|
||||
'description': self.description,
|
||||
'is_active': self.is_active,
|
||||
'classrooms_count': self.classrooms_count,
|
||||
'total_capacity': self.total_capacity,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Building {self.name}>'
|
||||
@@ -0,0 +1,27 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Career(db.Model):
|
||||
__tablename__ = 'careers'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), unique=True, nullable=False, index=True)
|
||||
code = db.Column(db.String(50), nullable=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
subjects = db.relationship('Subject', backref='career_obj', lazy=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'description': self.description,
|
||||
'subjects_count': len(self.subjects) if self.subjects else 0,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Career {self.name}>'
|
||||
@@ -6,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 {
|
||||
|
||||
@@ -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,9 +57,30 @@ 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'))
|
||||
|
||||
@@ -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}>'
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -1,7 +1,9 @@
|
||||
from .main import main_bp
|
||||
from .auth import auth_bp
|
||||
from .classrooms import classrooms_bp
|
||||
from .buildings import buildings_bp
|
||||
from .schedule import schedule_bp
|
||||
from .genetic_algorithm import genetic_bp, genetic_web_bp
|
||||
from .admin import admin_bp
|
||||
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp']
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp']
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, jsonify
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.forms.auth import LoginForm
|
||||
@@ -19,7 +20,7 @@ def login():
|
||||
|
||||
if user and user.check_password(form.password.data):
|
||||
if not user.is_active:
|
||||
flash('Your account has been deactivated. Please contact administrator.', 'error')
|
||||
flash(_('Tu cuenta ha sido desactivada. Por favor contacta al administrador.'), 'error')
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
@@ -28,15 +29,20 @@ def login():
|
||||
# Si el usuario tiene idioma configurado en su perfil, sobrescribe el navegador en toda la sesión
|
||||
if user.preferred_language and user.preferred_language in ['es', 'en']:
|
||||
session['language'] = user.preferred_language
|
||||
else:
|
||||
session.pop('language', None)
|
||||
|
||||
session.pop('login_lang_explicit', None)
|
||||
|
||||
next_page = request.args.get('next')
|
||||
if not next_page or not next_page.startswith('/'):
|
||||
next_page = url_for('main.dashboard')
|
||||
|
||||
flash(f'Welcome back, {user.first_name} {user.last_name}!', 'success')
|
||||
user_full_name = f'{user.first_name} {user.last_name}'.strip() or user.name
|
||||
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
|
||||
return redirect(next_page)
|
||||
else:
|
||||
flash('Invalid email or password.', 'error')
|
||||
flash(_('Correo electrónico o contraseña inválidos.'), 'error')
|
||||
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
@@ -45,25 +51,48 @@ def login():
|
||||
def logout():
|
||||
"""Handle user logout"""
|
||||
logout_user()
|
||||
# Al cerrar sesión, se limpia el idioma para que el login use el del navegador por defecto
|
||||
# Al cerrar sesión, se limpian las cookies de idioma para que el login detecte el navegador o use español
|
||||
session.pop('language', None)
|
||||
flash('You have been logged out successfully.', 'info')
|
||||
session.pop('login_lang_explicit', None)
|
||||
flash(_('Has cerrado sesión correctamente.'), 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@auth_bp.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def profile():
|
||||
"""User profile page"""
|
||||
"""User profile page with language and theme settings"""
|
||||
if request.method == 'POST':
|
||||
lang = request.form.get('preferred_language')
|
||||
if lang in ['es', 'en']:
|
||||
current_user.preferred_language = lang
|
||||
session['language'] = lang
|
||||
db.session.commit()
|
||||
flash('Idioma de preferencia actualizado correctamente.', 'success')
|
||||
else:
|
||||
# Cadena vacía o None restablece la detección automática del navegador
|
||||
current_user.preferred_language = None
|
||||
session.pop('language', None)
|
||||
|
||||
theme = request.form.get('theme_preference')
|
||||
if theme in ['auto', 'light', 'dark']:
|
||||
current_user.theme_preference = theme
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Preferencias de usuario actualizadas correctamente.'), 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
return render_template('auth/profile.html')
|
||||
|
||||
@auth_bp.route('/api/user/theme', methods=['POST'])
|
||||
@login_required
|
||||
def update_theme_api():
|
||||
"""Update user theme preference via fetch/AJAX from navbar toggle"""
|
||||
data = request.get_json(silent=True) or request.form
|
||||
theme = data.get('theme') if data else None
|
||||
if theme in ['auto', 'light', 'dark']:
|
||||
current_user.theme_preference = theme
|
||||
db.session.commit()
|
||||
return jsonify({'status': 'success', 'theme': theme})
|
||||
return jsonify({'status': 'error', 'message': 'Invalid theme'}), 400
|
||||
|
||||
|
||||
|
||||
@auth_bp.route('/change_password', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.building import Building
|
||||
from app.models.classroom import Classroom
|
||||
from app.forms.building import BuildingForm
|
||||
from app.utils.decorators import permission_required
|
||||
from datetime import datetime
|
||||
|
||||
buildings_bp = Blueprint('buildings', __name__, url_prefix='/buildings')
|
||||
|
||||
@buildings_bp.route('/')
|
||||
@login_required
|
||||
def list_buildings():
|
||||
"""List all buildings with statistics"""
|
||||
search = request.args.get('search', '').strip()
|
||||
status_filter = request.args.get('status', '')
|
||||
|
||||
query = Building.query
|
||||
if search:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Building.name.ilike(f'%{search}%'),
|
||||
Building.code.ilike(f'%{search}%'),
|
||||
Building.address.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
if status_filter == 'active':
|
||||
query = query.filter(Building.is_active == True)
|
||||
elif status_filter == 'inactive':
|
||||
query = query.filter(Building.is_active == False)
|
||||
|
||||
buildings = query.order_by(Building.name.asc()).all()
|
||||
|
||||
# Calculate global building stats
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
total_capacity = sum(c.capacity for c in Classroom.query.filter_by(is_active=True).all())
|
||||
|
||||
return render_template(
|
||||
'buildings/list.html',
|
||||
buildings=buildings,
|
||||
total_classrooms=total_classrooms,
|
||||
total_capacity=total_capacity,
|
||||
search=search,
|
||||
status_filter=status_filter,
|
||||
title='Building Management'
|
||||
)
|
||||
|
||||
@buildings_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def add_building():
|
||||
"""Add new building"""
|
||||
form = BuildingForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check unique name
|
||||
existing = Building.query.filter(
|
||||
db.func.lower(Building.name) == form.name.data.lower()
|
||||
).first()
|
||||
if existing:
|
||||
flash('Ya existe un edificio con este nombre.', 'danger')
|
||||
return render_template('buildings/form.html', form=form, title='Add Building')
|
||||
|
||||
building = Building(
|
||||
name=form.name.data.strip(),
|
||||
code=form.code.data.strip().upper() if form.code.data else None,
|
||||
address=form.address.data.strip() if form.address.data else None,
|
||||
floors=form.floors.data,
|
||||
description=form.description.data.strip() if form.description.data else None,
|
||||
is_active=form.is_active.data
|
||||
)
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" creado con éxito.', 'success')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al crear el edificio: {str(e)}', 'danger')
|
||||
|
||||
return render_template('buildings/form.html', form=form, title='Add Building')
|
||||
|
||||
@buildings_bp.route('/<int:id>')
|
||||
@login_required
|
||||
def view_building(id):
|
||||
"""View building details and its classrooms by floor"""
|
||||
building = Building.query.get_or_404(id)
|
||||
|
||||
# Group active classrooms by floor
|
||||
classrooms = Classroom.query.filter_by(building_id=building.id).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
# Fallback to name matching if building_id was not set
|
||||
if not classrooms:
|
||||
classrooms = Classroom.query.filter_by(building=building.name).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
|
||||
classrooms_by_floor = {}
|
||||
for c in classrooms:
|
||||
floor_key = c.floor
|
||||
if floor_key not in classrooms_by_floor:
|
||||
classrooms_by_floor[floor_key] = []
|
||||
classrooms_by_floor[floor_key].append(c)
|
||||
|
||||
return render_template(
|
||||
'buildings/view.html',
|
||||
building=building,
|
||||
classrooms=classrooms,
|
||||
classrooms_by_floor=classrooms_by_floor,
|
||||
title=f'Building - {building.name}'
|
||||
)
|
||||
|
||||
@buildings_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def edit_building(id):
|
||||
"""Edit building"""
|
||||
building = Building.query.get_or_404(id)
|
||||
form = BuildingForm(obj=building)
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check unique name excluding current
|
||||
existing = Building.query.filter(
|
||||
db.func.lower(Building.name) == form.name.data.lower(),
|
||||
Building.id != id
|
||||
).first()
|
||||
if existing:
|
||||
flash('Ya existe otro edificio con este nombre.', 'danger')
|
||||
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
|
||||
|
||||
old_name = building.name
|
||||
new_name = form.name.data.strip()
|
||||
|
||||
building.name = new_name
|
||||
building.code = form.code.data.strip().upper() if form.code.data else None
|
||||
building.address = form.address.data.strip() if form.address.data else None
|
||||
building.floors = form.floors.data
|
||||
building.description = form.description.data.strip() if form.description.data else None
|
||||
building.is_active = form.is_active.data
|
||||
building.updated_at = datetime.utcnow()
|
||||
|
||||
# If name changed, update string building on classrooms
|
||||
if old_name != new_name:
|
||||
Classroom.query.filter_by(building_id=building.id).update({'building': new_name})
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" actualizado con éxito.', 'success')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al actualizar el edificio: {str(e)}', 'danger')
|
||||
|
||||
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
|
||||
|
||||
@buildings_bp.route('/<int:id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def toggle_building(id):
|
||||
"""Toggle building active status"""
|
||||
building = Building.query.get_or_404(id)
|
||||
try:
|
||||
building.is_active = not building.is_active
|
||||
building.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
status_text = 'activado' if building.is_active else 'desactivado'
|
||||
flash(f'Edificio "{building.name}" {status_text} con éxito.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al cambiar estado del edificio: {str(e)}', 'danger')
|
||||
return redirect(url_for('buildings.list_buildings'))
|
||||
|
||||
@buildings_bp.route('/<int:id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def delete_building(id):
|
||||
"""Delete building if it has no classrooms assigned"""
|
||||
building = Building.query.get_or_404(id)
|
||||
has_classrooms = Classroom.query.filter(
|
||||
db.or_(Classroom.building_id == building.id, Classroom.building == building.name)
|
||||
).count()
|
||||
|
||||
if has_classrooms > 0:
|
||||
flash(f'No se puede eliminar el edificio "{building.name}" porque tiene {has_classrooms} aulas asociadas. Primero reasigna o elimina las aulas.', 'danger')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
|
||||
try:
|
||||
db.session.delete(building)
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" eliminado correctamente.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al eliminar edificio: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('buildings.list_buildings'))
|
||||
@@ -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()
|
||||
|
||||
@@ -1,132 +1,137 @@
|
||||
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.reservation import Reservation
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject
|
||||
from app.models.subject import Commission
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
from flask_babel import gettext as _
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Main dashboard page"""
|
||||
# Get statistics
|
||||
try:
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
except:
|
||||
total_classrooms = 0
|
||||
|
||||
try:
|
||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||
except:
|
||||
total_subjects = 0
|
||||
|
||||
# Get today's reservations
|
||||
today = datetime.utcnow().date()
|
||||
try:
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
except:
|
||||
today_reservations = 0
|
||||
|
||||
# Get this week's reservations
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
try:
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
except:
|
||||
week_reservations = 0
|
||||
|
||||
# Get upcoming reservations (next 7 days)
|
||||
try:
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow(),
|
||||
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||
).limit(10).all()
|
||||
except:
|
||||
upcoming_reservations = []
|
||||
|
||||
stats = {
|
||||
'total_classrooms': total_classrooms,
|
||||
'total_subjects': total_subjects,
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations
|
||||
}
|
||||
|
||||
return render_template('dashboard.html',
|
||||
stats=stats,
|
||||
upcoming_reservations=upcoming_reservations)
|
||||
|
||||
@main_bp.route('/api/dashboard-stats')
|
||||
@login_required
|
||||
def dashboard_stats_api():
|
||||
"""API endpoint for dashboard statistics"""
|
||||
# Get today's data
|
||||
today = datetime.utcnow().date()
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
|
||||
# Get this week's data
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
|
||||
# Get monthly data for chart
|
||||
current_date = datetime.utcnow()
|
||||
monthly_data = []
|
||||
|
||||
for i in range(12):
|
||||
month_date = current_date - timedelta(days=30 * i)
|
||||
month_start = month_date.replace(day=1)
|
||||
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
|
||||
|
||||
monthly_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= month_start.date(),
|
||||
func.date(Reservation.start_time) <= month_end.date()
|
||||
).count()
|
||||
|
||||
monthly_data.append({
|
||||
'month': month_start.strftime('%Y-%m'),
|
||||
'reservations': monthly_reservations
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations,
|
||||
'monthly_data': list(reversed(monthly_data))
|
||||
})
|
||||
|
||||
@main_bp.route('/genetic-optimizer')
|
||||
@login_required
|
||||
def genetic_optimizer():
|
||||
"""Serve the genetic algorithm optimization interface"""
|
||||
if current_user.role not in ['ADMIN']:
|
||||
flash('You do not have permission to access the optimization tool.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('genetic_optimizer.html')
|
||||
|
||||
@main_bp.route('/set_language/<language>')
|
||||
def set_language(language=None):
|
||||
if language not in ['en', 'es']:
|
||||
language = 'en'
|
||||
|
||||
session['language'] = language
|
||||
if current_user.is_authenticated:
|
||||
current_user.preferred_language = language
|
||||
db.session.commit()
|
||||
|
||||
return redirect(request.referrer or url_for('main.dashboard'))
|
||||
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.reservation import Reservation
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject
|
||||
from app.models.subject import Commission
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
from flask_babel import gettext as _
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Main dashboard page"""
|
||||
# Get statistics
|
||||
try:
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
except:
|
||||
total_classrooms = 0
|
||||
|
||||
try:
|
||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||
except:
|
||||
total_subjects = 0
|
||||
|
||||
# Get today's reservations
|
||||
today = datetime.utcnow().date()
|
||||
try:
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
except:
|
||||
today_reservations = 0
|
||||
|
||||
# Get this week's reservations
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
try:
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
except:
|
||||
week_reservations = 0
|
||||
|
||||
# Get upcoming reservations (next 7 days)
|
||||
try:
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow(),
|
||||
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||
).limit(10).all()
|
||||
except:
|
||||
upcoming_reservations = []
|
||||
|
||||
stats = {
|
||||
'total_classrooms': total_classrooms,
|
||||
'total_subjects': total_subjects,
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations
|
||||
}
|
||||
|
||||
return render_template('dashboard.html',
|
||||
stats=stats,
|
||||
upcoming_reservations=upcoming_reservations)
|
||||
|
||||
@main_bp.route('/api/dashboard-stats')
|
||||
@login_required
|
||||
def dashboard_stats_api():
|
||||
"""API endpoint for dashboard statistics"""
|
||||
# Get today's data
|
||||
today = datetime.utcnow().date()
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
|
||||
# Get this week's data
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
|
||||
# Get monthly data for chart
|
||||
current_date = datetime.utcnow()
|
||||
monthly_data = []
|
||||
|
||||
for i in range(12):
|
||||
month_date = current_date - timedelta(days=30 * i)
|
||||
month_start = month_date.replace(day=1)
|
||||
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
|
||||
|
||||
monthly_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= month_start.date(),
|
||||
func.date(Reservation.start_time) <= month_end.date()
|
||||
).count()
|
||||
|
||||
monthly_data.append({
|
||||
'month': month_start.strftime('%Y-%m'),
|
||||
'reservations': monthly_reservations
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations,
|
||||
'monthly_data': list(reversed(monthly_data))
|
||||
})
|
||||
|
||||
@main_bp.route('/genetic-optimizer')
|
||||
@login_required
|
||||
def genetic_optimizer():
|
||||
"""Serve the genetic algorithm optimization interface"""
|
||||
if current_user.role not in ['ADMIN']:
|
||||
flash('You do not have permission to access the optimization tool.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('genetic_optimizer.html')
|
||||
|
||||
@main_bp.route('/set_language/<language>')
|
||||
def set_language(language=None):
|
||||
if language not in ['en', 'es']:
|
||||
language = 'es'
|
||||
|
||||
session['language'] = language
|
||||
|
||||
# Si se selecciona el idioma desde la pantalla de login, registrar la selección explícita
|
||||
if request.referrer and 'login' in request.referrer:
|
||||
session['login_lang_explicit'] = language
|
||||
|
||||
if current_user.is_authenticated:
|
||||
current_user.preferred_language = language
|
||||
db.session.commit()
|
||||
|
||||
return redirect(request.referrer or url_for('main.dashboard'))␍
|
||||
@@ -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))
|
||||
|
||||
@@ -64,12 +80,19 @@ def today_schedule():
|
||||
'occupied_classrooms': occupied_classrooms,
|
||||
'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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .sheets_importer import GoogleSheetsImporter
|
||||
|
||||
__all__ = ['GoogleSheetsImporter']
|
||||
@@ -0,0 +1,413 @@
|
||||
import urllib.request
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from datetime import datetime, date, time, timedelta
|
||||
from app import db
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.career import Career
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.user import User
|
||||
|
||||
BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv'
|
||||
|
||||
SHEETS_CONFIG = [
|
||||
{ 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 },
|
||||
{ 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 },
|
||||
{ 'name': 'Miércoles', 'gid': '615483345', 'day_idx': 2 },
|
||||
{ 'name': 'Jueves', 'gid': '682416571', 'day_idx': 3 },
|
||||
{ 'name': 'Viernes', 'gid': '1162906711', 'day_idx': 4 },
|
||||
{ 'name': 'Sábado', 'gid': '752305601', 'day_idx': 5 },
|
||||
]
|
||||
|
||||
TIME_REGEX = re.compile(r'(\d{1,2})(?::(\d{2}))?\s*(?:a|-|to)\s*(\d{1,2})(?::(\d{2}))?', re.IGNORECASE)
|
||||
|
||||
class GoogleSheetsImporter:
|
||||
"""
|
||||
Service to fetch, parse, and synchronize academic schedules,
|
||||
classrooms, careers, and subjects from the official UniCABA Google Sheet.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url=None):
|
||||
self.base_url = base_url or BASE_CSV_URL
|
||||
|
||||
def fetch_sheet_csv(self, gid):
|
||||
"""Fetch CSV string for a specific sheet gid"""
|
||||
url = f"{self.base_url}&gid={gid}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return resp.read().decode('utf-8', errors='replace')
|
||||
|
||||
def parse_sheet_rows(self, csv_content, sheet_name):
|
||||
"""Parse raw CSV text into a structured list of class records"""
|
||||
reader = csv.reader(io.StringIO(csv_content))
|
||||
rows = list(reader)
|
||||
|
||||
header_idx = -1
|
||||
col_offset = 0
|
||||
|
||||
for r_idx, row in enumerate(rows):
|
||||
for c_idx, cell in enumerate(row):
|
||||
cell_clean = cell.lower().strip()
|
||||
if 'codigo' in cell_clean or 'código' in cell_clean:
|
||||
header_idx = r_idx
|
||||
col_offset = c_idx
|
||||
break
|
||||
if header_idx != -1:
|
||||
break
|
||||
|
||||
if header_idx == -1:
|
||||
return []
|
||||
|
||||
records = []
|
||||
current_shift = 'Mañana'
|
||||
for row in rows[header_idx + 1:]:
|
||||
if len(row) >= col_offset + 5:
|
||||
raw_cell = row[col_offset].strip().replace('\n', ' - ')
|
||||
row_full_text = ' '.join(row).upper()
|
||||
|
||||
# Detect shift delimiters
|
||||
if 'TURNO MAÑANA' in row_full_text or 'TURNO MANANA' in row_full_text:
|
||||
current_shift = 'Mañana'
|
||||
continue
|
||||
elif 'TURNO VESPERTINO' in row_full_text or 'TURNO TARDE' in row_full_text or 'TURNO NOCHE' in row_full_text:
|
||||
current_shift = 'Tarde'
|
||||
continue
|
||||
elif raw_cell.lower().startswith('turno'):
|
||||
if 'mañ' in raw_cell.lower() or 'man' in raw_cell.lower():
|
||||
current_shift = 'Mañana'
|
||||
else:
|
||||
current_shift = 'Tarde'
|
||||
continue
|
||||
|
||||
codigo = raw_cell
|
||||
asignatura = row[col_offset + 1].strip().replace('\n', ' ')
|
||||
aula = row[col_offset + 2].strip().replace('\n', ' ')
|
||||
carrera = row[col_offset + 3].strip().replace('\n', ' ')
|
||||
horario = row[col_offset + 4].strip().replace('\n', ' ')
|
||||
|
||||
# Check valid data row
|
||||
if codigo and asignatura and not 'codigo' in codigo.lower():
|
||||
# Double-check shift from start hour if possible
|
||||
record_shift = current_shift
|
||||
time_match = re.search(r'(\d{1,2}):(\d{2})', horario)
|
||||
if time_match:
|
||||
h = int(time_match.group(1))
|
||||
if h >= 14:
|
||||
record_shift = 'Tarde'
|
||||
elif h < 13:
|
||||
record_shift = 'Mañana'
|
||||
|
||||
records.append({
|
||||
'dia': sheet_name,
|
||||
'codigo': codigo,
|
||||
'asignatura': asignatura,
|
||||
'aula': aula,
|
||||
'carrera': carrera,
|
||||
'horario': horario,
|
||||
'turno': record_shift
|
||||
})
|
||||
return records
|
||||
|
||||
def normalize_room(self, aula_raw):
|
||||
"""
|
||||
Normalize room string into (building, room_number, floor, capacity)
|
||||
"""
|
||||
aula_upper = aula_raw.upper().strip()
|
||||
if not aula_upper or any(x in aula_upper for x in ['FERIADO', 'SIN ACTIVIDAD', 'CONSULTAR EN BEDELIA']):
|
||||
return None
|
||||
|
||||
if 'VIRTUAL' in aula_upper:
|
||||
return {
|
||||
'building': 'Campus Virtual',
|
||||
'code': 'VIRTUAL',
|
||||
'floor': 0,
|
||||
'capacity': 0,
|
||||
'description': 'Espacio áulico virtual UniCABA (Capacidad Ilimitada ∞)'
|
||||
}
|
||||
|
||||
if 'AUDITORIO' in aula_upper:
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': 'Auditorio',
|
||||
'floor': 0,
|
||||
'capacity': 150,
|
||||
'description': 'Auditorio principal UniCABA'
|
||||
}
|
||||
|
||||
if 'SALA DE REUNI' in aula_upper:
|
||||
floor = 2 if ('2DO' in aula_upper or '2°' in aula_upper) else 4
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': f'Sala Reunión {floor}°P',
|
||||
'floor': floor,
|
||||
'capacity': 15,
|
||||
'description': f'Sala de reunión piso {floor}'
|
||||
}
|
||||
|
||||
# Standard classrooms: "Aula 402", "AULA 203", "306 - 406"
|
||||
num_match = re.search(r'\d+', aula_raw)
|
||||
if num_match:
|
||||
num = num_match.group(0)
|
||||
floor = int(num[0]) if len(num) >= 3 else 1
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': f'Aula {num}',
|
||||
'floor': floor,
|
||||
'capacity': 35,
|
||||
'description': f'Aula física {num} - Piso {floor}'
|
||||
}
|
||||
|
||||
# Generic fallback
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': aula_raw,
|
||||
'floor': 1,
|
||||
'capacity': 30,
|
||||
'description': aula_raw
|
||||
}
|
||||
|
||||
def parse_time_range(self, horario_str):
|
||||
"""Parse '8:00 a 9:00hs' into (time(8, 0), time(9, 0))"""
|
||||
m = TIME_REGEX.search(horario_str)
|
||||
if not m:
|
||||
return None, None
|
||||
try:
|
||||
h1 = int(m.group(1))
|
||||
m1 = int(m.group(2) or 0)
|
||||
h2 = int(m.group(3))
|
||||
m2 = int(m.group(4) or 0)
|
||||
return time(h1, m1), time(h2, m2)
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
def sync(self):
|
||||
"""
|
||||
Main synchronization process:
|
||||
1. Fetches all 6 day sheets.
|
||||
2. Upserts Careers, Classrooms, Subjects, Commissions.
|
||||
3. Generates Reservations in the calendar for the semester.
|
||||
"""
|
||||
stats = {
|
||||
'total_rows_processed': 0,
|
||||
'careers_created': 0,
|
||||
'careers_updated': 0,
|
||||
'classrooms_created': 0,
|
||||
'classrooms_updated': 0,
|
||||
'subjects_created': 0,
|
||||
'subjects_updated': 0,
|
||||
'commissions_created': 0,
|
||||
'reservations_created': 0,
|
||||
'errors': [],
|
||||
'sample_records': []
|
||||
}
|
||||
|
||||
all_raw_records = []
|
||||
for sheet_cfg in SHEETS_CONFIG:
|
||||
try:
|
||||
csv_data = self.fetch_sheet_csv(sheet_cfg['gid'])
|
||||
rows = self.parse_sheet_rows(csv_data, sheet_cfg['name'])
|
||||
all_raw_records.extend([(r, sheet_cfg['day_idx']) for r in rows])
|
||||
except Exception as e:
|
||||
stats['errors'].append(f"Error al descargar o procesar la hoja de {sheet_cfg['name']}: {str(e)}")
|
||||
|
||||
stats['total_rows_processed'] = len(all_raw_records)
|
||||
if not all_raw_records:
|
||||
return stats
|
||||
|
||||
# Default admin user for reservations
|
||||
default_user = User.query.filter_by(role='ADMIN').first() or User.query.first()
|
||||
default_user_id = default_user.id if default_user else 1
|
||||
|
||||
# Ensure institutional buildings exist with official address
|
||||
from app.models.building import Building
|
||||
b_central = Building.query.filter_by(name='Edificio Central').first()
|
||||
if not b_central:
|
||||
b_central = Building(
|
||||
name='Edificio Central',
|
||||
code='CENTRAL',
|
||||
address='Tte. Gral. Juan Domingo Perón 802, CABA',
|
||||
floors=4,
|
||||
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
|
||||
)
|
||||
db.session.add(b_central)
|
||||
db.session.flush()
|
||||
else:
|
||||
b_central.address = 'Tte. Gral. Juan Domingo Perón 802, CABA'
|
||||
|
||||
b_virtual = Building.query.filter_by(name='Campus Virtual').first()
|
||||
if not b_virtual:
|
||||
b_virtual = Building(
|
||||
name='Campus Virtual',
|
||||
code='VIRTUAL',
|
||||
address='Entorno Digital / Microsoft Teams - Moodle',
|
||||
floors=0,
|
||||
description='Entorno de aulas virtuales y clases a distancia'
|
||||
)
|
||||
db.session.add(b_virtual)
|
||||
db.session.flush()
|
||||
|
||||
buildings_cache = {b.name.strip().upper(): b for b in Building.query.all()}
|
||||
careers_cache = {c.name.strip().upper(): c for c in Career.query.all()}
|
||||
classrooms_cache = {(c.building.strip().upper(), c.code.strip().upper()): c for c in Classroom.query.all()}
|
||||
subjects_cache = {s.code.strip().upper(): s for s in Subject.query.all()}
|
||||
|
||||
# Calculate current semester dates (anchored on academic year 2026)
|
||||
# Week starting Monday 2026-08-31 to Saturday 2026-09-05
|
||||
base_monday = date(2026, 8, 31)
|
||||
weeks_to_generate = 4 # Generate reservations for 4 consecutive weeks
|
||||
|
||||
for rec, day_idx in all_raw_records:
|
||||
try:
|
||||
codigo = rec['codigo']
|
||||
asignatura = rec['asignatura']
|
||||
aula_raw = rec['aula']
|
||||
carrera_name = rec['carrera'].strip()
|
||||
horario_raw = rec['horario']
|
||||
dia = rec['dia']
|
||||
turno = rec.get('turno', 'Mañana')
|
||||
|
||||
# 1. Upsert Career
|
||||
carrera_key = carrera_name.upper()
|
||||
career = careers_cache.get(carrera_key)
|
||||
if not career:
|
||||
career = Career(name=carrera_name)
|
||||
db.session.add(career)
|
||||
db.session.flush()
|
||||
careers_cache[carrera_key] = career
|
||||
stats['careers_created'] += 1
|
||||
|
||||
# 2. Upsert Classroom
|
||||
room_info = self.normalize_room(aula_raw)
|
||||
classroom = None
|
||||
if room_info:
|
||||
room_key = (room_info['building'].upper(), room_info['code'].upper())
|
||||
classroom = classrooms_cache.get(room_key)
|
||||
b_entity = buildings_cache.get(room_info['building'].upper())
|
||||
if not classroom:
|
||||
classroom = Classroom(
|
||||
building=room_info['building'],
|
||||
building_id=b_entity.id if b_entity else None,
|
||||
floor=room_info['floor'],
|
||||
capacity=room_info['capacity'],
|
||||
description=room_info['description'],
|
||||
is_active=True
|
||||
)
|
||||
classroom.code = room_info['code']
|
||||
db.session.add(classroom)
|
||||
db.session.flush()
|
||||
classrooms_cache[room_key] = classroom
|
||||
stats['classrooms_created'] += 1
|
||||
else:
|
||||
classroom.capacity = max(classroom.capacity, room_info['capacity'])
|
||||
if not classroom.building_id and b_entity:
|
||||
classroom.building_id = b_entity.id
|
||||
|
||||
# 3. Upsert Subject
|
||||
subject_key = codigo.upper()
|
||||
subject = subjects_cache.get(subject_key)
|
||||
if not subject:
|
||||
subject = Subject(
|
||||
code=codigo,
|
||||
name=asignatura,
|
||||
career_id=career.id,
|
||||
department=career.name,
|
||||
credits=4,
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(subject)
|
||||
db.session.flush()
|
||||
subjects_cache[subject_key] = subject
|
||||
stats['subjects_created'] += 1
|
||||
else:
|
||||
if subject.name != asignatura:
|
||||
subject.name = asignatura
|
||||
stats['subjects_updated'] += 1
|
||||
subject.career_id = career.id
|
||||
subject.department = career.name
|
||||
|
||||
# 4. Upsert Commission
|
||||
# Identify commission subcode e.g. from code suffix or default 'C1'
|
||||
comm_code = 'C1'
|
||||
if '-E' in codigo:
|
||||
comm_code = codigo.split('-')[-1]
|
||||
|
||||
commission = Commission.query.filter_by(
|
||||
subject_id=subject.id,
|
||||
code=comm_code,
|
||||
semester='2026-2',
|
||||
year=2026
|
||||
).first()
|
||||
|
||||
if not commission:
|
||||
commission = Commission(
|
||||
subject_id=subject.id,
|
||||
code=comm_code,
|
||||
semester='2026-2',
|
||||
year=2026,
|
||||
teacher_id=default_user_id,
|
||||
max_students=classroom.capacity if classroom else 35,
|
||||
current_students=0,
|
||||
schedule=f"{dia} {horario_raw}",
|
||||
shift=turno,
|
||||
active=True
|
||||
)
|
||||
db.session.add(commission)
|
||||
db.session.flush()
|
||||
stats['commissions_created'] += 1
|
||||
else:
|
||||
commission.schedule = f"{dia} {horario_raw}"
|
||||
commission.shift = turno
|
||||
|
||||
# 5. Generate Calendar Reservations
|
||||
t_start, t_end = self.parse_time_range(horario_raw)
|
||||
if classroom and t_start and t_end and day_idx is not None:
|
||||
for w in range(weeks_to_generate):
|
||||
class_date = base_monday + timedelta(days=day_idx, weeks=w)
|
||||
dt_start = datetime.combine(class_date, t_start)
|
||||
dt_end = datetime.combine(class_date, t_end)
|
||||
|
||||
existing_res = Reservation.query.filter_by(
|
||||
classroom_id=classroom.id,
|
||||
start_time=dt_start,
|
||||
end_time=dt_end
|
||||
).first()
|
||||
|
||||
if not existing_res:
|
||||
res = Reservation(
|
||||
classroom_id=classroom.id,
|
||||
commission_id=commission.id,
|
||||
user_id=default_user_id,
|
||||
start_time=dt_start,
|
||||
end_time=dt_end,
|
||||
purpose=f"{subject.code} - {subject.name}",
|
||||
status='CONFIRMED',
|
||||
expected_attendees=min(classroom.capacity, 30),
|
||||
shift=turno,
|
||||
notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
||||
)
|
||||
db.session.add(res)
|
||||
stats['reservations_created'] += 1
|
||||
else:
|
||||
existing_res.shift = turno
|
||||
existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
||||
|
||||
if len(stats['sample_records']) < 10:
|
||||
stats['sample_records'].append({
|
||||
'dia': dia,
|
||||
'codigo': codigo,
|
||||
'asignatura': asignatura,
|
||||
'carrera': career.name,
|
||||
'aula': classroom.code_display if classroom else aula_raw,
|
||||
'horario': horario_raw
|
||||
})
|
||||
|
||||
except Exception as row_err:
|
||||
stats['errors'].append(f"Error en fila '{rec.get('codigo', '?')}': {str(row_err)}")
|
||||
|
||||
db.session.commit()
|
||||
return stats
|
||||
@@ -1,97 +1,44 @@
|
||||
/* Custom CSS for Edu-Space Admin */
|
||||
|
||||
/* Dark Theme Enhancements */
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
/* Custom Enhancements for Edu-Space Admin */
|
||||
|
||||
/* Card Styles */
|
||||
.card {
|
||||
border-radius: 15px;
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
border-radius: 14px;
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.navbar {
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn {
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #5568d8 0%, #654198 100%);
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.form-control, .form-select {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
background-color: #2a2a2a;
|
||||
color: #fff;
|
||||
transition: border-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
background-color: #2a2a2a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.table {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.table {
|
||||
background-color: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Table Hover */
|
||||
.table:hover tbody tr:hover {
|
||||
background-color: rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
/* Badge Styles */
|
||||
.badge {
|
||||
font-size: 0.75em;
|
||||
padding: 0.5em 0.8em;
|
||||
background-color: rgba(var(--brand-primary-rgb), 0.04);
|
||||
}
|
||||
|
||||
/* Status Badge Colors */
|
||||
.bg-confirmed-custom {
|
||||
background-color: #28a745;
|
||||
background-color: #10B981;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.bg-pending-custom {
|
||||
background-color: #ffc107;
|
||||
color: #000;
|
||||
background-color: #F59E0B;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.bg-cancelled-custom {
|
||||
background-color: #dc3545;
|
||||
background-color: #EF4444;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
@@ -99,9 +46,9 @@ body {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border: 3px solid rgba(var(--brand-primary-rgb), 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #667eea;
|
||||
border-top-color: var(--brand-primary);
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -111,28 +58,25 @@ body {
|
||||
|
||||
/* Calendar Customizations */
|
||||
.fc-event {
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
border: none !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 12px !important;
|
||||
margin: 1px 0 !important;
|
||||
}
|
||||
|
||||
.fc-day-sat, .fc-day-sun {
|
||||
background-color: rgba(255, 255, 255, 0.02) !important;
|
||||
}
|
||||
|
||||
/* Dashboard Stat Cards */
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 15px;
|
||||
background: var(--gradient-primary);
|
||||
color: #FFFFFF;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 20px rgba(var(--brand-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.stat-card-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
.stat-card-success { background: linear-gradient(135deg, #28a745 0%, #20c997 100%); }
|
||||
.stat-card-info { background: linear-gradient(135deg, #17a2b8 0%, #138496 100%); }
|
||||
.stat-card-warning { background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%); }
|
||||
.stat-card-primary { background: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%); }
|
||||
.stat-card-success { background: linear-gradient(135deg, #059669 0%, #10B981 100%); }
|
||||
.stat-card-info { background: linear-gradient(135deg, #682CAD 0%, #8B5CF6 100%); }
|
||||
.stat-card-warning { background: linear-gradient(135deg, #D97706 0%, #F59E0B 100%); }
|
||||
|
||||
/* Quick Action Buttons */
|
||||
.quick-action-btn {
|
||||
@@ -144,14 +88,14 @@ body {
|
||||
}
|
||||
|
||||
.quick-action-btn:hover {
|
||||
transform: translateX(5px);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Empty State Styles */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #6c757d;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
@@ -160,30 +104,43 @@ body {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Notification Toastenhancements */
|
||||
/* Notification Toast enhancements */
|
||||
.toast {
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Pagination Styles */
|
||||
.pagination .page-link {
|
||||
border-radius: 8px;
|
||||
margin: 0 2px;
|
||||
border: 1px solid #444;
|
||||
background-color: #2a2a2a;
|
||||
color: #fff;
|
||||
margin: 0 3px;
|
||||
border: 1px solid var(--card-border);
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pagination .page-link:hover {
|
||||
background-color: #667eea;
|
||||
border-color: #667eea;
|
||||
color: white;
|
||||
background-color: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background-color: #667eea;
|
||||
border-color: #667eea;
|
||||
background: var(--gradient-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Dashboard Stat Shortcuts */
|
||||
.dashboard-stat-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
.dashboard-stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 0.5rem 1.25rem rgba(0, 0, 0, 0.1) !important;
|
||||
border-color: rgba(30, 77, 140, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness */
|
||||
@@ -208,13 +165,13 @@ body {
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #ddd !important;
|
||||
border: 1px solid #DDD !important;
|
||||
box-shadow: none !important;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: white !important;
|
||||
color: black !important;
|
||||
background-color: #FFFFFF !important;
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
/**
|
||||
* Sistema de Diseño & Tokens de Color - Edu-Space Admin
|
||||
* Paleta Institucional Magenta / Uva y Soporte Claro/Oscuro
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* --- Brand Primary (Magenta / Uva) --- */
|
||||
--brand-primary: #B43E8E;
|
||||
--brand-primary-rgb: 180, 62, 142;
|
||||
--brand-primary-shade: #9E367D;
|
||||
--brand-primary-tint: #BC5199;
|
||||
--brand-primary-contrast: #FFFFFF;
|
||||
|
||||
/* --- Accent Colors (Modo Claro) --- */
|
||||
--accent-purple: #682CAD;
|
||||
--accent-purple-rgb: 104, 44, 173;
|
||||
--secondary-magenta: #7B2049;
|
||||
--secondary-magenta-rgb: 123, 32, 73;
|
||||
|
||||
/* --- Global Surface & Text (Modo Claro) --- */
|
||||
--app-bg: #F8FAFC;
|
||||
--card-bg: #FFFFFF;
|
||||
--card-border: rgba(0, 0, 0, 0.08);
|
||||
--card-hover-border: rgba(180, 62, 142, 0.3);
|
||||
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
--card-hover-shadow: 0 10px 25px rgba(180, 62, 142, 0.12);
|
||||
|
||||
--text-primary: #1E293B;
|
||||
--text-secondary: #64748B;
|
||||
--text-muted: #94A3B8;
|
||||
|
||||
--navbar-bg: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
|
||||
--navbar-text: #FFFFFF;
|
||||
--navbar-border: transparent;
|
||||
--navbar-shadow: 0 4px 20px rgba(180, 62, 142, 0.25);
|
||||
|
||||
--input-bg: #FFFFFF;
|
||||
--input-border: #CBD5E1;
|
||||
--input-text: #1E293B;
|
||||
--input-focus-border: #B43E8E;
|
||||
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.25);
|
||||
|
||||
/* --- Distinctive Gradient --- */
|
||||
--gradient-primary: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
|
||||
--gradient-primary-shadow: 0 8px 24px rgba(180, 62, 142, 0.25);
|
||||
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.08) 0%, rgba(104, 44, 173, 0.12) 100%);
|
||||
|
||||
/* --- Badges (Modo Claro) --- */
|
||||
--badge-primary-bg: #B43E8E;
|
||||
--badge-primary-text: #FFFFFF;
|
||||
--badge-accent-bg: #682CAD;
|
||||
--badge-accent-text: #FFFFFF;
|
||||
--badge-magenta-bg: #7B2049;
|
||||
--badge-magenta-text: #FFFFFF;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
MODO OSCURO ([data-bs-theme="dark"])
|
||||
======================================================= */
|
||||
[data-bs-theme="dark"] {
|
||||
/* --- Brand Tweaks for Dark Theme --- */
|
||||
--brand-primary: #B43E8E;
|
||||
--brand-primary-tint: #C95FA6;
|
||||
|
||||
/* --- Accent Colors (Modo Oscuro) --- */
|
||||
--accent-purple: #AE7FFF;
|
||||
--secondary-magenta: #FF8AB6;
|
||||
|
||||
/* --- Surfaces & Contrast in Dark Mode --- */
|
||||
--app-bg: #121622;
|
||||
--card-bg: #1E2442;
|
||||
--card-border: rgba(255, 255, 255, 0.08);
|
||||
--card-hover-border: rgba(180, 62, 142, 0.5);
|
||||
--card-shadow: 0 8px 25px rgba(0, 0, 0, 0.4);
|
||||
--card-hover-shadow: 0 12px 30px rgba(180, 62, 142, 0.25);
|
||||
|
||||
--text-primary: #E2E8F0;
|
||||
--text-secondary: #94A3B8;
|
||||
--text-muted: #64748B;
|
||||
|
||||
--navbar-bg: linear-gradient(135deg, #24142D 0%, #161A2E 100%);
|
||||
--navbar-text: #F8FAFC;
|
||||
--navbar-border: 1px solid rgba(180, 62, 142, 0.25);
|
||||
--navbar-shadow: 0 4px 25px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--input-bg: #161B30;
|
||||
--input-border: #2D3748;
|
||||
--input-text: #E2E8F0;
|
||||
--input-focus-border: #B43E8E;
|
||||
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.35);
|
||||
|
||||
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.18) 0%, rgba(30, 36, 66, 0.8) 100%);
|
||||
|
||||
/* --- Badges & Chips (Translucent in Dark Mode) --- */
|
||||
--badge-primary-bg: rgba(180, 62, 142, 0.25);
|
||||
--badge-primary-text: #FF8AB6;
|
||||
--badge-primary-border: 1px solid rgba(180, 62, 142, 0.45);
|
||||
|
||||
--badge-accent-bg: rgba(104, 44, 173, 0.25);
|
||||
--badge-accent-text: #AE7FFF;
|
||||
--badge-accent-border: 1px solid rgba(174, 127, 255, 0.45);
|
||||
|
||||
--badge-magenta-bg: rgba(123, 32, 73, 0.25);
|
||||
--badge-magenta-text: #FF96B4;
|
||||
--badge-magenta-border: 1px solid rgba(255, 138, 182, 0.4);
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
ELEMENTOS GLOBALES & TRANSICIONES
|
||||
======================================================= */
|
||||
body {
|
||||
background-color: var(--app-bg) !important;
|
||||
color: var(--text-primary) !important;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
transition: background-color 0.25s ease, color 0.25s ease;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar-app {
|
||||
background: var(--navbar-bg) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: var(--navbar-border);
|
||||
box-shadow: var(--navbar-shadow);
|
||||
padding: 0.65rem 1.25rem;
|
||||
transition: background 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.navbar-app .navbar-brand {
|
||||
color: var(--navbar-text) !important;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-app .nav-link {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
font-weight: 500;
|
||||
transition: color 0.15s ease, transform 0.15s ease;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.85rem !important;
|
||||
}
|
||||
|
||||
.navbar-app .nav-link:hover,
|
||||
.navbar-app .nav-link:focus {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.navbar-app .nav-link.active {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.navbar-logo {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
transition: filter 0.25s ease;
|
||||
}
|
||||
|
||||
/* En Modo Claro el navbar es magenta (#B43E8E), aplicamos color blanco al isotipo */
|
||||
[data-bs-theme="light"] .navbar-logo,
|
||||
:root:not([data-bs-theme="dark"]) .navbar-logo {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* En Modo Oscuro se mantiene el isotipo con su tono natural */
|
||||
[data-bs-theme="dark"] .navbar-logo {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* User Avatar Component */
|
||||
.user-avatar-small {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #B43E8E, #682CAD);
|
||||
color: #FFFFFF;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
/* Theme Toggle Button */
|
||||
.theme-toggle-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #FFFFFF;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
transform: rotate(15deg) scale(1.05);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Language Switcher Button (Alto Contraste en Navbar y Modo Claro) */
|
||||
.navbar-app .language-switcher .btn {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.16) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.32) !important;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.navbar-app .language-switcher .btn:hover,
|
||||
.navbar-app .language-switcher .btn:focus,
|
||||
.navbar-app .language-switcher .dropdown-toggle.show {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.28) !important;
|
||||
border-color: rgba(255, 255, 255, 0.5) !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.navbar-app .language-switcher .dropdown-menu {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--card-border);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Dropdown Item Active (Gradiente Magenta Institucional en lugar de Azul) */
|
||||
.dropdown-item {
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:active {
|
||||
background: var(--gradient-primary) !important;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.dropdown-item.active i,
|
||||
.dropdown-item:active i {
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
CARDS & CONTENEDORES
|
||||
======================================================= */
|
||||
.card {
|
||||
background-color: var(--card-bg) !important;
|
||||
border: 1px solid var(--card-border) !important;
|
||||
color: var(--text-primary) !important;
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 14px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--card-hover-border) !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: transparent !important;
|
||||
border-bottom: 1px solid var(--card-border) !important;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background-color: rgba(0, 0, 0, 0.02) !important;
|
||||
border-top: 1px solid var(--card-border) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .card-footer {
|
||||
background-color: rgba(255, 255, 255, 0.02) !important;
|
||||
}
|
||||
|
||||
/* Hero Banners */
|
||||
.hero-banner {
|
||||
background: var(--gradient-hero) !important;
|
||||
border: 1px solid var(--card-border) !important;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
BOTONES Y ACCIONES
|
||||
======================================================= */
|
||||
.btn {
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-primary) !important;
|
||||
border: none !important;
|
||||
color: var(--brand-primary-contrast) !important;
|
||||
box-shadow: 0 4px 14px rgba(var(--brand-primary-rgb), 0.35);
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus {
|
||||
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
|
||||
box-shadow: 0 6px 18px rgba(var(--brand-primary-rgb), 0.45);
|
||||
transform: translateY(-1px);
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover,
|
||||
.btn-outline-primary:focus {
|
||||
background: var(--gradient-primary) !important;
|
||||
border-color: var(--brand-primary-shade) !important;
|
||||
color: #FFFFFF !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(var(--brand-primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
/* Distinctive Text Accent */
|
||||
.text-primary,
|
||||
.text-brand {
|
||||
color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .text-primary,
|
||||
[data-bs-theme="dark"] .text-brand {
|
||||
color: var(--brand-primary-tint) !important;
|
||||
}
|
||||
|
||||
.text-accent-purple {
|
||||
color: var(--accent-purple) !important;
|
||||
}
|
||||
|
||||
.text-secondary-magenta {
|
||||
color: var(--secondary-magenta) !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
BADGES & CHIPS
|
||||
======================================================= */
|
||||
.badge {
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
padding: 0.4em 0.75em;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.badge-brand {
|
||||
background-color: var(--badge-primary-bg) !important;
|
||||
color: var(--badge-primary-text) !important;
|
||||
border: var(--badge-primary-border, none);
|
||||
}
|
||||
|
||||
.badge-approved {
|
||||
background-color: var(--badge-accent-bg) !important;
|
||||
color: var(--badge-accent-text) !important;
|
||||
border: var(--badge-accent-border, none);
|
||||
}
|
||||
|
||||
.badge-schedule {
|
||||
background-color: var(--badge-magenta-bg) !important;
|
||||
color: var(--badge-magenta-text) !important;
|
||||
border: var(--badge-magenta-border, none);
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
FORMULARIOS & INPUTS
|
||||
======================================================= */
|
||||
.form-control,
|
||||
.form-select {
|
||||
background-color: var(--input-bg) !important;
|
||||
border-color: var(--input-border) !important;
|
||||
color: var(--input-text) !important;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--input-focus-border) !important;
|
||||
box-shadow: var(--input-focus-shadow) !important;
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
TARJETA DE LOGIN (.auth-card)
|
||||
======================================================= */
|
||||
.auth-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: var(--app-bg);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background-color: var(--card-bg) !important;
|
||||
border: 1px solid var(--card-border) !important;
|
||||
border-left: 6px solid var(--brand-primary) !important;
|
||||
border-radius: 18px !important;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .auth-card {
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5), 0 0 25px rgba(180, 62, 142, 0.15);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
padding: 2.25rem 2rem 1.25rem;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
max-height: 52px;
|
||||
max-width: 220px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
margin-bottom: 1rem;
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.auth-controls {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Controles de Login en Modo Claro (Tarjeta Blanca) */
|
||||
[data-bs-theme="light"] .auth-controls .language-switcher .btn,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .language-switcher .btn {
|
||||
color: var(--brand-primary) !important;
|
||||
background: rgba(var(--brand-primary-rgb), 0.08) !important;
|
||||
border: 1px solid rgba(var(--brand-primary-rgb), 0.25) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] .auth-controls .language-switcher .btn:hover,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .language-switcher .btn:hover {
|
||||
color: #FFFFFF !important;
|
||||
background: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] .auth-controls .theme-toggle-btn,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .theme-toggle-btn {
|
||||
color: var(--brand-primary) !important;
|
||||
background: rgba(var(--brand-primary-rgb), 0.08) !important;
|
||||
border: 1px solid rgba(var(--brand-primary-rgb), 0.25) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] .auth-controls .theme-toggle-btn:hover,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .theme-toggle-btn:hover {
|
||||
color: #FFFFFF !important;
|
||||
background: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================
|
||||
FULLCALENDAR OVERRIDES
|
||||
======================================================= */
|
||||
.fc {
|
||||
--fc-border-color: var(--card-border);
|
||||
--fc-page-bg-color: transparent;
|
||||
--fc-neutral-bg-color: rgba(0, 0, 0, 0.02);
|
||||
--fc-today-bg-color: rgba(var(--brand-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fc {
|
||||
--fc-neutral-bg-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.fc-event {
|
||||
border-radius: 8px !important;
|
||||
border: none !important;
|
||||
padding: 3px 8px !important;
|
||||
font-size: 0.8rem !important;
|
||||
font-weight: 500 !important;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.fc-event:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.fc-button-primary {
|
||||
background: var(--gradient-primary) !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 2px 6px rgba(var(--brand-primary-rgb), 0.3) !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:hover {
|
||||
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:disabled {
|
||||
background: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
FORTIGATE-STYLE RBAC PERMISSIONS MATRIX
|
||||
======================================================= */
|
||||
.fg-permission-card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--card-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fg-permission-card .card-header {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
padding: 0.9rem 1.25rem;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-permission-card .card-header {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.fg-table {
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fg-table th {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
padding: 0.85rem 1.25rem;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-table th {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.fg-table td {
|
||||
padding: 0.85rem 1.25rem;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fg-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.fg-table tbody tr:hover {
|
||||
background-color: rgba(180, 62, 142, 0.04);
|
||||
}
|
||||
|
||||
/* FortiGate Segmented Control */
|
||||
.fg-segmented-control {
|
||||
display: inline-flex;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-segmented-control {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: #1A1F36;
|
||||
}
|
||||
|
||||
.fg-seg-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border-right: 1px solid #ced4da;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-seg-btn {
|
||||
color: #CBD5E1;
|
||||
border-right-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.fg-seg-btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.fg-seg-btn:hover:not(.active) {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-seg-btn:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Estado Activo FortiGate (Verde Esmeralda #2D9F6F) */
|
||||
.fg-seg-btn.active {
|
||||
background-color: #2D9F6F !important;
|
||||
color: #FFFFFF !important;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.fg-seg-btn.active i {
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* Set All Dropdown Button */
|
||||
.btn-fg-set-all {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ced4da;
|
||||
background: #FFFFFF;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .btn-fg-set-all {
|
||||
background: #1E2442;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: #E2E8F0;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Theme Toggle Manager - Edu-Space Admin
|
||||
* Manejo de Modo Claro y Modo Oscuro con persistencia en localStorage y anti-flickering.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// 1. Obtener tema almacenado o preferencia del sistema
|
||||
function getPreferredTheme() {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme) {
|
||||
return storedTheme;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// 2. Aplicar tema al elemento <html>
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
updateToggleButtons(theme);
|
||||
}
|
||||
|
||||
// 3. Actualizar iconos de los botones de toggle
|
||||
function updateToggleButtons(theme) {
|
||||
const buttons = document.querySelectorAll('.theme-toggle-btn');
|
||||
buttons.forEach(btn => {
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) {
|
||||
if (theme === 'dark') {
|
||||
icon.className = 'bi bi-sun-fill';
|
||||
btn.setAttribute('aria-label', 'Cambiar a modo claro');
|
||||
btn.setAttribute('title', 'Modo Claro');
|
||||
} else {
|
||||
icon.className = 'bi bi-moon-stars-fill';
|
||||
btn.setAttribute('aria-label', 'Cambiar a modo oscuro');
|
||||
btn.setAttribute('title', 'Modo Oscuro');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Función global para alternar tema
|
||||
window.toggleTheme = function() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-bs-theme') || getPreferredTheme();
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
localStorage.setItem('theme', newTheme);
|
||||
applyTheme(newTheme);
|
||||
|
||||
// Si el usuario está autenticado, sincronizar asíncronamente con su perfil en el backend
|
||||
if (document.body && document.body.getAttribute('data-user-authenticated') === 'true') {
|
||||
fetch('/api/user/theme', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ theme: newTheme })
|
||||
}).catch(err => {
|
||||
console.warn('No se pudo sincronizar el tema con el servidor:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 5. Inicializar tema inmediatamente (anti-flicker)
|
||||
const initialTheme = getPreferredTheme();
|
||||
applyTheme(initialTheme);
|
||||
|
||||
// 6. Al cargar el DOM, registrar eventos en los botones de toggle
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateToggleButtons(document.documentElement.getAttribute('data-bs-theme') || initialTheme);
|
||||
|
||||
document.querySelectorAll('.theme-toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.toggleTheme();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 7. Escuchar cambios de preferencia del sistema si el usuario no fijó una manual
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
applyTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,242 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Import from Google Sheets{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-file-earmark-spreadsheet text-success me-2"></i>{% trans %}Google Sheets Academic Sync{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Extract and synchronize classrooms, careers, subjects, and weekly schedules directly from the official Google Sheet.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Dashboard{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-primary mb-1"><i class="bi bi-mortarboard fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_careers }}</div>
|
||||
<small class="text-muted">{% trans %}Careers{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-info mb-1"><i class="bi bi-book fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_subjects }}</div>
|
||||
<small class="text-muted">{% trans %}Subjects{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-success mb-1"><i class="bi bi-door-open fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_classrooms }}</div>
|
||||
<small class="text-muted">{% trans %}Classrooms{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-warning mb-1"><i class="bi bi-people fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_commissions }}</div>
|
||||
<small class="text-muted">{% trans %}Commissions{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-danger mb-1"><i class="bi bi-calendar-check fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_reservations }}</div>
|
||||
<small class="text-muted">{% trans %}Calendar Classes{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Action Banner -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-lg-8 mb-3 mb-lg-0">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-1 me-2 fw-semibold">
|
||||
<i class="bi bi-cloud-check me-1"></i>Google Sheets Live Integration
|
||||
</span>
|
||||
<span class="badge bg-light text-muted border">UniCABA 2026</span>
|
||||
</div>
|
||||
<h4 class="h5 mb-2 fw-bold">{% trans %}Official Schedule Board (Cartelera de Aulas){% endtrans %}</h4>
|
||||
<p class="text-muted small mb-2">
|
||||
{% trans %}Reads published CSV sheets for all 6 days of the week, automatically creating or updating academic careers, classrooms, subject codes, commissions, and weekly calendar entries without duplicating records.{% endtrans %}
|
||||
</p>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for s in metrics.sheets %}
|
||||
<span class="badge bg-body-tertiary text-secondary border small">
|
||||
<i class="bi bi-calendar-event me-1 text-primary"></i>{{ s.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 text-lg-end">
|
||||
<form method="POST" id="syncForm" onsubmit="startSync()">
|
||||
<button type="submit" id="syncBtn" class="btn btn-success btn-lg px-4 py-2 shadow fw-semibold">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>{% trans %}Sync Data Now{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
<div id="syncSpinner" class="d-none mt-2 text-muted small">
|
||||
<div class="spinner-border spinner-border-sm text-success me-1" role="status"></div>
|
||||
{% trans %}Connecting to Google Sheets and processing records...{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Results (if just completed) -->
|
||||
{% if stats %}
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-success bg-opacity-10 text-success py-3">
|
||||
<h5 class="card-title h6 mb-0 fw-bold">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>{% trans %}Synchronization Report{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 text-center mb-3">
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5">{{ stats.total_rows_processed }}</div>
|
||||
<small class="text-muted">{% trans %}Rows Processed{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-primary">+{{ stats.careers_created }}</div>
|
||||
<small class="text-muted">{% trans %}Careers Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-success">+{{ stats.classrooms_created }}</div>
|
||||
<small class="text-muted">{% trans %}Classrooms Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-info">+{{ stats.subjects_created }}</div>
|
||||
<small class="text-muted">{% trans %}Subjects Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-warning">+{{ stats.commissions_created }}</div>
|
||||
<small class="text-muted">{% trans %}Commissions Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-danger">+{{ stats.reservations_created }}</div>
|
||||
<small class="text-muted">{% trans %}Reservations Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if stats.errors %}
|
||||
<div class="alert alert-warning mb-0 small">
|
||||
<h6 class="alert-heading fw-bold mb-1">{% trans %}Warnings / Errors detected:{% endtrans %}</h6>
|
||||
<ul class="mb-0 ps-3">
|
||||
{% for err in stats.errors %}
|
||||
<li>{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Database Preview Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary d-flex justify-content-between align-items-center py-3">
|
||||
<h5 class="card-title h6 mb-0">
|
||||
<i class="bi bi-table text-primary me-2"></i>{% trans %}Recently Synchronized Subjects & Comissions{% endtrans %}
|
||||
</h5>
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-calendar3 me-1"></i>{% trans %}View Calendar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-body-tertiary">
|
||||
<tr>
|
||||
<th class="ps-4">{% trans %}Code{% endtrans %}</th>
|
||||
<th>{% trans %}Subject Name{% endtrans %}</th>
|
||||
<th>{% trans %}Career{% endtrans %}</th>
|
||||
<th>{% trans %}Commissions{% endtrans %}</th>
|
||||
<th>{% trans %}Status{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in recent_subjects %}
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border">
|
||||
{{ s.code }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ s.name }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-brand small">
|
||||
{{ s.career_obj.name if s.career_obj else (s.department or _('General')) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="small text-muted">
|
||||
{{ s.commissions|length }} {% trans %}commission(s){% endtrans %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if s.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle small">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary small">{% trans %}Inactive{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-cloud-arrow-down fs-1 d-block mb-2 text-secondary"></i>
|
||||
{% trans %}No subjects in database yet. Click "Sync Data Now" to import.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function startSync() {
|
||||
const btn = document.getElementById('syncBtn');
|
||||
const spinner = document.getElementById('syncSpinner');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Sincronizando...';
|
||||
}
|
||||
if (spinner) {
|
||||
spinner.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,191 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if role %}{% trans %}Editar Rol{% endtrans %}: {{ role.name }}{% else %}{% trans %}Nuevo Rol{% endtrans %}{% endif %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-shield-lock text-primary me-2"></i>
|
||||
{% if role %}{% trans %}Editar Rol y Permisos de Acceso{% endtrans %}{% else %}{% trans %}Crear Rol y Permisos de Acceso{% endtrans %}{% endif %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Configure permisos granulares por módulo con la matriz de acceso estilo FortiGate.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.roles_list') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver a Roles{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
<div class="row">
|
||||
<!-- Left Column: Role Details -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary py-3">
|
||||
<h5 class="card-title h6 mb-0">
|
||||
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}Información del Rol{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label fw-semibold">{% trans %}Nombre del Rol{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="name" name="name"
|
||||
value="{{ role.name if role else '' }}" required
|
||||
placeholder="{% trans %}ej. Docente, Operador, Bedelía{% endtrans %}"
|
||||
{% if role and role.is_system %}readonly title="{% trans %}El nombre del rol del sistema no se puede modificar{% endtrans %}"{% endif %}>
|
||||
{% if role and role.is_system %}
|
||||
<small class="text-muted d-block mt-1">
|
||||
<i class="bi bi-info-circle me-1"></i>{% trans %}Rol del sistema protegido.{% endtrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3"
|
||||
placeholder="{% trans %}Describa las responsabilidades o alcance de este rol...{% endtrans %}">{{ role.description if role else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary py-2 fw-semibold">
|
||||
<i class="bi bi-check-lg me-1"></i>{% trans %}Guardar Rol y Permisos{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('admin.roles_list') }}" class="btn btn-outline-secondary">
|
||||
{% trans %}Cancelar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: FortiGate Permissions Matrix -->
|
||||
<div class="col-lg-8 mb-4">
|
||||
<div class="fg-permission-card">
|
||||
<!-- Matrix Header -->
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold text-primary">
|
||||
<i class="bi bi-grid-3x3-gap-fill me-2"></i>{% trans %}Permisos de Acceso{% endtrans %}
|
||||
</span>
|
||||
|
||||
<!-- Set All Action Dropdown (FortiGate style) -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-fg-set-all dropdown-toggle" type="button" id="setAllDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-lightning-charge me-1"></i>{% trans %}Establecer Todos{% endtrans %}
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="setAllDropdown">
|
||||
<li>
|
||||
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('none')">
|
||||
<i class="bi bi-slash-circle text-danger me-2"></i>{% trans %}Establecer Todos en Ninguno{% endtrans %}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('read')">
|
||||
<i class="bi bi-eye text-info me-2"></i>{% trans %}Establecer Todos en Solo Lectura{% endtrans %}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('read_write')">
|
||||
<i class="bi bi-pencil text-success me-2"></i>{% trans %}Establecer Todos en Lectura y Escritura{% endtrans %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Matrix Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="fg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 48%;">{% trans %}Módulo del Sistema{% endtrans %}</th>
|
||||
<th style="width: 52%;" class="text-end pe-4">{% trans %}Nivel de Permiso{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for mod in modules %}
|
||||
{% set cur_level = current_perms.get(mod.id, 'none') %}
|
||||
<tr data-module-row="{{ mod.id }}">
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary bg-opacity-10 text-primary rounded p-2 me-3 d-inline-flex align-items-center justify-content-center" style="width: 36px; height: 36px;">
|
||||
<i class="bi {{ mod.icon }} fs-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold">{{ mod.name }}</div>
|
||||
<small class="text-muted d-block">{{ mod.description }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<!-- Hidden Input to submit via POST -->
|
||||
<input type="hidden" name="perm_{{ mod.id }}" id="input_perm_{{ mod.id }}" value="{{ cur_level }}">
|
||||
|
||||
<!-- FortiGate Segmented Control -->
|
||||
<div class="fg-segmented-control" role="group" aria-label="Nivel de permiso para {{ mod.name }}">
|
||||
<button type="button"
|
||||
class="fg-seg-btn {% if cur_level == 'none' %}active{% endif %}"
|
||||
data-val="none"
|
||||
data-target="input_perm_{{ mod.id }}"
|
||||
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'none')">
|
||||
<i class="bi bi-slash-circle"></i> {% trans %}Ninguno{% endtrans %}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="fg-seg-btn {% if cur_level == 'read' %}active{% endif %}"
|
||||
data-val="read"
|
||||
data-target="input_perm_{{ mod.id }}"
|
||||
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'read')">
|
||||
<i class="bi bi-eye"></i> {% trans %}Lectura{% endtrans %}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="fg-seg-btn {% if cur_level == 'read_write' %}active{% endif %}"
|
||||
data-val="read_write"
|
||||
data-target="input_perm_{{ mod.id }}"
|
||||
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'read_write')">
|
||||
<i class="bi bi-pencil"></i> {% trans %}Lectura/Escritura{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function selectPerm(btn, inputId, value) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) {
|
||||
input.value = value;
|
||||
}
|
||||
|
||||
// Toggle active state in this segmented control
|
||||
const parentControl = btn.closest('.fg-segmented-control');
|
||||
if (parentControl) {
|
||||
parentControl.querySelectorAll('.fg-seg-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function setAllPermissions(level) {
|
||||
document.querySelectorAll('.fg-segmented-control').forEach(control => {
|
||||
const targetBtn = control.querySelector(`button[data-val="${level}"]`);
|
||||
if (targetBtn) {
|
||||
targetBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,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 %}
|
||||
@@ -0,0 +1,107 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if user %}{% trans %}Edit User{% endtrans %}: {{ user.name }}{% else %}{% trans %}New User{% endtrans %}{% endif %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-person-gear text-primary me-2"></i>
|
||||
{% if user %}{% trans %}Edit User Account{% endtrans %}{% else %}{% trans %}Create User Account{% endtrans %}{% endif %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Configure user credentials, profile information, and role assignment.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Users{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6 mx-auto">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary py-3">
|
||||
<h5 class="card-title h6 mb-0">
|
||||
<i class="bi bi-person-vcard text-primary me-2"></i>{% trans %}Account Details{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label fw-semibold">{% trans %}Full Name{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="name" name="name"
|
||||
value="{{ user.name if user else '' }}" required
|
||||
placeholder="{% trans %}e.g. María González{% endtrans %}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label fw-semibold">{% trans %}Email Address{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
value="{{ user.email if user else '' }}" required
|
||||
placeholder="{% trans %}e.g. mgonzalez@edu-space.com{% endtrans %}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label fw-semibold">
|
||||
{% trans %}Password{% endtrans %} {% if not user %}<span class="text-danger">*</span>{% endif %}
|
||||
</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
{% if not user %}required{% endif %}
|
||||
placeholder="{% if user %}{% trans %}Leave empty to keep current password{% endtrans %}{% else %}{% trans %}At least 6 characters{% endtrans %}{% endif %}">
|
||||
{% if user %}
|
||||
<small class="text-muted d-block mt-1">
|
||||
{% trans %}Only fill this in if you want to reset this user's password.{% endtrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="role_id" class="form-label fw-semibold">{% trans %}Role / Access Group{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="role_id" name="role_id" required>
|
||||
<option value="" disabled {% if not user or not user.role_id %}selected{% endif %}>{% trans %}Select a role...{% endtrans %}</option>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r.id }}" {% if user and user.role_id == r.id %}selected{% endif %}>
|
||||
{{ r.name }} - {{ r.description or '' }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="text-muted d-block mt-1">
|
||||
<a href="{{ url_for('admin.roles_list') }}" target="_blank" class="text-decoration-none">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}View or manage roles and permissions{% endtrans %}
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="is_active" name="is_active"
|
||||
{% if not user or user.is_active %}checked{% endif %}
|
||||
{% if user and user.id == current_user.id %}disabled{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="is_active">
|
||||
{% trans %}Active Account{% endtrans %}
|
||||
</label>
|
||||
<small class="text-muted d-block">
|
||||
{% trans %}Inactive users are prevented from signing in to the platform.{% endtrans %}
|
||||
</small>
|
||||
{% if user and user.id == current_user.id %}
|
||||
<input type="hidden" name="is_active" value="on">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center pt-2">
|
||||
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
|
||||
{% trans %}Cancel{% endtrans %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary px-4 fw-semibold">
|
||||
<i class="bi bi-check-lg me-1"></i>{% trans %}Save User{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,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) }}">«</a></li>
|
||||
{% endif %}
|
||||
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.users_list', page=p, search=search, role=role_filter, status=status_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.next_num, search=search, role=role_filter, status=status_filter) }}">»</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,114 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ session.language or request.accept_languages.best_match(['es', 'en']) or 'es' }}">
|
||||
<html lang="{{ current_lang }}" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% trans %}Login - Edu-Space Admin{% endtrans %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
|
||||
|
||||
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
|
||||
<script>
|
||||
(function() {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
const theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.login-card {
|
||||
background: rgba(30, 41, 59, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5);
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
color: #f8fafc;
|
||||
}
|
||||
.logo-section {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: white;
|
||||
position: relative;
|
||||
}
|
||||
.lang-switch {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
</style>
|
||||
<!-- Design System Theme -->
|
||||
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<div class="logo-section">
|
||||
<div class="lang-switch">
|
||||
{% include 'partials/language_switcher.html' %}
|
||||
</div>
|
||||
<i class="bi bi-building" style="font-size: 3rem;"></i>
|
||||
<h3 class="mt-3 mb-1 fw-bold">Edu-Space Admin</h3>
|
||||
<p class="mb-0 text-white-50 small">{% trans %}Classroom Management System{% endtrans %}</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label small text-secondary">{{ form.email.label.text }}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-envelope"></i></span>
|
||||
<input type="email" class="form-control bg-dark border-secondary text-white" id="email" name="email" value="{{ form.email.data or '' }}" required placeholder="{% trans %}Enter your email{% endtrans %}">
|
||||
</div>
|
||||
{% for error in form.email.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label small text-secondary">{{ form.password.label.text }}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-lock"></i></span>
|
||||
<input type="password" class="form-control bg-dark border-secondary text-white" id="password" name="password" required placeholder="{% trans %}Enter your password{% endtrans %}">
|
||||
</div>
|
||||
{% for error in form.password.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
{{ form.remember_me(class="form-check-input") }}
|
||||
<label class="form-check-label text-secondary small" for="remember_me">{{ form.remember_me.label.text }}</label>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg fw-semibold">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i> {{ form.submit.label.text }}
|
||||
<div class="auth-wrapper">
|
||||
<div class="auth-card">
|
||||
<!-- Header con Controles Superiores & Logo Oficial -->
|
||||
<div class="auth-header">
|
||||
<div class="auth-controls">
|
||||
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
|
||||
<i class="bi bi-moon-stars-fill"></i>
|
||||
</button>
|
||||
{% include 'partials/language_switcher.html' %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<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="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>
|
||||
<div class="px-4 pb-4 pt-2">
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show rounded-3 py-2 small" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close py-2" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label small fw-semibold text-muted">{{ form.email.label.text }}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
|
||||
<i class="bi bi-envelope"></i>
|
||||
</span>
|
||||
<input type="email" class="form-control border-start-0 ps-0" id="email" name="email"
|
||||
value="{{ form.email.data or '' }}" required
|
||||
placeholder="{% trans %}Enter your email{% endtrans %}">
|
||||
</div>
|
||||
{% for error in form.email.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label small fw-semibold text-muted">{{ form.password.label.text }}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
|
||||
<i class="bi bi-lock"></i>
|
||||
</span>
|
||||
<input type="password" class="form-control border-start-0 ps-0" id="password" name="password"
|
||||
required placeholder="{% trans %}Enter your password{% endtrans %}">
|
||||
</div>
|
||||
{% for error in form.password.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4 form-check">
|
||||
{{ form.remember_me(class="form-check-input") }}
|
||||
<label class="form-check-label text-muted small" for="remember_me">
|
||||
{{ form.remember_me.label.text }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg fw-semibold py-2">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i> {{ form.submit.label.text }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<small class="text-muted d-inline-flex align-items-center">
|
||||
<i class="bi bi-shield-check text-primary me-1"></i>
|
||||
{% trans %}Secure Login • All access is logged{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -76,17 +76,19 @@
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Language Preference Section -->
|
||||
<!-- Preferences Section -->
|
||||
<form method="POST" action="{{ url_for('auth.profile') }}">
|
||||
<label class="form-label fw-bold mb-2">
|
||||
<i class="bi bi-translate text-primary me-2"></i>{% trans %}Language Preference{% endtrans %}
|
||||
</label>
|
||||
<p class="text-muted small mb-3">
|
||||
{% trans %}If set, this language overrides the browser configuration for all your sessions.{% endtrans %}
|
||||
</p>
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-sm-8">
|
||||
<select class="form-select" name="preferred_language">
|
||||
<h5 class="fw-bold mb-3">
|
||||
<i class="bi bi-sliders text-primary me-2"></i>{% trans %}User Preferences{% endtrans %}
|
||||
</h5>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Preferred Language -->
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="preferred_language" class="form-label fw-semibold">
|
||||
<i class="bi bi-translate text-primary me-1"></i>{% trans %}Interface Language{% endtrans %}
|
||||
</label>
|
||||
<select class="form-select" id="preferred_language" name="preferred_language">
|
||||
<option value="" {% if not current_user.preferred_language %}selected{% endif %}>
|
||||
{% trans %}Auto-detect from browser (Default){% endtrans %}
|
||||
</option>
|
||||
@@ -97,13 +99,38 @@
|
||||
English
|
||||
</option>
|
||||
</select>
|
||||
<small class="text-muted d-block mt-1">
|
||||
{% trans %}If set, this language overrides the browser configuration for all your sessions.{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<button type="submit" class="btn btn-outline-primary w-100">
|
||||
<i class="bi bi-check2 me-1"></i>{% trans %}Save Preference{% endtrans %}
|
||||
</button>
|
||||
|
||||
<!-- Theme Preference -->
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="theme_preference" class="form-label fw-semibold">
|
||||
<i class="bi bi-palette text-primary me-1"></i>{% trans %}Visual Theme{% endtrans %}
|
||||
</label>
|
||||
<select class="form-select" id="theme_preference" name="theme_preference">
|
||||
<option value="auto" {% if current_user.theme_preference == 'auto' or not current_user.theme_preference %}selected{% endif %}>
|
||||
{% trans %}Automatic (follow system){% endtrans %}
|
||||
</option>
|
||||
<option value="light" {% if current_user.theme_preference == 'light' %}selected{% endif %}>
|
||||
{% trans %}Light Mode{% endtrans %}
|
||||
</option>
|
||||
<option value="dark" {% if current_user.theme_preference == 'dark' %}selected{% endif %}>
|
||||
{% trans %}Dark Mode{% endtrans %}
|
||||
</option>
|
||||
</select>
|
||||
<small class="text-muted d-block mt-1">
|
||||
{% trans %}Choose your preferred visual theme.{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-end mt-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-1"></i>{% trans %}Save Preferences{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer bg-body-tertiary px-4 py-3 d-flex justify-content-between align-items-center">
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ session.language or request.accept_languages.best_match(['es', 'en']) or 'en' }}" data-bs-theme="dark">
|
||||
<html lang="{{ current_lang }}" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{% trans %}Edu-Space Admin{% endtrans %}{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
|
||||
|
||||
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
|
||||
<script>
|
||||
(function() {
|
||||
let userTheme = "{{ current_user.theme_preference if (current_user and current_user.is_authenticated) else '' }}";
|
||||
let theme = userTheme;
|
||||
if (!theme || theme === 'auto') {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
}
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<!-- Custom CSS -->
|
||||
<!-- Design System & Custom Theme -->
|
||||
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<body data-user-authenticated="{{ 'true' if (current_user and current_user.is_authenticated) else 'false' }}">
|
||||
{% if current_user.is_authenticated %}
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark navbar-app fixed-top">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-building"></i> Edu-Space Admin
|
||||
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('main.dashboard') }}">
|
||||
<img src="{{ url_for('static', filename='img/logo_unicaba_mini.png') }}" alt="Logo UniCABA" class="navbar-logo me-2">
|
||||
<span>Edu-Space Admin</span>
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
@@ -42,9 +60,15 @@
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}">
|
||||
<i class="bi bi-list"></i> {% trans %}All Classrooms{% endtrans %}
|
||||
</a></li>
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}">
|
||||
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}">
|
||||
<i class="bi bi-buildings"></i> {% trans %}Buildings{% endtrans %}
|
||||
</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
@@ -58,37 +82,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">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{% if building %}{% trans %}Editar Edificio{% endtrans %} - {{ building.name }}{% else %}{% trans %}Nuevo Edificio{% endtrans %}{% endif %} - Edu-Space Admin
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-buildings text-primary me-2"></i>
|
||||
{% if building %}{% trans %}Editar Edificio{% endtrans %}{% else %}{% trans %}Nuevo Edificio{% endtrans %}{% endif %}
|
||||
</h1>
|
||||
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver a Edificios{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-info-circle text-primary me-2"></i>{% trans %}Información del Edificio / Sede{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
{{ form.name.label(class="form-label fw-semibold") }}
|
||||
{{ form.name(class="form-control" + (" is-invalid" if form.name.errors else ""), placeholder="ej. Edificio Central, Pabellón de Ciencias, Sede Norte") }}
|
||||
{% if form.name.errors %}
|
||||
<div class="invalid-feedback">{{ form.name.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
{{ form.code.label(class="form-label fw-semibold") }}
|
||||
{{ form.code(class="form-control" + (" is-invalid" if form.code.errors else ""), placeholder="ej. CENTRAL, VIRTUAL, PAB-A") }}
|
||||
{% if form.code.errors %}
|
||||
<div class="invalid-feedback">{{ form.code.errors[0] }}</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">{% trans %}Código identificador corto{% endtrans %}</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
{{ form.address.label(class="form-label fw-semibold") }}
|
||||
{{ form.address(class="form-control" + (" is-invalid" if form.address.errors else ""), placeholder="ej. Av. Paseo Colón 255, CABA") }}
|
||||
{% if form.address.errors %}
|
||||
<div class="invalid-feedback">{{ form.address.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
{{ form.floors.label(class="form-label fw-semibold") }}
|
||||
{{ form.floors(class="form-control" + (" is-invalid" if form.floors.errors else ""), min=1, max=100) }}
|
||||
{% if form.floors.errors %}
|
||||
<div class="invalid-feedback">{{ form.floors.errors[0] }}</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">{% trans %}Cantidad de plantas/pisos{% endtrans %}</small>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
{{ form.description.label(class="form-label fw-semibold") }}
|
||||
{{ form.description(class="form-control", rows=3, placeholder="Información relevante, accesos, comodidades o departamentos alojados...") }}
|
||||
{% if form.description.errors %}
|
||||
<div class="invalid-feedback">{{ form.description.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch pt-2">
|
||||
{{ form.is_active(class="form-check-input", role="switch") }}
|
||||
{{ form.is_active.label(class="form-check-label fw-semibold") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
|
||||
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary">{% trans %}Cancelar{% endtrans %}</a>
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
{% if building %}{% trans %}Guardar Cambios{% endtrans %}{% else %}{% trans %}Crear Edificio{% endtrans %}{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,223 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Gestión de Edificios{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col-md-7">
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-buildings text-primary me-2"></i>{% trans %}Gestión de Edificios{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Administra las sedes, edificios físicos y campus virtuales de UniCABA{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<a href="{{ url_for('buildings.add_building') }}" class="btn btn-primary me-2">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nuevo Edificio{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-door-open me-1"></i>{% trans %}Ver Aulas{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-3 p-3 bg-primary bg-opacity-10 text-primary me-3">
|
||||
<i class="bi bi-buildings fs-3"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small fw-semibold">{% trans %}Total Edificios{% endtrans %}</div>
|
||||
<h3 class="fw-bold mb-0">{{ buildings|length }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-3 p-3 bg-success bg-opacity-10 text-success me-3">
|
||||
<i class="bi bi-door-open fs-3"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small fw-semibold">{% trans %}Aulas Activas{% endtrans %}</div>
|
||||
<h3 class="fw-bold mb-0 text-success">{{ total_classrooms }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-3 p-3 bg-info bg-opacity-10 text-info me-3">
|
||||
<i class="bi bi-people fs-3"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small fw-semibold">{% trans %}Capacidad Total{% endtrans %}</div>
|
||||
<h3 class="fw-bold mb-0 text-info">{{ total_capacity }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-3 p-3 bg-warning bg-opacity-10 text-warning me-3">
|
||||
<i class="bi bi-layers fs-3"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small fw-semibold">{% trans %}Pisos Disponibles{% endtrans %}</div>
|
||||
<h3 class="fw-bold mb-0 text-warning">{{ buildings|sum(attribute='floors') }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter & Search Toolbar -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-3">
|
||||
<form method="GET" class="row g-2 align-items-center">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-body-tertiary border-end-0">
|
||||
<i class="bi bi-search text-muted"></i>
|
||||
</span>
|
||||
<input type="text" name="search" value="{{ search }}" class="form-control border-start-0" placeholder="{% trans %}Buscar por nombre, código o dirección...{% endtrans %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<select name="status" class="form-select" onchange="this.form.submit()">
|
||||
<option value="">{% trans %}Todos los estados{% endtrans %}</option>
|
||||
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>{% trans %}Activos únicamente{% endtrans %}</option>
|
||||
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>{% trans %}Inactivos{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary w-100">{% trans %}Buscar{% endtrans %}</button>
|
||||
{% if search or status_filter %}
|
||||
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary" title="{% trans %}Limpiar filtro{% endtrans %}">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buildings Cards List -->
|
||||
<div class="row g-3">
|
||||
{% for b in buildings %}
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100 position-relative">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border mb-1">
|
||||
{{ b.code or 'EDIF' }}
|
||||
</span>
|
||||
<h5 class="card-title fw-bold mb-0">
|
||||
<a href="{{ url_for('buildings.view_building', id=b.id) }}" class="text-decoration-none text-primary">
|
||||
{{ b.name }}
|
||||
</a>
|
||||
</h5>
|
||||
</div>
|
||||
<div>
|
||||
{% if b.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Activo{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
|
||||
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactivo{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if b.address %}
|
||||
<p class="text-muted small mb-3">
|
||||
<i class="bi bi-geo-alt-fill text-danger me-1"></i>{{ b.address }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if b.description %}
|
||||
<p class="small text-secondary mb-3">{{ b.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-2 text-center pt-2 border-top">
|
||||
<div class="col-4">
|
||||
<div class="p-2 rounded bg-body-tertiary">
|
||||
<small class="text-muted d-block">{% trans %}Pisos{% endtrans %}</small>
|
||||
<span class="fw-bold fs-6">{{ b.floors }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="p-2 rounded bg-body-tertiary">
|
||||
<small class="text-muted d-block">{% trans %}Aulas{% endtrans %}</small>
|
||||
<span class="fw-bold fs-6 text-primary">{{ b.classrooms_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="p-2 rounded bg-body-tertiary">
|
||||
<small class="text-muted d-block">{% trans %}Capacidad{% endtrans %}</small>
|
||||
<span class="fw-bold fs-6 text-success">{{ b.total_capacity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<a href="{{ url_for('buildings.view_building', id=b.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-eye me-1"></i>{% trans %}Ver Distribución de Aulas{% endtrans %}
|
||||
</a>
|
||||
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<div class="btn-group">
|
||||
<a href="{{ url_for('buildings.edit_building', id=b.id) }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Editar Edificio{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('buildings.toggle_building', id=b.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{% if b.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
|
||||
<i class="bi bi-power"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm p-5 text-center">
|
||||
<i class="bi bi-buildings text-muted fs-1 mb-3"></i>
|
||||
<h5 class="fw-bold">{% trans %}No se encontraron edificios{% endtrans %}</h5>
|
||||
<p class="text-muted mb-3">{% trans %}No hay edificios que coincidan con los criterios de búsqueda.{% endtrans %}</p>
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<div>
|
||||
<a href="{{ url_for('buildings.add_building') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Crear Nuevo Edificio{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,177 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ building.name }} - {% trans %}Detalle del Edificio{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Breadcrumb & Header -->
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col-md-7">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1 small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('buildings.list_buildings') }}">{% trans %}Edificios{% endtrans %}</a></li>
|
||||
<li class="breadcrumb-item active">{{ building.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 mb-0 d-flex align-items-center flex-wrap gap-2">
|
||||
<i class="bi bi-building text-primary"></i> {{ building.name }}
|
||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border fs-6">{{ building.code or 'EDIF' }}</span>
|
||||
{% if building.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle fs-6">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Activo{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fs-6">
|
||||
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactivo{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<a href="{{ url_for('buildings.edit_building', id=building.id) }}" class="btn btn-outline-secondary me-2">
|
||||
<i class="bi bi-pencil me-1"></i>{% trans %}Editar Edificio{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Aula{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="text-muted small fw-semibold">{% trans %}Ubicación / Dirección{% endtrans %}</div>
|
||||
<p class="h6 fw-bold mb-2">
|
||||
<i class="bi bi-geo-alt text-danger me-1"></i>{{ building.address or 'Sin dirección especificada' }}
|
||||
</p>
|
||||
{% if building.description %}
|
||||
<small class="text-secondary">{{ building.description }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm text-center p-3">
|
||||
<small class="text-muted text-uppercase fw-bold">{% trans %}Pisos{% endtrans %}</small>
|
||||
<span class="display-6 fw-bold text-primary">{{ building.floors }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm text-center p-3">
|
||||
<small class="text-muted text-uppercase fw-bold">{% trans %}Aulas Asociadas{% endtrans %}</small>
|
||||
<span class="display-6 fw-bold text-success">{{ classrooms|length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm text-center p-3">
|
||||
<small class="text-muted text-uppercase fw-bold">{% trans %}Capacidad Total{% endtrans %}</small>
|
||||
<span class="display-6 fw-bold text-info">{{ building.total_capacity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Classrooms by Floor -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-layers text-primary me-2"></i>{% trans %}Distribución de Aulas por Piso{% endtrans %}
|
||||
</h5>
|
||||
<span class="badge badge-brand">{{ classrooms|length }} {% trans %}aulas configuradas{% endtrans %}</span>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{% if classrooms_by_floor %}
|
||||
{% for floor_num in classrooms_by_floor|sort %}
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<span class="badge bg-dark bg-opacity-75 fs-6 px-3 py-2 me-2">
|
||||
<i class="bi bi-layer-forward me-1"></i>
|
||||
{% if floor_num == 0 %}Planta Baja{% else %}Piso {{ floor_num }}{% endif %}
|
||||
</span>
|
||||
<div class="border-bottom flex-grow-1"></div>
|
||||
<small class="text-muted ms-2">{{ classrooms_by_floor[floor_num]|length }} aulas</small>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for c in classrooms_by_floor[floor_num] %}
|
||||
<div class="col-md-6 col-xl-4">
|
||||
<div class="card border border-light-subtle shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h6 class="fw-bold mb-0">
|
||||
<a href="{{ url_for('classrooms.view_classroom', id=c.id) }}" class="text-decoration-none text-primary">
|
||||
<i class="bi bi-door-open me-1"></i>{{ c.code }}
|
||||
</a>
|
||||
</h6>
|
||||
{% if c.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle small">Activa</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle small">Inactiva</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="small text-muted mb-2">
|
||||
<i class="bi bi-people me-1"></i>Capacidad: <strong>{{ c.capacity }}</strong> estudiantes
|
||||
</div>
|
||||
|
||||
{% if c.description %}
|
||||
<p class="small text-secondary mb-2 text-truncate">{{ c.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center pt-2 border-top">
|
||||
<a href="{{ url_for('classrooms.view_classroom', id=c.id) }}" class="btn btn-outline-primary btn-sm py-0 px-2">
|
||||
<small>{% trans %}Ver Ficha{% endtrans %}</small>
|
||||
</a>
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<a href="{{ url_for('classrooms.edit_classroom', id=c.id) }}" class="btn btn-outline-secondary btn-sm py-0 px-2">
|
||||
<small>{% trans %}Editar{% endtrans %}</small>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-door-closed text-muted fs-1 mb-2"></i>
|
||||
<h6 class="fw-bold">{% trans %}No hay aulas asignadas a este edificio{% endtrans %}</h6>
|
||||
<p class="text-muted small mb-3">{% trans %}Puedes crear aulas nuevas o editar las existentes para vincularlas aquí.{% endtrans %}</p>
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% trans %}Agregar Primera Aula{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Danger Zone (Delete) -->
|
||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
||||
<div class="card border-danger border-opacity-25 shadow-sm mb-4">
|
||||
<div class="card-body p-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<h6 class="text-danger fw-bold mb-1">{% trans %}Eliminar Edificio{% endtrans %}</h6>
|
||||
<small class="text-muted">{% trans %}Solo es posible eliminar edificios que no tengan aulas asignadas.{% endtrans %}</small>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('buildings.delete_building', id=building.id) }}" onsubmit="return confirm('¿Estás seguro de que deseas eliminar este edificio?');">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" {% if classrooms|length > 0 %}disabled title="Tiene aulas asociadas"{% endif %}>
|
||||
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Edificio{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
{% if config.LANGUAGES|length > 1 %}
|
||||
<div class="dropdown language-switcher">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="languageDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle d-flex align-items-center" type="button" id="languageDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-globe me-1"></i>
|
||||
{% if session.language == 'es' or (session.language is none and request.accept_languages.best_match(['es', 'en']) == 'es') %}
|
||||
<span>
|
||||
{% if current_lang == 'es' %}
|
||||
Español
|
||||
{% else %}
|
||||
English
|
||||
{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="languageDropdown">
|
||||
{% set current_lang = session.language or request.accept_languages.best_match(['es', 'en']) or 'en' %}
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="languageDropdown">
|
||||
<li>
|
||||
<a class="dropdown-item {% if current_lang == 'en' %}active{% endif %}"
|
||||
href="{{ url_for('main.set_language', language='en') }}">
|
||||
<i class="bi bi-check-circle-fill text-primary me-2"></i>English
|
||||
<a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'es' %}active{% endif %}"
|
||||
href="{{ url_for('main.set_language', language='es') }}">
|
||||
<span>Español</span>
|
||||
{% if current_lang == 'es' %}
|
||||
<i class="bi bi-check-lg ms-2 text-white"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {% if current_lang == 'es' %}active{% endif %}"
|
||||
href="{{ url_for('main.set_language', language='es') }}">
|
||||
<i class="bi bi-check-circle-fill text-primary me-2"></i>Español
|
||||
<a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'en' %}active{% endif %}"
|
||||
href="{{ url_for('main.set_language', language='en') }}">
|
||||
<span>English</span>
|
||||
{% if current_lang == 'en' %}
|
||||
<i class="bi bi-check-lg ms-2 text-white"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,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 {
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label"> </label>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
<i class="bi bi-funnel"></i> Filter
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if reservations.has_prev %}
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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 %}
|
||||
@@ -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>
|
||||
</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 %}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>
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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);
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var toggleBtn = document.getElementById('toggle-view-btn');
|
||||
var timelineView = document.getElementById('timeline-view');
|
||||
var gridView = document.getElementById('grid-view');
|
||||
|
||||
// Refresh the page
|
||||
$('#refresh-btn').on('click', function() {
|
||||
location.reload();
|
||||
});
|
||||
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 %}';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .decorators import permission_required, admin_required
|
||||
|
||||
__all__ = ['permission_required', 'admin_required']
|
||||
@@ -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
|
||||