diff --git a/backend/app/models/building.py b/backend/app/models/building.py index e48105d..f475539 100644 --- a/backend/app/models/building.py +++ b/backend/app/models/building.py @@ -17,10 +17,40 @@ class Building(db.Model): # Relationships 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 def total_capacity(self): - """Total student capacity across active classrooms in this building""" - return sum(c.capacity for c in self.classrooms if c.is_active) + """Capacidad para compatibilidad histórica""" + 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 def classrooms_count(self): @@ -34,10 +64,14 @@ class Building(db.Model): 'code': self.code, 'address': self.address, 'floors': self.floors, + 'floors_display': self.floors_display, 'description': self.description, 'is_active': self.is_active, + 'is_virtual': self.is_virtual, '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 } diff --git a/backend/app/routes/api/admin.py b/backend/app/routes/api/admin.py index 1474229..ee3b376 100644 --- a/backend/app/routes/api/admin.py +++ b/backend/app/routes/api/admin.py @@ -131,7 +131,9 @@ def get_subjects(): 'name': s.name, 'career_id': s.career_id, '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', + 'description': getattr(s, 'description', '') or '', 'credits': getattr(s, 'credits', 4), 'is_active': s.is_active, 'commissions_count': len(s.commissions), @@ -162,6 +164,7 @@ def get_careers(): 'id': c.id, 'name': c.name, 'code': getattr(c, 'code', f'CAR-{c.id}'), + 'description': getattr(c, 'description', '') or '', 'degree_level': getattr(c, 'degree_level', 'Grado / Tecnicatura'), 'is_active': getattr(c, 'is_active', True), 'subjects_count': len(c.subjects), @@ -217,6 +220,7 @@ def get_commissions(): 'career_name': career_name, 'semester': getattr(c, 'semester', '1C') or '1C', 'year': getattr(c, 'year', 2026) or 2026, + 'academic_term_id': getattr(c, 'academic_term_id', None), 'teacher_id': c.teacher_id, '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 [], @@ -926,6 +930,11 @@ def update_commission(id): comm = Commission.query.get_or_404(id) 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']: comm.code = data['code'].strip() if 'semester' in data and data['semester']: @@ -934,7 +943,7 @@ def update_commission(id): comm.year = int(data['year']) if 'teacher_id' in data: 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: ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=comm.teacher_id).first() if not ct: @@ -971,6 +980,33 @@ def toggle_commission(id): 'message': f'Comisión {"activada" if comm.active else "desactivada"} correctamente.' }), 200 +@api_admin_bp.route('/commissions/', 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//assign-teacher', methods=['POST']) @jwt_required def assign_commission_teacher(id): @@ -2237,3 +2273,202 @@ def retry_all_failed_moodle_tasks(): 'status': 'success', 'message': f'Se reiniciaron {count} tareas fallidas a estado PENDIENTE.' }), 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/', 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/', 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//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/', 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 diff --git a/backend/app/routes/buildings.py b/backend/app/routes/buildings.py index 36408e0..a64004d 100644 --- a/backend/app/routes/buildings.py +++ b/backend/app/routes/buildings.py @@ -32,15 +32,18 @@ def list_buildings(): 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_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( 'buildings/list.html', buildings=buildings, total_classrooms=total_classrooms, total_capacity=total_capacity, + total_physical_capacity=total_physical_capacity, search=search, status_filter=status_filter, title='Building Management' diff --git a/frontend/src/routes/admin.js b/frontend/src/routes/admin.js index f86e40a..2bd1193 100644 --- a/frontend/src/routes/admin.js +++ b/frontend/src/routes/admin.js @@ -652,20 +652,13 @@ const handleCareersList = async (req, res) => { 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', { title: 'Gestión de Carreras - Edu-Space Admin', careers, stats, search, + msg: req.query.msg || '', + error: req.query.error || '', can_edit: true }); } catch (err) { @@ -678,10 +671,12 @@ const handleCareerAdd = async (req, res) => { const { name, code, description } = req.body; try { await req.apiClient.post('/careers', { name, code, description }); + res.redirect('/admin/careers_list?msg=created'); } catch (e) { 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) => { @@ -689,20 +684,24 @@ const handleCareerEdit = async (req, res) => { const { name, code, description } = req.body; try { await req.apiClient.put(`/careers/${id}`, { name, code, description }); + res.redirect('/admin/careers_list?msg=updated'); } catch (e) { 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 id = req.params.id || req.body.id; try { await req.apiClient.delete(`/careers/${id}`); + res.redirect('/admin/careers_list?msg=deleted'); } catch (e) { 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); @@ -767,10 +766,12 @@ const handleSubjectAdd = async (req, res) => { description, is_active: is_active === 'on' || is_active === 'true' || is_active === true }); + res.redirect('/admin/subjects_list?msg=created'); } catch (e) { 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) => { @@ -786,20 +787,23 @@ const handleSubjectEdit = async (req, res) => { description, is_active: is_active === 'on' || is_active === 'true' || is_active === true }); + res.redirect('/admin/subjects_list?msg=updated'); } catch (e) { 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 id = req.params.id || req.body.id; try { await req.apiClient.post(`/subjects/${id}/toggle`); + res.redirect('/admin/subjects_list?msg=toggled'); } catch (e) { 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) => { @@ -888,6 +892,9 @@ const handleCommissionsList = async (req, res) => { shift_filter: shift, active_filter: active, 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 } }); } catch (err) { @@ -1019,31 +1026,60 @@ const handleCommissionAdd = async (req, res) => { active: true }; await req.apiClient.post('/commissions', payload); - res.redirect('/admin/commissions_list?created=1'); + res.redirect('/admin/commissions_list?msg=created'); } catch (e) { 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 id = req.params.id || req.body.id; 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) { 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 id = req.params.id || req.body.id; try { await req.apiClient.post(`/commissions/${id}/toggle`); + res.redirect('/admin/commissions_list?msg=toggled'); } catch (e) { 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) => { @@ -1132,6 +1168,7 @@ router.get(['/commission_detail', '/commission_detail/:id', '/commissions/:id'], router.post(['/commission_add', '/commissions/add'], handleCommissionAdd); router.post(['/commission_edit', '/commission_edit/:id'], handleCommissionEdit); 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_add_teacher', '/commissions/:id/teachers', '/commissions/teachers/add'], handleCommissionAddTeacher); router.post(['/commission_remove_teacher', '/commissions/:id/teachers/delete', '/commissions/teachers/remove'], handleCommissionRemoveTeacher); diff --git a/frontend/src/routes/buildings.js b/frontend/src/routes/buildings.js index 9b56772..d615fe2 100644 --- a/frontend/src/routes/buildings.js +++ b/frontend/src/routes/buildings.js @@ -5,112 +5,120 @@ const { createFormMock } = require('../utils/formHelper'); router.use(requireAuth); -// Mock storage of buildings to support CRUD in memory/BFF layer -let buildingsData = [ +// Fallback data si la API estuviera inaccesible (con capacidad física real) +const defaultBuildings = [ { id: 1, code: 'CENTRAL', name: 'Edificio Central', is_active: true, - address: 'Av. Paseo Colón 255, CABA', - description: 'Sede académica principal, aulas de grado, laboratorios y auditorio.', + is_virtual: false, + address: 'Tte. Gral. Juan Domingo Perón 802, CABA', + description: 'Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.', floors: 4, classrooms_count: 29, - total_capacity: 1250 + physical_capacity: 1250, + total_capacity: 1250, + capacity_display: '1250' }, { id: 2, code: 'VIRTUAL', name: 'Campus Virtual', 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.', - floors: 1, + floors: 0, 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 const handleListBuildings = async (req, res) => { try { 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]; - - if (search) { - const s = search.toLowerCase(); - filtered = filtered.filter(b => - (b.name && b.name.toLowerCase().includes(s)) || - (b.code && b.code.toLowerCase().includes(s)) || - (b.address && b.address.toLowerCase().includes(s)) - ); + try { + const apiRes = await req.apiClient.get('/buildings', { params: { search, status } }); + if (apiRes.data && apiRes.data.buildings) { + buildings = apiRes.data.buildings; + if (apiRes.data.stats) { + stats = apiRes.data.stats; + } + } + } catch (apiErr) { + console.warn('API /buildings notice, using fallback:', apiErr.message); + buildings = [...defaultBuildings]; } - if (status === 'active') { - filtered = filtered.filter(b => b.is_active); - } else if (status === 'inactive') { - filtered = filtered.filter(b => !b.is_active); + // Si por alguna razón la lista vino vacía de la API y no había filtros de búsqueda, usar default + if (buildings.length === 0 && !search && !status) { + buildings = [...defaultBuildings]; } + // 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', { title: 'Gestión de Edificios - Edu-Space Admin', - buildings: filtered, - total_classrooms: stats.totalRooms, - total_capacity: stats.totalCap, + buildings, + total_classrooms: stats.total_classrooms || buildings.reduce((a, b) => a + (b.classrooms_count || 0), 0), + total_capacity: total_physical_capacity, + total_physical_capacity, search, status_filter: status }); } catch (error) { console.error('Error rendering buildings list:', error.message); res.render('buildings/list', { - title: 'Gestión de Edificios (Offline)', - buildings: buildingsData, + title: 'Gestión de Edificios - Edu-Space Admin', + buildings: defaultBuildings, total_classrooms: 31, - total_capacity: 11249, + total_capacity: 1250, + total_physical_capacity: 1250, search: '', status_filter: '' }); } }; -router.get('/', handleListBuildings); -router.get('/list', handleListBuildings); -router.get('/list_buildings', handleListBuildings); +router.get(['/', '/list', '/list_buildings'], handleListBuildings); -// 2. Add Building +// 2. Add Building (GET & POST) router.get(['/add', '/add_building'], (req, res) => { const form = createFormMock({ - is_active: true, - floors: 3 + name: '', + code: '', + address: '', + floors: 1, + description: '', + is_active: true }); res.render('buildings/form', { title: 'Nuevo Edificio - Edu-Space Admin', @@ -119,50 +127,139 @@ router.get(['/add', '/add_building'], (req, res) => { }); }); -router.post(['/add', '/add_building'], (req, res) => { - const { name, code, address, floors, description, is_active } = req.body; - const newBuilding = { - id: buildingsData.length + 1, - name: name || `Edificio ${buildingsData.length + 1}`, - code: code || `EDIF-${buildingsData.length + 1}`, - address: address || '', - floors: parseInt(floors, 10) || 1, - description: description || '', - is_active: is_active === 'true' || is_active === 'on' || is_active === true, - classrooms_count: 0, - total_capacity: 0 - }; - buildingsData.push(newBuilding); - res.redirect('/buildings/list_buildings'); +router.post(['/add', '/add_building'], async (req, res) => { + try { + const { name, code, address, floors, description, is_active } = req.body; + const payload = { + name: (name || '').trim(), + code: (code || '').trim().toUpperCase() || null, + address: (address || '').trim() || null, + floors: parseInt(floors, 10) || 1, + description: (description || '').trim() || null, + is_active: is_active === 'true' || is_active === 'on' || is_active === true || is_active === '1' + }; + await req.apiClient.post('/buildings', payload); + 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 -router.get(['/view/:id', '/view_building'], (req, res) => { +// 3. View Building +router.get(['/view/:id', '/view_building', '/:id'], async (req, res) => { 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', { title: `Edificio ${building.name} - Edu-Space Admin`, 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 building = buildingsData.find(b => b.id === id) || buildingsData[0]; - const form = createFormMock(building); + let building = null; + + 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', { title: `Editar Edificio - ${building.name}`, building, 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) => { - const id = parseInt(req.params.id || req.query.id, 10) || 1; - const building = buildingsData.find(b => b.id === id); - if (building) { - building.is_active = !building.is_active; +// 7. Delete Building +router.post(['/delete/:id', '/:id/delete', '/delete_building'], async (req, res) => { + const id = parseInt(req.params.id || req.query.id || req.body.id, 10); + try { + await req.apiClient.delete(`/buildings/${id}`); + } catch (e) { + console.warn('Error deleting building:', e.message); } res.redirect('/buildings/list_buildings'); }); diff --git a/frontend/src/routes/schedule.js b/frontend/src/routes/schedule.js index 6d6c833..9fc849a 100644 --- a/frontend/src/routes/schedule.js +++ b/frontend/src/routes/schedule.js @@ -633,6 +633,7 @@ const handleMyGrades = async (req, res) => { router.get(['/gradebook', '/gradebook/:id', '/commissions/:id/gradebook'], handleGradebook); 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; diff --git a/frontend/views/admin/careers/list.html b/frontend/views/admin/careers/list.html index f57dad1..34a774d 100644 --- a/frontend/views/admin/careers/list.html +++ b/frontend/views/admin/careers/list.html @@ -26,6 +26,39 @@ + + {% if msg == 'created' %} + + {% elif msg == 'updated' %} + + {% elif msg == 'deleted' %} + + {% elif error == 'has_subjects' %} + + {% elif error == 'conflict' %} + + {% elif error %} + + {% endif %} +
@@ -114,10 +147,15 @@
-
+ {% else %} + {% endif %}
diff --git a/frontend/views/admin/commissions/detail.html b/frontend/views/admin/commissions/detail.html index 59472e2..31f56ee 100644 --- a/frontend/views/admin/commissions/detail.html +++ b/frontend/views/admin/commissions/detail.html @@ -49,7 +49,7 @@
-
+
@@ -57,6 +57,11 @@ {{ commission.semester }} {{ commission.year }}
+ {% if can_edit %} + + {% endif %}
{{ commission.subject.name }}
@@ -389,4 +394,80 @@
+ + +{% if can_edit %} + +{% endif %} {% endblock %} diff --git a/frontend/views/admin/commissions/list.html b/frontend/views/admin/commissions/list.html index 2cc8a4c..127ddcd 100644 --- a/frontend/views/admin/commissions/list.html +++ b/frontend/views/admin/commissions/list.html @@ -22,7 +22,7 @@
- {% if created %} + {% if msg == 'created' or created %} + {% elif msg == 'updated' %} + + {% elif msg == 'toggled' %} + + {% elif msg == 'deleted' %} + + {% elif msg == 'deactivated' %} + + {% elif error == 'create' %} + + {% elif error == 'edit' %} + + {% elif error == 'delete' %} + + {% elif error %} + {% endif %} @@ -170,10 +226,44 @@ {% endif %} - - Gestionar - +
+ + + + +
+ + +
+ +
{% endfor %} @@ -366,13 +456,171 @@ + + + + + + diff --git a/frontend/views/admin/subjects/list.html b/frontend/views/admin/subjects/list.html index 364d0cf..10b8e07 100644 --- a/frontend/views/admin/subjects/list.html +++ b/frontend/views/admin/subjects/list.html @@ -27,7 +27,22 @@ - {% if msg == 'deleted' %} + {% if msg == 'created' %} + + {% elif msg == 'updated' %} + + {% elif msg == 'toggled' %} + + {% elif msg == 'deleted' %} + {% elif error == 'conflict' %} + {% elif error == 'delete' %} + {% elif error %} + {% endif %} diff --git a/frontend/views/buildings/form.html b/frontend/views/buildings/form.html index c83d468..c344668 100644 --- a/frontend/views/buildings/form.html +++ b/frontend/views/buildings/form.html @@ -25,7 +25,10 @@
-
+ + {% if building %} + + {% endif %}
@@ -47,7 +50,7 @@
{{ 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 %}
{{ form.address.errors[0] }}
{% endif %} @@ -55,11 +58,11 @@
{{ 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 %}
{{ form.floors.errors[0] }}
{% endif %} - Cantidad de plantas/pisos + Plantas físicas (0 para Campus Virtual)
diff --git a/frontend/views/buildings/list.html b/frontend/views/buildings/list.html index 37d45f0..4f58c73 100644 --- a/frontend/views/buildings/list.html +++ b/frontend/views/buildings/list.html @@ -24,6 +24,15 @@
+ + {% if success or updated or created %} + + {% endif %} +
@@ -34,8 +43,9 @@
-
Total Edificios
+
Sedes y Edificios

{{ buildings|length }}

+ Físicas y Digitales
@@ -50,8 +60,9 @@
-
Aulas Activas
+
Aulas Totales

{{ total_classrooms }}

+ Activas en el sistema
@@ -66,8 +77,11 @@
-
Capacidad Total
-

{{ total_capacity }}

+
Capacidad Física Instalada
+

{{ total_physical_capacity or total_capacity }}

+ + + Campus Virtual (Ilimitado) +
@@ -82,8 +96,9 @@
-
Pisos Disponibles
+
Pisos Presenciales

{{ buildings|sum(attribute='floors') }}

+ Plantas físicas activas
@@ -126,14 +141,14 @@
{% for b in buildings %}
-
+
- - {{ b.code or 'EDIF' }} + + {% if b.is_virtual %}{% endif %}{{ b.code or 'EDIF' }}
- + {{ b.name }}
@@ -153,7 +168,7 @@
{% if b.address %}

- {{ b.address }} + {{ b.address }}

{% endif %} @@ -165,7 +180,11 @@
Pisos + {% if b.is_virtual %} + Digital + {% else %} {{ b.floors }} + {% endif %}
@@ -177,22 +196,28 @@
Capacidad - {{ b.total_capacity }} + {% if b.is_virtual %} + + Ilimitada + + {% else %} + {{ b.physical_capacity or b.total_capacity }} + {% endif %}