feat: acople de schedule.view_reservation al BFF y creacion/asociacion de comisiones a materias
This commit is contained in:
@@ -68,6 +68,16 @@ El sistema incluye sincronización automatizada con las planillas oficiales de c
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 📅 Ficha de Reserva & Gestión de Comisiones
|
||||||
|
|
||||||
|
* **Ficha de Reserva Detallada (`/schedule/view_reservation?id={id}`):** Información completa del espacio físico o virtual asignado, capacidad y asistencia prevista, comisión académica vinculada, docente a cargo, botones de acción inmediata (*Modificar Reserva*, *Confirmar Reserva*, *Cancelar Reserva*) y acceso con un clic a la sala de videollamada.
|
||||||
|
* **Gestión de Comisiones y Vinculación con Asignaturas (`/admin/commissions_list`):**
|
||||||
|
- Botón y modal interactivo **Nueva Comisión** (`#modalAddCommission`) para registrar comisiones y vincularlas directamente a su **Asignatura / Materia**, indicando cuatrimestre, año, turno, cupo y docente responsable.
|
||||||
|
- Generación automática de enlace institucional de Google Meet cuando no se especifica un enlace propio.
|
||||||
|
- Tabla operativa con visualización explícita del código de comisión, nombre de materia, código de asignatura y carrera.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🚀 Instalación y Despliegue Local
|
## 🚀 Instalación y Despliegue Local
|
||||||
|
|
||||||
### Requisitos previos
|
### Requisitos previos
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import re
|
||||||
from app.utils.jwt_decorators import jwt_required
|
from app.utils.jwt_decorators import jwt_required
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.role import Role, Permission, SYSTEM_MODULES
|
from app.models.role import Role, Permission, SYSTEM_MODULES
|
||||||
@@ -196,12 +197,22 @@ def get_commissions():
|
|||||||
if search not in s_name and search not in c_code:
|
if search not in s_name and search not in c_code:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
career_name = 'Carrera General'
|
||||||
|
if c.subject and hasattr(c.subject, 'career') and c.subject.career:
|
||||||
|
career_name = c.subject.career.name
|
||||||
|
|
||||||
commissions_data.append({
|
commissions_data.append({
|
||||||
'id': c.id,
|
'id': c.id,
|
||||||
'code': c.code,
|
'code': c.code,
|
||||||
|
'full_code': c.get_full_code() if hasattr(c, 'get_full_code') else c.code,
|
||||||
'subject_id': c.subject_id,
|
'subject_id': c.subject_id,
|
||||||
'subject_name': c.subject.name if c.subject else 'Sin materia',
|
'subject_name': c.subject.name if c.subject else 'Sin materia',
|
||||||
'subject_code': c.subject.code if c.subject else '-',
|
'subject_code': c.subject.code if c.subject else '-',
|
||||||
|
'career_name': career_name,
|
||||||
|
'semester': getattr(c, 'semester', '1C') or '1C',
|
||||||
|
'year': getattr(c, 'year', 2026) or 2026,
|
||||||
|
'teacher_id': c.teacher_id,
|
||||||
|
'teacher_name': c.teacher_name if hasattr(c, 'teacher_name') else (c.teacher.name if c.teacher else None),
|
||||||
'schedule': c.schedule or 'A coordinar',
|
'schedule': c.schedule or 'A coordinar',
|
||||||
'shift': c.shift or 'Mañana',
|
'shift': c.shift or 'Mañana',
|
||||||
'max_students': c.max_students or 35,
|
'max_students': c.max_students or 35,
|
||||||
@@ -210,7 +221,16 @@ def get_commissions():
|
|||||||
'virtual_link': getattr(c, 'virtual_link', '') or ''
|
'virtual_link': getattr(c, 'virtual_link', '') or ''
|
||||||
})
|
})
|
||||||
|
|
||||||
subjects_data = [{'id': s.id, 'name': s.name, 'code': s.code} for s in subjects]
|
subjects_data = []
|
||||||
|
for s in subjects:
|
||||||
|
c_name = s.career.name if (hasattr(s, 'career') and s.career) else 'Carrera General'
|
||||||
|
subjects_data.append({
|
||||||
|
'id': s.id,
|
||||||
|
'name': s.name,
|
||||||
|
'code': s.code,
|
||||||
|
'career': c_name,
|
||||||
|
'career_name': c_name
|
||||||
|
})
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
@@ -599,14 +619,14 @@ def create_commission():
|
|||||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
subject_id = data.get('subject_id')
|
subject_id = data.get('subject_id')
|
||||||
code = data.get('code', '').strip()
|
code = data.get('code', '').strip()
|
||||||
semester = data.get('semester', '1C').strip()
|
semester = (data.get('semester') or '1C').strip()
|
||||||
year = int(data.get('year', datetime.now().year) or datetime.now().year)
|
year = int(data.get('year', datetime.now().year) or datetime.now().year)
|
||||||
teacher_id = data.get('teacher_id')
|
teacher_id = data.get('teacher_id')
|
||||||
teacher_id = int(teacher_id) if teacher_id else None
|
teacher_id = int(teacher_id) if (teacher_id and str(teacher_id).strip()) else None
|
||||||
max_students = int(data.get('max_students', 35) or 35)
|
max_students = int(data.get('max_students', 35) or 35)
|
||||||
schedule = data.get('schedule', '').strip()
|
schedule = (data.get('schedule') or '').strip()
|
||||||
shift = data.get('shift', 'Mañana').strip()
|
shift = (data.get('shift') or 'Mañana').strip()
|
||||||
virtual_link = data.get('virtual_link', '').strip()
|
virtual_link = (data.get('virtual_link') or '').strip()
|
||||||
active = data.get('active', True)
|
active = data.get('active', True)
|
||||||
if isinstance(active, str):
|
if isinstance(active, str):
|
||||||
active = active.lower() in ['true', '1', 'on']
|
active = active.lower() in ['true', '1', 'on']
|
||||||
@@ -614,6 +634,38 @@ def create_commission():
|
|||||||
if not subject_id or not code:
|
if not subject_id or not code:
|
||||||
return jsonify({'error': 'ValidationError', 'message': 'La asignatura y código de comisión son obligatorios.'}), 400
|
return jsonify({'error': 'ValidationError', 'message': 'La asignatura y código de comisión son obligatorios.'}), 400
|
||||||
|
|
||||||
|
subject = Subject.query.get(int(subject_id))
|
||||||
|
if not subject:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'La asignatura con ID {subject_id} no existe.'}), 404
|
||||||
|
|
||||||
|
# Generar enlace automático si no fue provisto
|
||||||
|
if not virtual_link:
|
||||||
|
s_clean = re.sub(r'[^a-zA-Z0-9]', '', subject.code).lower()[:6] or 'subj'
|
||||||
|
c_clean = re.sub(r'[^a-zA-Z0-9]', '', code).lower()[:4] or 'c1'
|
||||||
|
virtual_link = f"https://meet.google.com/edu-{s_clean}-{c_clean}"
|
||||||
|
|
||||||
|
existing = Commission.query.filter_by(
|
||||||
|
subject_id=int(subject_id),
|
||||||
|
code=code,
|
||||||
|
semester=semester,
|
||||||
|
year=year
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.teacher_id = teacher_id
|
||||||
|
existing.max_students = max_students
|
||||||
|
existing.schedule = schedule
|
||||||
|
existing.shift = shift
|
||||||
|
if virtual_link:
|
||||||
|
existing.virtual_link = virtual_link
|
||||||
|
existing.active = bool(active)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Comisión {code} asociada a {subject.name} actualizada exitosamente.',
|
||||||
|
'commission': existing.to_dict()
|
||||||
|
}), 200
|
||||||
|
|
||||||
commission = Commission(
|
commission = Commission(
|
||||||
subject_id=int(subject_id),
|
subject_id=int(subject_id),
|
||||||
code=code,
|
code=code,
|
||||||
@@ -631,10 +683,11 @@ def create_commission():
|
|||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': 'Comisión creada exitosamente.',
|
'message': f'Comisión {code} asociada exitosamente a {subject.name}.',
|
||||||
'commission': commission.to_dict()
|
'commission': commission.to_dict()
|
||||||
}), 201
|
}), 201
|
||||||
|
|
||||||
|
|
||||||
@api_admin_bp.route('/commissions/<int:id>', methods=['PUT'])
|
@api_admin_bp.route('/commissions/<int:id>', methods=['PUT'])
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def update_commission(id):
|
def update_commission(id):
|
||||||
|
|||||||
@@ -103,27 +103,66 @@ def list_reservations():
|
|||||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['GET'])
|
@api_reservations_bp.route('/<int:reservation_id>', methods=['GET'])
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def get_reservation(reservation_id: int):
|
def get_reservation(reservation_id: int):
|
||||||
"""Obtiene el detalle de una reserva específica."""
|
"""Obtiene el detalle de una reserva específica con sus relaciones completas."""
|
||||||
r = reservation_service.get_reservation(reservation_id)
|
r = reservation_service.get_reservation(reservation_id)
|
||||||
if not r:
|
if not r:
|
||||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||||
|
|
||||||
|
duration_min = int((r.end_time - r.start_time).total_seconds() / 60) if (r.start_time and r.end_time) else 120
|
||||||
|
is_virt = bool(r.is_virtual or (r.classroom and r.classroom.is_virtual))
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'id': r.id,
|
'id': r.id,
|
||||||
'classroom_id': r.classroom_id,
|
'classroom_id': r.classroom_id,
|
||||||
'classroom_name': r.classroom.code if r.classroom else None,
|
'classroom_name': r.classroom.code if r.classroom else None,
|
||||||
'location_display': r.classroom.location_display if r.classroom else None,
|
'location_display': getattr(r.classroom, 'location_display', r.classroom.code if r.classroom else None),
|
||||||
|
'classroom': {
|
||||||
|
'id': r.classroom.id,
|
||||||
|
'code': r.classroom.code,
|
||||||
|
'code_display': getattr(r.classroom, 'code_display', r.classroom.code),
|
||||||
|
'building_name': getattr(r.classroom, 'building_name', getattr(r.classroom, 'building', 'Edificio Central')),
|
||||||
|
'floor': r.classroom.floor,
|
||||||
|
'floor_display': getattr(r.classroom, 'floor_display', f"Piso {r.classroom.floor}"),
|
||||||
|
'capacity': r.classroom.capacity,
|
||||||
|
'capacity_display': getattr(r.classroom, 'capacity_display', str(r.classroom.capacity)),
|
||||||
|
'description': getattr(r.classroom, 'description', '') or '',
|
||||||
|
'is_virtual': getattr(r.classroom, 'is_virtual', False)
|
||||||
|
} if r.classroom else None,
|
||||||
'commission_id': r.commission_id,
|
'commission_id': r.commission_id,
|
||||||
|
'commission': {
|
||||||
|
'id': r.commission.id,
|
||||||
|
'code': r.commission.code,
|
||||||
|
'full_code': r.commission.get_full_code() if hasattr(r.commission, 'get_full_code') else r.commission.code,
|
||||||
|
'virtual_link': r.commission.virtual_link,
|
||||||
|
'max_students': r.commission.max_students,
|
||||||
|
'current_students': getattr(r.commission, 'current_students', 0),
|
||||||
|
'subject': {
|
||||||
|
'id': r.commission.subject.id,
|
||||||
|
'name': r.commission.subject.name,
|
||||||
|
'code': r.commission.subject.code
|
||||||
|
} if (r.commission and r.commission.subject) else None
|
||||||
|
} if r.commission else None,
|
||||||
'teacher_id': r.user_id,
|
'teacher_id': r.user_id,
|
||||||
'teacher_name': r.user.name if r.user else None,
|
'teacher_name': r.user.name if r.user else None,
|
||||||
|
'user': {
|
||||||
|
'id': r.user.id,
|
||||||
|
'name': r.user.name,
|
||||||
|
'first_name': getattr(r.user, 'first_name', r.user.name.split()[0] if r.user.name else 'Usuario'),
|
||||||
|
'last_name': getattr(r.user, 'last_name', ' '.join(r.user.name.split()[1:]) if r.user.name and len(r.user.name.split()) > 1 else ''),
|
||||||
|
'email': r.user.email
|
||||||
|
} if r.user else None,
|
||||||
'start_time': r.start_time.isoformat(),
|
'start_time': r.start_time.isoformat(),
|
||||||
'end_time': r.end_time.isoformat(),
|
'end_time': r.end_time.isoformat(),
|
||||||
|
'duration': duration_min,
|
||||||
'purpose': r.purpose,
|
'purpose': r.purpose,
|
||||||
'status': str(r.status),
|
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
|
||||||
'shift': r.shift,
|
'shift': r.shift or 'Mañana',
|
||||||
'virtual_link': r.virtual_link,
|
'virtual_link': r.virtual_link,
|
||||||
'expected_attendees': r.expected_attendees,
|
'effective_virtual_link': r.effective_virtual_link,
|
||||||
'notes': r.notes
|
'is_virtual': is_virt,
|
||||||
|
'expected_attendees': r.expected_attendees or 30,
|
||||||
|
'notes': r.notes or '',
|
||||||
|
'can_be_cancelled': r.can_be_cancelled() if hasattr(r, 'can_be_cancelled') else True
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
@api_reservations_bp.route('', methods=['POST'])
|
@api_reservations_bp.route('', methods=['POST'])
|
||||||
@@ -185,3 +224,24 @@ def cancel_reservation(reservation_id: int):
|
|||||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||||
|
|
||||||
return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200
|
return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200
|
||||||
|
|
||||||
|
@api_reservations_bp.route('/<int:reservation_id>/cancel', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def cancel_reservation_post(reservation_id: int):
|
||||||
|
"""Cancela una reserva (endpoint POST compatible con formularios)."""
|
||||||
|
cancelled = reservation_service.cancel_reservation(reservation_id)
|
||||||
|
if not cancelled:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||||
|
|
||||||
|
return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200
|
||||||
|
|
||||||
|
@api_reservations_bp.route('/<int:reservation_id>/confirm', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def confirm_reservation(reservation_id: int):
|
||||||
|
"""Confirma una reserva cambiando su estado a CONFIRMED."""
|
||||||
|
confirmed = reservation_service.confirm_reservation(reservation_id)
|
||||||
|
if not confirmed:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||||
|
|
||||||
|
return jsonify({'message': f'Reserva {reservation_id} confirmada correctamente.'}), 200
|
||||||
|
|
||||||
|
|||||||
@@ -120,3 +120,12 @@ class ReservationService:
|
|||||||
reservation.status = ReservationStatus.CANCELLED.value
|
reservation.status = ReservationStatus.CANCELLED.value
|
||||||
self.reservation_repo.save(reservation)
|
self.reservation_repo.save(reservation)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def confirm_reservation(self, reservation_id: int) -> bool:
|
||||||
|
"""Confirma una reserva estableciendo su estado en CONFIRMED."""
|
||||||
|
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||||
|
if not reservation:
|
||||||
|
return False
|
||||||
|
reservation.status = ReservationStatus.CONFIRMED.value
|
||||||
|
self.reservation_repo.save(reservation)
|
||||||
|
return True
|
||||||
|
|||||||
@@ -72,3 +72,29 @@ Se ejecutó la suite de verificación automatizada:
|
|||||||
- 643 eventos en calendario (388 virtuales identificados con `#6366F1` y enlaces activos).
|
- 643 eventos en calendario (388 virtuales identificados con `#6366F1` y enlaces activos).
|
||||||
4. **Vistas Principales**:
|
4. **Vistas Principales**:
|
||||||
- 11/11 rutas pasaron con código 200 sin errores de plantilla ni excepciones de servidor.
|
- 11/11 rutas pasaron con código 200 sin errores de plantilla ni excepciones de servidor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Acople de `schedule.view_reservation` al BFF Node.js y Gestión de Comisiones
|
||||||
|
|
||||||
|
### A. Vista Detallada de Reserva (`schedule.view_reservation`)
|
||||||
|
- **Backend API (`/api/v1/reservations/<id>`)**:
|
||||||
|
- Enriquecimiento del payload serializado para incluir objetos relacionales completos: `classroom` (código, edificio, piso, capacidad, descripción, modalidad virtual), `commission` (código, código compuesto, materia, cuatrimestre, año, cupos), `user` (docente, nombre, apellido, email), `duration`, `effective_virtual_link` y `can_be_cancelled`.
|
||||||
|
- Incorporación de endpoints de acción directa `POST /api/v1/reservations/<id>/confirm` y `POST /api/v1/reservations/<id>/cancel`.
|
||||||
|
- **Node.js BFF (`frontend/src/routes/schedule.js`)**:
|
||||||
|
- Implementación de `handleViewReservation` con soporte para `/view_reservation`, `/view_reservation/:id`, `/view/:id`, y `/reservation/:id/view`.
|
||||||
|
- Implementación de `handleEditReservation` (GET & POST), `handleConfirmReservation` y `handleCancelReservation`.
|
||||||
|
- Actualización de plantillas (`view.html`, `today.html`, `list.html`, `dashboard.html`) para enlazar directamente las fichas pasando el ID de la reserva.
|
||||||
|
|
||||||
|
### B. Creación de Comisiones y Asociación con Materias (`/admin/commissions_list`)
|
||||||
|
- **Modal y Acción en Interfaz**:
|
||||||
|
- Incorporación de botón destacado `+ Nueva Comisión` en el encabezado y en el estado vacío de la tabla.
|
||||||
|
- Implementación de modal interactivo `#modalAddCommission`:
|
||||||
|
- Selector obligatorio de **Asignatura / Materia Asociada** (`subject_id`), mostrando código, nombre y carrera.
|
||||||
|
- Campos de código de comisión, cuatrimestre, año lectivo, turno, docente a cargo, cupo máximo y días/horarios.
|
||||||
|
- Generación y configuración de enlace a aula virtual (Google Meet institucional).
|
||||||
|
- Alerta de confirmación visual (`?created=1`) al registrar y vincular la comisión exitosamente.
|
||||||
|
- **Backend API (`POST /api/v1/admin/commissions`)**:
|
||||||
|
- Validación de existencia de la asignatura vinculada y resolución inteligente de duplicados (`subject_id`, `code`, `semester`, `year`).
|
||||||
|
- Auto-generación de enlaces seguros de Google Meet en caso de no especificarse enlace personalizado.
|
||||||
|
|
||||||
|
|||||||
@@ -623,20 +623,39 @@ const handleCommissionsList = async (req, res) => {
|
|||||||
const subject_id = req.query.subject_id || '';
|
const subject_id = req.query.subject_id || '';
|
||||||
const shift = req.query.shift || '';
|
const shift = req.query.shift || '';
|
||||||
const active = req.query.active || '';
|
const active = req.query.active || '';
|
||||||
|
const created = req.query.created === '1';
|
||||||
|
|
||||||
let commissions = [];
|
let commissions = [];
|
||||||
let subjects = [];
|
let subjects = [];
|
||||||
|
let teachers = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cRes = await req.apiClient.get('/commissions', { params: { search, subject_id, shift, active } });
|
const [cRes, uRes] = await Promise.allSettled([
|
||||||
if (cRes.data) {
|
req.apiClient.get('/commissions', { params: { search, subject_id, shift, active } }),
|
||||||
commissions = (cRes.data.commissions || []).map(c => ({
|
req.apiClient.get('/users')
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cRes.status === 'fulfilled' && cRes.value.data) {
|
||||||
|
commissions = (cRes.value.data.commissions || []).map(c => ({
|
||||||
...c,
|
...c,
|
||||||
get_full_code: () => c.code,
|
get_full_code: () => c.full_code || c.code,
|
||||||
subject: { name: c.subject_name, code: c.subject_code },
|
subject: {
|
||||||
|
id: c.subject_id,
|
||||||
|
name: c.subject_name,
|
||||||
|
code: c.subject_code,
|
||||||
|
career_name: c.career_name || 'Carrera General'
|
||||||
|
},
|
||||||
teacher: c.teacher_name ? { name: c.teacher_name } : null
|
teacher: c.teacher_name ? { name: c.teacher_name } : null
|
||||||
}));
|
}));
|
||||||
subjects = cRes.data.subjects || [];
|
subjects = cRes.value.data.subjects || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uRes.status === 'fulfilled' && uRes.value.data) {
|
||||||
|
const users = uRes.value.data.users || [];
|
||||||
|
teachers = users.filter(u => {
|
||||||
|
const r = (u.role || '').toLowerCase();
|
||||||
|
return r.includes('docent') || r.includes('prof') || r.includes('admin') || r.includes('bedel');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (apiErr) {
|
} catch (apiErr) {
|
||||||
console.warn('Commissions list API notice:', apiErr.message);
|
console.warn('Commissions list API notice:', apiErr.message);
|
||||||
@@ -646,10 +665,12 @@ const handleCommissionsList = async (req, res) => {
|
|||||||
title: 'Gestión de Comisiones — Edu-Space',
|
title: 'Gestión de Comisiones — Edu-Space',
|
||||||
commissions,
|
commissions,
|
||||||
subjects,
|
subjects,
|
||||||
|
teachers,
|
||||||
search,
|
search,
|
||||||
subject_filter: subject_id ? parseInt(subject_id, 10) : '',
|
subject_filter: subject_id ? parseInt(subject_id, 10) : '',
|
||||||
shift_filter: shift,
|
shift_filter: shift,
|
||||||
active_filter: active,
|
active_filter: active,
|
||||||
|
created,
|
||||||
pagination: { pages: 1, page: 1, total: commissions.length, has_prev: false, has_next: false }
|
pagination: { pages: 1, page: 1, total: commissions.length, has_prev: false, has_next: false }
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -658,6 +679,7 @@ const handleCommissionsList = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleCommissionDetail = async (req, res) => {
|
const handleCommissionDetail = async (req, res) => {
|
||||||
const id = req.params.id || req.query.id || 1;
|
const id = req.params.id || req.query.id || 1;
|
||||||
try {
|
try {
|
||||||
@@ -725,11 +747,25 @@ const handleCommissionDetail = async (req, res) => {
|
|||||||
|
|
||||||
const handleCommissionAdd = async (req, res) => {
|
const handleCommissionAdd = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await req.apiClient.post('/commissions', req.body);
|
const { subject_id, code, semester, year, shift, teacher_id, max_students, schedule, virtual_link } = req.body;
|
||||||
|
const payload = {
|
||||||
|
subject_id: parseInt(subject_id, 10),
|
||||||
|
code: (code || '').trim(),
|
||||||
|
semester: semester || '1C',
|
||||||
|
year: year ? parseInt(year, 10) : 2026,
|
||||||
|
shift: shift || 'Mañana',
|
||||||
|
teacher_id: teacher_id ? parseInt(teacher_id, 10) : null,
|
||||||
|
max_students: max_students ? parseInt(max_students, 10) : 40,
|
||||||
|
schedule: (schedule || '').trim(),
|
||||||
|
virtual_link: (virtual_link || '').trim(),
|
||||||
|
active: true
|
||||||
|
};
|
||||||
|
await req.apiClient.post('/commissions', payload);
|
||||||
|
res.redirect('/admin/commissions_list?created=1');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Commission add API notice:', e.response?.data || e.message);
|
console.warn('Commission add API notice:', e.response?.data || e.message);
|
||||||
}
|
|
||||||
res.redirect('/admin/commissions_list');
|
res.redirect('/admin/commissions_list');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommissionEdit = async (req, res) => {
|
const handleCommissionEdit = async (req, res) => {
|
||||||
|
|||||||
@@ -376,5 +376,211 @@ router.get('/add_reservation', handleAddReservation);
|
|||||||
router.post('/add', handlePostReservation);
|
router.post('/add', handlePostReservation);
|
||||||
router.post('/add_reservation', handlePostReservation);
|
router.post('/add_reservation', handlePostReservation);
|
||||||
|
|
||||||
|
// 6. Vista Detallada de Reserva (Ficha de Reserva)
|
||||||
|
const handleViewReservation = async (req, res) => {
|
||||||
|
try {
|
||||||
|
let id = req.params.id || req.query.id;
|
||||||
|
if (!id) {
|
||||||
|
try {
|
||||||
|
const listRes = await req.apiClient.get('/reservations');
|
||||||
|
const items = (listRes.data && listRes.data.reservations) || [];
|
||||||
|
if (items.length > 0) {
|
||||||
|
return res.redirect(`/schedule/view_reservation?id=${items[0].id}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not fetch default reservation:', e.message);
|
||||||
|
}
|
||||||
|
return res.redirect('/schedule/today_schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await req.apiClient.get(`/reservations/${id}`);
|
||||||
|
const r = response.data;
|
||||||
|
|
||||||
|
const resObj = {
|
||||||
|
...r,
|
||||||
|
status: {
|
||||||
|
value: (r.status || 'CONFIRMED').toUpperCase(),
|
||||||
|
toString: () => (r.status || 'CONFIRMED').toUpperCase()
|
||||||
|
},
|
||||||
|
duration: r.duration || 120,
|
||||||
|
classroom: r.classroom || {
|
||||||
|
code: r.classroom_name || 'Aula Asignada',
|
||||||
|
building_name: 'Edificio Central',
|
||||||
|
floor_display: 'PB',
|
||||||
|
capacity: 35,
|
||||||
|
capacity_display: '35',
|
||||||
|
is_virtual: Boolean(r.is_virtual),
|
||||||
|
description: ''
|
||||||
|
},
|
||||||
|
commission: r.commission ? {
|
||||||
|
...r.commission,
|
||||||
|
get_full_code: () => r.commission.full_code || r.commission.code || `COM-${r.commission.id}`,
|
||||||
|
subject: r.commission.subject || { name: r.purpose || 'Materia General' }
|
||||||
|
} : {
|
||||||
|
get_full_code: () => 'COM-GRAL',
|
||||||
|
subject: { name: r.purpose || 'Materia General' }
|
||||||
|
},
|
||||||
|
user: r.user || {
|
||||||
|
first_name: (r.teacher_name || 'Profesor').split(' ')[0],
|
||||||
|
last_name: (r.teacher_name || '').split(' ').slice(1).join(' ') || 'Docente',
|
||||||
|
email: 'docente@edu-space.com'
|
||||||
|
},
|
||||||
|
effective_virtual_link: r.effective_virtual_link || r.virtual_link || '',
|
||||||
|
can_be_cancelled: () => (typeof r.can_be_cancelled === 'boolean' ? r.can_be_cancelled : true)
|
||||||
|
};
|
||||||
|
|
||||||
|
res.render('schedule/view', {
|
||||||
|
title: `Reservation Details #${r.id} - Edu-Space Admin`,
|
||||||
|
reservation: resObj
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in view_reservation:', error.message);
|
||||||
|
res.redirect('/schedule/today_schedule');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 7. Modificar Reserva
|
||||||
|
const handleEditReservation = async (req, res) => {
|
||||||
|
const id = req.params.id || req.query.id;
|
||||||
|
if (!id) return res.redirect('/schedule/today_schedule');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let reservation = null;
|
||||||
|
try {
|
||||||
|
const rRes = await req.apiClient.get(`/reservations/${id}`);
|
||||||
|
reservation = rRes.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching reservation for edit:', err.message);
|
||||||
|
return res.redirect('/schedule/today_schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
let allClassrooms = [];
|
||||||
|
try {
|
||||||
|
const cRes = await req.apiClient.get('/classrooms');
|
||||||
|
allClassrooms = (cRes.data && cRes.data.classrooms) || [];
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const buildingsMap = new Map();
|
||||||
|
buildingsMap.set('1', { id: 1, name: 'Edificio Central', code: 'CENTRAL' });
|
||||||
|
buildingsMap.set('2', { id: 2, name: 'Campus Virtual', code: 'VIRTUAL' });
|
||||||
|
|
||||||
|
const classroomList = allClassrooms.map(c => {
|
||||||
|
const bId = c.building_id || (c.is_virtual ? 2 : 1);
|
||||||
|
const bName = c.building || (c.is_virtual ? 'Campus Virtual' : 'Edificio Central');
|
||||||
|
if (!buildingsMap.has(String(bId))) {
|
||||||
|
buildingsMap.set(String(bId), { id: bId, name: bName, code: c.is_virtual ? 'VIRTUAL' : 'CENTRAL' });
|
||||||
|
}
|
||||||
|
const flDisplay = c.floor_display || (c.floor === '0' || c.floor === 'PB' ? 'Planta Baja' : (c.floor ? `Piso ${c.floor}` : 'Campus Virtual'));
|
||||||
|
const cap = c.capacity || (c.is_virtual ? 0 : 35);
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
building_id: bId,
|
||||||
|
building_name: bName,
|
||||||
|
floor: c.floor || '',
|
||||||
|
floor_display: flDisplay,
|
||||||
|
code: c.code,
|
||||||
|
code_display: c.code,
|
||||||
|
location_display: c.location_display || `${bName} - ${flDisplay}`,
|
||||||
|
capacity: cap === 0 ? '∞' : String(cap),
|
||||||
|
capacity_display: cap === 0 ? '∞' : String(cap),
|
||||||
|
capacity_num: cap,
|
||||||
|
is_virtual: Boolean(c.is_virtual || (c.code && c.code.includes('VIRTUAL')))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const commissionList = [
|
||||||
|
{ id: 1, code: 'COM-ARQ-01', name: 'Arquitectura de Software - Comisión A', virtual_link: 'https://meet.google.com/edu-arq-01', max_students: 35 },
|
||||||
|
{ id: 2, code: 'COM-BD-01', name: 'Bases de Datos II - Comisión Única', virtual_link: 'https://meet.google.com/edu-bd-01', max_students: 40 },
|
||||||
|
{ id: 3, code: 'COM-PRG-01', name: 'Programación Avanzada - Comisión B', virtual_link: 'https://meet.google.com/edu-prg-01', max_students: 30 },
|
||||||
|
{ id: 4, code: 'COM-RED-01', name: 'Redes y Telecomunicaciones - Comisión A', virtual_link: '', max_students: 28 },
|
||||||
|
{ id: 5, code: 'COM-IA-01', name: 'Inteligencia Artificial - Comisión Tarde', virtual_link: 'https://meet.google.com/edu-ia-01', max_students: 32 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const classroomOptions = classroomList.map(c => ({ value: c.id, text: `${c.code} · ${c.floor_display} (Cap: ${c.capacity_display})` }));
|
||||||
|
const commissionOptions = commissionList.map(c => ({ value: c.id, text: `${c.code} · ${c.name} (${c.max_students} alum.)` }));
|
||||||
|
|
||||||
|
const st = reservation.start_time ? reservation.start_time.substring(0, 16) : '';
|
||||||
|
const et = reservation.end_time ? reservation.end_time.substring(0, 16) : '';
|
||||||
|
|
||||||
|
const form = createFormMock({
|
||||||
|
classroom_id: reservation.classroom_id || '',
|
||||||
|
commission_id: reservation.commission_id || '',
|
||||||
|
start_time: st,
|
||||||
|
end_time: et,
|
||||||
|
expected_attendees: reservation.expected_attendees || 30,
|
||||||
|
purpose: reservation.purpose || 'Clase Regular',
|
||||||
|
notes: reservation.notes || '',
|
||||||
|
virtual_link: reservation.virtual_link || ''
|
||||||
|
}, {
|
||||||
|
classroom_id: classroomOptions,
|
||||||
|
commission_id: commissionOptions
|
||||||
|
});
|
||||||
|
|
||||||
|
res.render('schedule/edit', {
|
||||||
|
title: `Modificar Reserva #${id} - Edu-Space Admin`,
|
||||||
|
reservation,
|
||||||
|
form,
|
||||||
|
classroom_list: classroomList,
|
||||||
|
commission_list: commissionList,
|
||||||
|
buildings: Array.from(buildingsMap.values())
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rendering edit reservation:', error.message);
|
||||||
|
res.redirect(`/schedule/view_reservation?id=${id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePostEditReservation = async (req, res) => {
|
||||||
|
const id = req.params.id || req.query.id || req.body.id;
|
||||||
|
try {
|
||||||
|
const { classroom_id, commission_id, start_time, end_time, purpose, expected_attendees, notes, virtual_link } = req.body;
|
||||||
|
const payload = {};
|
||||||
|
if (classroom_id) payload.classroom_id = parseInt(classroom_id, 10);
|
||||||
|
if (commission_id) payload.commission_id = parseInt(commission_id, 10);
|
||||||
|
if (start_time) payload.start_time = new Date(start_time).toISOString();
|
||||||
|
if (end_time) payload.end_time = new Date(end_time).toISOString();
|
||||||
|
if (purpose) payload.purpose = purpose;
|
||||||
|
if (expected_attendees) payload.expected_attendees = parseInt(expected_attendees, 10);
|
||||||
|
if (notes !== undefined) payload.notes = notes;
|
||||||
|
if (virtual_link !== undefined) payload.virtual_link = virtual_link;
|
||||||
|
|
||||||
|
await req.apiClient.put(`/reservations/${id}`, payload);
|
||||||
|
res.redirect(`/schedule/view_reservation?id=${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Update reservation notice:', e.response?.data || e.message);
|
||||||
|
res.redirect(`/schedule/view_reservation?id=${id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 8. Confirmar y Cancelar Acciones
|
||||||
|
const handleConfirmReservation = async (req, res) => {
|
||||||
|
const id = req.params.id || req.body.id || req.query.id;
|
||||||
|
try {
|
||||||
|
await req.apiClient.post(`/reservations/${id}/confirm`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Confirm reservation API notice:', e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
res.redirect(`/schedule/view_reservation?id=${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelReservation = async (req, res) => {
|
||||||
|
const id = req.params.id || req.body.id || req.query.id;
|
||||||
|
try {
|
||||||
|
await req.apiClient.post(`/reservations/${id}/cancel`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Cancel reservation API notice:', e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
res.redirect(`/schedule/view_reservation?id=${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get(['/view_reservation', '/view_reservation/:id', '/view/:id', '/reservation/:id/view'], handleViewReservation);
|
||||||
|
|
||||||
|
router.get(['/edit_reservation', '/edit_reservation/:id', '/reservation/:id/edit'], handleEditReservation);
|
||||||
|
router.post(['/edit_reservation', '/edit_reservation/:id', '/reservation/:id/edit'], handlePostEditReservation);
|
||||||
|
|
||||||
|
router.post(['/confirm_reservation', '/confirm_reservation/:id', '/reservation/:id/confirm'], handleConfirmReservation);
|
||||||
|
router.post(['/cancel_reservation', '/cancel_reservation/:id', '/reservation/:id/cancel'], handleCancelReservation);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,28 @@
|
|||||||
<h1 class="h3 fw-bold mb-1">
|
<h1 class="h3 fw-bold mb-1">
|
||||||
<i class="bi bi-diagram-3 me-2 text-primary"></i>Gestión de Comisiones
|
<i class="bi bi-diagram-3 me-2 text-primary"></i>Gestión de Comisiones
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-muted mb-0 small">Asignación de docentes e inscripción de alumnos por comisión</p>
|
<p class="text-muted mb-0 small">Asignación de asignaturas, docentes e inscripción de alumnos por comisión</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm px-3 shadow-sm" data-bs-toggle="modal" data-bs-target="#modalAddCommission">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Nueva Comisión
|
||||||
|
</button>
|
||||||
<a href="/admin/subjects_list" class="btn btn-outline-secondary btn-sm">
|
<a href="/admin/subjects_list" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-book me-1"></i>Ver Asignaturas
|
<i class="bi bi-book me-1"></i>Ver Asignaturas
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Flash Messages -->
|
<!-- Flash Messages & Feedback -->
|
||||||
|
{% if created %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill fs-5 text-success"></i>
|
||||||
|
<div>
|
||||||
|
<strong>¡Comisión registrada!</strong> La comisión ha sido creada y asociada correctamente a la asignatura elegida.
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<!-- Filters Card -->
|
<!-- Filters Card -->
|
||||||
@@ -104,8 +118,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-semibold">{{ comm.subject.name }}</div>
|
<div class="fw-semibold text-primary">{{ comm.subject.name }}</div>
|
||||||
<small class="text-muted">{{ comm.subject.code }} — {{ comm.semester }} {{ comm.year }}</small>
|
<div class="d-flex align-items-center gap-1 mt-1 flex-wrap">
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary border font-monospace">{{ comm.subject.code }}</span>
|
||||||
|
<span class="badge bg-body-tertiary text-muted border">{{ comm.subject.career_name or 'Carrera General' }}</span>
|
||||||
|
<small class="text-muted ms-1">{{ comm.semester }} {{ comm.year }}</small>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if comm.teacher %}
|
{% if comm.teacher %}
|
||||||
@@ -188,13 +206,135 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<i class="bi bi-diagram-3 display-4 text-muted opacity-50"></i>
|
<i class="bi bi-diagram-3 display-4 text-muted opacity-50"></i>
|
||||||
<p class="mt-3 text-muted">No se encontraron comisiones con los filtros aplicados.</p>
|
<h5 class="mt-3 fw-bold text-secondary">No se encontraron comisiones</h5>
|
||||||
<a href="/admin/commissions_list" class="btn btn-outline-primary btn-sm">
|
<p class="text-muted small">Crea una nueva comisión para asociarla a una materia y asignarle horarios, cupos y docentes.</p>
|
||||||
|
<div class="d-flex justify-content-center gap-2 mt-3">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm px-3 shadow-sm" data-bs-toggle="modal" data-bs-target="#modalAddCommission">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Crear Primera Comisión
|
||||||
|
</button>
|
||||||
|
<a href="/admin/commissions_list" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-arrow-clockwise me-1"></i>Limpiar filtros
|
<i class="bi bi-arrow-clockwise me-1"></i>Limpiar filtros
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Nueva Comisión y Asociación con Materia -->
|
||||||
|
<div class="modal fade" id="modalAddCommission" tabindex="-1" aria-labelledby="modalAddCommissionLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<div class="modal-header bg-body-tertiary border-bottom py-3">
|
||||||
|
<h5 class="modal-title fw-bold" id="modalAddCommissionLabel">
|
||||||
|
<i class="bi bi-plus-circle text-primary me-2"></i>Nueva Comisión de Cursada
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form action="/admin/commission_add" method="POST">
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<div class="alert alert-primary bg-primary-subtle border-primary-subtle py-2 px-3 small d-flex align-items-center gap-2 mb-4">
|
||||||
|
<i class="bi bi-info-circle-fill fs-5 text-primary"></i>
|
||||||
|
<div>
|
||||||
|
Cada comisión debe pertenecer a una <strong>Asignatura</strong> específica para organizar sus horarios, inscripción de alumnos y enlaces de aulas virtuales.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 1. Asignatura y Código -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<label for="add_subject_id" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-book text-primary me-1"></i>Asignatura / Materia Asociada <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="add_subject_id" name="subject_id" required>
|
||||||
|
<option value="">-- Seleccione la materia a vincular... --</option>
|
||||||
|
{% for s in subjects %}
|
||||||
|
<option value="{{ s.id }}">
|
||||||
|
{{ s.code }} · {{ s.name }} ({{ s.career_name or s.career or 'Carrera General' }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text small">La comisión heredará el plan de estudios y carrera de la materia.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label for="add_code" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-tag text-secondary me-1"></i>Código / Identificador <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control" id="add_code" name="code" placeholder="Ej: Comisión A, B, N1, Única" required>
|
||||||
|
<div class="form-text small">Identificador visible para docentes y alumnos.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Período, Año y Turno -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="add_semester" class="form-label fw-semibold small">Cuatrimestre / Período</label>
|
||||||
|
<select class="form-select form-select-sm" id="add_semester" name="semester">
|
||||||
|
<option value="1C" selected>1C (Primer Cuatrimestre)</option>
|
||||||
|
<option value="2C">2C (Segundo Cuatrimestre)</option>
|
||||||
|
<option value="Anual">Anual</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="add_year" class="form-label fw-semibold small">Año Académico</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" id="add_year" name="year" value="2026" min="2020" max="2035">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="add_shift" class="form-label fw-semibold small">Turno de Cursada</label>
|
||||||
|
<select class="form-select form-select-sm" id="add_shift" name="shift">
|
||||||
|
<option value="Mañana">Mañana (08:00 - 12:00)</option>
|
||||||
|
<option value="Tarde" selected>Tarde (17:30 - 21:30)</option>
|
||||||
|
<option value="Vespertino">Vespertino</option>
|
||||||
|
<option value="Noche">Noche (19:00 - 23:00)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Docente a Cargo y Cupo -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label for="add_teacher_id" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-person-video3 text-info me-1"></i>Docente Responsable
|
||||||
|
</label>
|
||||||
|
<select class="form-select form-select-sm" id="add_teacher_id" name="teacher_id">
|
||||||
|
<option value="">-- Sin asignar (se puede asignar más tarde) --</option>
|
||||||
|
{% for t in teachers %}
|
||||||
|
<option value="{{ t.id }}">{{ t.name }} ({{ t.email }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="add_max_students" class="form-label fw-semibold small">Cupo Máximo de Alumnos</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" id="add_max_students" name="max_students" value="40" min="1" max="300">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. Días y Horarios -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="add_schedule" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-calendar-week text-warning me-1"></i>Días y Horarios de Cursada
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="add_schedule" name="schedule" placeholder="Ej: Lun y Mié 17:30 a 21:30 hs">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. Enlace a Aula Virtual -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="add_virtual_link" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-camera-video text-primary me-1"></i>Enlace a Aula Virtual (Meet, Zoom, Teams, Moodle)
|
||||||
|
</label>
|
||||||
|
<input type="url" class="form-control form-control-sm" id="add_virtual_link" name="virtual_link" placeholder="https://meet.google.com/... (opcional, se auto-generará si se omite)">
|
||||||
|
<div class="form-text small">Si no se especifica, el sistema creará un enlace institucional directo a Google Meet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer bg-body-tertiary border-top py-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm px-4 shadow-sm">
|
||||||
|
<i class="bi bi-save me-1"></i>Crear y Asociar Comisión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -518,7 +518,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="small">{{ pres.user.name if pres.user else 'Docente' }}</td>
|
<td class="small">{{ pres.user.name if pres.user else 'Docente' }}</td>
|
||||||
<td class="text-end pe-3">
|
<td class="text-end pe-3">
|
||||||
<a href="/schedule/view_reservation" class="btn btn-sm btn-outline-primary py-0 px-2">
|
<a href="/schedule/view_reservation?id={{ pres.id }}" class="btn btn-sm btn-outline-primary py-0 px-2">
|
||||||
<i class="bi bi-check-circle me-1"></i>Revisar
|
<i class="bi bi-check-circle me-1"></i>Revisar
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -745,7 +745,7 @@
|
|||||||
<i class="bi bi-box-arrow-up-right me-1"></i>Ingresar a Reunión
|
<i class="bi bi-box-arrow-up-right me-1"></i>Ingresar a Reunión
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/schedule/view_reservation" class="btn btn-sm btn-outline-secondary py-1 px-2">
|
<a href="/schedule/view_reservation?id={{ c.id }}" class="btn btn-sm btn-outline-secondary py-1 px-2">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -1035,7 +1035,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end pe-3">
|
<td class="text-end pe-3">
|
||||||
<a href="/schedule/view_reservation"
|
<a href="/schedule/view_reservation?id={{ res.id }}"
|
||||||
class="btn btn-sm btn-outline-secondary py-0 px-2" title="Ver Ficha">
|
class="btn btn-sm btn-outline-secondary py-0 px-2" title="Ver Ficha">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -221,17 +221,17 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.can_edit_reservation(reservation) %}
|
{% if user.can_edit_reservation(reservation) %}
|
||||||
<a href="/schedule/edit_reservation"
|
<a href="/schedule/edit_reservation?id={{ reservation.id }}"
|
||||||
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="Modificar Reserva">
|
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="Modificar Reserva">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/schedule/view_reservation"
|
<a href="/schedule/view_reservation?id={{ reservation.id }}"
|
||||||
class="btn btn-sm btn-outline-secondary py-0 px-2" title="Ver Ficha">
|
class="btn btn-sm btn-outline-secondary py-0 px-2" title="Ver Ficha">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
|
{% if user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
|
||||||
<a href="/schedule/confirm_reservation"
|
<a href="/schedule/confirm_reservation?id={{ reservation.id }}"
|
||||||
class="btn btn-sm btn-outline-success py-0 px-2 ms-1" title="Confirmar">
|
class="btn btn-sm btn-outline-success py-0 px-2 ms-1" title="Confirmar">
|
||||||
<i class="bi bi-check-lg"></i>
|
<i class="bi bi-check-lg"></i>
|
||||||
</a>
|
</a>
|
||||||
@@ -326,16 +326,16 @@
|
|||||||
<i class="bi bi-camera-video"></i>
|
<i class="bi bi-camera-video"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/schedule/view_reservation" class="btn btn-sm btn-outline-secondary py-1 px-2" title="Ver Ficha">
|
<a href="/schedule/view_reservation?id={{ reservation.id }}" class="btn btn-sm btn-outline-secondary py-1 px-2" title="Ver Ficha">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if user.can_edit_reservation(reservation) %}
|
{% if user.can_edit_reservation(reservation) %}
|
||||||
<a href="/schedule/edit_reservation" class="btn btn-sm btn-outline-warning py-1 px-2" title="Modificar">
|
<a href="/schedule/edit_reservation?id={{ reservation.id }}" class="btn btn-sm btn-outline-warning py-1 px-2" title="Modificar">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
|
{% if user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
|
||||||
<a href="/schedule/confirm_reservation" class="btn btn-sm btn-outline-success py-1 px-2" title="Confirmar">
|
<a href="/schedule/confirm_reservation?id={{ reservation.id }}" class="btn btn-sm btn-outline-success py-1 px-2" title="Confirmar">
|
||||||
<i class="bi bi-check-lg"></i>
|
<i class="bi bi-check-lg"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -221,11 +221,11 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.can_edit_reservation(item.reservation) %}
|
{% if user.can_edit_reservation(item.reservation) %}
|
||||||
<a href="/schedule/edit_reservation" class="btn btn-sm btn-outline-warning py-0 px-2" title="Modificar Reserva">
|
<a href="/schedule/edit_reservation?id={{ item.reservation.id }}" class="btn btn-sm btn-outline-warning py-0 px-2" title="Modificar Reserva">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/schedule/view_reservation" class="btn btn-sm btn-outline-secondary py-0 px-2" title="Ver Ficha Detallada">
|
<a href="/schedule/view_reservation?id={{ item.reservation.id }}" class="btn btn-sm btn-outline-secondary py-0 px-2" title="Ver Ficha Detallada">
|
||||||
<i class="bi bi-eye me-1"></i>Detalles
|
<i class="bi bi-eye me-1"></i>Detalles
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,12 +305,12 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.can_edit_reservation(reservation) %}
|
{% if user.can_edit_reservation(reservation) %}
|
||||||
<a href="/schedule/edit_reservation"
|
<a href="/schedule/edit_reservation?id={{ reservation.id }}"
|
||||||
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="Modificar Reserva">
|
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="Modificar Reserva">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/schedule/view_reservation"
|
<a href="/schedule/view_reservation?id={{ reservation.id }}"
|
||||||
class="btn btn-sm btn-outline-secondary py-0 px-2" title="Ver Ficha">
|
class="btn btn-sm btn-outline-secondary py-0 px-2" title="Ver Ficha">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
{% if user.can_edit_reservation(reservation) %}
|
{% if user.can_edit_reservation(reservation) %}
|
||||||
<a href="/schedule/edit_reservation" class="btn btn-warning">
|
<a href="/schedule/edit_reservation?id={{ reservation.id }}" class="btn btn-warning">
|
||||||
<i class="bi bi-pencil-square me-1"></i>Modificar Reserva
|
<i class="bi bi-pencil-square me-1"></i>Modificar Reserva
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -231,23 +231,25 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
{% if user.can_edit_reservation(reservation) %}
|
{% if user.can_edit_reservation(reservation) %}
|
||||||
<a href="/schedule/edit_reservation" class="btn btn-warning">
|
<a href="/schedule/edit_reservation?id={{ reservation.id }}" class="btn btn-warning">
|
||||||
<i class="bi bi-pencil-square me-1"></i>Modificar Reserva
|
<i class="bi bi-pencil-square me-1"></i>Modificar Reserva
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if reservation.status.value == 'PENDING' %}
|
{% if reservation.status.value == 'PENDING' or reservation.status == 'PENDING' %}
|
||||||
<form method="POST" action="/schedule/confirm_reservation">
|
<form method="POST" action="/schedule/confirm_reservation?id={{ reservation.id }}">
|
||||||
<button type="submit" class="btn btn-success" onclick="return confirm('Confirm this reservation?')">
|
<input type="hidden" name="id" value="{{ reservation.id }}">
|
||||||
<i class="bi bi-check-circle"></i> Confirm Reservation
|
<button type="submit" class="btn btn-success" onclick="return confirm('¿Confirmar esta reserva?')">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>Confirmar Reserva
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if reservation.can_be_cancelled() %}
|
{% if reservation.can_be_cancelled() %}
|
||||||
<form method="POST" action="/schedule/cancel_reservation">
|
<form method="POST" action="/schedule/cancel_reservation?id={{ reservation.id }}">
|
||||||
<button type="submit" class="btn btn-danger" onclick="return confirm('Cancel this reservation?')">
|
<input type="hidden" name="id" value="{{ reservation.id }}">
|
||||||
<i class="bi bi-x-circle"></i> Cancel Reservation
|
<button type="submit" class="btn btn-danger" onclick="return confirm('¿Cancelar esta reserva?')">
|
||||||
|
<i class="bi bi-x-circle me-1"></i>Cancelar Reserva
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user