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