feat: optimizacion zocalos aulas, metricas ocupacion dia/semana/mes, modificacion de reservas y permisos RBAC en gestion

This commit is contained in:
Carlos Tello
2026-09-03 11:16:07 -03:00
parent cde62c9b0a
commit c9dc3d9208
13 changed files with 957 additions and 87 deletions
+6 -2
View File
@@ -27,6 +27,10 @@ class ReservationForm(FlaskForm):
]) ])
submit = SubmitField(_l('Make Reservation')) submit = SubmitField(_l('Make Reservation'))
def __init__(self, *args, is_edit=False, **kwargs):
super().__init__(*args, **kwargs)
self.is_edit = is_edit
def validate_end_time(self, field): def validate_end_time(self, field):
if field.data <= self.start_time.data: if field.data <= self.start_time.data:
raise ValidationError('End time must be after start time.') raise ValidationError('End time must be after start time.')
@@ -36,8 +40,8 @@ class ReservationForm(FlaskForm):
raise ValidationError('Reservation cannot exceed 8 hours.') raise ValidationError('Reservation cannot exceed 8 hours.')
def validate_start_time(self, field): def validate_start_time(self, field):
# Don't allow reservations in the past # Don't allow new reservations in the past
if field.data < datetime.now(): if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10):
raise ValidationError('Start time cannot be in the past.') raise ValidationError('Start time cannot be in the past.')
# Don't allow reservations too far in the future (max 3 months) # Don't allow reservations too far in the future (max 3 months)
+8 -1
View File
@@ -22,7 +22,7 @@ SYSTEM_MODULES = [
'name': 'Reservas de Aulas', 'name': 'Reservas de Aulas',
'name_en': 'Classroom Reservations', 'name_en': 'Classroom Reservations',
'icon': 'bi-calendar-check', 'icon': 'bi-calendar-check',
'description': 'Creación, aprobación, confirmación y cancelación de reservas.' 'description': 'Creación, modificación, aprobación, confirmación y cancelación de reservas.'
}, },
{ {
'id': 'schedule', 'id': 'schedule',
@@ -65,6 +65,13 @@ SYSTEM_MODULES = [
'name_en': 'Milestone Types', 'name_en': 'Milestone Types',
'icon': 'bi-tags', 'icon': 'bi-tags',
'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.' 'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.'
},
{
'id': 'metrics',
'name': 'Métricas de Ocupación',
'name_en': 'Occupancy Metrics',
'icon': 'bi-graph-up',
'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).'
} }
] ]
+23 -1
View File
@@ -59,9 +59,31 @@ class User(UserMixin, db.Model):
self.is_admin() or self.is_admin() or
self.has_permission('users', 'read') or self.has_permission('users', 'read') or
self.has_permission('milestone_types', 'read') or self.has_permission('milestone_types', 'read') or
self.has_permission('import', 'read') self.has_permission('import', 'read') or
self.has_permission('reservations', 'read_write') or
self.has_permission('classrooms', 'read_write') or
self.has_permission('buildings', 'read_write') or
self.has_permission('metrics', 'read')
) )
def can_edit_reservation(self, reservation):
"""Check if user has permission to edit a given reservation"""
if not reservation:
return False
if self.is_admin():
return True
role_name = (self.role_obj.name if self.role_obj else self.role or '').lower()
if role_name in ['admin', 'administrador', 'bedelia', 'bedelía', 'operador']:
return True
if self.has_permission('reservations', 'read_write'):
# El docente creador puede modificar su propia reserva
if reservation.user_id == self.id:
return True
# Bedelía o roles de gestión con permiso de escritura total
if role_name in ['bedelia', 'bedelía', 'operador']:
return True
return False
def update_last_login(self): def update_last_login(self):
"""Update last login timestamp""" """Update last login timestamp"""
+71 -2
View File
@@ -122,13 +122,80 @@ def list_classrooms():
) )
) )
classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate( classrooms = query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
page=page, per_page=12, error_out=False page=page, per_page=20, error_out=False
) )
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all() 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()] 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()]
# Fechas y cálculo de métricas de ocupación (Día, Semana, Mes)
import calendar
from datetime import date, timedelta
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
week_start = datetime.combine(today - timedelta(days=today.weekday()), datetime.min.time())
week_end = datetime.combine(week_start.date() + timedelta(days=6), datetime.max.time())
_, last_day = calendar.monthrange(today.year, today.month)
month_start = datetime(today.year, today.month, 1, 0, 0, 0)
month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
# Consultar en lote las reservas del mes para las aulas visibles
from app.models.reservation import Reservation, ReservationStatus
visible_ids = [c.id for c in classrooms.items]
month_reservations = Reservation.query.filter(
Reservation.classroom_id.in_(visible_ids),
Reservation.start_time >= month_start,
Reservation.start_time <= month_end,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
).all() if visible_ids else []
# Horas base operativas académicas:
# Día: 15 hs (07:00 a 22:00 = 900 min)
# Semana: 6 días x 15 hs = 5400 min
# Mes: ~24 días hábiles x 15 hs = 21600 min
DAILY_OP_MINS = 15 * 60
WEEKLY_OP_MINS = 6 * 15 * 60
MONTHLY_OP_MINS = 24 * 15 * 60
occupancy_metrics = {}
for c in classrooms.items:
c_res = [r for r in month_reservations if r.classroom_id == c.id]
# Día
day_res = [r for r in c_res if today_start <= r.start_time <= today_end]
day_mins = sum((min(r.end_time, today_end) - max(r.start_time, today_start)).total_seconds() / 60 for r in day_res)
day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100))) if not c.is_virtual else len(day_res)
# Semana
week_res = [r for r in c_res if week_start <= r.start_time <= week_end]
week_mins = sum((min(r.end_time, week_end) - max(r.start_time, week_start)).total_seconds() / 60 for r in week_res)
week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100))) if not c.is_virtual else len(week_res)
# Mes
month_mins = sum((min(r.end_time, month_end) - max(r.start_time, month_start)).total_seconds() / 60 for r in c_res)
month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100))) if not c.is_virtual else len(c_res)
occupancy_metrics[c.id] = {
'day_pct': day_pct,
'day_count': len(day_res),
'week_pct': week_pct,
'week_count': len(week_res),
'month_pct': month_pct,
'month_count': len(c_res)
}
date_filters = {
'today': today.strftime('%Y-%m-%d'),
'week_start': week_start.strftime('%Y-%m-%d'),
'week_end': week_end.strftime('%Y-%m-%d'),
'month_start': month_start.strftime('%Y-%m-%d'),
'month_end': month_end.strftime('%Y-%m-%d')
}
return render_template('classrooms/list.html', return render_template('classrooms/list.html',
classrooms=classrooms, classrooms=classrooms,
buildings=buildings, buildings=buildings,
@@ -137,6 +204,8 @@ def list_classrooms():
floor_filter=floor_filter, floor_filter=floor_filter,
active_only=active_only, active_only=active_only,
search=search, search=search,
occupancy_metrics=occupancy_metrics,
date_filters=date_filters,
title='Classrooms') title='Classrooms')
except Exception as e: except Exception as e:
+93
View File
@@ -139,6 +139,92 @@ def view_reservation(id):
flash(f'Error loading reservation details: {str(e)}', 'error') flash(f'Error loading reservation details: {str(e)}', 'error')
return redirect(url_for('schedule.today_schedule')) return redirect(url_for('schedule.today_schedule'))
@schedule_bp.route('/reservation/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_reservation(id):
"""Edit existing reservation"""
from app.models.classroom import Classroom
from app.models.subject import Commission
from app.models.building import Building
from flask_babel import gettext as _
reservation = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission),
joinedload(Reservation.user)
).get_or_404(id)
if not current_user.can_edit_reservation(reservation):
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
form = ReservationForm(is_edit=True)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
commissions = Commission.query.filter_by(active=True).all()
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'GET':
form.classroom_id.data = reservation.classroom_id
form.commission_id.data = reservation.commission_id
form.start_time.data = reservation.start_time
form.end_time.data = reservation.end_time
form.purpose.data = reservation.purpose
form.expected_attendees.data = reservation.expected_attendees
form.notes.data = reservation.notes or ''
form.virtual_link.data = reservation.virtual_link or ''
elif request.method == 'POST':
if form.validate_on_submit():
try:
# Verificar colisión de horario si no es virtual
target_room = Classroom.query.get(form.classroom_id.data)
if target_room and not target_room.is_virtual:
conflict = Reservation.query.filter(
Reservation.id != reservation.id,
Reservation.classroom_id == form.classroom_id.data,
Reservation.status == ReservationStatus.CONFIRMED.value,
Reservation.start_time < form.end_time.data,
Reservation.end_time > form.start_time.data
).first()
if conflict:
flash(_('El aula seleccionada ya cuenta con una reserva confirmada en esa franja horaria.'), 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
# Asignar cambios
reservation.classroom_id = form.classroom_id.data
reservation.commission_id = form.commission_id.data
reservation.start_time = form.start_time.data
reservation.end_time = form.end_time.data
reservation.purpose = form.purpose.data
reservation.expected_attendees = form.expected_attendees.data
reservation.notes = form.notes.data
reservation.virtual_link = form.virtual_link.data.strip() if form.virtual_link.data else None
# Turno
h = reservation.start_time.hour
if h < 13:
reservation.shift = 'Mañana'
elif h < 18:
reservation.shift = 'Tarde'
elif h < 21:
reservation.shift = 'Vespertino'
else:
reservation.shift = 'Noche'
db.session.commit()
flash(_('Reserva #%(id)d modificada exitosamente.', id=reservation.id), 'success')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
except Exception as e:
db.session.rollback()
flash(f'Error al modificar reserva: {str(e)}', 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
@schedule_bp.route('/calendar') @schedule_bp.route('/calendar')
@login_required @login_required
def calendar_view(): def calendar_view():
@@ -518,6 +604,7 @@ def list_reservations():
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True'] hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor_filter = request.args.get('floor', '').strip() floor_filter = request.args.get('floor', '').strip()
shift_filter = request.args.get('shift', '').strip() shift_filter = request.args.get('shift', '').strip()
classroom_id = request.args.get('classroom_id', type=int)
search = request.args.get('search', '').strip() search = request.args.get('search', '').strip()
query = Reservation.query.options( query = Reservation.query.options(
@@ -526,6 +613,9 @@ def list_reservations():
joinedload(Reservation.user) joinedload(Reservation.user)
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject) ).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
if classroom_id:
query = query.filter(Reservation.classroom_id == classroom_id)
if status_filter and status_filter != 'all': if status_filter and status_filter != 'all':
query = query.filter(Reservation.status == status_filter.upper()) query = query.filter(Reservation.status == status_filter.upper())
@@ -575,6 +665,7 @@ def list_reservations():
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all() floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors] available_floors = [f[0] for f in floors]
all_classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
return render_template( return render_template(
'schedule/list.html', 'schedule/list.html',
@@ -585,6 +676,8 @@ def list_reservations():
hide_virtual=hide_virtual, hide_virtual=hide_virtual,
floor_filter=floor_filter, floor_filter=floor_filter,
shift_filter=shift_filter, shift_filter=shift_filter,
classroom_id=classroom_id,
all_classrooms=all_classrooms,
search=search, search=search,
available_floors=available_floors available_floors=available_floors
) )
+4 -1
View File
@@ -109,8 +109,11 @@
<ul class="dropdown-menu shadow-sm"> <ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li> <li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li> <li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-calendar-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"><i class="bi bi-door-open me-2 text-info"></i>{% trans %}Aulas y Métricas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones{% endtrans %}</a></li> <li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2"></i>{% trans %}Buildings{% endtrans %}</a></li> <li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Buildings{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-warning"></i>{% trans %}Tipificaciones{% endtrans %}</a></li> <li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-warning"></i>{% trans %}Tipificaciones{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li> <li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li>
+175 -68
View File
@@ -2,6 +2,86 @@
{% block title %}{% trans %}Classrooms{% endtrans %} - Edu-Space Admin{% endblock %} {% block title %}{% trans %}Classrooms{% endtrans %} - Edu-Space Admin{% endblock %}
{% block extra_css %}
<style>
.classroom-card {
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
border-radius: 0.65rem;
}
.classroom-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
}
.metric-pill {
padding: 0.35rem 0.2rem;
border-radius: 0.45rem;
transition: all 0.15s ease;
cursor: pointer;
line-height: 1.2;
}
.metric-pill:hover {
transform: translateY(-1px);
filter: brightness(1.06);
}
.metric-label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 2px;
}
.metric-val {
font-size: 0.85rem;
font-weight: 700;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* Semantics light */
.metric-high {
background-color: #FEE2E2;
border-color: #FCA5A5 !important;
color: #991B1B !important;
}
.metric-medium {
background-color: #FEF3C7;
border-color: #FCD34D !important;
color: #92400E !important;
}
.metric-low {
background-color: #D1FAE5;
border-color: #6EE7B7 !important;
color: #065F46 !important;
}
.metric-zero {
background-color: rgba(148, 163, 184, 0.1);
border-color: rgba(148, 163, 184, 0.25) !important;
color: #64748B !important;
}
/* Semantics dark */
[data-bs-theme="dark"] .metric-high {
background-color: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.4) !important;
color: #FCA5A5 !important;
}
[data-bs-theme="dark"] .metric-medium {
background-color: rgba(245, 158, 11, 0.18);
border-color: rgba(245, 158, 11, 0.4) !important;
color: #FCD34D !important;
}
[data-bs-theme="dark"] .metric-low {
background-color: rgba(16, 185, 129, 0.18);
border-color: rgba(16, 185, 129, 0.4) !important;
color: #6EE7B7 !important;
}
[data-bs-theme="dark"] .metric-zero {
background-color: rgba(148, 163, 184, 0.08);
border-color: rgba(148, 163, 184, 0.18) !important;
color: #94A3B8 !important;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid pt-5 mt-4"> <div class="container-fluid pt-5 mt-4">
<div class="row"> <div class="row">
@@ -88,86 +168,113 @@
<!-- Classrooms Grid --> <!-- Classrooms Grid -->
{% if classrooms and classrooms.items %} {% if classrooms and classrooms.items %}
<div class="row mt-4"> <div class="row g-3 mt-1">
{% for classroom in classrooms.items %} {% for classroom in classrooms.items %}
<div class="col-lg-4 col-md-6 mb-4"> <div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
<div class="card border-0 shadow-sm h-100"> <div class="card classroom-card border shadow-sm h-100">
<div class="card-body"> <div class="card-body p-3 d-flex flex-column justify-content-between">
<div class="d-flex justify-content-between align-items-start mb-2"> <div>
<div> <!-- Encabezado con Código y Estado -->
<h5 class="card-title mb-1 fw-bold text-primary"> <div class="d-flex justify-content-between align-items-start mb-2">
{{ classroom.code }} <div class="text-truncate me-2">
</h5> <h5 class="card-title mb-0 fw-bold text-primary text-truncate fs-6" title="{{ classroom.code }}">
<div class="d-flex flex-wrap gap-1 mb-2"> <i class="bi bi-door-open me-1"></i>{{ classroom.code }}
<span class="badge bg-primary-subtle text-primary border"> </h5>
<i class="bi bi-buildings me-1"></i>{{ classroom.building_name }} </div>
</span> <span class="badge rounded-pill {% if classroom.is_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %}" style="font-size: 0.68rem;">
<span class="badge bg-secondary-subtle text-secondary border"> {% if classroom.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %}
<i class="bi bi-layers me-1"></i>{{ classroom.floor_display }} </span>
</span> </div>
<!-- Ubicación: Edificio y Piso -->
<div class="d-flex flex-wrap gap-1 mb-2">
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.7rem;">
<i class="bi bi-buildings me-1"></i>{{ classroom.building_name }}
</span>
<span class="badge bg-secondary-subtle text-secondary border" style="font-size: 0.7rem;">
<i class="bi bi-layers me-1"></i>{{ classroom.floor_display }}
</span>
</div>
<!-- Capacidad y Recursos -->
<div class="d-flex justify-content-between align-items-center small py-1 px-2 bg-body-tertiary rounded mb-2" style="font-size: 0.75rem;">
<div>
<span class="text-muted">{% trans %}Cap:{% endtrans %}</span>
<strong>
{% if classroom.is_unlimited %}
<span class="badge bg-primary-subtle text-primary border"><i class="bi bi-infinity"></i></span>
{% else %}
{{ classroom.capacity }}
{% endif %}
</strong>
</div>
<div>
<span class="text-muted"><i class="bi bi-tools me-1"></i>{{ classroom.resources|length if classroom.resources else 0 }}</span>
</div> </div>
</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 %} <!-- Barra de Métricas de Ocupación (Día, Semana, Mes) -->
</span> {% set metrics = occupancy_metrics.get(classroom.id, {'day_pct': 0, 'day_count': 0, 'week_pct': 0, 'week_count': 0, 'month_pct': 0, 'month_count': 0}) %}
</div> <div class="classroom-occupancy-bar p-2 rounded bg-body-tertiary border mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<div class="row small py-1 bg-body-tertiary rounded px-2 mb-2"> <span class="small fw-semibold text-muted" style="font-size: 0.72rem;">
<div class="col-6"> <i class="bi bi-graph-up me-1 text-primary"></i>{% trans %}Ocupación{% endtrans %}
<span class="text-muted">{% trans %}Capacidad{% endtrans %}:</span> </span>
<strong> <span class="text-muted" style="font-size: 0.65rem;" title="{% trans %}Haz clic en cualquier métrica para ver las reservas de ese período{% endtrans %}">
{% if classroom.is_unlimited %} <i class="bi bi-info-circle me-1"></i>{% trans %}Ver reservas{% endtrans %}
<span class="badge bg-primary-subtle text-primary border"><i class="bi bi-infinity me-1"></i>∞</span> </span>
{% else %} </div>
{{ classroom.capacity }} <div class="row g-1 text-center">
{% endif %} <!-- Día -->
</strong> <div class="col-4">
</div> <a href="{{ url_for('schedule.list_reservations', classroom_id=classroom.id, date_from=date_filters.today, date_to=date_filters.today) }}"
<div class="col-6 text-end"> class="d-block p-1 text-decoration-none border metric-pill metric-{% if metrics.day_pct > 80 %}high{% elif metrics.day_pct > 40 %}medium{% elif metrics.day_pct > 0 %}low{% else %}zero{% endif %}"
<span class="text-muted">{% trans %}Ubicación{% endtrans %}:</span> title="{% trans %}Ocupación Hoy{% endtrans %}: {{ metrics.day_count }} {% trans %}clases registradas{% endtrans %}">
<strong class="text-truncate d-inline-block" style="max-width: 100px;">{{ classroom.floor_display }}</strong> <div class="metric-label">{% trans %}Hoy{% endtrans %}</div>
<div class="metric-val">{% if classroom.is_virtual %}{{ metrics.day_count }} cl.{% else %}{{ metrics.day_pct }}%{% endif %}</div>
</a>
</div>
<!-- Semana -->
<div class="col-4">
<a href="{{ url_for('schedule.list_reservations', classroom_id=classroom.id, date_from=date_filters.week_start, date_to=date_filters.week_end) }}"
class="d-block p-1 text-decoration-none border metric-pill metric-{% if metrics.week_pct > 80 %}high{% elif metrics.week_pct > 40 %}medium{% elif metrics.week_pct > 0 %}low{% else %}zero{% endif %}"
title="{% trans %}Ocupación Semana{% endtrans %}: {{ metrics.week_count }} {% trans %}clases registradas{% endtrans %}">
<div class="metric-label">{% trans %}Semana{% endtrans %}</div>
<div class="metric-val">{% if classroom.is_virtual %}{{ metrics.week_count }} cl.{% else %}{{ metrics.week_pct }}%{% endif %}</div>
</a>
</div>
<!-- Mes -->
<div class="col-4">
<a href="{{ url_for('schedule.list_reservations', classroom_id=classroom.id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}"
class="d-block p-1 text-decoration-none border metric-pill metric-{% if metrics.month_pct > 80 %}high{% elif metrics.month_pct > 40 %}medium{% elif metrics.month_pct > 0 %}low{% else %}zero{% endif %}"
title="{% trans %}Ocupación Mes{% endtrans %}: {{ metrics.month_count }} {% trans %}clases registradas{% endtrans %}">
<div class="metric-label">{% trans %}Mes{% endtrans %}</div>
<div class="metric-val">{% if classroom.is_virtual %}{{ metrics.month_count }} cl.{% else %}{{ metrics.month_pct }}%{% endif %}</div>
</a>
</div>
</div>
</div> </div>
</div> </div>
{% if classroom.description %} <!-- Footer con Botones Compactos -->
<p class="card-text small text-muted mt-2"> <div class="pt-2 border-top d-flex justify-content-between align-items-center">
{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %} <div class="btn-group btn-group-sm">
</p>
{% endif %}
<!-- Resources indicator -->
<div class="mt-2">
<small class="text-muted">
<i class="bi bi-tools"></i>
{% if classroom.resources %}
{{ classroom.resources|length }} {% trans %}resources{% endtrans %}
{% else %}
{% trans %}No resources registered{% endtrans %}
{% endif %}
</small>
</div>
</div>
<div class="card-footer bg-body-tertiary border-0">
<div class="d-flex justify-content-between">
<div>
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" <a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}"
class="btn btn-sm btn-outline-primary"> class="btn btn-outline-primary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}" style="font-size: 0.75rem;">
<i class="bi bi-eye"></i> {% trans %}View{% endtrans %} <i class="bi bi-eye me-1"></i>{% trans %}Ficha{% endtrans %}
</a> </a>
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}" <a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}"
class="btn btn-sm btn-outline-secondary"> class="btn btn-outline-secondary py-0 px-2" title="{% trans %}Editar Aula{% endtrans %}" style="font-size: 0.75rem;">
<i class="bi bi-pencil"></i> {% trans %}Edit{% endtrans %} <i class="bi bi-pencil"></i>
</a> </a>
</div> </div>
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}" <form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}" class="d-inline mb-0">
class="d-inline"> <button type="submit" class="btn btn-sm py-0 px-2 {% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
<button type="submit" class="btn btn-sm title="{% if classroom.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}" style="font-size: 0.75rem;">
{% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
{% if classroom.is_active %} {% if classroom.is_active %}
<i class="bi bi-pause-circle"></i> {% trans %}Deactivate{% endtrans %} <i class="bi bi-pause"></i>
{% else %} {% else %}
<i class="bi bi-play-circle"></i> {% trans %}Activate{% endtrans %} <i class="bi bi-play"></i>
{% endif %} {% endif %}
</button> </button>
</form> </form>
+15 -4
View File
@@ -277,9 +277,16 @@
<!-- Footer de Acciones Rápidas --> <!-- Footer de Acciones Rápidas -->
<div class="modal-footer border-0 p-3 pt-0 d-flex justify-content-between"> <div class="modal-footer border-0 p-3 pt-0 d-flex justify-content-between">
<a id="previewFullLink" href="#" class="btn btn-outline-primary btn-sm px-3"> <div class="d-flex gap-2">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Ver Ficha Completa{% endtrans %} <a id="previewFullLink" href="#" class="btn btn-outline-primary btn-sm px-3">
</a> <i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Ver Ficha Completa{% endtrans %}
</a>
{% if current_user.has_permission('reservations', 'read_write') or (current_user.role and current_user.role|upper in ['ADMIN', 'BEDELIA']) %}
<a id="previewFooterEditLink" href="#" class="btn btn-warning btn-sm px-3">
<i class="bi bi-pencil me-1"></i>{% trans %}Modificar Reserva{% endtrans %}
</a>
{% endif %}
</div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal"> <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">
{% trans %}Cerrar{% endtrans %} {% trans %}Cerrar{% endtrans %}
</button> </button>
@@ -991,11 +998,15 @@ document.addEventListener('DOMContentLoaded', function() {
// Enlaces y Acciones // Enlaces y Acciones
var detailUrl = info.event.url || ('/schedule/reservation/' + info.event.id + '/view'); var detailUrl = info.event.url || ('/schedule/reservation/' + info.event.id + '/view');
var editUrl = '/schedule/reservation/' + info.event.id + '/edit';
var fullLink = document.getElementById('previewFullLink'); var fullLink = document.getElementById('previewFullLink');
if (fullLink) fullLink.href = detailUrl; if (fullLink) fullLink.href = detailUrl;
var editBtn = document.getElementById('btnPreviewEdit'); var editBtn = document.getElementById('btnPreviewEdit');
if (editBtn) editBtn.href = detailUrl; if (editBtn) editBtn.href = editUrl;
var footerEditBtn = document.getElementById('previewFooterEditLink');
if (footerEditBtn) footerEditBtn.href = editUrl;
var cancelForm = document.getElementById('formPreviewCancel'); var cancelForm = document.getElementById('formPreviewCancel');
if (cancelForm) { if (cancelForm) {
+482
View File
@@ -0,0 +1,482 @@
{% extends "base.html" %}
{% block title %}{% trans %}Modificar Reserva{% endtrans %} #{{ reservation.id }} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="{{ url_for('schedule.today_schedule') }}">{% trans %}Schedule{% endtrans %}</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('schedule.list_reservations') }}">{% trans %}Reservas{% endtrans %}</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}">#{{ reservation.id }}</a></li>
<li class="breadcrumb-item active">{% trans %}Modificar{% endtrans %}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="bi bi-pencil-square text-warning me-2"></i>{% trans %}Modificar Reserva{% endtrans %} #{{ reservation.id }}
</h1>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver al Detalle{% endtrans %}
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-10 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-calendar3 text-warning me-2"></i>{% trans %}Editar Parámetros de la Reserva{% endtrans %}
</h5>
<span class="badge {% if reservation.status == 'CONFIRMED' %}bg-success{% elif reservation.status == 'PENDING' %}bg-warning{% else %}bg-secondary{% endif %}">
{{ reservation.status }}
</span>
</div>
<div class="card-body p-4">
<form method="POST" id="reservation-form" novalidate>
{{ form.hidden_tag() }}
<!-- 1. SELECTORES EN CASCADA: Edificio -> Piso -> Aula -->
<div class="mb-4">
<div class="p-3 rounded bg-body-tertiary border">
<label class="form-label fw-bold text-primary mb-2">
<i class="bi bi-geo-alt-fill me-1"></i>{% trans %}Espacio Físico / Modalidad{% endtrans %} <span class="text-danger">*</span>
</label>
<div class="row g-3">
<!-- Edificio / Sede -->
<div class="col-md-4">
<label for="cascade-building" class="form-label small fw-semibold text-muted mb-1">
<i class="bi bi-buildings text-primary me-1"></i>1. {% trans %}Edificio / Sede{% endtrans %}
</label>
<select id="cascade-building" class="form-select form-select-sm">
<option value="">{% trans %}-- Selecciona Edificio --{% endtrans %}</option>
{% for b in buildings %}
<option value="{{ b.id }}" data-virtual="{{ 'true' if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) else 'false' }}">
{% if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) %}🌐{% else %}🏢{% endif %} {{ b.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Piso / Nivel -->
<div class="col-md-4">
<label for="cascade-floor" class="form-label small fw-semibold text-muted mb-1">
<i class="bi bi-layers text-secondary me-1"></i>2. {% trans %}Piso / Nivel{% endtrans %}
</label>
<select id="cascade-floor" class="form-select form-select-sm" disabled>
<option value="">{% trans %}-- Selecciona Piso --{% endtrans %}</option>
</select>
</div>
<!-- Aula / Espacio -->
<div class="col-md-4">
<label for="classroom_id" class="form-label small fw-semibold text-muted mb-1">
<i class="bi bi-door-open text-success me-1"></i>3. {% trans %}Aula Final{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.classroom_id(class="form-select form-select-sm", id="classroom_id") }}
{% if form.classroom_id.errors %}
<div class="invalid-feedback d-block">{{ form.classroom_id.errors[0] }}</div>
{% endif %}
</div>
</div>
<!-- Card de Info Dinámica del Aula -->
<div id="classroom-info-card" class="mt-3 p-2 rounded bg-body border" style="display: none;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div>
<span class="badge bg-primary me-2" id="info-room-code">-</span>
<small class="text-muted" id="info-room-location">-</small>
</div>
<div>
<span class="badge bg-info-subtle text-info border me-1" id="info-room-capacity">Cap: -</span>
<span class="badge bg-success-subtle text-success border" id="info-room-type">Presencial</span>
</div>
</div>
</div>
</div>
</div>
<!-- 2. DATOS ACADÉMICOS Y HORARIOS -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<label for="commission_id" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Comisión Académica{% endtrans %}
</label>
{{ form.commission_id(class="form-select", id="commission_id") }}
<div class="form-text small text-muted">{% trans %}Comisión y asignatura vinculada a esta reserva.{% endtrans %}</div>
{% if form.commission_id.errors %}
<div class="invalid-feedback d-block">{{ form.commission_id.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label for="start_time" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-clock me-1"></i>{% trans %}Inicio{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.start_time(class="form-control", id="start_time", type="datetime-local") }}
{% if form.start_time.errors %}
<div class="invalid-feedback d-block">{{ form.start_time.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label for="end_time" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-clock-fill me-1"></i>{% trans %}Fin{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.end_time(class="form-control", id="end_time", type="datetime-local") }}
{% if form.end_time.errors %}
<div class="invalid-feedback d-block">{{ form.end_time.errors[0] }}</div>
{% endif %}
</div>
</div>
<!-- 3. PROPÓSITO Y ASISTENTES -->
<div class="row g-3 mb-4">
<div class="col-md-8">
<label for="purpose" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-journal-text me-1"></i>{% trans %}Propósito / Motivo{% endtrans %} <span class="text-danger">*</span>
</label>
{{ form.purpose(class="form-control", id="purpose", placeholder="ej. Cursada regular, Examen Parcial, Clase Especial") }}
{% if form.purpose.errors %}
<div class="invalid-feedback d-block">{{ form.purpose.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
<label for="expected_attendees" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-people me-1"></i>{% trans %}Asistentes Estimados{% endtrans %}
</label>
{{ form.expected_attendees(class="form-control", id="expected_attendees", type="number", min="0") }}
{% if form.expected_attendees.errors %}
<div class="invalid-feedback d-block">{{ form.expected_attendees.errors[0] }}</div>
{% endif %}
</div>
</div>
<!-- 4. ENLACE DE AULA VIRTUAL (Zoom, Meet, Teams, etc) -->
<div class="mb-4">
<div class="p-3 rounded bg-primary-subtle border border-primary-subtle">
<label for="virtual_link" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-camera-video-fill me-1"></i>{% trans %}Enlace de Aula Virtual / Videoconferencia{% endtrans %}
</label>
<div class="input-group">
<span class="input-group-text bg-body text-primary"><i class="bi bi-link-45deg"></i></span>
{{ form.virtual_link(class="form-control font-monospace", id="virtual_link", placeholder="https://zoom.us/j/... o https://meet.google.com/...") }}
<button type="button" class="btn btn-outline-primary" id="btn-test-link" title="{% trans %}Probar enlace en nueva pestaña{% endtrans %}">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Probar{% endtrans %}
</button>
</div>
<div class="form-text small text-muted">
{% trans %}Enlace directo a Zoom, Google Meet, Teams o Campus Virtual. Si se ingresa, se habilitará el botón de acceso directo en cartelera y cronograma.{% endtrans %}
</div>
{% if form.virtual_link.errors %}
<div class="invalid-feedback d-block">{{ form.virtual_link.errors[0] }}</div>
{% endif %}
</div>
</div>
<!-- 5. NOTAS ADICIONALES -->
<div class="mb-4">
<label for="notes" class="form-label fw-bold text-primary mb-1">
<i class="bi bi-card-text me-1"></i>{% trans %}Observaciones / Notas Adicionales{% endtrans %}
</label>
{{ form.notes(class="form-control", id="notes", rows="3", placeholder="Observaciones sobre equipamiento, requerimientos o modalidad...") }}
{% if form.notes.errors %}
<div class="invalid-feedback d-block">{{ form.notes.errors[0] }}</div>
{% endif %}
</div>
<!-- 6. RESUMEN DINÁMICO DE MODIFICACIÓN -->
<div class="card bg-body-tertiary border mb-4 shadow-none">
<div class="card-header bg-transparent py-2 border-bottom">
<h6 class="card-title mb-0 small fw-bold text-primary">
<i class="bi bi-check2-circle me-1"></i>{% trans %}Resumen de Cambios{% endtrans %}
</h6>
</div>
<div class="card-body p-3">
<div class="row g-2 small mb-2">
<div class="col-md-4">
<span class="text-muted">🏢 {% trans %}Edificio:{% endtrans %}</span>
<strong id="summary-building" class="text-body ms-1">-</strong>
</div>
<div class="col-md-4">
<span class="text-muted">📍 {% trans %}Piso / Nivel:{% endtrans %}</span>
<strong id="summary-floor" class="text-body ms-1">-</strong>
</div>
<div class="col-md-4">
<span class="text-muted">🚪 {% trans %}Aula Seleccionada:{% endtrans %}</span>
<strong id="summary-classroom" class="text-primary ms-1">-</strong>
</div>
</div>
<div class="row g-2 small mb-2">
<div class="col-md-4">
<span class="text-muted">⏱️ {% trans %}Duración:{% endtrans %}</span>
<strong id="summary-duration" class="text-body ms-1">-</strong>
</div>
<div class="col-md-4">
<span class="text-muted">👥 {% trans %}Capacidad:{% endtrans %}</span>
<strong id="summary-capacity" class="text-body ms-1">-</strong>
</div>
<div class="col-md-4">
<span class="text-muted">📚 {% trans %}Comisión:{% endtrans %}</span>
<strong id="summary-commission" class="text-body ms-1">-</strong>
</div>
</div>
<div class="p-2 rounded bg-body border d-flex align-items-center justify-content-between">
<div class="text-truncate me-2">
<small class="text-muted d-block">🎥 {% trans %}Enlace de Aula Virtual:{% endtrans %}</small>
<span id="summary-virtual-link" class="small font-monospace text-primary text-break">{% trans %}Sin enlace configurado{% endtrans %}</span>
</div>
<span class="badge bg-success-subtle text-success border" id="summary-virtual-badge" style="display:none;">
<i class="bi bi-camera-video me-1"></i>Virtual
</span>
</div>
</div>
</div>
<!-- Botones -->
<div class="d-flex justify-content-between align-items-center">
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>{% trans %}Cancelar{% endtrans %}
</a>
<button type="submit" class="btn btn-warning px-4 fw-bold">
<i class="bi bi-check-circle me-1"></i>{% trans %}Guardar Modificaciones{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script type="application/json" id="classroom-data">
{
{% for c in classroom_list %}
"{{ c.id }}": {
"id": {{ c.id }},
"building_id": {{ (c.building_id or 0)|tojson }},
"building_name": {{ c.building_name|tojson }},
"floor": {{ c.floor|tojson }},
"floor_display": {{ c.floor_display|tojson }},
"code": {{ c.code|tojson }},
"code_display": {{ c.code_display|tojson }},
"location_display": {{ c.location_display|tojson }},
"capacity": {{ c.capacity_display|tojson }},
"capacity_num": {{ c.capacity }},
"is_virtual": {{ c.is_virtual|tojson }}
}{% if not loop.last %},{% endif %}
{% endfor %}
}
</script>
<script type="application/json" id="commission-data">
{
{% for c in commission_list %}
"{{ c.id }}": {
"id": {{ c.id }},
"code": {{ c.code|tojson }},
"virtual_link": {{ (c.virtual_link or '')|tojson }},
"max_students": {{ c.max_students }}
}{% if not loop.last %},{% endif %}
{% endfor %}
}
</script>
<script>
$(document).ready(function() {
let classroomData = {};
try {
const rawData = document.getElementById('classroom-data')?.textContent;
if (rawData) classroomData = JSON.parse(rawData);
} catch (err) {
console.error('Error parsing classroomData:', err);
}
let commissionData = {};
try {
const rawComm = document.getElementById('commission-data')?.textContent;
if (rawComm) commissionData = JSON.parse(rawComm);
} catch (err) {
console.error('Error parsing commissionData:', err);
}
const $bldgSelect = $('#cascade-building');
const $floorSelect = $('#cascade-floor');
const $roomSelect = $('#classroom_id');
const $commSelect = $('#commission_id');
const $virtualLinkInput = $('#virtual_link');
const $btnTestLink = $('#btn-test-link');
const initialRoomId = "{{ reservation.classroom_id }}";
// 1. Población de Pisos al cambiar Edificio
$bldgSelect.on('change', function() {
const selectedBldg = $(this).val();
$floorSelect.empty().append('<option value="">-- Selecciona Piso --</option>');
$roomSelect.empty().append('<option value="">-- Selecciona Aula --</option>');
if (!selectedBldg) {
$floorSelect.prop('disabled', true);
$roomSelect.prop('disabled', true);
updateSummary();
return;
}
const floors = new Set();
Object.values(classroomData).forEach(function(room) {
if (String(room.building_id) === String(selectedBldg)) {
floors.add(room.floor);
}
});
const sortedFloors = Array.from(floors).sort(function(a, b) {
if (a === '0' || a === 'PB') return -1;
if (b === '0' || b === 'PB') return 1;
return String(a).localeCompare(String(b), undefined, {numeric: true});
});
sortedFloors.forEach(function(fl) {
let label = (fl === '0' || fl === 'PB') ? 'Planta Baja' : ('Piso ' + fl);
$floorSelect.append($('<option>', { value: fl, text: label }));
});
$floorSelect.prop('disabled', false);
$roomSelect.prop('disabled', true);
updateSummary();
});
// 2. Población de Aulas al cambiar Piso
$floorSelect.on('change', function() {
const selectedBldg = $bldgSelect.val();
const selectedFloor = $(this).val();
$roomSelect.empty().append('<option value="">-- Selecciona Aula --</option>');
if (!selectedFloor) {
$roomSelect.prop('disabled', true);
updateSummary();
return;
}
const matchingRooms = Object.values(classroomData).filter(function(room) {
return String(room.building_id) === String(selectedBldg) && String(room.floor) === String(selectedFloor);
});
matchingRooms.sort(function(a, b) { return a.code.localeCompare(b.code); });
matchingRooms.forEach(function(room) {
const capText = room.is_virtual ? '∞ Remoto' : ('Cap: ' + room.capacity);
$roomSelect.append($('<option>', {
value: room.id,
text: room.code + ' (' + capText + ')'
}));
});
$roomSelect.prop('disabled', false);
updateSummary();
});
// 3. Cambio de Aula
$roomSelect.on('change', function() {
updateClassroomInfo();
updateSummary();
});
// 4. Preselección inicial de Aula actual
if (initialRoomId && classroomData[initialRoomId]) {
const initRoom = classroomData[initialRoomId];
$bldgSelect.val(initRoom.building_id).trigger('change');
$floorSelect.val(initRoom.floor).trigger('change');
$roomSelect.val(initRoom.id).trigger('change');
}
function updateClassroomInfo() {
const roomId = $roomSelect.val();
const room = classroomData[roomId];
const $card = $('#classroom-info-card');
if (!room) {
$card.slideUp(150);
return;
}
$('#info-room-code').text(room.code);
$('#info-room-location').text(room.location_display);
$('#info-room-capacity').text('Capacidad: ' + room.capacity);
if (room.is_virtual) {
$('#info-room-type').removeClass('bg-success-subtle text-success').addClass('bg-primary-subtle text-primary').text('🌐 Campus Virtual');
} else {
$('#info-room-type').removeClass('bg-primary-subtle text-primary').addClass('bg-success-subtle text-success').text('🏢 Presencial');
}
$card.slideDown(150);
}
function updateSummary() {
const bldgName = $bldgSelect.find('option:selected').text().trim();
const floorName = $floorSelect.find('option:selected').text().trim();
const roomId = $roomSelect.val();
const room = classroomData[roomId];
$('#summary-building').text(bldgName && !$bldgSelect.val() ? '-' : (bldgName || '-'));
$('#summary-floor').text(floorName && !$floorSelect.val() ? '-' : (floorName || '-'));
$('#summary-classroom').text(room ? room.code : '-');
$('#summary-capacity').text(room ? (room.is_virtual ? '∞ Ilimitada' : room.capacity) : '-');
const commText = $commSelect.find('option:selected').text().trim();
$('#summary-commission').text(commText && $commSelect.val() ? commText : '-');
// Duración
const st = $('#start_time').val();
const et = $('#end_time').val();
if (st && et) {
const startDate = new Date(st);
const endDate = new Date(et);
const diffMs = endDate - startDate;
if (diffMs > 0) {
const diffHrs = Math.floor(diffMs / 3600000);
const diffMins = Math.round((diffMs % 3600000) / 60000);
let durText = '';
if (diffHrs > 0) durText += diffHrs + ' hs ';
if (diffMins > 0) durText += diffMins + ' min';
$('#summary-duration').text(durText);
} else {
$('#summary-duration').text('Rango inválido');
}
}
// Enlace virtual
const vLink = $virtualLinkInput.val() ? $virtualLinkInput.val().trim() : '';
if (vLink) {
$('#summary-virtual-link').html('<a href="' + vLink + '" target="_blank" class="text-decoration-none text-primary"><i class="bi bi-box-arrow-up-right me-1"></i>' + vLink + '</a>');
$('#summary-virtual-badge').show();
} else {
$('#summary-virtual-link').text('Sin enlace configurado');
$('#summary-virtual-badge').hide();
}
}
// Eventos reactivos
$('#start_time, #end_time, #commission_id, #virtual_link').on('input change', updateSummary);
// Botón Probar enlace
$btnTestLink.on('click', function() {
const link = $virtualLinkInput.val() ? $virtualLinkInput.val().trim() : '';
if (link) {
window.open(link, '_blank');
} else {
alert('Por favor ingresa primero una URL para probar.');
}
});
updateSummary();
});
</script>
{% endblock %}
+33 -6
View File
@@ -67,8 +67,21 @@
</select> </select>
</div> </div>
<!-- Aula Filter -->
<div class="col-lg-3 col-md-4">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-door-open text-primary me-1"></i>{% trans %}Aula / Espacio{% endtrans %}
</label>
<select name="classroom_id" class="form-select form-select-sm">
<option value="">{% trans %}Todas las aulas{% endtrans %}</option>
{% for c in all_classrooms %}
<option value="{{ c.id }}" {% if classroom_id == c.id %}selected{% endif %}>{{ c.building_name }} - {{ c.code }}</option>
{% endfor %}
</select>
</div>
<!-- Shift Filter --> <!-- Shift Filter -->
<div class="col-lg-2 col-md-4"> <div class="col-lg-2 col-md-3">
<label class="form-label small fw-bold text-muted mb-1"> <label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %} <i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
</label> </label>
@@ -79,14 +92,22 @@
</select> </select>
</div> </div>
<!-- Date Range --> <!-- Date Range From -->
<div class="col-lg-3 col-md-4"> <div class="col-lg-2 col-md-3">
<label class="form-label small fw-bold text-muted mb-1"> <label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-calendar-event me-1"></i>{% trans %}Desde Fecha{% endtrans %} <i class="bi bi-calendar-event me-1"></i>{% trans %}Desde Fecha{% endtrans %}
</label> </label>
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from or '' }}"> <input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from or '' }}">
</div> </div>
<!-- Date Range To -->
<div class="col-lg-2 col-md-3">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-calendar-event me-1"></i>{% trans %}Hasta Fecha{% endtrans %}
</label>
<input type="date" name="date_to" class="form-control form-control-sm" value="{{ date_to or '' }}">
</div>
<!-- Row 2: Hide Virtual, Submit, Clear --> <!-- Row 2: Hide Virtual, Submit, Clear -->
<div class="col-lg-6 col-md-6 pt-2"> <div class="col-lg-6 col-md-6 pt-2">
<div class="form-check form-switch pt-1"> <div class="form-check form-switch pt-1">
@@ -185,6 +206,12 @@
<i class="bi bi-camera-video"></i> <i class="bi bi-camera-video"></i>
</a> </a>
{% endif %} {% endif %}
{% if current_user.can_edit_reservation(reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="{% trans %}Modificar Reserva{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}" <a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}"> class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
@@ -210,7 +237,7 @@
<ul class="pagination pagination-sm mb-0"> <ul class="pagination pagination-sm mb-0">
{% if reservations.has_prev %} {% if reservations.has_prev %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}"> <a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-left"></i> <i class="bi bi-chevron-left"></i>
</a> </a>
</li> </li>
@@ -222,7 +249,7 @@
<li class="page-item active"><span class="page-link">{{ p }}</span></li> <li class="page-item active"><span class="page-link">{{ p }}</span></li>
{% else %} {% else %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a> <a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a>
</li> </li>
{% endif %} {% endif %}
{% else %} {% else %}
@@ -232,7 +259,7 @@
{% if reservations.has_next %} {% if reservations.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}"> <a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, classroom_id=classroom_id or '', hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-right"></i> <i class="bi bi-chevron-right"></i>
</a> </a>
</li> </li>
+11
View File
@@ -220,6 +220,11 @@
<i class="bi bi-camera-video me-1"></i>Zoom/Meet <i class="bi bi-camera-video me-1"></i>Zoom/Meet
</a> </a>
{% endif %} {% endif %}
{% if current_user.can_edit_reservation(item.reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-warning py-0 px-2" title="{% trans %}Modificar Reserva{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha Detallada{% endtrans %}"> <a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha Detallada{% endtrans %}">
<i class="bi bi-eye me-1"></i>{% trans %}Detalles{% endtrans %} <i class="bi bi-eye me-1"></i>{% trans %}Detalles{% endtrans %}
</a> </a>
@@ -299,6 +304,12 @@
<i class="bi bi-camera-video"></i> <i class="bi bi-camera-video"></i>
</a> </a>
{% endif %} {% endif %}
{% if current_user.can_edit_reservation(reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="{% trans %}Modificar Reserva{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}" <a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}"> class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
+13 -2
View File
@@ -10,9 +10,14 @@
<h1 class="h3"> <h1 class="h3">
<i class="bi bi-calendar-check text-info"></i> Reservation Details <i class="bi bi-calendar-check text-info"></i> Reservation Details
</h1> </h1>
<div> <div class="d-flex gap-2">
{% if current_user.can_edit_reservation(reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}" class="btn btn-warning">
<i class="bi bi-pencil-square me-1"></i>{% trans %}Modificar Reserva{% endtrans %}
</a>
{% endif %}
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to List <i class="bi bi-arrow-left me-1"></i>{% trans %}Back to List{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
@@ -225,6 +230,12 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="d-grid gap-2"> <div class="d-grid gap-2">
{% if current_user.can_edit_reservation(reservation) %}
<a href="{{ url_for('schedule.edit_reservation', id=reservation.id) }}" class="btn btn-warning">
<i class="bi bi-pencil-square me-1"></i>{% trans %}Modificar Reserva{% endtrans %}
</a>
{% endif %}
{% if reservation.status.value == 'PENDING' %} {% if reservation.status.value == 'PENDING' %}
<form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"> <form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}">
<button type="submit" class="btn btn-success" onclick="return confirm('Confirm this reservation?')"> <button type="submit" class="btn btn-success" onclick="return confirm('Confirm this reservation?')">
+23
View File
@@ -39,3 +39,26 @@ def admin_required(f):
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
def can_edit_reservation_required(f):
"""
Decorator requiring permission to modify a specific reservation.
Allows administrators, Bedelía, or the creator/teacher of the reservation.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login', next=request.url))
res_id = kwargs.get('id')
if res_id:
from app.models.reservation import Reservation
reservation = Reservation.query.get_or_404(res_id)
if not current_user.can_edit_reservation(reservation):
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
abort(403)
return f(*args, **kwargs)
return decorated_function