feat: refactorizar espacios fisicos desacoplando Edificio, Piso y Aula con selectores en cascada y filtros

This commit is contained in:
Carlos Tello
2026-09-03 05:18:01 -03:00
parent 5e0b905e55
commit 6ccc9e8c87
11 changed files with 1093 additions and 522 deletions
+27
View File
@@ -319,6 +319,33 @@
* **Formato de Códigos de Comisión ([app/models/subject.py](file:///c:/Workspace/admin-edu-space/app/models/subject.py)):** * **Formato de Códigos de Comisión ([app/models/subject.py](file:///c:/Workspace/admin-edu-space/app/models/subject.py)):**
* Se optimizó `Commission.get_full_code()` para no repetir el año cuando ya viene incluido en el string del semestre (ej: `2026-2`). * Se optimizó `Commission.get_full_code()` para no repetir el año cuando ya viene incluido en el string del semestre (ej: `2026-2`).
---
## 20. Refactorización Integral de Espacios Físicos: Desacoplamiento de Edificio, Piso y Aula
* **Modelo de Datos y Base de Datos ([app/models/classroom.py](file:///c:/Workspace/admin-edu-space/app/models/classroom.py), [app/models/building.py](file:///c:/Workspace/admin-edu-space/app/models/building.py)):**
* **Desacoplamiento de Edificio:** Se reforzó la clave foránea `Classroom.building_id` vinculada a `buildings.id`. El nombre del edificio se obtiene directamente a través de `building_entity` manteniendo retrocompatibilidad.
* **Pisos Flexibles (Alfanuméricos):** Se migró la columna `classrooms.floor` a `VARCHAR(50)`, permitiendo registrar tanto valores numéricos como designaciones textuales ("PB", "Planta Baja", "1", "2°P", "Subsuelo").
* **Propiedades de Formato:** Se incorporaron `floor_display` ("Planta Baja", "Piso X"), `is_virtual` y `location_display` (`Edificio · Piso · Aula`).
* **Rutina de Migración:** Se verificó la integridad referencial garantizando que todas las aulas existentes queden asociadas a su `building_id` correspondiente en PostgreSQL.
* **Formulario y Flujo Dinámico de Creación/Edición de Aulas ([app/forms/classroom.py](file:///c:/Workspace/admin-edu-space/app/forms/classroom.py), [app/routes/classrooms.py](file:///c:/Workspace/admin-edu-space/app/routes/classrooms.py), [app/templates/classrooms/add.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/add.html), [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html)):**
* **Selector de Edificio con Alta Dinámica:** Desplegable con las sedes activas más la opción destacada `+ Crear nuevo edificio`. Al seleccionarla, despliega de forma inmediata un campo de texto para escribir el nuevo edificio sin abandonar la pantalla.
* **Sugerencia de Pisos:** Campo con datalist interactivo que sugiere los pisos ya cargados en la sede seleccionada o permite tipear uno nuevo libremente.
* **Validación de Unicidad:** Se valida tanto en cliente como en servidor que no existan aulas duplicadas dentro del mismo edificio y piso.
* **Selectores en Cascada para Reservas ([app/templates/schedule/add.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/add.html), [app/routes/schedule.py](file:///c:/Workspace/admin-edu-space/app/routes/schedule.py)):**
* **Flujo Dependiente:**
1. `Edificio / Sede`: Selección de sede física o modalidad `Campus Virtual`.
2. `Piso / Nivel`: Se puebla dinámicamente con los pisos disponibles de esa sede (se deshabilita automáticamente si es Virtual).
3. `Aula / Espacio`: Filtra y muestra únicamente las aulas del edificio y piso seleccionados con su capacidad.
* **Resumen Desglosado con Badges:**
* 🏢 **Edificio / Sede:** Muestra el nombre limpio de la sede.
* 🪜 **Piso / Nivel:** Muestra la planta o "Remoto (Virtual)".
* 🚪 **Aula Seleccionada:** Muestra el número de aula y su capacidad.
* **Filtros en Listado de Aulas y Calendario ([app/templates/classrooms/list.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/list.html), [app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html), [app/templates/schedule/view.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/view.html)):**
* **Listado de Aulas:** Incorporación de dropdowns independientes para filtrar por **Edificio** y **Piso** en la barra superior. Tarjetas rediseñadas con badges distintivos de sede y piso.
* **Calendario Semanal:** Agregado el filtro **Filtrar por Edificio** en la barra superior junto al filtro de Piso.
* **Modal Estilo Google Calendar:** La sección de espacio físico desglosa limpiamente en badges: 🏢 Edificio, 🪜 Piso y 🚪 Aula.
+10 -12
View File
@@ -19,27 +19,25 @@ def validate_capacity(form, field):
raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.')) raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.'))
class ClassroomForm(FlaskForm): class ClassroomForm(FlaskForm):
building = StringField(_l('Building'), validators=[ building_id = SelectField(_l('Edificio'), validators=[DataRequired()])
DataRequired(), new_building_name = StringField(_l('Nombre del Nuevo Edificio'), validators=[Optional(), Length(max=100)])
Length(min=2, max=100) room_number = StringField(_l('Número / Nombre del Aula'), validators=[
])
room_number = StringField(_l('Room Number'), validators=[
DataRequired(), DataRequired(),
Length(min=1, max=50) Length(min=1, max=50)
]) ])
floor = IntegerField(_l('Floor'), validators=[ floor = StringField(_l('Piso'), validators=[
InputRequired(message=_l('El piso es obligatorio (0 para Planta Baja)')), DataRequired(message=_l('El piso es obligatorio (ej: PB, 1, 2, 4°P)')),
NumberRange(min=0, max=50, message=_l('El piso debe estar entre 0 (Planta Baja) y 50')) Length(min=1, max=50)
]) ])
capacity = StringField(_l('Capacity'), validators=[ capacity = StringField(_l('Capacidad'), validators=[
validate_capacity validate_capacity
]) ])
description = TextAreaField(_l('Description'), validators=[ description = TextAreaField(_l('Descripción'), validators=[
Optional(), Optional(),
Length(max=500) Length(max=500)
]) ])
is_active = BooleanField(_l('Active'), default=True) is_active = BooleanField(_l('Activa'), default=True)
submit = SubmitField(_l('Save Classroom')) submit = SubmitField(_l('Guardar Aula'))
class ClassroomResourceForm(FlaskForm): class ClassroomResourceForm(FlaskForm):
resource_type = SelectField(_l('Resource Type'), choices=[ resource_type = SelectField(_l('Resource Type'), choices=[
+52 -13
View File
@@ -9,23 +9,57 @@ class Classroom(db.Model):
building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True) building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True)
building = db.Column(db.String(100), nullable=False) 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 code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB
floor = db.Column(db.Integer, nullable=False) floor = db.Column(db.String(50), nullable=False, default='1')
capacity = db.Column(db.Integer, nullable=False) capacity = db.Column(db.Integer, nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB 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) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación con Building (mapeada vía backref 'building_entity' en Building.classrooms)
@property
def building_rel(self):
return self.building_entity
@property
def building_name(self):
"""Nombre del edificio desacoplado"""
if self.building_rel:
return self.building_rel.name
return self.building or 'Edificio Central'
@property
def floor_display(self):
"""Formato amigable para el piso: PB, Planta Baja, Piso X"""
f = str(self.floor).strip() if self.floor is not None else ''
if f in ['0', 'PB', 'pb', 'Planta Baja', 'planta baja']:
return 'Planta Baja'
if f.lower().startswith('piso') or '°' in f:
return f
return f"Piso {f}"
@property
def is_virtual(self):
"""Indica si el aula es virtual"""
return (self.code and 'VIRTUAL' in self.code.upper()) or (self.building and 'VIRTUAL' in self.building.upper())
@property
def location_display(self):
"""Ubicación completa y limpia: Edificio · Piso · Aula"""
if self.is_virtual:
return '🌐 Campus Virtual'
return f"{self.building_name} · {self.floor_display} · {self.code}"
# Virtual properties for compatibility # Virtual properties for compatibility
@property @property
def name(self): def name(self):
"""Generate name from building and code""" """Generate name from building and code"""
return f"{self.building}-{self.code}" return f"{self.building_name}-{self.code}"
@property @property
def campus_location(self): def campus_location(self):
"""Placeholder for campus location - can be expanded when campus model is implemented""" """Placeholder for campus location"""
return None return self.building_rel.address if self.building_rel else None
# Virtual field for compatibility - combines building and code # Virtual field for compatibility - combines building and code
@property @property
@@ -39,28 +73,34 @@ class Classroom(db.Model):
# Override code property for consistency # Override code property for consistency
@property @property
def code_display(self): def code_display(self):
return f"{self.building}-{self.code}" return f"{self.building_name} - {self.code}"
@property @property
def capacity_display(self): def capacity_display(self):
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero.""" """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()): if self.capacity == 0 or self.is_virtual:
return '∞' return '∞'
return str(self.capacity) return str(self.capacity)
@property @property
def is_unlimited(self): def is_unlimited(self):
return self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper()) return self.capacity == 0 or self.is_virtual
def to_dict(self): def to_dict(self):
"""Convert classroom to dictionary""" """Convert classroom to dictionary"""
return { return {
'id': self.id, 'id': self.id,
'code': self.code_display, 'building_id': self.building_id,
'building': self.building, 'building': self.building_name,
'code': self.code,
'code_display': self.code_display,
'name': self.name, 'name': self.name,
'floor': self.floor, 'floor': self.floor,
'floor_display': self.floor_display,
'location_display': self.location_display,
'capacity': self.capacity, 'capacity': self.capacity,
'capacity_display': self.capacity_display,
'is_virtual': self.is_virtual,
'description': self.description, 'description': self.description,
'campus_location': self.campus_location, 'campus_location': self.campus_location,
'is_active': self.is_active, 'is_active': self.is_active,
@@ -71,10 +111,9 @@ class Classroom(db.Model):
def get_full_location(self): def get_full_location(self):
"""Get full classroom location""" """Get full classroom location"""
location = f"{self.building} - Floor {self.floor}" if self.is_virtual:
if self.campus_location: return 'Campus Virtual (Remoto)'
location += f" ({self.campus_location})" return f"{self.building_name} - {self.floor_display}"
return location
@staticmethod @staticmethod
def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None): def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None):
+105 -38
View File
@@ -12,23 +12,49 @@ classrooms_bp = Blueprint('classrooms', __name__)
@classrooms_bp.route('/add', methods=['GET', 'POST']) @classrooms_bp.route('/add', methods=['GET', 'POST'])
@login_required @login_required
def add_classroom(): def add_classroom():
"""Add a new classroom""" """Add a new classroom with dynamic building creation and floor suggestion"""
form = ClassroomForm() from app.models.building import Building
import re
from sqlalchemy import func
# TODO: Update when campus model is implemented form = ClassroomForm()
# form.campus_id.choices = [(1, 'Main Campus')] buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
# Map of existing floors per building for datalist
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
building = None
if form.building_id.data == '__new__':
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
if not new_building_name:
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
building = Building(name=new_building_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.commit()
else:
building = Building.query.get_or_404(int(form.building_id.data))
room_code = form.room_number.data.strip()
floor_val = str(form.floor.data).strip()
# Prevent duplicate room in same building & floor
existing_classroom = Classroom.query.filter_by( existing_classroom = Classroom.query.filter_by(
building=form.building.data, building_id=building.id,
code=form.room_number.data, code=room_code,
floor=form.floor.data floor=floor_val
).first() ).first()
if existing_classroom: if existing_classroom:
flash('Classroom with this building, room number, and floor already exists.', 'error') flash(f'Ya existe un aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
return render_template('classrooms/add.html', form=form, title='Add Classroom') return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
cap_raw = str(form.capacity.data or '').strip() cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']: if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
@@ -37,10 +63,10 @@ def add_classroom():
cap_val = int(cap_raw) cap_val = int(cap_raw)
classroom = Classroom( classroom = Classroom(
# campus_id=form.campus_id.data, building_id=building.id,
building=form.building.data, building=building.name,
code=form.room_number.data, code=room_code,
floor=form.floor.data, floor=floor_val,
capacity=cap_val, capacity=cap_val,
description=form.description.data, description=form.description.data,
is_active=form.is_active.data is_active=form.is_active.data
@@ -49,32 +75,40 @@ def add_classroom():
db.session.add(classroom) db.session.add(classroom)
db.session.commit() db.session.commit()
flash(f'Classroom {classroom.code_display} has been created successfully!', 'success') flash(f'Aula {classroom.location_display} creada exitosamente.', 'success')
return redirect(url_for('classrooms.list_classrooms')) return redirect(url_for('classrooms.list_classrooms'))
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
flash(f'Error creating classroom: {str(e)}', 'error') flash(f'Error creating classroom: {str(e)}', 'error')
return render_template('classrooms/add.html', form=form, title='Add Classroom') return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
@classrooms_bp.route('/', endpoint='index') @classrooms_bp.route('/', endpoint='index')
@classrooms_bp.route('/list') @classrooms_bp.route('/list')
@login_required @login_required
def list_classrooms(): def list_classrooms():
"""List all classrooms""" """List all classrooms with Building and Floor filters"""
try: try:
# Get filters from app.models.building import Building
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
active_only = request.args.get('active_only', 'true').lower() == 'true' active_only = request.args.get('active_only', 'true').lower() == 'true'
search = request.args.get('search', '', type=str) search = request.args.get('search', '', type=str)
building_filter = request.args.get('building_id', '', type=str).strip()
floor_filter = request.args.get('floor', '', type=str).strip()
# Build query
query = Classroom.query query = Classroom.query
if active_only: if active_only:
query = query.filter_by(is_active=True) query = query.filter_by(is_active=True)
if building_filter and building_filter.isdigit():
query = query.filter(Classroom.building_id == int(building_filter))
if floor_filter:
query = query.filter(Classroom.floor == floor_filter)
if search: if search:
query = query.filter( query = query.filter(
db.or_( db.or_(
@@ -84,13 +118,19 @@ def list_classrooms():
) )
) )
# Paginate results
classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate( classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate(
page=page, per_page=12, error_out=False page=page, per_page=12, error_out=False
) )
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
floors = [f[0] for f in db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()]
return render_template('classrooms/list.html', return render_template('classrooms/list.html',
classrooms=classrooms, classrooms=classrooms,
buildings=buildings,
floors=floors,
building_filter=building_filter,
floor_filter=floor_filter,
active_only=active_only, active_only=active_only,
search=search, search=search,
title='Classrooms') title='Classrooms')
@@ -99,14 +139,13 @@ def list_classrooms():
flash(f'Error loading classrooms: {str(e)}', 'error') flash(f'Error loading classrooms: {str(e)}', 'error')
return render_template('classrooms/list.html', classrooms=None, title='Classrooms') return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
@classrooms_bp.route('/<int:id>') @classrooms_bp.route('/<int:id>')
@login_required @login_required
def view_classroom(id): def view_classroom(id):
"""View classroom details""" """View classroom details"""
try: try:
classroom = Classroom.query.get_or_404(id) classroom = Classroom.query.get_or_404(id)
# Get upcoming reservations for this classroom
from app.models.reservation import Reservation, ReservationStatus from app.models.reservation import Reservation, ReservationStatus
upcoming_reservations = Reservation.query.filter( upcoming_reservations = Reservation.query.filter(
@@ -124,35 +163,63 @@ def view_classroom(id):
flash(f'Error loading classroom: {str(e)}', 'error') flash(f'Error loading classroom: {str(e)}', 'error')
return redirect(url_for('classrooms.list_classrooms')) return redirect(url_for('classrooms.list_classrooms'))
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST']) @classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required @login_required
def edit_classroom(id): def edit_classroom(id):
"""Edit classroom""" """Edit classroom with decoupled building and floor"""
from app.models.building import Building
import re
from sqlalchemy import func
classroom = Classroom.query.get_or_404(id) classroom = Classroom.query.get_or_404(id)
form = ClassroomForm(obj=classroom) form = ClassroomForm(obj=classroom)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
if request.method == 'GET': if request.method == 'GET':
if classroom.capacity == 0 or (classroom.code and 'VIRTUAL' in classroom.code.upper()): form.building_id.data = str(classroom.building_id or '')
form.room_number.data = classroom.code
form.floor.data = classroom.floor
if classroom.capacity == 0 or classroom.is_virtual:
form.capacity.data = '∞' form.capacity.data = '∞'
else: else:
form.capacity.data = str(classroom.capacity) form.capacity.data = str(classroom.capacity)
# TODO: Update when campus model is implemented
# form.campus_id.choices = [(1, 'Main Campus')]
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
# Check for duplicates (excluding current classroom) building = None
if form.building_id.data == '__new__':
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
if not new_building_name:
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
building = Building(name=new_building_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.commit()
else:
building = Building.query.get_or_404(int(form.building_id.data))
room_code = form.room_number.data.strip()
floor_val = str(form.floor.data).strip()
# Check duplicate excluding current
existing_classroom = Classroom.query.filter( existing_classroom = Classroom.query.filter(
Classroom.building == form.building.data, Classroom.building_id == building.id,
Classroom.code == form.room_number.data, Classroom.code == room_code,
Classroom.floor == form.floor.data, Classroom.floor == floor_val,
Classroom.id != id Classroom.id != id
).first() ).first()
if existing_classroom: if existing_classroom:
flash('Classroom with this building, room number, and floor already exists.', 'error') flash(f'Ya existe otra aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom') return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
cap_raw = str(form.capacity.data or '').strip() cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']: if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
@@ -160,10 +227,10 @@ def edit_classroom(id):
else: else:
cap_val = int(cap_raw) cap_val = int(cap_raw)
# Update classroom classroom.building_id = building.id
classroom.building = form.building.data classroom.building = building.name
classroom.code = form.room_number.data classroom.code = room_code
classroom.floor = form.floor.data classroom.floor = floor_val
classroom.capacity = cap_val classroom.capacity = cap_val
classroom.description = form.description.data classroom.description = form.description.data
classroom.is_active = form.is_active.data classroom.is_active = form.is_active.data
@@ -171,14 +238,14 @@ def edit_classroom(id):
db.session.commit() db.session.commit()
flash(f'Classroom {classroom.code_display} has been updated successfully!', 'success') flash(f'Aula {classroom.location_display} actualizada correctamente.', 'success')
return redirect(url_for('classrooms.view_classroom', id=classroom.id)) return redirect(url_for('classrooms.view_classroom', id=classroom.id))
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
flash(f'Error updating classroom: {str(e)}', 'error') flash(f'Error updating classroom: {str(e)}', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom') return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
@classrooms_bp.route('/<int:id>/toggle', methods=['POST']) @classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
@login_required @login_required
+32 -19
View File
@@ -40,8 +40,8 @@ def today_schedule():
~Classroom.building.ilike('%Virtual%') ~Classroom.building.ilike('%Virtual%')
) )
if floor_filter and floor_filter.isdigit(): if floor_filter:
query = query.filter(Classroom.floor == int(floor_filter)) query = query.filter(Classroom.floor == str(floor_filter).strip())
if shift_filter: if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%')) query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
@@ -140,9 +140,11 @@ def view_reservation(id):
def calendar_view(): def calendar_view():
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering""" """Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
from app.models.career import Career from app.models.career import Career
from app.models.building import Building
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all() 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() subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
careers = Career.query.order_by(Career.name.asc()).all() careers = Career.query.order_by(Career.name.asc()).all()
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all() 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] available_floors = [f[0] for f in floors]
@@ -154,6 +156,7 @@ def calendar_view():
classrooms=classrooms, classrooms=classrooms,
subjects=subjects, subjects=subjects,
careers=careers, careers=careers,
buildings=buildings,
available_floors=available_floors available_floors=available_floors
) )
@@ -400,23 +403,23 @@ def add_reservation():
"""Add new reservation""" """Add new reservation"""
from app.models.classroom import Classroom from app.models.classroom import Classroom
from app.models.subject import Commission from app.models.subject import Commission
from app.models.building import Building
form = ReservationForm() form = ReservationForm()
# Get active classrooms for the dropdown # Get active buildings and classrooms
classrooms = Classroom.query.filter_by(is_active=True).all() buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
# Get active commissions for the dropdown
commissions = Commission.query.filter_by(active=True).all() commissions = Commission.query.filter_by(active=True).all()
# Populate form choices # Populate form choices
form.classroom_id.choices = [(c.id, f'{c.building} - {c.code} (Cap: {c.capacity_display})') for c in classrooms] form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions] form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'POST': if request.method == 'POST':
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
# Check for existing reservations at the same time # Check for existing reservations at the same time
from app.models.reservation import ReservationStatus from app.models.reservation import ReservationStatus
existing_reservation = Reservation.query.filter_by( existing_reservation = Reservation.query.filter_by(
classroom_id=form.classroom_id.data, classroom_id=form.classroom_id.data,
@@ -428,7 +431,7 @@ def add_reservation():
if existing_reservation: if existing_reservation:
flash('This classroom is already booked for the selected time slot.', 'danger') flash('This classroom is already booked for the selected time slot.', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions) return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
# Create new reservation # Create new reservation
reservation = Reservation( reservation = Reservation(
@@ -451,10 +454,9 @@ def add_reservation():
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
current_app.logger.error(f"Error creating reservation: {str(e)}")
flash(f'Error creating reservation: {str(e)}', 'danger') flash(f'Error creating reservation: {str(e)}', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions) return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
@schedule_bp.route('/list') @schedule_bp.route('/list')
@login_required @login_required
@@ -498,8 +500,8 @@ def list_reservations():
~Classroom.building.ilike('%Virtual%') ~Classroom.building.ilike('%Virtual%')
) )
if floor_filter and floor_filter.isdigit(): if floor_filter:
query = query.filter(Classroom.floor == int(floor_filter)) query = query.filter(Classroom.floor == str(floor_filter).strip())
if shift_filter: if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%')) query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
@@ -666,7 +668,6 @@ def calendar_data():
search = request.args.get('search', '').strip() search = request.args.get('search', '').strip()
if start and end: if start and end:
from datetime import datetime
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison # Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
start_clean = start.replace('Z', '+00:00') start_clean = start.replace('Z', '+00:00')
end_clean = end.replace('Z', '+00:00') end_clean = end.replace('Z', '+00:00')
@@ -695,6 +696,16 @@ def calendar_data():
if career_id and career_id.isdigit(): if career_id and career_id.isdigit():
query = query.filter(Subject.career_id == int(career_id)) query = query.filter(Subject.career_id == int(career_id))
building = request.args.get('building', '').strip()
if building:
from app.models.building import Building
query = query.filter(
or_(
Classroom.building.ilike(f'%{building}%'),
Classroom.building_entity.has(Building.name.ilike(f'%{building}%'))
)
)
if shift: if shift:
query = query.filter(Reservation.shift.ilike(f'%{shift}%')) query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
@@ -704,8 +715,8 @@ def calendar_data():
~Classroom.building.ilike('%Virtual%') ~Classroom.building.ilike('%Virtual%')
) )
if floor and floor.isdigit(): if floor:
query = query.filter(Classroom.floor == int(floor)) query = query.filter(Classroom.floor == str(floor).strip())
if search: if search:
query = query.filter( query = query.filter(
@@ -725,8 +736,9 @@ def calendar_data():
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '') 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') subject_name = subject.name if subject else (reservation.purpose or 'Clase')
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula' classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
classroom_floor = reservation.classroom.floor if reservation.classroom else None classroom_floor = reservation.classroom.floor_display 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())) classroom_building = reservation.classroom.building_name if reservation.classroom else ''
is_virtual = bool(reservation.classroom and reservation.classroom.is_virtual)
career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '') 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) status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
@@ -741,9 +753,10 @@ def calendar_data():
'subject_code': subject_code, 'subject_code': subject_code,
'subject_name': subject_name, 'subject_name': subject_name,
'classroom': classroom_name, 'classroom': classroom_name,
'building': reservation.classroom.building if reservation.classroom else '', 'building': classroom_building,
'floor': classroom_floor, 'floor': classroom_floor,
'is_virtual': is_virtual, 'is_virtual': is_virtual,
'location_display': reservation.classroom.location_display if reservation.classroom else '',
'career': career_name, 'career': career_name,
'commission': reservation.commission.code if reservation.commission else '', 'commission': reservation.commission.code if reservation.commission else '',
'shift': reservation.shift or '', 'shift': reservation.shift or '',
+161 -123
View File
@@ -7,11 +7,19 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3"> <div>
<i class="bi bi-plus-circle text-primary"></i> {% trans %}Add Classroom{% endtrans %} <nav aria-label="breadcrumb">
</h1> <ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="{{ url_for('classrooms.list_classrooms') }}">{% trans %}Classrooms{% endtrans %}</a></li>
<li class="breadcrumb-item active">{% trans %}Add Classroom{% endtrans %}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="bi bi-plus-circle text-primary me-2"></i>{% trans %}Add Classroom{% endtrans %}
</h1>
</div>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> {% trans %}Back to Classrooms{% endtrans %} <i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
@@ -22,59 +30,81 @@
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3"> <div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-door-open text-primary me-2"></i> {% trans %}Classroom Information{% endtrans %} <i class="bi bi-door-open text-primary me-2"></i>{% trans %}Classroom Information{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<form method="POST" id="classroom-form" novalidate> <form method="POST" id="classroom-form" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="row"> <div class="row g-3 mb-3">
<div class="col-md-4 mb-3"> <!-- Selector de Edificio / Sede -->
{{ form.building.label(class="form-label") }} <div class="col-md-6">
{{ form.building(class="form-control", placeholder="A") }} <label for="building_id" class="form-label fw-semibold">
{% if form.building.errors %} <i class="bi bi-buildings text-primary me-1"></i>{% trans %}Edificio / Sede{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.building_id(class="form-select", id="building_id") }}
{% if form.building_id.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.building.errors[0] }} {{ form.building_id.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<!-- Input emergente si selecciona "+ Crear nuevo edificio" -->
<div id="newBuildingContainer" class="mt-2 p-2 rounded bg-body-tertiary border border-success-subtle" style="display: none;">
<label for="new_building_name" class="form-label small fw-bold text-success mb-1">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nombre del Nuevo Edificio / Sede{% endtrans %}:
</label>
{{ form.new_building_name(class="form-control form-control-sm", id="new_building_name", placeholder="ej. Anexo Belgrano, Pabellón III") }}
<div class="form-text small text-muted">{% trans %}Se registrará automáticamente en el catálogo de sedes.{% endtrans %}</div>
</div>
</div> </div>
<div class="col-md-4 mb-3"> <!-- Selector / Input de Piso con sugerencias -->
{{ form.room_number.label(class="form-label") }} <div class="col-md-3">
{{ form.room_number(class="form-control", placeholder="101") }} <label for="floor" class="form-label fw-semibold">
{% if form.room_number.errors %} <i class="bi bi-layers text-secondary me-1"></i>{% trans %}Piso / Nivel{% endtrans %} <span class="text-danger">*</span>
<div class="text-danger small mt-1"> </label>
{{ form.room_number.errors[0] }} {{ form.floor(class="form-control", id="floor", list="floorSuggestions", placeholder="ej. PB, 1, 2, 4°P") }}
</div> <datalist id="floorSuggestions">
{% endif %} <!-- Poblado dinámicamente por JS según el edificio -->
</div> </datalist>
<div class="form-text small text-muted">{% trans %}Sugerido o nuevo{% endtrans %}</div>
<div class="col-md-4 mb-3">
{{ form.floor.label(class="form-label") }}
{{ form.floor(class="form-control") }}
{% if form.floor.errors %} {% if form.floor.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.floor.errors[0] }} {{ form.floor.errors[0] }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Número / Nombre de Aula -->
<div class="col-md-3">
<label for="room_number" class="form-label fw-semibold">
<i class="bi bi-door-open text-info me-1"></i>{% trans %}Aula / Sala{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.room_number(class="form-control", id="room_number", placeholder="ej. Aula 304, Lab 1") }}
{% if form.room_number.errors %}
<div class="text-danger small mt-1">
{{ form.room_number.errors[0] }}
</div>
{% endif %}
</div>
</div> </div>
<div class="row"> <div class="row g-3 mb-3">
<div class="col-md-6 mb-3"> <div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
{{ form.capacity.label(class="form-label mb-0") }} <label for="capacity" class="form-label fw-semibold mb-0">{% trans %}Capacidad de Alumnos{% endtrans %} <span class="text-danger">*</span></label>
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinityAdd" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}"> <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 %} <i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</button> </button>
</div> </div>
<div class="input-group"> <div class="input-group">
{{ form.capacity(class="form-control", id="capacity", placeholder="ej. 30 o ∞") }} {{ form.capacity(class="form-control", id="capacity", placeholder="ej. 35 o ∞") }}
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span> <span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
</div> </div>
<div class="form-text small text-muted mt-1"> <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 %} <i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o sin límite de aforo, ingrese <strong>∞</strong> o <strong>0</strong>.{% endtrans %}
</div> </div>
{% if form.capacity.errors %} {% if form.capacity.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
@@ -83,18 +113,17 @@
{% endif %} {% endif %}
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 d-flex align-items-center pt-md-3">
<label class="form-label d-block">&nbsp;</label> <div class="form-check form-switch pt-2">
<div class="form-check pt-2"> {{ form.is_active(class="form-check-input", id="is_active") }}
{{ form.is_active(class="form-check-input") }} <label class="form-check-label fw-semibold" for="is_active">{% trans %}Aula Habilitada para Reservas{% endtrans %}</label>
{{ form.is_active.label(class="form-check-label") }}
</div> </div>
</div> </div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
{{ form.description.label(class="form-label") }} {{ form.description.label(class="form-label fw-semibold") }}
{{ form.description(class="form-control", rows="3", placeholder=_("Optional: Add any additional details about this classroom...")) }} {{ form.description(class="form-control", rows="3", placeholder=_("Opcional: Detalles adicionales de equipamiento, tomas de corriente, proyector...")) }}
{% if form.description.errors %} {% if form.description.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.description.errors[0] }} {{ form.description.errors[0] }}
@@ -102,27 +131,36 @@
{% endif %} {% endif %}
</div> </div>
<!-- Preview Section --> <!-- Preview Card -->
<div class="alert alert-info"> <div class="card bg-body-tertiary border-0 shadow-xs mb-4">
<h6 class="alert-heading"> <div class="card-body py-3 px-4">
<i class="bi bi-info-circle"></i> {% trans %}Preview{% endtrans %} <h6 class="card-subtitle mb-2 text-muted small fw-bold">
</h6> <i class="bi bi-eye me-1"></i>{% trans %}Vista Previa de Ubicación{% endtrans %}
<div id="classroom-preview"> </h6>
<p class="mb-1">{% trans %}Fill in the form to see a preview of the classroom code:{% endtrans %} <div class="row g-2">
<span class="fw-bold" id="preview-code">-</span> <div class="col-md-4">
</p> <small class="text-muted d-block">🏢 {% trans %}Edificio{% endtrans %}</small>
<p class="mb-0">{% trans %}Full location:{% endtrans %} <strong id="preview-building" class="text-primary">-</strong>
<span class="fw-bold" id="preview-location">-</span> </div>
</p> <div class="col-md-4">
<small class="text-muted d-block">🪜 {% trans %}Piso{% endtrans %}</small>
<strong id="preview-floor" class="text-secondary">-</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">🚪 {% trans %}Aula / Código{% endtrans %}</small>
<strong id="preview-room" class="text-success">-</strong>
</div>
</div>
</div> </div>
</div> </div>
<!-- Buttons -->
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %} {% trans %}Cancelar{% endtrans %}
</a> </a>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary px-4">
<i class="bi bi-save me-1"></i> {{ form.submit.label.text }} <i class="bi bi-check-circle me-1"></i>{% trans %}Crear Aula{% endtrans %}
</button> </button>
</div> </div>
</form> </form>
@@ -130,89 +168,89 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Resources Section -->
<div class="row mt-4 mb-4">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-tools text-warning me-2"></i> {% trans %}Resources (Optional){% endtrans %}
</h5>
</div>
<div class="card-body">
<p class="text-muted mb-0">{% trans %}You can add classroom resources after creating the classroom.{% endtrans %}</p>
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
$(document).ready(function() { document.addEventListener('DOMContentLoaded', function() {
function updatePreview() { // Map of existing floors per building
const building = $('#building').val(); var buildingFloors = {{ building_floors|tojson }};
const roomNumber = $('#room_number').val();
const floor = $('#floor').val();
const floorLabel = '{{ _("Floor") }}';
if (building && roomNumber && floor) { var buildingSelect = document.getElementById('building_id');
$('#preview-code').text(`${building}-${roomNumber}`); var newBuildingContainer = document.getElementById('newBuildingContainer');
$('#preview-location').text(`${building} - ${floorLabel} ${floor}`); var newBuildingNameInput = document.getElementById('new_building_name');
} else { var floorInput = document.getElementById('floor');
$('#preview-code').text('-'); var floorDatalist = document.getElementById('floorSuggestions');
$('#preview-location').text('-'); var roomInput = document.getElementById('room_number');
var previewBuilding = document.getElementById('preview-building');
var previewFloor = document.getElementById('preview-floor');
var previewRoom = document.getElementById('preview-room');
function updateFloorsDatalist(bId) {
if (!floorDatalist) return;
floorDatalist.innerHTML = '';
var floors = buildingFloors[bId] || [];
floors.forEach(function(f) {
var opt = document.createElement('option');
opt.value = f;
opt.textContent = (f === '0' || f === 'PB') ? 'Planta Baja' : 'Piso ' + f;
floorDatalist.appendChild(opt);
});
}
function updatePreview() {
var bText = '-';
if (buildingSelect) {
if (buildingSelect.value === '__new__') {
bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}';
} else if (buildingSelect.selectedIndex >= 0) {
bText = buildingSelect.options[buildingSelect.selectedIndex].text;
}
}
var fText = floorInput ? (floorInput.value.trim() || '-') : '-';
var rText = roomInput ? (roomInput.value.trim() || '-') : '-';
if (previewBuilding) previewBuilding.textContent = bText;
if (previewFloor) previewFloor.textContent = (fText === '0' || fText.toUpperCase() === 'PB') ? 'Planta Baja' : fText;
if (previewRoom) previewRoom.textContent = rText;
}
if (buildingSelect) {
buildingSelect.addEventListener('change', function() {
if (this.value === '__new__') {
newBuildingContainer.style.display = 'block';
if (newBuildingNameInput) newBuildingNameInput.focus();
} else {
newBuildingContainer.style.display = 'none';
updateFloorsDatalist(this.value);
}
updatePreview();
});
// Initial call
if (buildingSelect.value === '__new__') {
newBuildingContainer.style.display = 'block';
} else if (buildingSelect.value) {
updateFloorsDatalist(buildingSelect.value);
} }
} }
// Update preview when inputs change if (newBuildingNameInput) newBuildingNameInput.addEventListener('input', updatePreview);
$('#building, #room_number, #floor').on('input', updatePreview); if (floorInput) floorInput.addEventListener('input', updatePreview);
if (roomInput) roomInput.addEventListener('input', updatePreview);
// Initialize preview
updatePreview(); updatePreview();
// Form validation // Infinity button
$('#classroom-form').on('submit', function(e) { var btnInf = document.getElementById('btnInfinityAdd');
let isValid = true; var capInput = document.getElementById('capacity');
if (btnInf && capInput) {
if (!$('#building').val().trim()) { btnInf.addEventListener('click', function() {
$('#building').addClass('is-invalid'); capInput.value = '∞';
isValid = false; capInput.focus();
} else { });
$('#building').removeClass('is-invalid'); }
}
if (!$('#room_number').val().trim()) {
$('#room_number').addClass('is-invalid');
isValid = false;
} else {
$('#room_number').removeClass('is-invalid');
}
var floorVal = $('#floor').val();
if (floorVal === '' || isNaN(floorVal)) {
$('#floor').addClass('is-invalid');
isValid = false;
} else {
$('#floor').removeClass('is-invalid');
}
var capVal = $('#capacity').val() ? $('#capacity').val().trim() : '';
if (!capVal || (capVal !== '∞' && capVal !== '0' && isNaN(capVal))) {
$('#capacity').addClass('is-invalid');
isValid = false;
} else {
$('#capacity').removeClass('is-invalid');
}
return isValid;
});
$('#btnInfinityAdd').on('click', function() {
$('#capacity').val('∞').focus();
});
}); });
</script> </script>
{% endblock %} {% endblock %}
+166 -81
View File
@@ -1,32 +1,36 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{% trans %}Edit Classroom{% endtrans %} - {{ classroom.code_display }} - Edu-Space Admin{% endblock %} {% block title %}{% trans %}Edit Classroom{% endtrans %} - {{ classroom.code_display }}{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid pt-5 mt-4"> <div class="container-fluid pt-5 mt-4">
<div class="row"> <!-- Header -->
<div class="col-12"> <div class="row mb-4 align-items-center">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="col-md-7">
<div> <nav aria-label="breadcrumb">
<h1 class="h3 mb-1"> <ol class="breadcrumb mb-1 small">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Edit Classroom{% endtrans %} <li class="breadcrumb-item"><a href="{{ url_for('classrooms.list_classrooms') }}">{% trans %}Classrooms{% endtrans %}</a></li>
</h1> <li class="breadcrumb-item"><a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}">{{ classroom.code_display }}</a></li>
<p class="text-muted small mb-0">{{ classroom.building }} — {{ classroom.code }}</p> <li class="breadcrumb-item active">{% trans %}Edit{% endtrans %}</li>
</div> </ol>
<div class="d-flex gap-2"> </nav>
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-primary"> <h1 class="h3 mb-1">
<i class="bi bi-eye me-1"></i>{% trans %}Ver Ficha{% endtrans %} <i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Edit Classroom{% endtrans %}
</a> </h1>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary"> <p class="text-muted small mb-0">{{ classroom.location_display }}</p>
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %} </div>
</a> <div class="col-md-5 text-md-end mt-3 mt-md-0">
</div> <a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-secondary me-2">
</div> <i class="bi bi-eye me-1"></i>{% trans %}Ver Detalle{% endtrans %}
</a>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver al Listado{% endtrans %}
</a>
</div> </div>
</div> </div>
<div class="row"> <div class="row justify-content-center">
<div class="col-lg-8 mx-auto"> <div class="col-lg-8">
<div class="card border-0 shadow-sm"> <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"> <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"> <h5 class="mb-0 h6 fw-bold">
@@ -38,34 +42,56 @@
<form method="POST" id="classroom-form" novalidate> <form method="POST" id="classroom-form" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="row g-3"> <div class="row g-3 mb-3">
<div class="col-md-4"> <!-- Edificio -->
{{ form.building.label(class="form-label fw-semibold") }} <div class="col-md-6">
{{ form.building(class="form-control" + (" is-invalid" if form.building.errors else ""), placeholder="Edificio Central") }} <label for="building_id" class="form-label fw-semibold">
{% if form.building.errors %} <i class="bi bi-buildings text-primary me-1"></i>{% trans %}Edificio / Sede{% endtrans %} <span class="text-danger">*</span>
<div class="invalid-feedback">{{ form.building.errors[0] }}</div> </label>
{{ form.building_id(class="form-select", id="building_id") }}
{% if form.building_id.errors %}
<div class="text-danger small mt-1">{{ form.building_id.errors[0] }}</div>
{% endif %} {% endif %}
<div id="newBuildingContainer" class="mt-2 p-2 rounded bg-body-tertiary border border-success-subtle" style="display: none;">
<label for="new_building_name" class="form-label small fw-bold text-success mb-1">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nombre del Nuevo Edificio{% endtrans %}:
</label>
{{ form.new_building_name(class="form-control form-control-sm", id="new_building_name", placeholder="ej. Pabellón III") }}
</div>
</div> </div>
<div class="col-md-4"> <!-- Piso -->
{{ form.room_number.label(class="form-label fw-semibold") }} <div class="col-md-3">
{{ form.room_number(class="form-control" + (" is-invalid" if form.room_number.errors else ""), placeholder="402") }} <label for="floor" class="form-label fw-semibold">
{% if form.room_number.errors %} <i class="bi bi-layers text-secondary me-1"></i>{% trans %}Piso / Nivel{% endtrans %} <span class="text-danger">*</span>
<div class="invalid-feedback">{{ form.room_number.errors[0] }}</div> </label>
{% endif %} {{ form.floor(class="form-control", id="floor", list="floorSuggestions", placeholder="ej. PB, 1, 2, 4°P") }}
</div> <datalist id="floorSuggestions">
<!-- sugerencias dinámicas -->
<div class="col-md-4"> </datalist>
{{ form.floor.label(class="form-label fw-semibold") }} <div class="form-text small text-muted">{% trans %}Sugerido o nuevo{% endtrans %}</div>
{{ form.floor(class="form-control" + (" is-invalid" if form.floor.errors else "")) }}
{% if form.floor.errors %} {% if form.floor.errors %}
<div class="invalid-feedback">{{ form.floor.errors[0] }}</div> <div class="text-danger small mt-1">{{ form.floor.errors[0] }}</div>
{% endif %} {% endif %}
</div> </div>
<!-- Aula -->
<div class="col-md-3">
<label for="room_number" class="form-label fw-semibold">
<i class="bi bi-door-open text-info me-1"></i>{% trans %}Aula / Sala{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.room_number(class="form-control", id="room_number", placeholder="ej. Aula 304, Lab 1") }}
{% if form.room_number.errors %}
<div class="text-danger small mt-1">{{ form.room_number.errors[0] }}</div>
{% endif %}
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
{{ form.capacity.label(class="form-label fw-semibold mb-0") }} <label for="capacityInput" class="form-label fw-semibold mb-0">{% trans %}Capacidad de Alumnos{% endtrans %} <span class="text-danger">*</span></label>
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinity" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}"> <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 %} <i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</button> </button>
@@ -75,43 +101,55 @@
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span> <span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
</div> </div>
<div class="form-text small text-muted mt-1"> <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 %} <i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o eventos sin aforo restringido, ingrese <strong>∞</strong> o <strong>0</strong>.{% endtrans %}
</div> </div>
{% if form.capacity.errors %} {% if form.capacity.errors %}
<div class="invalid-feedback d-block">{{ form.capacity.errors[0] }}</div> <div class="invalid-feedback d-block">{{ form.capacity.errors[0] }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="col-md-6 d-flex align-items-center pt-md-4"> <div class="col-md-6 d-flex align-items-center pt-md-3">
<div class="form-check form-switch pt-2"> <div class="form-check form-switch pt-2">
{{ form.is_active(class="form-check-input", role="switch") }} {{ form.is_active(class="form-check-input", id="is_active") }}
{{ form.is_active.label(class="form-check-label fw-semibold") }} <label class="form-check-label fw-semibold" for="is_active">
{% trans %}Aula Habilitada para Reservas{% endtrans %}
</label>
</div> </div>
</div> </div>
</div>
<div class="col-12"> <div class="mb-4">
{{ form.description.label(class="form-label fw-semibold") }} {{ 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...") }} {{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), rows="3", placeholder=_("Opcional: Detalles adicionales del equipamiento...")) }}
{% if form.description.errors %} {% if form.description.errors %}
<div class="invalid-feedback">{{ form.description.errors[0] }}</div> <div class="invalid-feedback">{{ form.description.errors[0] }}</div>
{% endif %} {% endif %}
</div>
<!-- Preview Card -->
<div class="card bg-body-tertiary border-0 shadow-xs mb-4">
<div class="card-body py-3 px-4">
<h6 class="card-subtitle mb-2 text-muted small fw-bold">
<i class="bi bi-eye me-1"></i>{% trans %}Vista Previa de Ubicación{% endtrans %}
</h6>
<div class="row g-2">
<div class="col-md-4">
<small class="text-muted d-block">🏢 {% trans %}Edificio{% endtrans %}</small>
<strong id="preview-building" class="text-primary">{{ classroom.building_name }}</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">🪜 {% trans %}Piso{% endtrans %}</small>
<strong id="preview-floor" class="text-secondary">{{ classroom.floor_display }}</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">🚪 {% trans %}Aula / Código{% endtrans %}</small>
<strong id="preview-room" class="text-success">{{ classroom.code }}</strong>
</div>
</div>
</div> </div>
</div> </div>
<!-- Live Preview Card --> <div class="d-flex justify-content-between">
<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"> <a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-secondary">
{% trans %}Cancelar{% endtrans %} {% trans %}Cancelar{% endtrans %}
</a> </a>
@@ -130,31 +168,78 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var buildingInput = document.getElementById('building'); var buildingFloors = {{ building_floors|tojson }};
var buildingSelect = document.getElementById('building_id');
var newBuildingContainer = document.getElementById('newBuildingContainer');
var newBuildingNameInput = document.getElementById('new_building_name');
var floorInput = document.getElementById('floor');
var floorDatalist = document.getElementById('floorSuggestions');
var roomInput = document.getElementById('room_number'); var roomInput = document.getElementById('room_number');
var previewCode = document.getElementById('preview-code');
var capacityInput = document.getElementById('capacityInput'); var previewBuilding = document.getElementById('preview-building');
var btnInfinity = document.getElementById('btnInfinity'); var previewFloor = document.getElementById('preview-floor');
var previewRoom = document.getElementById('preview-room');
function updateFloorsDatalist(bId) {
if (!floorDatalist) return;
floorDatalist.innerHTML = '';
var floors = buildingFloors[bId] || [];
floors.forEach(function(f) {
var opt = document.createElement('option');
opt.value = f;
opt.textContent = (f === '0' || f === 'PB') ? 'Planta Baja' : 'Piso ' + f;
floorDatalist.appendChild(opt);
});
}
function updatePreview() { function updatePreview() {
var b = buildingInput ? buildingInput.value.trim() : ''; var bText = '-';
var r = roomInput ? roomInput.value.trim() : ''; if (buildingSelect) {
if (b && r) { if (buildingSelect.value === '__new__') {
previewCode.textContent = b + '-' + r; bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}';
} else if (r) { } else if (buildingSelect.selectedIndex >= 0) {
previewCode.textContent = r; bText = buildingSelect.options[buildingSelect.selectedIndex].text;
} else { }
previewCode.textContent = '---'; }
var fText = floorInput ? (floorInput.value.trim() || '-') : '-';
var rText = roomInput ? (roomInput.value.trim() || '-') : '-';
if (previewBuilding) previewBuilding.textContent = bText;
if (previewFloor) previewFloor.textContent = (fText === '0' || fText.toUpperCase() === 'PB') ? 'Planta Baja' : fText;
if (previewRoom) previewRoom.textContent = rText;
}
if (buildingSelect) {
buildingSelect.addEventListener('change', function() {
if (this.value === '__new__') {
newBuildingContainer.style.display = 'block';
if (newBuildingNameInput) newBuildingNameInput.focus();
} else {
newBuildingContainer.style.display = 'none';
updateFloorsDatalist(this.value);
}
updatePreview();
});
if (buildingSelect.value === '__new__') {
newBuildingContainer.style.display = 'block';
} else if (buildingSelect.value) {
updateFloorsDatalist(buildingSelect.value);
} }
} }
if (buildingInput) buildingInput.addEventListener('input', updatePreview); if (newBuildingNameInput) newBuildingNameInput.addEventListener('input', updatePreview);
if (floorInput) floorInput.addEventListener('input', updatePreview);
if (roomInput) roomInput.addEventListener('input', updatePreview); if (roomInput) roomInput.addEventListener('input', updatePreview);
if (btnInfinity && capacityInput) { updatePreview();
btnInfinity.addEventListener('click', function() {
capacityInput.value = '∞'; var btnInf = document.getElementById('btnInfinity');
capacityInput.focus(); var capInput = document.getElementById('capacityInput');
if (btnInf && capInput) {
btnInf.addEventListener('click', function() {
capInput.value = '∞';
capInput.focus();
}); });
} }
}); });
+64 -26
View File
@@ -24,27 +24,60 @@
<div class="col-12"> <div class="col-12">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body"> <div class="card-body">
<form method="GET" class="row g-3"> <form method="GET" class="row g-2 align-items-center">
<div class="col-md-4">
<input type="text" name="search" class="form-control"
placeholder="{% trans %}Search by building, room, description...{% endtrans %}"
value="{{ search }}">
</div>
<div class="col-md-3"> <div class="col-md-3">
<div class="form-check pt-2"> <div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
<input type="text" name="search" class="form-control"
placeholder="{% trans %}Buscar aula, descripción...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<!-- Filtro por Edificio -->
<div class="col-md-3">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-buildings text-primary"></i></span>
<select name="building_id" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans %}Todos los edificios{% endtrans %}</option>
{% for b in buildings %}
<option value="{{ b.id }}" {% if building_filter == (b.id|string) %}selected{% endif %}>{{ b.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Filtro por Piso -->
<div class="col-md-2">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-layers text-secondary"></i></span>
<select name="floor" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in floors %}
<option value="{{ f }}" {% if floor_filter == (f|string) %}selected{% endif %}>
{% if f == '0' or f == 'PB' %}{% trans %}Planta Baja{% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-2">
<div class="form-check pt-1">
<input class="form-check-input" type="checkbox" name="active_only" <input class="form-check-input" type="checkbox" name="active_only"
id="activeOnly" {% if active_only %}checked{% endif %}> id="activeOnly" {% if active_only %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label" for="activeOnly"> <label class="form-check-label small" for="activeOnly">
{% trans %}Show active classrooms only{% endtrans %} {% trans %}Solo activas{% endtrans %}
</label> </label>
</div> </div>
</div> </div>
<div class="col-md-3">
<button type="submit" class="btn btn-outline-primary"> <div class="col-md-2 text-md-end">
<i class="bi bi-search"></i> {% trans %}Search{% endtrans %} <button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-filter"></i> {% trans %}Filtrar{% endtrans %}
</button> </button>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Clear{% endtrans %} <i class="bi bi-x-circle"></i> {% trans %}Limpiar{% endtrans %}
</a> </a>
</div> </div>
</form> </form>
@@ -60,23 +93,28 @@
<div class="col-lg-4 col-md-6 mb-4"> <div class="col-lg-4 col-md-6 mb-4">
<div class="card border-0 shadow-sm h-100"> <div class="card border-0 shadow-sm h-100">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3"> <div class="d-flex justify-content-between align-items-start mb-2">
<div> <div>
<h5 class="card-title mb-1"> <h5 class="card-title mb-1 fw-bold text-primary">
{{ classroom.code_display }} {{ classroom.code }}
</h5> </h5>
<p class="text-muted small mb-2"> <div class="d-flex flex-wrap gap-1 mb-2">
<i class="bi bi-geo-alt"></i> {{ classroom.get_full_location() }} <span class="badge bg-primary-subtle text-primary border">
</p> <i class="bi bi-buildings me-1"></i>{{ classroom.building_name }}
</span>
<span class="badge bg-secondary-subtle text-secondary border">
<i class="bi bi-layers me-1"></i>{{ classroom.floor_display }}
</span>
</div>
</div> </div>
<span class="badge {% if classroom.is_active %}bg-success{% else %}bg-secondary{% endif %}"> <span class="badge {% if classroom.is_active %}bg-success{% else %}bg-secondary{% endif %}">
{% if classroom.is_active %}{% trans %}Active{% endtrans %}{% else %}{% trans %}Inactive{% endtrans %}{% endif %} {% if classroom.is_active %}{% trans %}Active{% endtrans %}{% else %}{% trans %}Inactive{% endtrans %}{% endif %}
</span> </span>
</div> </div>
<div class="row small"> <div class="row small py-1 bg-body-tertiary rounded px-2 mb-2">
<div class="col-6"> <div class="col-6">
<span class="text-muted">{% trans %}Capacity{% endtrans %}:</span> <span class="text-muted">{% trans %}Capacidad{% endtrans %}:</span>
<strong> <strong>
{% if classroom.is_unlimited %} {% if classroom.is_unlimited %}
<span class="badge bg-primary-subtle text-primary border"><i class="bi bi-infinity me-1"></i>∞</span> <span class="badge bg-primary-subtle text-primary border"><i class="bi bi-infinity me-1"></i>∞</span>
@@ -85,9 +123,9 @@
{% endif %} {% endif %}
</strong> </strong>
</div> </div>
<div class="col-6"> <div class="col-6 text-end">
<span class="text-muted">{% trans %}Building{% endtrans %}:</span> <span class="text-muted">{% trans %}Ubicación{% endtrans %}:</span>
<strong>{{ classroom.building }}</strong> <strong class="text-truncate d-inline-block" style="max-width: 100px;">{{ classroom.floor_display }}</strong>
</div> </div>
</div> </div>
+357 -138
View File
@@ -7,80 +7,131 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3"> <div>
<i class="bi bi-calendar-plus text-success"></i> {% trans %}New Reservation{% endtrans %} <nav aria-label="breadcrumb">
</h1> <ol class="breadcrumb mb-1 small">
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary"> <li class="breadcrumb-item"><a href="{{ url_for('schedule.today_schedule') }}">{% trans %}Schedule{% endtrans %}</a></li>
<i class="bi bi-arrow-left"></i> {% trans %}Back to Reservations{% endtrans %} <li class="breadcrumb-item active">{% trans %}New Reservation{% endtrans %}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}New Reservation{% endtrans %}
</h1>
</div>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Reservations{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-lg-8 mx-auto"> <div class="col-lg-10 mx-auto">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3"> <div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-calendar3 text-success"></i> {% trans %}Reservation Details{% endtrans %} <i class="bi bi-calendar3 text-primary me-2"></i>{% trans %}Detalles de la Reserva y Espacio Físico{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<form method="POST" id="reservation-form" novalidate> <form method="POST" id="reservation-form" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="row"> <!-- 1. SELECTORES EN CASCADA: Edificio -> Piso -> Aula -->
<div class="col-md-6 mb-3"> <div class="mb-4">
{{ form.classroom_id.label(class="form-label") }} <div class="p-3 rounded bg-body-tertiary border">
{{ form.classroom_id(class="form-select", id="classroom-select") }} <label class="form-label fw-bold text-primary mb-2">
{% if form.classroom_id.errors %} <i class="bi bi-geo-alt-fill me-1"></i>{% trans %}Selección de Espacio Físico / Modalidad{% endtrans %} <span class="text-danger">*</span>
<div class="text-danger small mt-1"> </label>
{{ form.classroom_id.errors[0] }} <div class="row g-3">
<!-- Edificio / Sede -->
<div class="col-md-4">
<label for="cascade-building" class="form-label small fw-semibold text-muted mb-1">
<i class="bi bi-buildings text-primary me-1"></i>1. {% trans %}Edificio / Sede{% endtrans %}
</label>
<select id="cascade-building" class="form-select form-select-sm">
<option value="">{% trans %}-- Selecciona Edificio --{% endtrans %}</option>
{% for b in buildings %}
<option value="{{ b.id }}" data-virtual="{{ 'true' if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) else 'false' }}">
{% if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) %}🌐{% else %}🏢{% endif %} {{ b.name }}
</option>
{% endfor %}
</select>
<div class="form-text small text-muted">{% trans %}Sede física o Campus Virtual{% endtrans %}</div>
</div> </div>
{% endif %}
<small class="text-muted">{% trans %}Select a classroom for your reservation{% endtrans %}</small>
</div>
<div class="col-md-6 mb-3"> <!-- Piso / Nivel -->
{{ form.commission_id.label(class="form-label") }} <div class="col-md-4">
<label for="cascade-floor" class="form-label small fw-semibold text-muted mb-1">
<i class="bi bi-layers text-secondary me-1"></i>2. {% trans %}Piso / Nivel{% endtrans %}
</label>
<select id="cascade-floor" class="form-select form-select-sm" disabled>
<option value="">{% trans %}-- Selecciona Piso --{% endtrans %}</option>
</select>
<div class="form-text small text-muted" id="floorHelpText">{% trans %}Filtra por planta o nivel{% endtrans %}</div>
</div>
<!-- Aula / Espacio (Campo del Formulario) -->
<div class="col-md-4">
<label for="classroom-select" class="form-label small fw-semibold text-muted mb-1">
<i class="bi bi-door-open text-success me-1"></i>3. {% trans %}Aula / Espacio{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.classroom_id(class="form-select form-select-sm", id="classroom-select") }}
{% if form.classroom_id.errors %}
<div class="text-danger small mt-1">
{{ form.classroom_id.errors[0] }}
</div>
{% endif %}
<div class="form-text small text-muted">{% trans %}Capacidad y disponibilidad{% endtrans %}</div>
</div>
</div>
</div>
</div>
<!-- 2. Comisión y Materia -->
<div class="row g-3 mb-3">
<div class="col-12">
{{ form.commission_id.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
{{ form.commission_id(class="form-select", id="commission-select") }} {{ form.commission_id(class="form-select", id="commission-select") }}
{% if form.commission_id.errors %} {% if form.commission_id.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.commission_id.errors[0] }} {{ form.commission_id.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">{% trans %}Select the subject/commission{% endtrans %}</small> <div class="form-text small text-muted">{% trans %}Materia, código de comisión y cantidad de alumnos inscriptos{% endtrans %}</div>
</div> </div>
</div> </div>
<div class="row"> <!-- 3. Horario de Inicio y Fin -->
<div class="col-md-6 mb-3"> <div class="row g-3 mb-3">
{{ form.start_time.label(class="form-label") }} <div class="col-md-6">
{{ form.start_time.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
{{ form.start_time(class="form-control", id="start-time") }} {{ form.start_time(class="form-control", id="start-time") }}
{% if form.start_time.errors %} {% if form.start_time.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.start_time.errors[0] }} {{ form.start_time.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">{% trans %}When will the class start?{% endtrans %}</small> <small class="text-muted">{% trans %}¿Cuándo comenzará la clase?{% endtrans %}</small>
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6">
{{ form.end_time.label(class="form-label") }} {{ form.end_time.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
{{ form.end_time(class="form-control", id="end-time") }} {{ form.end_time(class="form-control", id="end-time") }}
{% if form.end_time.errors %} {% if form.end_time.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.end_time.errors[0] }} {{ form.end_time.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">{% trans %}When will the class end?{% endtrans %}</small> <small class="text-muted">{% trans %}¿Cuándo finalizará la clase?{% endtrans %}</small>
</div> </div>
</div> </div>
<div class="row"> <!-- 4. Propósito y Asistentes -->
<div class="col-md-8 mb-3"> <div class="row g-3 mb-3">
{{ form.purpose.label(class="form-label") }} <div class="col-md-8">
{{ form.purpose(class="form-control", placeholder=_("Regular class, exam, study session, etc.")) }} {{ form.purpose.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
{{ form.purpose(class="form-control", placeholder=_("Clase regular, examen, sesión de estudio, etc.")) }}
{% if form.purpose.errors %} {% if form.purpose.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.purpose.errors[0] }} {{ form.purpose.errors[0] }}
@@ -88,21 +139,22 @@
{% endif %} {% endif %}
</div> </div>
<div class="col-md-4 mb-3"> <div class="col-md-4">
{{ form.expected_attendees.label(class="form-label") }} {{ form.expected_attendees.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
{{ form.expected_attendees(class="form-control", placeholder="30") }} {{ form.expected_attendees(class="form-control", id="expected_attendees", placeholder="30") }}
{% if form.expected_attendees.errors %} {% if form.expected_attendees.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.expected_attendees.errors[0] }} {{ form.expected_attendees.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">{% trans %}Number of expected attendees{% endtrans %}</small> <small class="text-muted">{% trans %}Cantidad de asistentes esperados{% endtrans %}</small>
</div> </div>
</div> </div>
<!-- 5. Notas -->
<div class="mb-4"> <div class="mb-4">
{{ form.notes.label(class="form-label") }} {{ form.notes.label(class="form-label fw-semibold") }}
{{ form.notes(class="form-control", rows="3", placeholder=_("Any additional notes or special requirements...")) }} {{ form.notes(class="form-control", rows="2", placeholder=_("Cualquier nota adicional o requerimientos especiales...")) }}
{% if form.notes.errors %} {% if form.notes.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.notes.errors[0] }} {{ form.notes.errors[0] }}
@@ -110,83 +162,82 @@
{% endif %} {% endif %}
</div> </div>
<!-- Summary Section --> <!-- 6. TARJETA DE RESUMEN DESGLOSADA: Edificio, Piso, Aula -->
<div class="alert alert-info" id="reservation-summary"> <div class="card bg-body-tertiary border-0 shadow-xs mb-4" id="reservation-summary">
<h6 class="alert-heading"> <div class="card-body p-3">
<i class="bi bi-info-circle"></i> {% trans %}Reservation Summary{% endtrans %} <h6 class="card-subtitle mb-2 text-muted small fw-bold">
</h6> <i class="bi bi-info-circle text-info me-1"></i>{% trans %}Resumen de la Reserva{% endtrans %}
<div id="summary-content"> </h6>
<p class="mb-2">
<strong>{% trans %}Selected Classroom:{% endtrans %}</strong> <!-- Badges desglosados de Ubicación -->
<span id="summary-classroom">{% trans %}Not selected{% endtrans %}</span> <div class="row g-2 mb-3">
</p> <div class="col-md-4">
<p class="mb-2"> <div class="p-2 rounded bg-body border">
<strong>{% trans %}Duration:{% endtrans %}</strong> <small class="text-muted d-block">🏢 {% trans %}Edificio / Sede{% endtrans %}</small>
<span id="summary-duration">{% trans %}Not calculated{% endtrans %}</span> <strong id="summary-building" class="text-primary">-</strong>
</p> </div>
<p class="mb-0"> </div>
<strong>{% trans %}Commission:{% endtrans %}</strong> <div class="col-md-4">
<span id="summary-commission">{% trans %}Not selected{% endtrans %}</span> <div class="p-2 rounded bg-body border">
</p> <small class="text-muted d-block">🪜 {% trans %}Piso / Nivel{% endtrans %}</small>
<strong id="summary-floor" class="text-secondary">-</strong>
</div>
</div>
<div class="col-md-4">
<div class="p-2 rounded bg-body border">
<small class="text-muted d-block">🚪 {% trans %}Aula Seleccionada{% endtrans %}</small>
<strong id="summary-classroom" class="text-success">{% trans %}No seleccionada{% endtrans %}</strong>
</div>
</div>
</div>
<!-- Duración y Comisión -->
<div class="row g-2 small">
<div class="col-md-4">
<span class="text-muted">⏱️ {% trans %}Duración:{% endtrans %}</span>
<strong id="summary-duration" class="text-body ms-1">{% trans %}No calculada{% endtrans %}</strong>
</div>
<div class="col-md-8">
<span class="text-muted">📚 {% trans %}Comisión:{% endtrans %}</span>
<strong id="summary-commission" class="text-body ms-1">{% trans %}No seleccionada{% endtrans %}</strong>
</div>
</div>
</div> </div>
</div> </div>
<!-- Botones -->
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %} <i class="bi bi-x-circle me-1"></i>{% trans %}Cancelar{% endtrans %}
</a> </a>
{{ form.submit(class="btn btn-success") }} <button type="submit" class="btn btn-success px-4">
<i class="bi bi-calendar-check me-1"></i>{% trans %}Realizar Reserva{% endtrans %}
</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Instructions Card -->
<div class="row mt-4">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3">
<h5 class="mb-0">
<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">📅 {% trans %}Booking Rules{% endtrans %}</h6>
<ul class="small">
<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">🚨 {% trans %}Important Notes{% endtrans %}</h6>
<ul class="small">
<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>
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script type="application/json" id="classroom-data"> <script type="application/json" id="classroom-data">
{ {
{% for classroom in classroom_list %} {% for c in classroom_list %}
"{{ classroom.id }}": { "{{ c.id }}": {
"capacity": {{ classroom.capacity_display|tojson }}, "id": {{ c.id }},
"code_display": {{ (classroom.building ~ ' - ' ~ classroom.code)|tojson }} "building_id": {{ (c.building_id or 0)|tojson }},
"building_name": {{ c.building_name|tojson }},
"floor": {{ c.floor|tojson }},
"floor_display": {{ c.floor_display|tojson }},
"code": {{ c.code|tojson }},
"code_display": {{ c.code_display|tojson }},
"location_display": {{ c.location_display|tojson }},
"capacity": {{ c.capacity_display|tojson }},
"capacity_num": {{ c.capacity }},
"is_virtual": {{ c.is_virtual|tojson }}
}{% if not loop.last %},{% endif %} }{% if not loop.last %},{% endif %}
{% endfor %} {% endfor %}
} }
@@ -194,53 +245,215 @@
<script type="application/json" id="i18n-strings"> <script type="application/json" id="i18n-strings">
{ {
"not_selected": "{% trans %}Not selected{% endtrans %}", "not_selected": "{% trans %}No seleccionada{% endtrans %}",
"not_calculated": "{% trans %}Not calculated{% endtrans %}", "not_calculated": "{% trans %}No calculada{% endtrans %}",
"invalid_time": "{% trans %}Invalid time range{% endtrans %}", "invalid_time": "{% trans %}Rango horario inválido{% endtrans %}",
"hour": "{% trans %}hour{% endtrans %}", "hour": "{% trans %}hora{% endtrans %}",
"hours": "{% trans %}hours{% endtrans %}", "hours": "{% trans %}horas{% endtrans %}",
"minute": "{% trans %}minute{% endtrans %}", "minute": "{% trans %}minuto{% endtrans %}",
"minutes": "{% trans %}minutes{% endtrans %}", "minutes": "{% trans %}minutos{% endtrans %}",
"capacity": "{% trans %}Capacity{% endtrans %}", "capacity": "{% trans %}Capacidad{% endtrans %}",
"capacity_exceeded": "{% trans %}Expected attendees exceeds classroom capacity{% endtrans %}" "capacity_exceeded": "{% trans %}Los asistentes esperados superan la capacidad del aula{% endtrans %}"
} }
</script> </script>
<script> <script>
$(document).ready(function() { $(document).ready(function() {
// Classroom data for capacity checking
let classroomData = {}; let classroomData = {};
try { try {
const rawData = document.getElementById('classroom-data')?.textContent; const rawData = document.getElementById('classroom-data')?.textContent;
if (rawData) { if (rawData) classroomData = JSON.parse(rawData);
classroomData = JSON.parse(rawData);
}
} catch (err) { } catch (err) {
console.error('Error parsing classroomData:', err); console.error('Error parsing classroomData:', err);
} }
// I18n strings
let i18n = {}; let i18n = {};
try { try {
const rawI18n = document.getElementById('i18n-strings')?.textContent; const rawI18n = document.getElementById('i18n-strings')?.textContent;
if (rawI18n) { if (rawI18n) i18n = JSON.parse(rawI18n);
i18n = JSON.parse(rawI18n);
}
} catch (err) { } catch (err) {
console.error('Error parsing i18n:', err); console.error('Error parsing i18n:', err);
} }
const $building = $('#cascade-building');
const $floor = $('#cascade-floor');
const $classroom = $('#classroom-select');
const $floorHelp = $('#floorHelpText');
// Guardar todas las opciones originales de aulas
const originalClassroomOptions = [];
$classroom.find('option').each(function() {
originalClassroomOptions.push({
value: $(this).val(),
text: $(this).text()
});
});
// 1. Manejo del cambio de Edificio
$building.on('change', function() {
const selectedBuildingId = $(this).val();
const isVirtual = $(this).find(':selected').data('virtual') === true || $(this).find(':selected').data('virtual') === 'true';
$floor.empty().append('<option value="">-- ' + (isVirtual ? 'No aplica (Campus Virtual)' : 'Selecciona Piso') + ' --</option>');
$classroom.empty().append('<option value="">-- Selecciona Aula --</option>');
if (!selectedBuildingId) {
$floor.prop('disabled', true);
$classroom.prop('disabled', false);
// Restaurar todas
originalClassroomOptions.forEach(opt => {
if (opt.value) $classroom.append($('<option>', { value: opt.value, text: opt.text }));
});
updateSummary();
return;
}
if (isVirtual) {
$floor.prop('disabled', true);
$floorHelp.text('Modalidad 100% remota sin piso asignado');
// Filtrar solo aulas virtuales
let virtualCount = 0;
let lastVirtualId = null;
Object.values(classroomData).forEach(c => {
if (c.is_virtual || c.building_id == selectedBuildingId) {
$classroom.append($('<option>', {
value: c.id,
text: `${c.code_display} (Cap: ${c.capacity})`
}));
lastVirtualId = c.id;
virtualCount++;
}
});
if (virtualCount === 1 && lastVirtualId) {
$classroom.val(lastVirtualId);
}
updateSummary();
return;
}
// Edificio Físico
$floor.prop('disabled', false);
$floorHelp.text('Filtra por planta o nivel');
// Extraer pisos únicos de este edificio
const buildingFloors = [];
Object.values(classroomData).forEach(c => {
if (c.building_id == selectedBuildingId && c.floor) {
if (!buildingFloors.some(f => f.floor === c.floor)) {
buildingFloors.push({
floor: c.floor,
floor_display: c.floor_display
});
}
}
});
// Ordenar pisos: PB primero, luego numérico
buildingFloors.sort((a, b) => {
if (a.floor === '0' || a.floor.toUpperCase() === 'PB') return -1;
if (b.floor === '0' || b.floor.toUpperCase() === 'PB') return 1;
return a.floor.localeCompare(b.floor, undefined, { numeric: true });
});
buildingFloors.forEach(f => {
$floor.append($('<option>', {
value: f.floor,
text: f.floor_display
}));
});
// Poblar aulas de este edificio inicialmente
Object.values(classroomData).forEach(c => {
if (c.building_id == selectedBuildingId) {
$classroom.append($('<option>', {
value: c.id,
text: `${c.code} · ${c.floor_display} (Cap: ${c.capacity})`
}));
}
});
updateSummary();
});
// 2. Manejo del cambio de Piso
$floor.on('change', function() {
const selectedBuildingId = $building.val();
const selectedFloor = $(this).val();
$classroom.empty().append('<option value="">-- Selecciona Aula --</option>');
let count = 0;
let lastId = null;
Object.values(classroomData).forEach(c => {
const matchesBuilding = !selectedBuildingId || c.building_id == selectedBuildingId;
const matchesFloor = !selectedFloor || c.floor == selectedFloor;
if (matchesBuilding && matchesFloor) {
$classroom.append($('<option>', {
value: c.id,
text: `${c.code} (Cap: ${c.capacity})`
}));
lastId = c.id;
count++;
}
});
if (count === 1 && lastId) {
$classroom.val(lastId);
}
updateSummary();
});
// 3. Manejo del cambio de Aula directo
$classroom.on('change', function() {
const cId = $(this).val();
if (cId && classroomData[cId]) {
const c = classroomData[cId];
// Sincronizar selectores en cascada si no coinciden
if ($building.val() != c.building_id) {
$building.val(c.building_id);
// Si no es virtual, sincronizar piso
if (!c.is_virtual) {
$floor.prop('disabled', false);
$floor.val(c.floor);
}
} else if (!c.is_virtual && $floor.val() != c.floor) {
$floor.val(c.floor);
}
}
updateSummary();
});
// Inicializar cascada si ya hay un aula seleccionada (ej. post-error o query string)
const initialClassroomId = $classroom.val();
if (initialClassroomId && classroomData[initialClassroomId]) {
const c = classroomData[initialClassroomId];
$building.val(c.building_id).trigger('change');
if (!c.is_virtual) {
$floor.val(c.floor).trigger('change');
}
$classroom.val(initialClassroomId);
}
function updateSummary() { function updateSummary() {
const classroomId = $('#classroom-select').val(); const classroomId = $classroom.val();
const commissionId = $('#commission-select').val(); const commissionId = $('#commission-select').val();
const startTime = $('#start-time').val(); const startTime = $('#start-time').val();
const endTime = $('#end-time').val(); const endTime = $('#end-time').val();
// Update classroom summary // Update classroom badges
if (classroomId && classroomData[classroomId]) { if (classroomId && classroomData[classroomId]) {
const capLabel = i18n.capacity || 'Capacidad'; const c = classroomData[classroomId];
$('#summary-classroom').text(`${classroomData[classroomId].code_display} (${capLabel}: ${classroomData[classroomId].capacity})`); $('#summary-building').text(c.building_name);
$('#summary-floor').text(c.is_virtual ? 'Remoto (Virtual)' : c.floor_display);
$('#summary-classroom').text(`${c.code} (Cap: ${c.capacity})`);
} else { } else {
$('#summary-building').text('-');
$('#summary-floor').text('-');
$('#summary-classroom').text(i18n.not_selected || 'No seleccionada'); $('#summary-classroom').text(i18n.not_selected || 'No seleccionada');
} }
@@ -284,20 +497,28 @@ $(document).ready(function() {
// Check capacity vs expected attendees // Check capacity vs expected attendees
function checkCapacity() { function checkCapacity() {
const classroomId = $('#classroom-select').val(); const classroomId = $classroom.val();
const attendees = parseInt($('#expected_attendees').val()) || 0; const attendees = parseInt($('#expected_attendees').val()) || 0;
if (classroomId && classroomData[classroomId] && attendees > 0) { if (classroomId && classroomData[classroomId] && attendees > 0) {
const capacity = classroomData[classroomId].capacity; const c = classroomData[classroomId];
// No validar si es virtual o capacidad ilimitada
if (c.is_virtual || c.capacity === '∞' || c.capacity_num === 0) {
$('#expected_attendees').removeClass('is-invalid');
$('#expected_attendees').siblings('.invalid-feedback').remove();
return;
}
if (attendees > capacity) { if (attendees > c.capacity_num) {
$('#expected_attendees').addClass('is-invalid'); $('#expected_attendees').addClass('is-invalid');
const msg = i18n.capacity_exceeded || 'Los asistentes esperados superan la capacidad del aula'; const msg = i18n.capacity_exceeded || 'Los asistentes esperados superan la capacidad del aula';
$('#expected_attendees').after(` if ($('#expected_attendees').siblings('.invalid-feedback').length === 0) {
<div class="invalid-feedback"> $('#expected_attendees').after(`
${msg} (${attendees} > ${capacity}) <div class="invalid-feedback">
</div> ${msg} (${attendees} > ${c.capacity_num})
`); </div>
`);
}
} else { } else {
$('#expected_attendees').removeClass('is-invalid'); $('#expected_attendees').removeClass('is-invalid');
$('#expected_attendees').siblings('.invalid-feedback').remove(); $('#expected_attendees').siblings('.invalid-feedback').remove();
@@ -305,23 +526,23 @@ $(document).ready(function() {
} }
} }
// Update on change $('#commission-select, #start-time, #end-time').on('change', updateSummary);
$('#classroom-select, #commission-select, #start-time, #end-time, #expected_attendees').on('change', updateSummary); $('#expected_attendees').on('input', function() {
$('#expected_attendees').on('input', checkCapacity); checkCapacity();
updateSummary();
});
// Initialize summary
updateSummary(); updateSummary();
// Form validation // Form validation
$('#reservation-form').on('submit', function(e) { $('#reservation-form').on('submit', function(e) {
let isValid = true; let isValid = true;
// Check required fields if (!$classroom.val()) {
if (!$('#classroom-select').val()) { $classroom.addClass('is-invalid');
$('#classroom-select').addClass('is-invalid');
isValid = false; isValid = false;
} else { } else {
$('#classroom-select').removeClass('is-invalid'); $classroom.removeClass('is-invalid');
} }
if (!$('#commission-select').val()) { if (!$('#commission-select').val()) {
@@ -345,7 +566,6 @@ $(document).ready(function() {
$('#end-time').removeClass('is-invalid'); $('#end-time').removeClass('is-invalid');
} }
// Validate time range
const startTime = new Date($('#start-time').val()); const startTime = new Date($('#start-time').val());
const endTime = new Date($('#end-time').val()); const endTime = new Date($('#end-time').val());
@@ -354,7 +574,6 @@ $(document).ready(function() {
isValid = false; isValid = false;
} }
// Check capacity
checkCapacity(); checkCapacity();
if ($('#expected_attendees').hasClass('is-invalid')) { if ($('#expected_attendees').hasClass('is-invalid')) {
isValid = false; isValid = false;
+52 -19
View File
@@ -80,8 +80,21 @@
</div> </div>
</div> </div>
<!-- Second row of filters: Floor & Hide Virtual --> <!-- Second row of filters: Building, Floor & Hide Virtual -->
<div class="row g-2 align-items-center mt-2 pt-2 border-top"> <div class="row g-2 align-items-center mt-2 pt-2 border-top">
<!-- Filter by Building -->
<div class="col-lg-3 col-md-4">
<label for="filterBuilding" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-buildings text-primary me-1"></i>{% trans %}Filtrar por Edificio{% endtrans %}
</label>
<select id="filterBuilding" class="form-select form-select-sm">
<option value="">{% trans %}Todos los edificios{% endtrans %}</option>
{% for b in buildings %}
<option value="{{ b.name }}">{{ b.name }}</option>
{% endfor %}
</select>
</div>
<!-- Filter by Floor --> <!-- Filter by Floor -->
<div class="col-lg-3 col-md-4"> <div class="col-lg-3 col-md-4">
<label for="filterFloor" class="form-label small fw-bold text-muted mb-1"> <label for="filterFloor" class="form-label small fw-bold text-muted mb-1">
@@ -90,26 +103,25 @@
<select id="filterFloor" class="form-select form-select-sm"> <select id="filterFloor" class="form-select form-select-sm">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option> <option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in available_floors %} {% for f in available_floors %}
<option value="{{ f }}">{% if f == 0 %}{% trans %}Planta Baja (Piso 0){% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}</option> <option value="{{ f }}">{% if f == '0' or f == 0 or f == 'PB' %}{% trans %}Planta Baja{% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<!-- Hide Virtual Classrooms Toggle --> <!-- Hide Virtual Classrooms Toggle -->
<div class="col-lg-6 col-md-5 pt-md-3"> <div class="col-lg-4 col-md-4 pt-md-3">
<div class="form-check form-switch pt-1"> <div class="form-check form-switch pt-1">
<input class="form-check-input" type="checkbox" role="switch" id="filterHideVirtual"> <input class="form-check-input" type="checkbox" role="switch" id="filterHideVirtual">
<label class="form-check-label fw-semibold small text-dark" for="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 %} <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> </label>
</div> </div>
</div> </div>
<!-- Reset Filters Button --> <!-- Reset Filters Button -->
<div class="col-lg-3 col-md-3 text-md-end pt-md-3"> <div class="col-lg-2 col-md-12 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 %}"> <button type="button" class="btn btn-outline-secondary btn-sm w-100 w-md-auto" id="btnResetFilters">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar Filtros{% endtrans %} <i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %}
</button> </button>
</div> </div>
</div> </div>
@@ -209,16 +221,19 @@
</div> </div>
</div> </div>
<!-- 📍 Espacio / Aula / Virtual --> <!-- 📍 Espacio / Aula / Virtual Desglosado -->
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-start gap-3">
<div class="gc-icon-circle text-danger" id="previewLocationIcon"> <div class="gc-icon-circle text-danger mt-1" id="previewLocationIcon">
<i class="bi bi-geo-alt fs-5"></i> <i class="bi bi-geo-alt fs-5"></i>
</div> </div>
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="fw-semibold text-body" id="previewClassroom">Aula 304</div> <div class="d-flex flex-wrap gap-1 mb-1" id="previewLocationBadges">
<small class="text-muted" id="previewBuilding">Piso 3 · Edificio Central (Perón 802)</small> <span class="badge bg-primary-subtle text-primary border" id="previewBadgeBuilding">🏢 Edificio Central</span>
<div id="previewVirtualWrapper" class="d-none mt-1"> <span class="badge bg-secondary-subtle text-secondary border" id="previewBadgeFloor">🪜 Piso 3</span>
<span class="badge bg-primary-subtle text-primary border"> <span class="badge bg-success-subtle text-success border" id="previewBadgeRoom">🚪 Aula 304</span>
</div>
<div id="previewVirtualWrapper" class="d-none">
<span class="badge bg-info-subtle text-info border">
<i class="bi bi-camera-video me-1"></i>{% trans %}Campus Virtual UniCABA (Teams / Moodle){% endtrans %} <i class="bi bi-camera-video me-1"></i>{% trans %}Campus Virtual UniCABA (Teams / Moodle){% endtrans %}
</span> </span>
</div> </div>
@@ -685,6 +700,7 @@ document.addEventListener('DOMContentLoaded', function() {
var filterSubj = document.getElementById('filterSubject'); var filterSubj = document.getElementById('filterSubject');
var filterCar = document.getElementById('filterCareer'); var filterCar = document.getElementById('filterCareer');
var filterShift = document.getElementById('filterShift'); var filterShift = document.getElementById('filterShift');
var filterBuilding = document.getElementById('filterBuilding');
var filterFloor = document.getElementById('filterFloor'); var filterFloor = document.getElementById('filterFloor');
var filterHideVirtual = document.getElementById('filterHideVirtual'); var filterHideVirtual = document.getElementById('filterHideVirtual');
var activeBadge = document.getElementById('activeFilterBadge'); var activeBadge = document.getElementById('activeFilterBadge');
@@ -704,6 +720,9 @@ document.addEventListener('DOMContentLoaded', function() {
if (filterCar.value && filterCar.selectedOptions[0]) { if (filterCar.value && filterCar.selectedOptions[0]) {
parts.push('Carrera: ' + filterCar.selectedOptions[0].text.trim()); parts.push('Carrera: ' + filterCar.selectedOptions[0].text.trim());
} }
if (filterBuilding && filterBuilding.value) {
parts.push('Edificio: ' + filterBuilding.value);
}
if (filterShift && filterShift.value) { if (filterShift && filterShift.value) {
parts.push('Turno ' + filterShift.value); parts.push('Turno ' + filterShift.value);
} }
@@ -763,6 +782,7 @@ document.addEventListener('DOMContentLoaded', function() {
subject_id: filterSubj.value || '', subject_id: filterSubj.value || '',
career_id: filterCar.value || '', career_id: filterCar.value || '',
shift: (filterShift ? filterShift.value : '') || '', shift: (filterShift ? filterShift.value : '') || '',
building: (filterBuilding ? filterBuilding.value : '') || '',
floor: (filterFloor ? filterFloor.value : '') || '', floor: (filterFloor ? filterFloor.value : '') || '',
hide_virtual: (filterHideVirtual && filterHideVirtual.checked) ? '1' : '' hide_virtual: (filterHideVirtual && filterHideVirtual.checked) ? '1' : ''
}); });
@@ -922,18 +942,24 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('previewDateFull').textContent = `Horario académico registrado`; document.getElementById('previewDateFull').textContent = `Horario académico registrado`;
} }
// Espacio Asignado // Espacio Asignado Desglosado con Badges
var locIcon = document.getElementById('previewLocationIcon'); var locIcon = document.getElementById('previewLocationIcon');
var virtualWrap = document.getElementById('previewVirtualWrapper'); var virtualWrap = document.getElementById('previewVirtualWrapper');
var badgeB = document.getElementById('previewBadgeBuilding');
var badgeF = document.getElementById('previewBadgeFloor');
var badgeR = document.getElementById('previewBadgeRoom');
if (isVirtual) { if (isVirtual) {
if (locIcon) locIcon.innerHTML = '<i class="bi bi-camera-video fs-5 text-primary"></i>'; if (locIcon) locIcon.innerHTML = '<i class="bi bi-camera-video fs-5 text-primary"></i>';
document.getElementById('previewClassroom').textContent = 'Campus Virtual'; if (badgeB) badgeB.textContent = '🏢 Campus Virtual';
document.getElementById('previewBuilding').textContent = 'Entorno Digital UniCABA'; if (badgeF) badgeF.textContent = '🪜 Remoto';
if (badgeR) badgeR.textContent = '🚪 ' + classroom;
if (virtualWrap) virtualWrap.classList.remove('d-none'); if (virtualWrap) virtualWrap.classList.remove('d-none');
} else { } else {
if (locIcon) locIcon.innerHTML = '<i class="bi bi-geo-alt fs-5 text-danger"></i>'; if (locIcon) locIcon.innerHTML = '<i class="bi bi-geo-alt fs-5 text-danger"></i>';
document.getElementById('previewClassroom').textContent = classroom; if (badgeB) badgeB.textContent = '🏢 ' + (building || 'Edificio Central');
document.getElementById('previewBuilding').textContent = `${ext.floor !== null && ext.floor !== undefined ? (ext.floor === 0 ? 'Planta Baja' : 'Piso ' + ext.floor) + ' · ' : ''}${building}`; if (badgeF) badgeF.textContent = '🪜 ' + (ext.floor || 'Planta Baja');
if (badgeR) badgeR.textContent = '🚪 ' + classroom;
if (virtualWrap) virtualWrap.classList.add('d-none'); if (virtualWrap) virtualWrap.classList.add('d-none');
} }
@@ -987,6 +1013,12 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
if (filterBuilding) {
filterBuilding.addEventListener('change', function() {
calendar.refetchEvents();
});
}
if (filterFloor) { if (filterFloor) {
filterFloor.addEventListener('change', function() { filterFloor.addEventListener('change', function() {
calendar.refetchEvents(); calendar.refetchEvents();
@@ -1004,6 +1036,7 @@ document.addEventListener('DOMContentLoaded', function() {
filterSubj.value = ''; filterSubj.value = '';
filterCar.value = ''; filterCar.value = '';
if (filterShift) filterShift.value = ''; if (filterShift) filterShift.value = '';
if (filterBuilding) filterBuilding.value = '';
if (filterFloor) filterFloor.value = ''; if (filterFloor) filterFloor.value = '';
if (filterHideVirtual) filterHideVirtual.checked = false; if (filterHideVirtual) filterHideVirtual.checked = false;
calendar.refetchEvents(); calendar.refetchEvents();
+35 -21
View File
@@ -86,35 +86,49 @@
<!-- Classroom Details --> <!-- Classroom Details -->
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3"> <div class="card-header border-0 py-3 bg-body-tertiary">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-building text-info"></i> Classroom Information <i class="bi bi-geo-alt text-primary me-2"></i>{% trans %}Ubicación y Espacio Asignado{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
{% if reservation.classroom %} {% if reservation.classroom %}
<div class="row"> <div class="row g-3 mb-3">
<div class="col-md-4 mb-3"> <div class="col-md-4">
<label class="text-muted small">Classroom</label> <div class="p-3 rounded bg-body-tertiary border">
<p class="mb-0"><strong>{{ reservation.classroom.code_display }}</strong></p> <small class="text-muted d-block mb-1">🏢 {% trans %}Edificio / Sede{% endtrans %}</small>
<h6 class="mb-0 fw-bold text-primary">{{ reservation.classroom.building_name }}</h6>
</div>
</div> </div>
<div class="col-md-4 mb-3"> <div class="col-md-4">
<label class="text-muted small">Building</label> <div class="p-3 rounded bg-body-tertiary border">
<p class="mb-0">{{ reservation.classroom.building }}</p> <small class="text-muted d-block mb-1">🪜 {% trans %}Piso / Nivel{% endtrans %}</small>
<h6 class="mb-0 fw-bold text-secondary">
{% if reservation.classroom.is_virtual %}
<span class="badge bg-primary-subtle text-primary border">Campus Virtual</span>
{% else %}
{{ reservation.classroom.floor_display }}
{% endif %}
</h6>
</div>
</div> </div>
<div class="col-md-4 mb-3"> <div class="col-md-4">
<label class="text-muted small">Capacity</label> <div class="p-3 rounded bg-body-tertiary border">
<p class="mb-0">{{ reservation.classroom.capacity }} people</p> <small class="text-muted d-block mb-1">🚪 {% trans %}Aula{% endtrans %}</small>
<h6 class="mb-0 fw-bold text-success">{{ reservation.classroom.code }}</h6>
<small class="text-muted">{% trans %}Capacidad:{% endtrans %} <strong>{{ reservation.classroom.capacity_display }}</strong></small>
</div>
</div> </div>
{% if reservation.classroom.description %}
<div class="col-12">
<label class="text-muted small">Description</label>
<p class="mb-0">{{ reservation.classroom.description }}</p>
</div>
{% endif %}
</div> </div>
{% if reservation.classroom.description %}
<div class="mt-2">
<label class="text-muted small">{% trans %}Descripción del Aula{% endtrans %}</label>
<p class="mb-0 small">{{ reservation.classroom.description }}</p>
</div>
{% endif %}
{% else %} {% else %}
<p class="text-muted mb-0">Classroom information not available</p> <p class="text-muted mb-0">{% trans %}Classroom information not available{% endtrans %}</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>