feat: implementar ABM completo de comisiones, carreras y asignaturas con proteccion de integridad y corregir edificios y campus virtual
This commit is contained in:
@@ -17,10 +17,40 @@ class Building(db.Model):
|
|||||||
# Relationships
|
# Relationships
|
||||||
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
|
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_virtual(self):
|
||||||
|
"""Identifica si la sede corresponde al Campus Virtual"""
|
||||||
|
c = (self.code or '').upper()
|
||||||
|
n = (self.name or '').upper()
|
||||||
|
return 'VIRTUAL' in c or 'VIRTUAL' in n
|
||||||
|
|
||||||
|
@property
|
||||||
|
def physical_capacity(self):
|
||||||
|
"""Capacidad física instalada real (excluye aulas virtuales con capacidad 9999 o infinitas)"""
|
||||||
|
if self.is_virtual:
|
||||||
|
return 0
|
||||||
|
return sum(c.capacity for c in self.classrooms if c.is_active and (c.capacity or 0) < 999 and not getattr(c, 'is_virtual', False))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_capacity(self):
|
def total_capacity(self):
|
||||||
"""Total student capacity across active classrooms in this building"""
|
"""Capacidad para compatibilidad histórica"""
|
||||||
return sum(c.capacity for c in self.classrooms if c.is_active)
|
if self.is_virtual:
|
||||||
|
return 0
|
||||||
|
return self.physical_capacity
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capacity_display(self):
|
||||||
|
"""Representación textual clara para UI/UX"""
|
||||||
|
if self.is_virtual:
|
||||||
|
return "Ilimitada (Virtual)"
|
||||||
|
return str(self.physical_capacity)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def floors_display(self):
|
||||||
|
"""Representación de pisos"""
|
||||||
|
if self.is_virtual:
|
||||||
|
return "Digital"
|
||||||
|
return str(self.floors)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def classrooms_count(self):
|
def classrooms_count(self):
|
||||||
@@ -34,10 +64,14 @@ class Building(db.Model):
|
|||||||
'code': self.code,
|
'code': self.code,
|
||||||
'address': self.address,
|
'address': self.address,
|
||||||
'floors': self.floors,
|
'floors': self.floors,
|
||||||
|
'floors_display': self.floors_display,
|
||||||
'description': self.description,
|
'description': self.description,
|
||||||
'is_active': self.is_active,
|
'is_active': self.is_active,
|
||||||
|
'is_virtual': self.is_virtual,
|
||||||
'classrooms_count': self.classrooms_count,
|
'classrooms_count': self.classrooms_count,
|
||||||
'total_capacity': self.total_capacity,
|
'physical_capacity': self.physical_capacity,
|
||||||
|
'total_capacity': self.physical_capacity if not self.is_virtual else 0,
|
||||||
|
'capacity_display': self.capacity_display,
|
||||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,9 @@ def get_subjects():
|
|||||||
'name': s.name,
|
'name': s.name,
|
||||||
'career_id': s.career_id,
|
'career_id': s.career_id,
|
||||||
'career_name': s.career_obj.name if s.career_obj else (s.department or 'General'),
|
'career_name': s.career_obj.name if s.career_obj else (s.department or 'General'),
|
||||||
|
'career_obj': {'id': s.career_obj.id, 'name': s.career_obj.name} if s.career_obj else None,
|
||||||
'department': s.department or 'General',
|
'department': s.department or 'General',
|
||||||
|
'description': getattr(s, 'description', '') or '',
|
||||||
'credits': getattr(s, 'credits', 4),
|
'credits': getattr(s, 'credits', 4),
|
||||||
'is_active': s.is_active,
|
'is_active': s.is_active,
|
||||||
'commissions_count': len(s.commissions),
|
'commissions_count': len(s.commissions),
|
||||||
@@ -162,6 +164,7 @@ def get_careers():
|
|||||||
'id': c.id,
|
'id': c.id,
|
||||||
'name': c.name,
|
'name': c.name,
|
||||||
'code': getattr(c, 'code', f'CAR-{c.id}'),
|
'code': getattr(c, 'code', f'CAR-{c.id}'),
|
||||||
|
'description': getattr(c, 'description', '') or '',
|
||||||
'degree_level': getattr(c, 'degree_level', 'Grado / Tecnicatura'),
|
'degree_level': getattr(c, 'degree_level', 'Grado / Tecnicatura'),
|
||||||
'is_active': getattr(c, 'is_active', True),
|
'is_active': getattr(c, 'is_active', True),
|
||||||
'subjects_count': len(c.subjects),
|
'subjects_count': len(c.subjects),
|
||||||
@@ -217,6 +220,7 @@ def get_commissions():
|
|||||||
'career_name': career_name,
|
'career_name': career_name,
|
||||||
'semester': getattr(c, 'semester', '1C') or '1C',
|
'semester': getattr(c, 'semester', '1C') or '1C',
|
||||||
'year': getattr(c, 'year', 2026) or 2026,
|
'year': getattr(c, 'year', 2026) or 2026,
|
||||||
|
'academic_term_id': getattr(c, 'academic_term_id', None),
|
||||||
'teacher_id': c.teacher_id,
|
'teacher_id': c.teacher_id,
|
||||||
'teacher_name': c.teacher_name if hasattr(c, 'teacher_name') else (c.teacher.name if c.teacher else None),
|
'teacher_name': c.teacher_name if hasattr(c, 'teacher_name') else (c.teacher.name if c.teacher else None),
|
||||||
'teachers': c.teachers if hasattr(c, 'teachers') else [],
|
'teachers': c.teachers if hasattr(c, 'teachers') else [],
|
||||||
@@ -926,6 +930,11 @@ def update_commission(id):
|
|||||||
comm = Commission.query.get_or_404(id)
|
comm = Commission.query.get_or_404(id)
|
||||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
|
||||||
|
if 'subject_id' in data and data['subject_id']:
|
||||||
|
comm.subject_id = int(data['subject_id'])
|
||||||
|
if 'academic_term_id' in data:
|
||||||
|
t_id = data['academic_term_id']
|
||||||
|
comm.academic_term_id = int(t_id) if (t_id and str(t_id).strip()) else None
|
||||||
if 'code' in data and data['code']:
|
if 'code' in data and data['code']:
|
||||||
comm.code = data['code'].strip()
|
comm.code = data['code'].strip()
|
||||||
if 'semester' in data and data['semester']:
|
if 'semester' in data and data['semester']:
|
||||||
@@ -934,7 +943,7 @@ def update_commission(id):
|
|||||||
comm.year = int(data['year'])
|
comm.year = int(data['year'])
|
||||||
if 'teacher_id' in data:
|
if 'teacher_id' in data:
|
||||||
t_id = data['teacher_id']
|
t_id = data['teacher_id']
|
||||||
comm.teacher_id = int(t_id) if t_id else None
|
comm.teacher_id = int(t_id) if (t_id and str(t_id).strip()) else None
|
||||||
if comm.teacher_id:
|
if comm.teacher_id:
|
||||||
ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=comm.teacher_id).first()
|
ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=comm.teacher_id).first()
|
||||||
if not ct:
|
if not ct:
|
||||||
@@ -971,6 +980,33 @@ def toggle_commission(id):
|
|||||||
'message': f'Comisión {"activada" if comm.active else "desactivada"} correctamente.'
|
'message': f'Comisión {"activada" if comm.active else "desactivada"} correctamente.'
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
@api_admin_bp.route('/commissions/<int:id>', methods=['DELETE'])
|
||||||
|
@jwt_required
|
||||||
|
def delete_commission(id):
|
||||||
|
comm = Commission.query.get_or_404(id)
|
||||||
|
reservations_count = len(comm.reservations) if comm.reservations else 0
|
||||||
|
enrollments_count = len(comm.enrollments) if hasattr(comm, 'enrollments') and comm.enrollments else 0
|
||||||
|
|
||||||
|
if reservations_count > 0 or enrollments_count > 0:
|
||||||
|
comm.active = False
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'deactivated',
|
||||||
|
'action': 'deactivated',
|
||||||
|
'message': f'La comisión tiene {reservations_count} reserva(s) y {enrollments_count} alumno(s) inscriptos. Se ha desactivado en su lugar para mantener la consistencia histórica.'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
from app.models.commission_teacher import CommissionTeacher
|
||||||
|
CommissionTeacher.query.filter_by(commission_id=comm.id).delete()
|
||||||
|
code = comm.code
|
||||||
|
db.session.delete(comm)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'deleted',
|
||||||
|
'action': 'deleted',
|
||||||
|
'message': f'Comisión {code} eliminada exitosamente.'
|
||||||
|
}), 200
|
||||||
|
|
||||||
@api_admin_bp.route('/commissions/<int:id>/assign-teacher', methods=['POST'])
|
@api_admin_bp.route('/commissions/<int:id>/assign-teacher', methods=['POST'])
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def assign_commission_teacher(id):
|
def assign_commission_teacher(id):
|
||||||
@@ -2237,3 +2273,202 @@ def retry_all_failed_moodle_tasks():
|
|||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': f'Se reiniciaron {count} tareas fallidas a estado PENDIENTE.'
|
'message': f'Se reiniciaron {count} tareas fallidas a estado PENDIENTE.'
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# BUILDINGS CRUD
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
@api_admin_bp.route('/buildings', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def get_buildings():
|
||||||
|
from app.models.building import Building
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
|
||||||
|
search = request.args.get('search', '').strip().lower()
|
||||||
|
status_filter = request.args.get('status', '').strip().lower()
|
||||||
|
|
||||||
|
query = Building.query
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
Building.name.ilike(f'%{search}%'),
|
||||||
|
Building.code.ilike(f'%{search}%'),
|
||||||
|
Building.address.ilike(f'%{search}%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status_filter == 'active':
|
||||||
|
query = query.filter(Building.is_active == True)
|
||||||
|
elif status_filter == 'inactive':
|
||||||
|
query = query.filter(Building.is_active == False)
|
||||||
|
|
||||||
|
buildings = query.order_by(Building.name.asc()).all()
|
||||||
|
buildings_data = [b.to_dict() for b in buildings]
|
||||||
|
|
||||||
|
# Estadísticas globales: Capacidad física real sin sumar 9999 de aulas virtuales
|
||||||
|
all_classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||||
|
physical_classrooms = [c for c in all_classrooms if not getattr(c, 'is_virtual', False) and (c.capacity or 0) < 999 and 'VIRTUAL' not in (c.code or '').upper()]
|
||||||
|
total_physical_capacity = sum(c.capacity for c in physical_classrooms)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'total': len(buildings_data),
|
||||||
|
'buildings': buildings_data,
|
||||||
|
'stats': {
|
||||||
|
'total_buildings': len(buildings),
|
||||||
|
'total_classrooms': len(all_classrooms),
|
||||||
|
'physical_classrooms_count': len(physical_classrooms),
|
||||||
|
'total_physical_capacity': total_physical_capacity,
|
||||||
|
'total_floors': sum(b.floors for b in buildings if not b.is_virtual)
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_admin_bp.route('/buildings/<int:id>', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def get_building(id):
|
||||||
|
from app.models.building import Building
|
||||||
|
building = Building.query.get(id)
|
||||||
|
if not building:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Edificio con ID {id} no encontrado.'}), 404
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'building': building.to_dict(),
|
||||||
|
'classrooms': [c.to_dict() for c in building.classrooms]
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_admin_bp.route('/buildings', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def create_building():
|
||||||
|
from app.models.building import Building
|
||||||
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
name = (data.get('name') or '').strip()
|
||||||
|
code = (data.get('code') or '').strip().upper() or None
|
||||||
|
address = (data.get('address') or '').strip() or None
|
||||||
|
floors = int(data.get('floors', 1) or 1)
|
||||||
|
description = (data.get('description') or '').strip() or None
|
||||||
|
is_active = data.get('is_active', True)
|
||||||
|
if isinstance(is_active, str):
|
||||||
|
is_active = is_active.lower() in ['true', '1', 'on']
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return jsonify({'error': 'ValidationError', 'message': 'El nombre del edificio es obligatorio.'}), 400
|
||||||
|
|
||||||
|
existing = Building.query.filter(db.func.lower(Building.name) == name.lower()).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'error': 'Conflict', 'message': f'Ya existe un edificio con el nombre "{name}".'}), 409
|
||||||
|
|
||||||
|
building = Building(
|
||||||
|
name=name,
|
||||||
|
code=code,
|
||||||
|
address=address,
|
||||||
|
floors=floors,
|
||||||
|
description=description,
|
||||||
|
is_active=bool(is_active)
|
||||||
|
)
|
||||||
|
db.session.add(building)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Edificio "{name}" creado con éxito.',
|
||||||
|
'building': building.to_dict()
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@api_admin_bp.route('/buildings/<int:id>', methods=['PUT', 'POST'])
|
||||||
|
@jwt_required
|
||||||
|
def update_building(id):
|
||||||
|
from app.models.building import Building
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
building = Building.query.get(id)
|
||||||
|
if not building:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Edificio con ID {id} no encontrado.'}), 404
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
name = (data.get('name') or '').strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({'error': 'ValidationError', 'message': 'El nombre del edificio es obligatorio.'}), 400
|
||||||
|
|
||||||
|
existing = Building.query.filter(
|
||||||
|
db.func.lower(Building.name) == name.lower(),
|
||||||
|
Building.id != id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'error': 'Conflict', 'message': f'Ya existe otro edificio con el nombre "{name}".'}), 409
|
||||||
|
|
||||||
|
old_name = building.name
|
||||||
|
building.name = name
|
||||||
|
if 'code' in data:
|
||||||
|
building.code = (data.get('code') or '').strip().upper() or None
|
||||||
|
if 'address' in data:
|
||||||
|
building.address = (data.get('address') or '').strip() or None
|
||||||
|
if 'floors' in data:
|
||||||
|
building.floors = int(data.get('floors', 0) or 0)
|
||||||
|
if 'description' in data:
|
||||||
|
building.description = (data.get('description') or '').strip() or None
|
||||||
|
if 'is_active' in data:
|
||||||
|
is_active = data.get('is_active')
|
||||||
|
if isinstance(is_active, str):
|
||||||
|
building.is_active = is_active.lower() in ['true', '1', 'on']
|
||||||
|
else:
|
||||||
|
building.is_active = bool(is_active)
|
||||||
|
building.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
if old_name != name:
|
||||||
|
Classroom.query.filter_by(building_id=building.id).update({'building': name})
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Edificio "{building.name}" actualizado con éxito.',
|
||||||
|
'building': building.to_dict()
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_admin_bp.route('/buildings/<int:id>/toggle', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def toggle_building(id):
|
||||||
|
from app.models.building import Building
|
||||||
|
building = Building.query.get(id)
|
||||||
|
if not building:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Edificio con ID {id} no encontrado.'}), 404
|
||||||
|
|
||||||
|
building.is_active = not building.is_active
|
||||||
|
building.updated_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Edificio "{building.name}" {"activado" if building.is_active else "desactivado"} exitosamente.',
|
||||||
|
'building': building.to_dict()
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_admin_bp.route('/buildings/<int:id>', methods=['DELETE'])
|
||||||
|
@jwt_required
|
||||||
|
def delete_building(id):
|
||||||
|
from app.models.building import Building
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
building = Building.query.get(id)
|
||||||
|
if not building:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Edificio con ID {id} no encontrado.'}), 404
|
||||||
|
|
||||||
|
count = Classroom.query.filter(
|
||||||
|
db.or_(Classroom.building_id == building.id, Classroom.building == building.name)
|
||||||
|
).count()
|
||||||
|
if count > 0:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Conflict',
|
||||||
|
'message': f'No se puede eliminar el edificio "{building.name}" porque tiene {count} aulas asociadas.'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
db.session.delete(building)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Edificio "{building.name}" eliminado correctamente.'
|
||||||
|
}), 200
|
||||||
|
|||||||
@@ -32,15 +32,18 @@ def list_buildings():
|
|||||||
|
|
||||||
buildings = query.order_by(Building.name.asc()).all()
|
buildings = query.order_by(Building.name.asc()).all()
|
||||||
|
|
||||||
# Calculate global building stats
|
# Calculate global building stats (excluye aulas virtuales con 9999)
|
||||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||||
total_capacity = sum(c.capacity for c in Classroom.query.filter_by(is_active=True).all())
|
physical_classrooms = [c for c in Classroom.query.filter_by(is_active=True).all() if not getattr(c, 'is_virtual', False) and (c.capacity or 0) < 999 and 'VIRTUAL' not in (c.code or '').upper()]
|
||||||
|
total_physical_capacity = sum(c.capacity for c in physical_classrooms)
|
||||||
|
total_capacity = total_physical_capacity
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'buildings/list.html',
|
'buildings/list.html',
|
||||||
buildings=buildings,
|
buildings=buildings,
|
||||||
total_classrooms=total_classrooms,
|
total_classrooms=total_classrooms,
|
||||||
total_capacity=total_capacity,
|
total_capacity=total_capacity,
|
||||||
|
total_physical_capacity=total_physical_capacity,
|
||||||
search=search,
|
search=search,
|
||||||
status_filter=status_filter,
|
status_filter=status_filter,
|
||||||
title='Building Management'
|
title='Building Management'
|
||||||
|
|||||||
@@ -652,20 +652,13 @@ const handleCareersList = async (req, res) => {
|
|||||||
console.warn('Careers list API notice:', apiErr.message);
|
console.warn('Careers list API notice:', apiErr.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (careers.length === 0) {
|
|
||||||
careers = [
|
|
||||||
{ id: 1, name: 'Licenciatura en Sistemas', code: 'LIC-SIS', description: 'Formación en ingeniería de software y sistemas.' },
|
|
||||||
{ id: 2, name: 'Tecnicatura en Desarrollo de Software', code: 'TEC-DS', description: 'Desarrollo web y aplicaciones.' }
|
|
||||||
];
|
|
||||||
stats[1] = { subjects_count: 8, commissions_count: 12 };
|
|
||||||
stats[2] = { subjects_count: 6, commissions_count: 8 };
|
|
||||||
}
|
|
||||||
|
|
||||||
res.render('admin/careers/list', {
|
res.render('admin/careers/list', {
|
||||||
title: 'Gestión de Carreras - Edu-Space Admin',
|
title: 'Gestión de Carreras - Edu-Space Admin',
|
||||||
careers,
|
careers,
|
||||||
stats,
|
stats,
|
||||||
search,
|
search,
|
||||||
|
msg: req.query.msg || '',
|
||||||
|
error: req.query.error || '',
|
||||||
can_edit: true
|
can_edit: true
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -678,10 +671,12 @@ const handleCareerAdd = async (req, res) => {
|
|||||||
const { name, code, description } = req.body;
|
const { name, code, description } = req.body;
|
||||||
try {
|
try {
|
||||||
await req.apiClient.post('/careers', { name, code, description });
|
await req.apiClient.post('/careers', { name, code, description });
|
||||||
|
res.redirect('/admin/careers_list?msg=created');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Career add API notice:', e.response?.data || e.message);
|
console.warn('Career add API notice:', e.response?.data || e.message);
|
||||||
|
const errCode = e.response?.status === 409 ? 'conflict' : 'create';
|
||||||
|
res.redirect(`/admin/careers_list?error=${errCode}`);
|
||||||
}
|
}
|
||||||
res.redirect('/admin/careers_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCareerEdit = async (req, res) => {
|
const handleCareerEdit = async (req, res) => {
|
||||||
@@ -689,20 +684,24 @@ const handleCareerEdit = async (req, res) => {
|
|||||||
const { name, code, description } = req.body;
|
const { name, code, description } = req.body;
|
||||||
try {
|
try {
|
||||||
await req.apiClient.put(`/careers/${id}`, { name, code, description });
|
await req.apiClient.put(`/careers/${id}`, { name, code, description });
|
||||||
|
res.redirect('/admin/careers_list?msg=updated');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Career edit API notice:', e.response?.data || e.message);
|
console.warn('Career edit API notice:', e.response?.data || e.message);
|
||||||
|
const errCode = e.response?.status === 409 ? 'conflict' : 'edit';
|
||||||
|
res.redirect(`/admin/careers_list?error=${errCode}`);
|
||||||
}
|
}
|
||||||
res.redirect('/admin/careers_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCareerDelete = async (req, res) => {
|
const handleCareerDelete = async (req, res) => {
|
||||||
const id = req.params.id || req.body.id;
|
const id = req.params.id || req.body.id;
|
||||||
try {
|
try {
|
||||||
await req.apiClient.delete(`/careers/${id}`);
|
await req.apiClient.delete(`/careers/${id}`);
|
||||||
|
res.redirect('/admin/careers_list?msg=deleted');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Career delete API notice:', e.response?.data || e.message);
|
console.warn('Career delete API notice:', e.response?.data || e.message);
|
||||||
|
const errCode = e.response?.status === 409 ? 'has_subjects' : 'delete';
|
||||||
|
res.redirect(`/admin/careers_list?error=${errCode}`);
|
||||||
}
|
}
|
||||||
res.redirect('/admin/careers_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
router.get(['/careers_list', '/careers', '/career_list'], handleCareersList);
|
router.get(['/careers_list', '/careers', '/career_list'], handleCareersList);
|
||||||
@@ -767,10 +766,12 @@ const handleSubjectAdd = async (req, res) => {
|
|||||||
description,
|
description,
|
||||||
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
||||||
});
|
});
|
||||||
|
res.redirect('/admin/subjects_list?msg=created');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Subject add API notice:', e.response?.data || e.message);
|
console.warn('Subject add API notice:', e.response?.data || e.message);
|
||||||
|
const errCode = e.response?.status === 409 ? 'conflict' : 'create';
|
||||||
|
res.redirect(`/admin/subjects_list?error=${errCode}`);
|
||||||
}
|
}
|
||||||
res.redirect('/admin/subjects_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubjectEdit = async (req, res) => {
|
const handleSubjectEdit = async (req, res) => {
|
||||||
@@ -786,20 +787,23 @@ const handleSubjectEdit = async (req, res) => {
|
|||||||
description,
|
description,
|
||||||
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
||||||
});
|
});
|
||||||
|
res.redirect('/admin/subjects_list?msg=updated');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Subject edit API notice:', e.response?.data || e.message);
|
console.warn('Subject edit API notice:', e.response?.data || e.message);
|
||||||
|
const errCode = e.response?.status === 409 ? 'conflict' : 'edit';
|
||||||
|
res.redirect(`/admin/subjects_list?error=${errCode}`);
|
||||||
}
|
}
|
||||||
res.redirect('/admin/subjects_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubjectToggle = async (req, res) => {
|
const handleSubjectToggle = async (req, res) => {
|
||||||
const id = req.params.id || req.body.id;
|
const id = req.params.id || req.body.id;
|
||||||
try {
|
try {
|
||||||
await req.apiClient.post(`/subjects/${id}/toggle`);
|
await req.apiClient.post(`/subjects/${id}/toggle`);
|
||||||
|
res.redirect('/admin/subjects_list?msg=toggled');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Subject toggle API notice:', e.message);
|
console.warn('Subject toggle API notice:', e.message);
|
||||||
|
res.redirect('/admin/subjects_list?error=toggle');
|
||||||
}
|
}
|
||||||
res.redirect('/admin/subjects_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubjectDelete = async (req, res) => {
|
const handleSubjectDelete = async (req, res) => {
|
||||||
@@ -888,6 +892,9 @@ const handleCommissionsList = async (req, res) => {
|
|||||||
shift_filter: shift,
|
shift_filter: shift,
|
||||||
active_filter: active,
|
active_filter: active,
|
||||||
created,
|
created,
|
||||||
|
msg: req.query.msg || (created ? 'created' : ''),
|
||||||
|
error: req.query.error || '',
|
||||||
|
can_edit: true,
|
||||||
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) {
|
||||||
@@ -1019,31 +1026,60 @@ const handleCommissionAdd = async (req, res) => {
|
|||||||
active: true
|
active: true
|
||||||
};
|
};
|
||||||
await req.apiClient.post('/commissions', payload);
|
await req.apiClient.post('/commissions', payload);
|
||||||
res.redirect('/admin/commissions_list?created=1');
|
res.redirect('/admin/commissions_list?msg=created');
|
||||||
} 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?error=create');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommissionEdit = async (req, res) => {
|
const handleCommissionEdit = async (req, res) => {
|
||||||
const id = req.params.id || req.body.id;
|
const id = req.params.id || req.body.id;
|
||||||
try {
|
try {
|
||||||
await req.apiClient.put(`/commissions/${id}`, req.body);
|
const { subject_id, code, semester, year, shift, teacher_id, max_students, schedule, virtual_link, academic_term_id, active } = req.body;
|
||||||
|
const payload = {
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
if (subject_id) payload.subject_id = parseInt(subject_id, 10);
|
||||||
|
if (academic_term_id) payload.academic_term_id = parseInt(academic_term_id, 10);
|
||||||
|
if (active !== undefined) payload.active = (active === 'on' || active === 'true' || active === true);
|
||||||
|
|
||||||
|
await req.apiClient.put(`/commissions/${id}`, payload);
|
||||||
|
res.redirect('/admin/commissions_list?msg=updated');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Commission edit API notice:', e.response?.data || e.message);
|
console.warn('Commission edit API notice:', e.response?.data || e.message);
|
||||||
|
res.redirect('/admin/commissions_list?error=edit');
|
||||||
}
|
}
|
||||||
res.redirect('/admin/commissions_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommissionToggle = async (req, res) => {
|
const handleCommissionToggle = async (req, res) => {
|
||||||
const id = req.params.id || req.body.id;
|
const id = req.params.id || req.body.id;
|
||||||
try {
|
try {
|
||||||
await req.apiClient.post(`/commissions/${id}/toggle`);
|
await req.apiClient.post(`/commissions/${id}/toggle`);
|
||||||
|
res.redirect('/admin/commissions_list?msg=toggled');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Commission toggle API notice:', e.message);
|
console.warn('Commission toggle API notice:', e.message);
|
||||||
|
res.redirect('/admin/commissions_list?error=toggle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommissionDelete = async (req, res) => {
|
||||||
|
const id = req.params.id || req.body.id;
|
||||||
|
try {
|
||||||
|
const delRes = await req.apiClient.delete(`/commissions/${id}`);
|
||||||
|
const action = delRes.data?.action || 'deleted';
|
||||||
|
res.redirect(`/admin/commissions_list?msg=${action}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Commission delete API notice:', e.response?.data || e.message);
|
||||||
|
res.redirect('/admin/commissions_list?error=delete');
|
||||||
}
|
}
|
||||||
res.redirect('/admin/commissions_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommissionAssignTeacher = async (req, res) => {
|
const handleCommissionAssignTeacher = async (req, res) => {
|
||||||
@@ -1132,6 +1168,7 @@ router.get(['/commission_detail', '/commission_detail/:id', '/commissions/:id'],
|
|||||||
router.post(['/commission_add', '/commissions/add'], handleCommissionAdd);
|
router.post(['/commission_add', '/commissions/add'], handleCommissionAdd);
|
||||||
router.post(['/commission_edit', '/commission_edit/:id'], handleCommissionEdit);
|
router.post(['/commission_edit', '/commission_edit/:id'], handleCommissionEdit);
|
||||||
router.post(['/commission_toggle', '/commission_toggle/:id'], handleCommissionToggle);
|
router.post(['/commission_toggle', '/commission_toggle/:id'], handleCommissionToggle);
|
||||||
|
router.post(['/commission_delete', '/commission_delete/:id', '/commissions/:id/delete'], handleCommissionDelete);
|
||||||
router.post(['/commission_assign_teacher', '/commission_assign_teacher/:id'], handleCommissionAssignTeacher);
|
router.post(['/commission_assign_teacher', '/commission_assign_teacher/:id'], handleCommissionAssignTeacher);
|
||||||
router.post(['/commission_add_teacher', '/commissions/:id/teachers', '/commissions/teachers/add'], handleCommissionAddTeacher);
|
router.post(['/commission_add_teacher', '/commissions/:id/teachers', '/commissions/teachers/add'], handleCommissionAddTeacher);
|
||||||
router.post(['/commission_remove_teacher', '/commissions/:id/teachers/delete', '/commissions/teachers/remove'], handleCommissionRemoveTeacher);
|
router.post(['/commission_remove_teacher', '/commissions/:id/teachers/delete', '/commissions/teachers/remove'], handleCommissionRemoveTeacher);
|
||||||
|
|||||||
@@ -5,112 +5,120 @@ const { createFormMock } = require('../utils/formHelper');
|
|||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
// Mock storage of buildings to support CRUD in memory/BFF layer
|
// Fallback data si la API estuviera inaccesible (con capacidad física real)
|
||||||
let buildingsData = [
|
const defaultBuildings = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
code: 'CENTRAL',
|
code: 'CENTRAL',
|
||||||
name: 'Edificio Central',
|
name: 'Edificio Central',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
address: 'Av. Paseo Colón 255, CABA',
|
is_virtual: false,
|
||||||
description: 'Sede académica principal, aulas de grado, laboratorios y auditorio.',
|
address: 'Tte. Gral. Juan Domingo Perón 802, CABA',
|
||||||
|
description: 'Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.',
|
||||||
floors: 4,
|
floors: 4,
|
||||||
classrooms_count: 29,
|
classrooms_count: 29,
|
||||||
total_capacity: 1250
|
physical_capacity: 1250,
|
||||||
|
total_capacity: 1250,
|
||||||
|
capacity_display: '1250'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
code: 'VIRTUAL',
|
code: 'VIRTUAL',
|
||||||
name: 'Campus Virtual',
|
name: 'Campus Virtual',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
address: 'campus.unicaba.edu.ar',
|
is_virtual: true,
|
||||||
|
address: 'campus.unicaba.edu.ar / MS Teams & Moodle',
|
||||||
description: 'Entorno de aprendizaje digital, aulas sincrónicas y videoconferencias.',
|
description: 'Entorno de aprendizaje digital, aulas sincrónicas y videoconferencias.',
|
||||||
floors: 1,
|
floors: 0,
|
||||||
classrooms_count: 2,
|
classrooms_count: 2,
|
||||||
total_capacity: 9999
|
physical_capacity: 0,
|
||||||
|
total_capacity: 0,
|
||||||
|
capacity_display: 'Ilimitada (Virtual)'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper to compute live totals
|
|
||||||
const getLiveStats = async (apiClient) => {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get('/classrooms');
|
|
||||||
const classrooms = (response.data && response.data.classrooms) || [];
|
|
||||||
const physical = classrooms.filter(c => !c.is_virtual && (!c.code || !c.code.includes('VIRTUAL')));
|
|
||||||
const virtual = classrooms.filter(c => c.is_virtual || (c.code && c.code.includes('VIRTUAL')));
|
|
||||||
|
|
||||||
const physCap = physical.reduce((acc, c) => acc + (c.capacity || 0), 0);
|
|
||||||
|
|
||||||
if (buildingsData[0]) {
|
|
||||||
buildingsData[0].classrooms_count = physical.length;
|
|
||||||
buildingsData[0].total_capacity = physCap;
|
|
||||||
}
|
|
||||||
if (buildingsData[1]) {
|
|
||||||
buildingsData[1].classrooms_count = virtual.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalRooms = classrooms.length;
|
|
||||||
const totalCap = physCap + (buildingsData[1]?.total_capacity || 9999);
|
|
||||||
|
|
||||||
return { totalRooms, totalCap };
|
|
||||||
} catch (e) {
|
|
||||||
return { totalRooms: 31, totalCap: 11249 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. List Buildings
|
// 1. List Buildings
|
||||||
const handleListBuildings = async (req, res) => {
|
const handleListBuildings = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { search = '', status = '' } = req.query;
|
const { search = '', status = '' } = req.query;
|
||||||
const stats = await getLiveStats(req.apiClient);
|
let buildings = [];
|
||||||
|
let stats = {
|
||||||
|
total_buildings: 2,
|
||||||
|
total_classrooms: 31,
|
||||||
|
physical_classrooms_count: 29,
|
||||||
|
total_physical_capacity: 1250,
|
||||||
|
total_floors: 4
|
||||||
|
};
|
||||||
|
|
||||||
let filtered = [...buildingsData];
|
try {
|
||||||
|
const apiRes = await req.apiClient.get('/buildings', { params: { search, status } });
|
||||||
if (search) {
|
if (apiRes.data && apiRes.data.buildings) {
|
||||||
const s = search.toLowerCase();
|
buildings = apiRes.data.buildings;
|
||||||
filtered = filtered.filter(b =>
|
if (apiRes.data.stats) {
|
||||||
(b.name && b.name.toLowerCase().includes(s)) ||
|
stats = apiRes.data.stats;
|
||||||
(b.code && b.code.toLowerCase().includes(s)) ||
|
}
|
||||||
(b.address && b.address.toLowerCase().includes(s))
|
}
|
||||||
);
|
} catch (apiErr) {
|
||||||
|
console.warn('API /buildings notice, using fallback:', apiErr.message);
|
||||||
|
buildings = [...defaultBuildings];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'active') {
|
// Si por alguna razón la lista vino vacía de la API y no había filtros de búsqueda, usar default
|
||||||
filtered = filtered.filter(b => b.is_active);
|
if (buildings.length === 0 && !search && !status) {
|
||||||
} else if (status === 'inactive') {
|
buildings = [...defaultBuildings];
|
||||||
filtered = filtered.filter(b => !b.is_active);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asegurar campos calculados consistentes para cada edificio
|
||||||
|
buildings = buildings.map(b => {
|
||||||
|
const is_virtual = Boolean(b.is_virtual || (b.code && b.code.toUpperCase() === 'VIRTUAL') || (b.name && b.name.toUpperCase().includes('VIRTUAL')));
|
||||||
|
return {
|
||||||
|
...b,
|
||||||
|
is_virtual,
|
||||||
|
capacity_display: is_virtual ? 'Ilimitada (Virtual)' : String(b.physical_capacity !== undefined ? b.physical_capacity : (b.total_capacity || 0)),
|
||||||
|
total_capacity: is_virtual ? 0 : (b.physical_capacity !== undefined ? b.physical_capacity : (b.total_capacity || 0)),
|
||||||
|
floors_display: is_virtual ? 'Digital' : String(b.floors ?? 1)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capacidad física total: suma exclusiva de sedes presenciales (nunca suma 9999)
|
||||||
|
const total_physical_capacity = stats.total_physical_capacity || buildings
|
||||||
|
.filter(b => !b.is_virtual)
|
||||||
|
.reduce((acc, b) => acc + (b.physical_capacity || b.total_capacity || 0), 0);
|
||||||
|
|
||||||
res.render('buildings/list', {
|
res.render('buildings/list', {
|
||||||
title: 'Gestión de Edificios - Edu-Space Admin',
|
title: 'Gestión de Edificios - Edu-Space Admin',
|
||||||
buildings: filtered,
|
buildings,
|
||||||
total_classrooms: stats.totalRooms,
|
total_classrooms: stats.total_classrooms || buildings.reduce((a, b) => a + (b.classrooms_count || 0), 0),
|
||||||
total_capacity: stats.totalCap,
|
total_capacity: total_physical_capacity,
|
||||||
|
total_physical_capacity,
|
||||||
search,
|
search,
|
||||||
status_filter: status
|
status_filter: status
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error rendering buildings list:', error.message);
|
console.error('Error rendering buildings list:', error.message);
|
||||||
res.render('buildings/list', {
|
res.render('buildings/list', {
|
||||||
title: 'Gestión de Edificios (Offline)',
|
title: 'Gestión de Edificios - Edu-Space Admin',
|
||||||
buildings: buildingsData,
|
buildings: defaultBuildings,
|
||||||
total_classrooms: 31,
|
total_classrooms: 31,
|
||||||
total_capacity: 11249,
|
total_capacity: 1250,
|
||||||
|
total_physical_capacity: 1250,
|
||||||
search: '',
|
search: '',
|
||||||
status_filter: ''
|
status_filter: ''
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
router.get('/', handleListBuildings);
|
router.get(['/', '/list', '/list_buildings'], handleListBuildings);
|
||||||
router.get('/list', handleListBuildings);
|
|
||||||
router.get('/list_buildings', handleListBuildings);
|
|
||||||
|
|
||||||
// 2. Add Building
|
// 2. Add Building (GET & POST)
|
||||||
router.get(['/add', '/add_building'], (req, res) => {
|
router.get(['/add', '/add_building'], (req, res) => {
|
||||||
const form = createFormMock({
|
const form = createFormMock({
|
||||||
is_active: true,
|
name: '',
|
||||||
floors: 3
|
code: '',
|
||||||
|
address: '',
|
||||||
|
floors: 1,
|
||||||
|
description: '',
|
||||||
|
is_active: true
|
||||||
});
|
});
|
||||||
res.render('buildings/form', {
|
res.render('buildings/form', {
|
||||||
title: 'Nuevo Edificio - Edu-Space Admin',
|
title: 'Nuevo Edificio - Edu-Space Admin',
|
||||||
@@ -119,50 +127,139 @@ router.get(['/add', '/add_building'], (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post(['/add', '/add_building'], (req, res) => {
|
router.post(['/add', '/add_building'], async (req, res) => {
|
||||||
const { name, code, address, floors, description, is_active } = req.body;
|
try {
|
||||||
const newBuilding = {
|
const { name, code, address, floors, description, is_active } = req.body;
|
||||||
id: buildingsData.length + 1,
|
const payload = {
|
||||||
name: name || `Edificio ${buildingsData.length + 1}`,
|
name: (name || '').trim(),
|
||||||
code: code || `EDIF-${buildingsData.length + 1}`,
|
code: (code || '').trim().toUpperCase() || null,
|
||||||
address: address || '',
|
address: (address || '').trim() || null,
|
||||||
floors: parseInt(floors, 10) || 1,
|
floors: parseInt(floors, 10) || 1,
|
||||||
description: description || '',
|
description: (description || '').trim() || null,
|
||||||
is_active: is_active === 'true' || is_active === 'on' || is_active === true,
|
is_active: is_active === 'true' || is_active === 'on' || is_active === true || is_active === '1'
|
||||||
classrooms_count: 0,
|
};
|
||||||
total_capacity: 0
|
await req.apiClient.post('/buildings', payload);
|
||||||
};
|
res.redirect('/buildings/list_buildings?created=1');
|
||||||
buildingsData.push(newBuilding);
|
} catch (err) {
|
||||||
res.redirect('/buildings/list_buildings');
|
console.warn('Error creating building:', err.response?.data || err.message);
|
||||||
|
res.redirect('/buildings/list_buildings?error=create');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. View / Edit / Toggle Building
|
// 3. View Building
|
||||||
router.get(['/view/:id', '/view_building'], (req, res) => {
|
router.get(['/view/:id', '/view_building', '/:id'], async (req, res) => {
|
||||||
const id = parseInt(req.params.id || req.query.id, 10) || 1;
|
const id = parseInt(req.params.id || req.query.id, 10) || 1;
|
||||||
const building = buildingsData.find(b => b.id === id) || buildingsData[0];
|
let building = null;
|
||||||
|
let classrooms = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiRes = await req.apiClient.get(`/buildings/${id}`);
|
||||||
|
if (apiRes.data && apiRes.data.building) {
|
||||||
|
building = apiRes.data.building;
|
||||||
|
classrooms = apiRes.data.classrooms || [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
building = defaultBuildings.find(b => b.id === id) || defaultBuildings[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!building) {
|
||||||
|
return res.redirect('/buildings/list_buildings');
|
||||||
|
}
|
||||||
|
|
||||||
|
const is_virtual = Boolean(building.is_virtual || (building.code && building.code.toUpperCase() === 'VIRTUAL') || (building.name && building.name.toUpperCase().includes('VIRTUAL')));
|
||||||
|
building = {
|
||||||
|
...building,
|
||||||
|
is_virtual,
|
||||||
|
capacity_display: is_virtual ? 'Ilimitada (Virtual)' : String(building.physical_capacity || building.total_capacity || 0),
|
||||||
|
floors_display: is_virtual ? 'Digital' : String(building.floors ?? 1)
|
||||||
|
};
|
||||||
|
|
||||||
res.render('buildings/view', {
|
res.render('buildings/view', {
|
||||||
title: `Edificio ${building.name} - Edu-Space Admin`,
|
title: `Edificio ${building.name} - Edu-Space Admin`,
|
||||||
building,
|
building,
|
||||||
classrooms: []
|
classrooms
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get(['/edit/:id', '/edit_building'], (req, res) => {
|
// 4. Edit Building (GET)
|
||||||
|
const handleEditBuildingGet = async (req, res) => {
|
||||||
const id = parseInt(req.params.id || req.query.id, 10) || 1;
|
const id = parseInt(req.params.id || req.query.id, 10) || 1;
|
||||||
const building = buildingsData.find(b => b.id === id) || buildingsData[0];
|
let building = null;
|
||||||
const form = createFormMock(building);
|
|
||||||
|
try {
|
||||||
|
const apiRes = await req.apiClient.get(`/buildings/${id}`);
|
||||||
|
if (apiRes.data && apiRes.data.building) {
|
||||||
|
building = apiRes.data.building;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
building = defaultBuildings.find(b => b.id === id) || defaultBuildings[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!building) {
|
||||||
|
return res.redirect('/buildings/list_buildings');
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = createFormMock({
|
||||||
|
name: building.name,
|
||||||
|
code: building.code,
|
||||||
|
address: building.address,
|
||||||
|
floors: building.floors,
|
||||||
|
description: building.description,
|
||||||
|
is_active: building.is_active
|
||||||
|
});
|
||||||
|
|
||||||
res.render('buildings/form', {
|
res.render('buildings/form', {
|
||||||
title: `Editar Edificio - ${building.name}`,
|
title: `Editar Edificio - ${building.name}`,
|
||||||
building,
|
building,
|
||||||
form
|
form
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get(['/edit/:id', '/:id/edit', '/edit_building'], handleEditBuildingGet);
|
||||||
|
|
||||||
|
// 5. Edit Building (POST)
|
||||||
|
const handleEditBuildingPost = async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id || req.query.id || req.body.id, 10) || 1;
|
||||||
|
const { name, code, address, floors, description, is_active } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: (name || '').trim(),
|
||||||
|
code: (code || '').trim().toUpperCase() || null,
|
||||||
|
address: (address || '').trim() || null,
|
||||||
|
floors: parseInt(floors, 10) || 0,
|
||||||
|
description: (description || '').trim() || null,
|
||||||
|
is_active: is_active === 'true' || is_active === 'on' || is_active === true || is_active === '1'
|
||||||
|
};
|
||||||
|
|
||||||
|
await req.apiClient.put(`/buildings/${id}`, payload);
|
||||||
|
res.redirect('/buildings/list_buildings?updated=1');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Error updating building:', err.response?.data || err.message);
|
||||||
|
res.redirect('/buildings/list_buildings?error=update');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.post(['/edit/:id', '/:id/edit', '/edit_building'], handleEditBuildingPost);
|
||||||
|
|
||||||
|
// 6. Toggle Building
|
||||||
|
router.post(['/toggle/:id', '/:id/toggle', '/toggle_building'], async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id || req.query.id || req.body.id, 10) || 1;
|
||||||
|
try {
|
||||||
|
await req.apiClient.post(`/buildings/${id}/toggle`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error toggling building:', e.message);
|
||||||
|
}
|
||||||
|
res.redirect('/buildings/list_buildings');
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post(['/toggle/:id', '/toggle_building'], (req, res) => {
|
// 7. Delete Building
|
||||||
const id = parseInt(req.params.id || req.query.id, 10) || 1;
|
router.post(['/delete/:id', '/:id/delete', '/delete_building'], async (req, res) => {
|
||||||
const building = buildingsData.find(b => b.id === id);
|
const id = parseInt(req.params.id || req.query.id || req.body.id, 10);
|
||||||
if (building) {
|
try {
|
||||||
building.is_active = !building.is_active;
|
await req.apiClient.delete(`/buildings/${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error deleting building:', e.message);
|
||||||
}
|
}
|
||||||
res.redirect('/buildings/list_buildings');
|
res.redirect('/buildings/list_buildings');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -633,6 +633,7 @@ const handleMyGrades = async (req, res) => {
|
|||||||
|
|
||||||
router.get(['/gradebook', '/gradebook/:id', '/commissions/:id/gradebook'], handleGradebook);
|
router.get(['/gradebook', '/gradebook/:id', '/commissions/:id/gradebook'], handleGradebook);
|
||||||
router.get(['/my_grades', '/my-grades', '/mis_calificaciones', '/mis-notas'], handleMyGrades);
|
router.get(['/my_grades', '/my-grades', '/mis_calificaciones', '/mis-notas'], handleMyGrades);
|
||||||
|
router.get(['/commissions_view', '/commissions'], (req, res) => res.redirect('/admin/commissions_list'));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification Alerts -->
|
||||||
|
{% if msg == 'created' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>La carrera ha sido registrada exitosamente.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'updated' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>Los datos de la carrera se actualizaron correctamente.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'deleted' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>La carrera ha sido eliminada permanentemente del sistema.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error == 'has_subjects' %}
|
||||||
|
<div class="alert alert-warning alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>No es posible eliminar esta carrera porque tiene asignaturas vinculadas. Debe reasignar o eliminar primero sus materias.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error == 'conflict' %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill me-2"></i>Ya existe una carrera registrada con ese nombre o código institucional.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill me-2"></i>Ocurrió un error al procesar la solicitud en el catálogo de carreras.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Search & Filter Bar -->
|
<!-- Search & Filter Bar -->
|
||||||
<div class="card border-0 shadow-sm mb-4">
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
<div class="card-body py-2">
|
<div class="card-body py-2">
|
||||||
@@ -114,10 +147,15 @@
|
|||||||
<form method="POST" action="/admin/career_delete" class="d-inline"
|
<form method="POST" action="/admin/career_delete" class="d-inline"
|
||||||
onsubmit="return confirm('¿Confirma que desea eliminar esta carrera permanentemente?');">
|
onsubmit="return confirm('¿Confirma que desea eliminar esta carrera permanentemente?');">
|
||||||
<input type="hidden" name="id" value="{{ career.id }}">
|
<input type="hidden" name="id" value="{{ career.id }}">
|
||||||
<button type="submit" class="btn btn-outline-danger py-0 px-2" title="Eliminar">
|
<button type="submit" class="btn btn-outline-danger py-0 px-2" title="Eliminar Carrera">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<button type="button" class="btn btn-outline-secondary py-0 px-2 opacity-50"
|
||||||
|
disabled title="No se puede eliminar: posee {{ c_stat.subjects_count }} asignatura(s) vinculada(s)">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
<!-- Commission Card -->
|
<!-- Commission Card -->
|
||||||
<div class="card shadow-sm border-0 mb-4">
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
<div class="card-header border-0 bg-primary text-white rounded-top py-3">
|
<div class="card-header border-0 bg-primary text-white rounded-top py-3 d-flex justify-content-between align-items-center">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<i class="bi bi-diagram-3 fs-4"></i>
|
<i class="bi bi-diagram-3 fs-4"></i>
|
||||||
<div>
|
<div>
|
||||||
@@ -57,6 +57,11 @@
|
|||||||
<small class="opacity-75">{{ commission.semester }} {{ commission.year }}</small>
|
<small class="opacity-75">{{ commission.semester }} {{ commission.year }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="button" class="btn btn-sm btn-light py-1 px-2 fw-semibold text-primary" data-bs-toggle="modal" data-bs-target="#modalEditCommissionDetail">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Editar
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="fw-bold mb-1">{{ commission.subject.name }}</h5>
|
<h5 class="fw-bold mb-1">{{ commission.subject.name }}</h5>
|
||||||
@@ -389,4 +394,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Editar Datos de la Comisión -->
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="modal fade" id="modalEditCommissionDetail" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<form action="/admin/commission_edit" method="POST">
|
||||||
|
<input type="hidden" name="id" value="{{ commission.id }}">
|
||||||
|
<div class="modal-header bg-body-tertiary border-bottom py-3">
|
||||||
|
<h5 class="modal-title fw-bold">
|
||||||
|
<i class="bi bi-pencil-square text-primary me-2"></i>Editar Datos de la Comisión
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold small">Identificador / Código</label>
|
||||||
|
<input type="text" class="form-control form-control-sm font-monospace" name="code" value="{{ commission.code }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-semibold small">Cuatrimestre</label>
|
||||||
|
<select class="form-select form-select-sm" name="semester">
|
||||||
|
<option value="1C" {% if commission.semester == '1C' %}selected{% endif %}>1C (Primer Cuatrimestre)</option>
|
||||||
|
<option value="2C" {% if commission.semester == '2C' %}selected{% endif %}>2C (Segundo Cuatrimestre)</option>
|
||||||
|
<option value="Anual" {% if commission.semester == 'Anual' %}selected{% endif %}>Anual</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-semibold small">Año</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" name="year" value="{{ commission.year }}" min="2020" max="2035">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold small">Turno</label>
|
||||||
|
<select class="form-select form-select-sm" name="shift">
|
||||||
|
<option value="Mañana" {% if commission.shift == 'Mañana' %}selected{% endif %}>Mañana (08:00 - 12:00)</option>
|
||||||
|
<option value="Tarde" {% if commission.shift == 'Tarde' %}selected{% endif %}>Tarde (17:30 - 21:30)</option>
|
||||||
|
<option value="Vespertino" {% if commission.shift == 'Vespertino' %}selected{% endif %}>Vespertino</option>
|
||||||
|
<option value="Noche" {% if commission.shift == 'Noche' %}selected{% endif %}>Noche (19:00 - 23:00)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold small">Cupo Máximo de Alumnos</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" name="max_students" value="{{ commission.max_students }}" min="1" max="300">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold small">Días y Horarios</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" name="schedule" value="{{ commission.schedule or '' }}" placeholder="Ej: Lun y Mié 17:30 a 21:30 hs">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold small">Enlace Aula Virtual (Meet, Teams, Zoom)</label>
|
||||||
|
<input type="url" class="form-control form-control-sm" name="virtual_link" value="{{ commission.virtual_link or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="active" id="detail_edit_active" value="on" {% if commission.active %}checked{% endif %}>
|
||||||
|
<label class="form-check-label small fw-semibold" for="detail_edit_active">
|
||||||
|
Comisión Activa
|
||||||
|
</label>
|
||||||
|
</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">Guardar Cambios</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Flash Messages & Feedback -->
|
<!-- Flash Messages & Feedback -->
|
||||||
{% if created %}
|
{% if msg == 'created' or created %}
|
||||||
<div class="alert alert-success alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
<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>
|
<i class="bi bi-check-circle-fill fs-5 text-success"></i>
|
||||||
<div>
|
<div>
|
||||||
@@ -30,6 +30,62 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif msg == 'updated' %}
|
||||||
|
<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 actualizada!</strong> Los datos de la comisión, cupos y horarios fueron guardados correctamente.
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'toggled' %}
|
||||||
|
<div class="alert alert-info alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill fs-5 text-info"></i>
|
||||||
|
<div>
|
||||||
|
El estado de actividad de la comisión ha sido modificado.
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'deleted' %}
|
||||||
|
<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>
|
||||||
|
La comisión ha sido eliminada permanentemente del sistema.
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'deactivated' %}
|
||||||
|
<div class="alert alert-warning alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill fs-5 text-warning"></i>
|
||||||
|
<div>
|
||||||
|
La comisión posee reservas horarias o alumnos inscriptos. Por integridad del historial académico fue <strong>desactivada</strong>.
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error == 'create' %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill fs-5 text-danger"></i>
|
||||||
|
<div>No se pudo crear la comisión. Verifique que no exista otra comisión con el mismo código en el mismo período.</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error == 'edit' %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill fs-5 text-danger"></i>
|
||||||
|
<div>Ocurrió un error al actualizar los datos de la comisión.</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error == 'delete' %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill fs-5 text-danger"></i>
|
||||||
|
<div>Ocurrió un error al intentar eliminar la comisión.</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill fs-5 text-danger"></i>
|
||||||
|
<div>Ocurrió una eventualidad procesando la comisión.</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
@@ -170,10 +226,44 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end pe-3">
|
<td class="text-end pe-3">
|
||||||
<a href="/admin/commission_detail?id={{ comm.id }}"
|
<div class="btn-group btn-group-sm">
|
||||||
class="btn btn-sm btn-outline-primary" title="Ver y gestionar comisión">
|
<a href="/admin/commission_detail?id={{ comm.id }}"
|
||||||
<i class="bi bi-gear me-1"></i>Gestionar
|
class="btn btn-outline-primary py-0 px-2" title="Gestionar e Inscribir Alumnos">
|
||||||
</a>
|
<i class="bi bi-gear"></i>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-secondary py-0 px-2"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#modalEditCommission"
|
||||||
|
data-id="{{ comm.id }}"
|
||||||
|
data-code="{{ comm.code }}"
|
||||||
|
data-subject-id="{{ comm.subject_id }}"
|
||||||
|
data-academic-term-id="{{ comm.academic_term_id or '' }}"
|
||||||
|
data-semester="{{ comm.semester }}"
|
||||||
|
data-year="{{ comm.year }}"
|
||||||
|
data-shift="{{ comm.shift }}"
|
||||||
|
data-teacher-id="{{ comm.teacher_id or '' }}"
|
||||||
|
data-max-students="{{ comm.max_students }}"
|
||||||
|
data-schedule="{{ comm.schedule or '' }}"
|
||||||
|
data-virtual-link="{{ comm.virtual_link or '' }}"
|
||||||
|
data-active="{{ 'true' if comm.active else 'false' }}"
|
||||||
|
title="Editar Comisión">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<form method="POST" action="/admin/commission_toggle" class="d-inline">
|
||||||
|
<input type="hidden" name="id" value="{{ comm.id }}">
|
||||||
|
<button type="submit" class="btn btn-outline-warning py-0 px-2"
|
||||||
|
title="{% if comm.active %}Desactivar comisión{% else %}Activar comisión{% endif %}">
|
||||||
|
<i class="bi {% if comm.active %}bi-pause{% else %}bi-play{% endif %}"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button type="button" class="btn btn-outline-danger py-0 px-2"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#modalDeleteCommission"
|
||||||
|
data-id="{{ comm.id }}"
|
||||||
|
data-code="{{ comm.get_full_code() }}"
|
||||||
|
data-subject-name="{{ comm.subject.name }}"
|
||||||
|
title="Eliminar Comisión">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -366,13 +456,171 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Editar Comisión -->
|
||||||
|
<div class="modal fade" id="modalEditCommission" tabindex="-1" aria-labelledby="modalEditCommissionLabel" 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="modalEditCommissionLabel">
|
||||||
|
<i class="bi bi-pencil-square text-primary me-2"></i>Editar Comisión de Cursada
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form action="/admin/commission_edit" method="POST" id="formEditCommission">
|
||||||
|
<input type="hidden" name="id" id="edit_commission_id">
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<!-- 1. Asignatura y Código -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<label for="edit_subject_id" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-book text-primary me-1"></i>Asignatura Asociada <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select form-select-sm" id="edit_subject_id" name="subject_id" required>
|
||||||
|
{% for subj in subjects %}
|
||||||
|
<option value="{{ subj.id }}">{{ subj.code }} — {{ subj.name }} ({{ subj.career_name or 'General' }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label for="edit_code" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-tag text-primary me-1"></i>Identificador / Código <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control form-control-sm font-monospace" id="edit_code" name="code" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Ciclo Lectivo, Semestre y Año -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label for="edit_academic_term_id" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-calendar-event text-info me-1"></i>Ciclo Lectivo
|
||||||
|
</label>
|
||||||
|
<select class="form-select form-select-sm" id="edit_academic_term_id" name="academic_term_id">
|
||||||
|
<option value="">-- Sin ciclo específico --</option>
|
||||||
|
{% for term in academic_terms %}
|
||||||
|
<option value="{{ term.id }}" data-year="{{ term.year }}" data-period="{{ term.period_type or '' }}" data-code="{{ term.code }}">
|
||||||
|
{{ term.name }} ({{ term.code }}) - {{ term.year }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="edit_semester" class="form-label fw-semibold small">Período / Cuatrimestre</label>
|
||||||
|
<select class="form-select form-select-sm" id="edit_semester" name="semester">
|
||||||
|
<option value="1C">1C (Primer Cuatrimestre)</option>
|
||||||
|
<option value="2C">2C (Segundo Cuatrimestre)</option>
|
||||||
|
<option value="Anual">Anual</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="edit_year" class="form-label fw-semibold small">Año Académico</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" id="edit_year" name="year" min="2020" max="2035">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="edit_shift" class="form-label fw-semibold small">Turno de Cursada</label>
|
||||||
|
<select class="form-select form-select-sm" id="edit_shift" name="shift">
|
||||||
|
<option value="Mañana">Mañana (08:00 - 12:00)</option>
|
||||||
|
<option value="Tarde">Tarde (17:30 - 21:30)</option>
|
||||||
|
<option value="Vespertino">Vespertino</option>
|
||||||
|
<option value="Noche">Noche (19:00 - 23:00)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="edit_max_students" class="form-label fw-semibold small">Cupo Máximo de Alumnos</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" id="edit_max_students" name="max_students" min="1" max="300">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Docente Responsable -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="edit_teacher_id" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-person-video3 text-info me-1"></i>Docente Responsable (Titular)
|
||||||
|
</label>
|
||||||
|
<select class="form-select form-select-sm" id="edit_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>
|
||||||
|
|
||||||
|
<!-- 4. Días y Horarios -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_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="edit_schedule" name="schedule" placeholder="Ej: Lun y Mié 17:30 a 21:30 hs">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. Enlace Virtual -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_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)
|
||||||
|
</label>
|
||||||
|
<input type="url" class="form-control form-control-sm" id="edit_virtual_link" name="virtual_link">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 6. Estado Activo -->
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="active" id="edit_active" value="on">
|
||||||
|
<label class="form-check-label small fw-semibold" for="edit_active">
|
||||||
|
Comisión Activa para Cursada y Reservas
|
||||||
|
</label>
|
||||||
|
</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>Guardar Cambios
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Eliminar Comisión -->
|
||||||
|
<div class="modal fade" id="modalDeleteCommission" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<form action="/admin/commission_delete" method="POST">
|
||||||
|
<input type="hidden" name="id" id="delete_commission_id">
|
||||||
|
<div class="modal-header bg-danger text-white py-3">
|
||||||
|
<h5 class="modal-title h6 fw-bold">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>Eliminar Comisión
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body py-4">
|
||||||
|
<p class="mb-2">¿Estás seguro de que deseas eliminar la comisión <strong id="delete_commission_code" class="font-monospace"></strong> vinculada a <strong id="delete_commission_subject"></strong>?</p>
|
||||||
|
<div class="alert alert-warning small mb-0">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
<strong>Integridad Académica:</strong> Si la comisión posee reservas en aulas o alumnos inscriptos, el sistema la <strong>desactivará automáticamente</strong> para salvaguardar las actas y calificaciones.
|
||||||
|
</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-danger btn-sm px-3">
|
||||||
|
<i class="bi bi-trash me-1"></i>Confirmar Eliminación
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const termSelect = document.getElementById('add_academic_term_id');
|
// Sincronización Ciclo Lectivo en Alta
|
||||||
const yearInput = document.getElementById('add_year');
|
const addTermSelect = document.getElementById('add_academic_term_id');
|
||||||
const semSelect = document.getElementById('add_semester');
|
const addYearInput = document.getElementById('add_year');
|
||||||
|
const addSemSelect = document.getElementById('add_semester');
|
||||||
|
|
||||||
function syncTerm() {
|
function syncTerm(termSelect, yearInput, semSelect) {
|
||||||
if (!termSelect) return;
|
if (!termSelect) return;
|
||||||
const opt = termSelect.options[termSelect.selectedIndex];
|
const opt = termSelect.options[termSelect.selectedIndex];
|
||||||
if (!opt || !opt.value) return;
|
if (!opt || !opt.value) return;
|
||||||
@@ -391,9 +639,51 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (termSelect) {
|
if (addTermSelect) {
|
||||||
termSelect.addEventListener('change', syncTerm);
|
addTermSelect.addEventListener('change', () => syncTerm(addTermSelect, addYearInput, addSemSelect));
|
||||||
if (termSelect.value) syncTerm();
|
if (addTermSelect.value) syncTerm(addTermSelect, addYearInput, addSemSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sincronización Ciclo Lectivo en Edición
|
||||||
|
const editTermSelect = document.getElementById('edit_academic_term_id');
|
||||||
|
const editYearInput = document.getElementById('edit_year');
|
||||||
|
const editSemSelect = document.getElementById('edit_semester');
|
||||||
|
|
||||||
|
if (editTermSelect) {
|
||||||
|
editTermSelect.addEventListener('change', () => syncTerm(editTermSelect, editYearInput, editSemSelect));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poblar Modal Editar Comisión
|
||||||
|
const modalEdit = document.getElementById('modalEditCommission');
|
||||||
|
if (modalEdit) {
|
||||||
|
modalEdit.addEventListener('show.bs.modal', function(event) {
|
||||||
|
const btn = event.relatedTarget;
|
||||||
|
if (!btn) return;
|
||||||
|
document.getElementById('edit_commission_id').value = btn.getAttribute('data-id') || '';
|
||||||
|
document.getElementById('edit_code').value = btn.getAttribute('data-code') || '';
|
||||||
|
document.getElementById('edit_subject_id').value = btn.getAttribute('data-subject-id') || '';
|
||||||
|
document.getElementById('edit_academic_term_id').value = btn.getAttribute('data-academic-term-id') || '';
|
||||||
|
document.getElementById('edit_semester').value = btn.getAttribute('data-semester') || '1C';
|
||||||
|
document.getElementById('edit_year').value = btn.getAttribute('data-year') || '2026';
|
||||||
|
document.getElementById('edit_shift').value = btn.getAttribute('data-shift') || 'Mañana';
|
||||||
|
document.getElementById('edit_teacher_id').value = btn.getAttribute('data-teacher-id') || '';
|
||||||
|
document.getElementById('edit_max_students').value = btn.getAttribute('data-max-students') || '40';
|
||||||
|
document.getElementById('edit_schedule').value = btn.getAttribute('data-schedule') || '';
|
||||||
|
document.getElementById('edit_virtual_link').value = btn.getAttribute('data-virtual-link') || '';
|
||||||
|
document.getElementById('edit_active').checked = (btn.getAttribute('data-active') === 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poblar Modal Eliminar Comisión
|
||||||
|
const modalDel = document.getElementById('modalDeleteCommission');
|
||||||
|
if (modalDel) {
|
||||||
|
modalDel.addEventListener('show.bs.modal', function(event) {
|
||||||
|
const btn = event.relatedTarget;
|
||||||
|
if (!btn) return;
|
||||||
|
document.getElementById('delete_commission_id').value = btn.getAttribute('data-id') || '';
|
||||||
|
document.getElementById('delete_commission_code').textContent = btn.getAttribute('data-code') || '';
|
||||||
|
document.getElementById('delete_commission_subject').textContent = btn.getAttribute('data-subject-name') || '';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -27,7 +27,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notification Alerts -->
|
<!-- Notification Alerts -->
|
||||||
{% if msg == 'deleted' %}
|
{% if msg == 'created' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>La asignatura ha sido registrada exitosamente en el catálogo académico.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'updated' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>Los datos de la asignatura fueron actualizados correctamente.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'toggled' %}
|
||||||
|
<div class="alert alert-info alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill me-2"></i>El estado de actividad de la asignatura ha sido modificado.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'deleted' %}
|
||||||
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
<i class="bi bi-check-circle-fill me-2"></i>La asignatura ha sido eliminada permanentemente del sistema.
|
<i class="bi bi-check-circle-fill me-2"></i>La asignatura ha sido eliminada permanentemente del sistema.
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
@@ -37,11 +52,21 @@
|
|||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>La asignatura posee comisiones históricas asociadas. Por integridad de los registros académicos fue <strong>desactivada</strong>.
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>La asignatura posee comisiones históricas asociadas. Por integridad de los registros académicos fue <strong>desactivada</strong>.
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif error == 'conflict' %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill me-2"></i>Ya existe una asignatura con ese código o denominación.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
{% elif error == 'delete' %}
|
{% elif error == 'delete' %}
|
||||||
<div class="alert alert-danger alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
<div class="alert alert-danger alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
<i class="bi bi-x-circle-fill me-2"></i>Ocurrió un error al procesar la eliminación de la asignatura.
|
<i class="bi bi-x-circle-fill me-2"></i>Ocurrió un error al procesar la eliminación de la asignatura.
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif error %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill me-2"></i>Ocurrió un error al procesar la operación sobre la asignatura.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Filters Bar -->
|
<!-- Filters Bar -->
|
||||||
|
|||||||
@@ -25,7 +25,10 @@
|
|||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<form method="POST" novalidate>
|
<form method="POST" action="{% if building %}/buildings/edit/{{ building.id }}{% else %}/buildings/add_building{% endif %}" novalidate>
|
||||||
|
{% if building %}
|
||||||
|
<input type="hidden" name="id" value="{{ building.id }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
@@ -47,7 +50,7 @@
|
|||||||
|
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
{{ form.address.label(class="form-label fw-semibold") }}
|
{{ form.address.label(class="form-label fw-semibold") }}
|
||||||
{{ form.address(class="form-control" + (" is-invalid" if form.address.errors else ""), placeholder="ej. Av. Paseo Colón 255, CABA") }}
|
{{ form.address(class="form-control" + (" is-invalid" if form.address.errors else ""), placeholder="ej. Av. Paseo Colón 255, CABA o campus.unicaba.edu.ar") }}
|
||||||
{% if form.address.errors %}
|
{% if form.address.errors %}
|
||||||
<div class="invalid-feedback">{{ form.address.errors[0] }}</div>
|
<div class="invalid-feedback">{{ form.address.errors[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -55,11 +58,11 @@
|
|||||||
|
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
{{ form.floors.label(class="form-label fw-semibold") }}
|
{{ form.floors.label(class="form-label fw-semibold") }}
|
||||||
{{ form.floors(class="form-control" + (" is-invalid" if form.floors.errors else ""), min=1, max=100) }}
|
{{ form.floors(class="form-control" + (" is-invalid" if form.floors.errors else ""), min=0, max=100) }}
|
||||||
{% if form.floors.errors %}
|
{% if form.floors.errors %}
|
||||||
<div class="invalid-feedback">{{ form.floors.errors[0] }}</div>
|
<div class="invalid-feedback">{{ form.floors.errors[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<small class="text-muted">Cantidad de plantas/pisos</small>
|
<small class="text-muted">Plantas físicas (0 para Campus Virtual)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
|
|||||||
@@ -24,6 +24,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alertas de estado -->
|
||||||
|
{% if success or updated or created %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show border-0 shadow-sm mb-4" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>
|
||||||
|
{% if created %}El edificio ha sido creado exitosamente.{% else %}Los datos del edificio se actualizaron correctamente.{% endif %}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Statistics Cards -->
|
<!-- Statistics Cards -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-sm-6 col-xl-3">
|
<div class="col-sm-6 col-xl-3">
|
||||||
@@ -34,8 +43,9 @@
|
|||||||
<i class="bi bi-buildings fs-3"></i>
|
<i class="bi bi-buildings fs-3"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small fw-semibold">Total Edificios</div>
|
<div class="text-muted small fw-semibold">Sedes y Edificios</div>
|
||||||
<h3 class="fw-bold mb-0">{{ buildings|length }}</h3>
|
<h3 class="fw-bold mb-0">{{ buildings|length }}</h3>
|
||||||
|
<small class="text-muted" style="font-size: 0.72rem;">Físicas y Digitales</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,8 +60,9 @@
|
|||||||
<i class="bi bi-door-open fs-3"></i>
|
<i class="bi bi-door-open fs-3"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small fw-semibold">Aulas Activas</div>
|
<div class="text-muted small fw-semibold">Aulas Totales</div>
|
||||||
<h3 class="fw-bold mb-0 text-success">{{ total_classrooms }}</h3>
|
<h3 class="fw-bold mb-0 text-success">{{ total_classrooms }}</h3>
|
||||||
|
<small class="text-muted" style="font-size: 0.72rem;">Activas en el sistema</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,8 +77,11 @@
|
|||||||
<i class="bi bi-people fs-3"></i>
|
<i class="bi bi-people fs-3"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small fw-semibold">Capacidad Total</div>
|
<div class="text-muted small fw-semibold">Capacidad Física Instalada</div>
|
||||||
<h3 class="fw-bold mb-0 text-info">{{ total_capacity }}</h3>
|
<h3 class="fw-bold mb-0 text-info">{{ total_physical_capacity or total_capacity }}</h3>
|
||||||
|
<small class="text-info fw-semibold" style="font-size: 0.72rem;">
|
||||||
|
<i class="bi bi-cloud-check me-1"></i>+ Campus Virtual (Ilimitado)
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,8 +96,9 @@
|
|||||||
<i class="bi bi-layers fs-3"></i>
|
<i class="bi bi-layers fs-3"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small fw-semibold">Pisos Disponibles</div>
|
<div class="text-muted small fw-semibold">Pisos Presenciales</div>
|
||||||
<h3 class="fw-bold mb-0 text-warning">{{ buildings|sum(attribute='floors') }}</h3>
|
<h3 class="fw-bold mb-0 text-warning">{{ buildings|sum(attribute='floors') }}</h3>
|
||||||
|
<small class="text-muted" style="font-size: 0.72rem;">Plantas físicas activas</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,14 +141,14 @@
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{% for b in buildings %}
|
{% for b in buildings %}
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<div class="card border-0 shadow-sm h-100 position-relative">
|
<div class="card border-0 shadow-sm h-100 position-relative {% if b.is_virtual %}border-start border-4 border-info{% endif %}">
|
||||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border mb-1">
|
<span class="badge {% if b.is_virtual %}bg-info-subtle text-info border border-info-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %} font-monospace mb-1">
|
||||||
{{ b.code or 'EDIF' }}
|
{% if b.is_virtual %}<i class="bi bi-cloud-fill me-1"></i>{% endif %}{{ b.code or 'EDIF' }}
|
||||||
</span>
|
</span>
|
||||||
<h5 class="card-title fw-bold mb-0">
|
<h5 class="card-title fw-bold mb-0">
|
||||||
<a href="/buildings/view_building" class="text-decoration-none text-primary">
|
<a href="/buildings/view/{{ b.id }}" class="text-decoration-none text-primary">
|
||||||
{{ b.name }}
|
{{ b.name }}
|
||||||
</a>
|
</a>
|
||||||
</h5>
|
</h5>
|
||||||
@@ -153,7 +168,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if b.address %}
|
{% if b.address %}
|
||||||
<p class="text-muted small mb-3">
|
<p class="text-muted small mb-3">
|
||||||
<i class="bi bi-geo-alt-fill text-danger me-1"></i>{{ b.address }}
|
<i class="bi {% if b.is_virtual %}bi-globe2 text-info{% else %}bi-geo-alt-fill text-danger{% endif %} me-1"></i>{{ b.address }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -165,7 +180,11 @@
|
|||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="p-2 rounded bg-body-tertiary">
|
<div class="p-2 rounded bg-body-tertiary">
|
||||||
<small class="text-muted d-block">Pisos</small>
|
<small class="text-muted d-block">Pisos</small>
|
||||||
|
{% if b.is_virtual %}
|
||||||
|
<span class="badge bg-info-subtle text-info border border-info-subtle mt-1">Digital</span>
|
||||||
|
{% else %}
|
||||||
<span class="fw-bold fs-6">{{ b.floors }}</span>
|
<span class="fw-bold fs-6">{{ b.floors }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
@@ -177,22 +196,28 @@
|
|||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="p-2 rounded bg-body-tertiary">
|
<div class="p-2 rounded bg-body-tertiary">
|
||||||
<small class="text-muted d-block">Capacidad</small>
|
<small class="text-muted d-block">Capacidad</small>
|
||||||
<span class="fw-bold fs-6 text-success">{{ b.total_capacity }}</span>
|
{% if b.is_virtual %}
|
||||||
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle mt-1" title="Capacidad de concurrencia virtual ilimitada">
|
||||||
|
<i class="bi bi-infinity me-1"></i>Ilimitada
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="fw-bold fs-6 text-success">{{ b.physical_capacity or b.total_capacity }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-transparent border-0 py-3 d-flex justify-content-between align-items-center">
|
<div class="card-footer bg-transparent border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
<a href="/buildings/view_building" class="btn btn-outline-primary btn-sm">
|
<a href="/buildings/view/{{ b.id }}" class="btn btn-outline-primary btn-sm">
|
||||||
<i class="bi bi-eye me-1"></i>Ver Distribución de Aulas
|
<i class="bi bi-eye me-1"></i>Ver Aulas
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if user.has_permission('classrooms', 'read_write') %}
|
{% if user.has_permission('classrooms', 'read_write') or user.is_admin() or user.is_bedelia() %}
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<a href="/buildings/edit_building" class="btn btn-outline-secondary btn-sm" title="Editar Edificio">
|
<a href="/buildings/edit/{{ b.id }}" class="btn btn-outline-secondary btn-sm" title="Editar Edificio">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil me-1"></i>Editar
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/buildings/toggle_building" class="d-inline">
|
<form method="POST" action="/buildings/toggle/{{ b.id }}" class="d-inline">
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{% if b.is_active %}Desactivar{% else %}Activar{% endif %}">
|
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{% if b.is_active %}Desactivar{% else %}Activar{% endif %}">
|
||||||
<i class="bi bi-power"></i>
|
<i class="bi bi-power"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||||
{% if user.has_permission('classrooms', 'read_write') %}
|
{% if user.has_permission('classrooms', 'read_write') or user.is_admin() or user.is_bedelia() %}
|
||||||
<a href="/buildings/edit_building" class="btn btn-outline-secondary me-2">
|
<a href="/buildings/edit/{{ building.id }}" class="btn btn-outline-secondary me-2">
|
||||||
<i class="bi bi-pencil me-1"></i>Editar Edificio
|
<i class="bi bi-pencil me-1"></i>Editar Edificio
|
||||||
</a>
|
</a>
|
||||||
<a href="/classrooms/add_classroom" class="btn btn-primary">
|
<a href="/classrooms/add_classroom" class="btn btn-primary">
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
<div class="text-muted small fw-semibold">Ubicación / Dirección</div>
|
<div class="text-muted small fw-semibold">Ubicación / Dirección</div>
|
||||||
<p class="h6 fw-bold mb-2">
|
<p class="h6 fw-bold mb-2">
|
||||||
<i class="bi bi-geo-alt text-danger me-1"></i>{{ building.address or 'Sin dirección especificada' }}
|
<i class="bi {% if building.is_virtual %}bi-globe2 text-info{% else %}bi-geo-alt text-danger{% endif %} me-1"></i>{{ building.address or 'Sin dirección especificada' }}
|
||||||
</p>
|
</p>
|
||||||
{% if building.description %}
|
{% if building.description %}
|
||||||
<small class="text-secondary">{{ building.description }}</small>
|
<small class="text-secondary">{{ building.description }}</small>
|
||||||
@@ -59,7 +59,11 @@
|
|||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
<div class="card border-0 shadow-sm text-center p-3">
|
<div class="card border-0 shadow-sm text-center p-3">
|
||||||
<small class="text-muted text-uppercase fw-bold">Pisos</small>
|
<small class="text-muted text-uppercase fw-bold">Pisos</small>
|
||||||
|
{% if building.is_virtual %}
|
||||||
|
<span class="fs-5 fw-bold text-info d-block mt-2">Digital / Nube</span>
|
||||||
|
{% else %}
|
||||||
<span class="display-6 fw-bold text-primary">{{ building.floors }}</span>
|
<span class="display-6 fw-bold text-primary">{{ building.floors }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
@@ -71,7 +75,13 @@
|
|||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
<div class="card border-0 shadow-sm text-center p-3">
|
<div class="card border-0 shadow-sm text-center p-3">
|
||||||
<small class="text-muted text-uppercase fw-bold">Capacidad Total</small>
|
<small class="text-muted text-uppercase fw-bold">Capacidad Total</small>
|
||||||
<span class="display-6 fw-bold text-info">{{ building.total_capacity }}</span>
|
{% if building.is_virtual %}
|
||||||
|
<span class="fs-5 fw-bold text-primary d-block mt-2" title="Capacidad de concurrencia virtual ilimitada">
|
||||||
|
<i class="bi bi-infinity me-1"></i>Ilimitada
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="display-6 fw-bold text-info">{{ building.physical_capacity or building.total_capacity }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user