feat: implementar ABM completo de comisiones, carreras y asignaturas con proteccion de integridad y corregir edificios y campus virtual

This commit is contained in:
2026-09-23 19:53:27 -03:00
parent 195bfd915e
commit 165a0b65b8
13 changed files with 1033 additions and 154 deletions
+37 -3
View File
@@ -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
} }
+236 -1
View File
@@ -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
+5 -2
View File
@@ -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'
+57 -20
View File
@@ -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);
+182 -85
View File
@@ -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) => {
try {
const { name, code, address, floors, description, is_active } = req.body; const { name, code, address, floors, description, is_active } = req.body;
const newBuilding = { const payload = {
id: buildingsData.length + 1, name: (name || '').trim(),
name: name || `Edificio ${buildingsData.length + 1}`, code: (code || '').trim().toUpperCase() || null,
code: code || `EDIF-${buildingsData.length + 1}`, address: (address || '').trim() || null,
address: address || '',
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
}; };
buildingsData.push(newBuilding); await req.apiClient.post('/buildings', payload);
res.redirect('/buildings/list_buildings'); res.redirect('/buildings/list_buildings?created=1');
} catch (err) {
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');
}); });
+1
View File
@@ -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;
+39 -1
View File
@@ -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>
+82 -1
View File
@@ -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 %}
+300 -10
View File
@@ -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">
<div class="btn-group btn-group-sm">
<a href="/admin/commission_detail?id={{ comm.id }}" <a href="/admin/commission_detail?id={{ comm.id }}"
class="btn btn-sm btn-outline-primary" title="Ver y gestionar comisión"> class="btn btn-outline-primary py-0 px-2" title="Gestionar e Inscribir Alumnos">
<i class="bi bi-gear me-1"></i>Gestionar <i class="bi bi-gear"></i>
</a> </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>
+26 -1
View File
@@ -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 -->
+7 -4
View File
@@ -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">
+42 -17
View File
@@ -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>
+14 -4
View File
@@ -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>