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
+57 -20
View File
@@ -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);
+185 -88
View File
@@ -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');
});
+1
View File
@@ -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;