feat: optimizacion zocalos aulas, metricas ocupacion dia/semana/mes, modificacion de reservas y permisos RBAC en gestion
This commit is contained in:
@@ -27,6 +27,10 @@ class ReservationForm(FlaskForm):
|
||||
])
|
||||
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):
|
||||
if field.data <= self.start_time.data:
|
||||
raise ValidationError('End time must be after start time.')
|
||||
@@ -36,8 +40,8 @@ class ReservationForm(FlaskForm):
|
||||
raise ValidationError('Reservation cannot exceed 8 hours.')
|
||||
|
||||
def validate_start_time(self, field):
|
||||
# Don't allow reservations in the past
|
||||
if field.data < datetime.now():
|
||||
# Don't allow new reservations in the past
|
||||
if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10):
|
||||
raise ValidationError('Start time cannot be in the past.')
|
||||
|
||||
# Don't allow reservations too far in the future (max 3 months)
|
||||
|
||||
+8
-1
@@ -22,7 +22,7 @@ SYSTEM_MODULES = [
|
||||
'name': 'Reservas de Aulas',
|
||||
'name_en': 'Classroom Reservations',
|
||||
'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',
|
||||
@@ -65,6 +65,13 @@ SYSTEM_MODULES = [
|
||||
'name_en': 'Milestone Types',
|
||||
'icon': 'bi-tags',
|
||||
'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
@@ -59,9 +59,31 @@ class User(UserMixin, db.Model):
|
||||
self.is_admin() or
|
||||
self.has_permission('users', '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):
|
||||
"""Update last login timestamp"""
|
||||
|
||||
@@ -122,13 +122,80 @@ def list_classrooms():
|
||||
)
|
||||
)
|
||||
|
||||
classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate(
|
||||
page=page, per_page=12, error_out=False
|
||||
classrooms = query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
|
||||
page=page, per_page=20, 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()]
|
||||
|
||||
# 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',
|
||||
classrooms=classrooms,
|
||||
buildings=buildings,
|
||||
@@ -137,6 +204,8 @@ def list_classrooms():
|
||||
floor_filter=floor_filter,
|
||||
active_only=active_only,
|
||||
search=search,
|
||||
occupancy_metrics=occupancy_metrics,
|
||||
date_filters=date_filters,
|
||||
title='Classrooms')
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -139,6 +139,92 @@ def view_reservation(id):
|
||||
flash(f'Error loading reservation details: {str(e)}', 'error')
|
||||
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')
|
||||
@login_required
|
||||
def calendar_view():
|
||||
@@ -518,6 +604,7 @@ def list_reservations():
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
floor_filter = request.args.get('floor', '').strip()
|
||||
shift_filter = request.args.get('shift', '').strip()
|
||||
classroom_id = request.args.get('classroom_id', type=int)
|
||||
search = request.args.get('search', '').strip()
|
||||
|
||||
query = Reservation.query.options(
|
||||
@@ -526,6 +613,9 @@ def list_reservations():
|
||||
joinedload(Reservation.user)
|
||||
).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':
|
||||
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()
|
||||
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(
|
||||
'schedule/list.html',
|
||||
@@ -585,6 +676,8 @@ def list_reservations():
|
||||
hide_virtual=hide_virtual,
|
||||
floor_filter=floor_filter,
|
||||
shift_filter=shift_filter,
|
||||
classroom_id=classroom_id,
|
||||
all_classrooms=all_classrooms,
|
||||
search=search,
|
||||
available_floors=available_floors
|
||||
)
|
||||
|
||||
@@ -109,8 +109,11 @@
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li>
|
||||
<li><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('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><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>
|
||||
|
||||
@@ -2,6 +2,86 @@
|
||||
|
||||
{% 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 %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="row">
|
||||
@@ -88,86 +168,113 @@
|
||||
|
||||
<!-- Classrooms Grid -->
|
||||
{% if classrooms and classrooms.items %}
|
||||
<div class="row mt-4">
|
||||
<div class="row g-3 mt-1">
|
||||
{% for classroom in classrooms.items %}
|
||||
<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-2">
|
||||
<div>
|
||||
<h5 class="card-title mb-1 fw-bold text-primary">
|
||||
{{ classroom.code }}
|
||||
</h5>
|
||||
<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 class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
|
||||
<div class="card classroom-card border shadow-sm h-100">
|
||||
<div class="card-body p-3 d-flex flex-column justify-content-between">
|
||||
<div>
|
||||
<!-- Encabezado con Código y Estado -->
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div class="text-truncate me-2">
|
||||
<h5 class="card-title mb-0 fw-bold text-primary text-truncate fs-6" title="{{ classroom.code }}">
|
||||
<i class="bi bi-door-open me-1"></i>{{ classroom.code }}
|
||||
</h5>
|
||||
</div>
|
||||
<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;">
|
||||
{% if classroom.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %}
|
||||
</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>
|
||||
<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 py-1 bg-body-tertiary rounded px-2 mb-2">
|
||||
<div class="col-6">
|
||||
<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>
|
||||
{% else %}
|
||||
{{ classroom.capacity }}
|
||||
{% endif %}
|
||||
</strong>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Barra de Métricas de Ocupación (Día, Semana, Mes) -->
|
||||
{% 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 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">
|
||||
<span class="small fw-semibold text-muted" style="font-size: 0.72rem;">
|
||||
<i class="bi bi-graph-up me-1 text-primary"></i>{% trans %}Ocupación{% endtrans %}
|
||||
</span>
|
||||
<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 %}">
|
||||
<i class="bi bi-info-circle me-1"></i>{% trans %}Ver reservas{% endtrans %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row g-1 text-center">
|
||||
<!-- Día -->
|
||||
<div class="col-4">
|
||||
<a href="{{ url_for('schedule.list_reservations', classroom_id=classroom.id, date_from=date_filters.today, date_to=date_filters.today) }}"
|
||||
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 %}"
|
||||
title="{% trans %}Ocupación Hoy{% endtrans %}: {{ metrics.day_count }} {% trans %}clases registradas{% endtrans %}">
|
||||
<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>
|
||||
|
||||
{% if classroom.description %}
|
||||
<p class="card-text small text-muted mt-2">
|
||||
{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %}
|
||||
</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>
|
||||
|
||||
<!-- Footer con Botones Compactos -->
|
||||
<div class="pt-2 border-top d-flex justify-content-between align-items-center">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> {% trans %}View{% endtrans %}
|
||||
class="btn btn-outline-primary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}" style="font-size: 0.75rem;">
|
||||
<i class="bi bi-eye me-1"></i>{% trans %}Ficha{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i> {% trans %}Edit{% endtrans %}
|
||||
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>
|
||||
</a>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}"
|
||||
class="d-inline">
|
||||
<button type="submit" class="btn btn-sm
|
||||
{% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
|
||||
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}" class="d-inline mb-0">
|
||||
<button type="submit" class="btn btn-sm py-0 px-2 {% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
|
||||
title="{% if classroom.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}" style="font-size: 0.75rem;">
|
||||
{% if classroom.is_active %}
|
||||
<i class="bi bi-pause-circle"></i> {% trans %}Deactivate{% endtrans %}
|
||||
<i class="bi bi-pause"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-play-circle"></i> {% trans %}Activate{% endtrans %}
|
||||
<i class="bi bi-play"></i>
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -277,9 +277,16 @@
|
||||
|
||||
<!-- Footer de Acciones Rápidas -->
|
||||
<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">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Ver Ficha Completa{% endtrans %}
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
<a id="previewFullLink" href="#" class="btn btn-outline-primary btn-sm px-3">
|
||||
<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">
|
||||
{% trans %}Cerrar{% endtrans %}
|
||||
</button>
|
||||
@@ -991,11 +998,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Enlaces y Acciones
|
||||
var detailUrl = info.event.url || ('/schedule/reservation/' + info.event.id + '/view');
|
||||
var editUrl = '/schedule/reservation/' + info.event.id + '/edit';
|
||||
var fullLink = document.getElementById('previewFullLink');
|
||||
if (fullLink) fullLink.href = detailUrl;
|
||||
|
||||
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');
|
||||
if (cancelForm) {
|
||||
|
||||
@@ -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 %}
|
||||
@@ -67,8 +67,21 @@
|
||||
</select>
|
||||
</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 -->
|
||||
<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">
|
||||
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
|
||||
</label>
|
||||
@@ -79,14 +92,22 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date Range -->
|
||||
<div class="col-lg-3 col-md-4">
|
||||
<!-- Date Range From -->
|
||||
<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 %}Desde Fecha{% endtrans %}
|
||||
</label>
|
||||
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from or '' }}">
|
||||
</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 -->
|
||||
<div class="col-lg-6 col-md-6 pt-2">
|
||||
<div class="form-check form-switch pt-1">
|
||||
@@ -185,6 +206,12 @@
|
||||
<i class="bi bi-camera-video"></i>
|
||||
</a>
|
||||
{% 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) }}"
|
||||
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
@@ -210,7 +237,7 @@
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if reservations.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to, 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>
|
||||
</a>
|
||||
</li>
|
||||
@@ -222,7 +249,7 @@
|
||||
<li class="page-item active"><span class="page-link">{{ p }}</span></li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a>
|
||||
<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>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
@@ -232,7 +259,7 @@
|
||||
|
||||
{% if reservations.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to, 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>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -220,6 +220,11 @@
|
||||
<i class="bi bi-camera-video me-1"></i>Zoom/Meet
|
||||
</a>
|
||||
{% 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 %}">
|
||||
<i class="bi bi-eye me-1"></i>{% trans %}Detalles{% endtrans %}
|
||||
</a>
|
||||
@@ -299,6 +304,12 @@
|
||||
<i class="bi bi-camera-video"></i>
|
||||
</a>
|
||||
{% 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) }}"
|
||||
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
|
||||
@@ -10,9 +10,14 @@
|
||||
<h1 class="h3">
|
||||
<i class="bi bi-calendar-check text-info"></i> Reservation Details
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,6 +230,12 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<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' %}
|
||||
<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?')">
|
||||
|
||||
@@ -39,3 +39,26 @@ def admin_required(f):
|
||||
|
||||
return f(*args, **kwargs)
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user