main
js 427 lines 19.2 KB
Raw
1 /**
2 * Panel Administrativo y Reportería.
3 * Contrato esperado de GET /admin/stats:
4 * { perfil, kpis: { totalEstudiantes, totalProfesores, totalPublicaciones,
5 * sesionesAbiertas, totalEmpresas, totalResenas, totalRecursos },
6 * actividad: [{ modulo, titulo, autor, tipo, estado, fecha }] }
7 */
8 document.addEventListener('DOMContentLoaded', async () => {
9 Layout.init('admin.html');
10 Layout.setPageTitle('Panel Administrativo');
11
12 const content = document.getElementById('main-content');
13 if (!content) return;
14
15 content.innerHTML = '<div class="admin-container"><p class="loading-message">Cargando panel administrativo…</p></div>';
16
17 let data = {};
18 try {
19 data = await API.request('/admin/stats');
20 } catch (e) {
21 content.innerHTML = `<div class="admin-container"><div class="card admin-empty">No se pudo cargar el panel: ${Utils.esc(e.message || 'sin permisos de administrador')}</div></div>`;
22 return;
23 }
24
25 renderAdmin(content, data || {});
26 });
27
28 function renderAdmin(content, data) {
29 const perfil = data.perfil || {};
30 const kpis = data.kpis || {};
31 const actividad = Array.isArray(data.actividad) ? data.actividad : [];
32
33 content.innerHTML = `
34 <div class="admin-container">
35 <header class="card admin-header">
36 <div class="admin-title">
37 <h2>Panel de Coordinación y Reportería Académica</h2>
38 <p>Profesor / Asesor Docente: <strong>${Utils.esc(perfil.nombre || 'Administrador')}</strong> • Campus Guadalajara</p>
39 </div>
40 <div>
41 <span class="badge badge-info">Rol: Administrador Docente</span>
42 </div>
43 </header>
44
45 <section class="kpi-grid">
46 <div class="kpi-card">
47 <div class="kpi-value">${Number(kpis.totalEstudiantes) || 0}</div>
48 <div class="kpi-label">Estudiantes</div>
49 <div class="kpi-sub">Certificados activos en la plataforma</div>
50 </div>
51 <div class="kpi-card">
52 <div class="kpi-value">${Number(kpis.totalProfesores) || 0}</div>
53 <div class="kpi-label">Docentes</div>
54 <div class="kpi-sub">Personal académico registrado</div>
55 </div>
56 <div class="kpi-card">
57 <div class="kpi-value">${Number(kpis.totalPublicaciones) || 0}</div>
58 <div class="kpi-label">Dudas / Publicaciones</div>
59 <div class="kpi-sub">Actividad del foro académico</div>
60 </div>
61 <div class="kpi-card">
62 <div class="kpi-value">${Number(kpis.sesionesAbiertas) || 0}</div>
63 <div class="kpi-label">Círculos de Estudio</div>
64 <div class="kpi-sub">Sesiones activas programadas</div>
65 </div>
66 <div class="kpi-card">
67 <div class="kpi-value">${Number(kpis.totalEmpresas) || 0}</div>
68 <div class="kpi-label">Empresas Vinculadas</div>
69 <div class="kpi-sub">Directorio del Semestre Empresarial</div>
70 </div>
71 <div class="kpi-card alert">
72 <div class="kpi-value">${Number(kpis.totalResenas) || 0}</div>
73 <div class="kpi-label">Reseñas Empresariales</div>
74 <div class="kpi-sub">Evaluaciones del ciclo vigente</div>
75 </div>
76 </section>
77
78 <section class="card">
79 <div class="section-header">
80 <strong>Extracción de Reportes Institucionales</strong>
81 <span style="font-size: 0.8rem; color: #666;">Exportación directa para comités y acreditaciones</span>
82 </div>
83 <form class="report-form" id="report-form">
84 <div class="form-group">
85 <label for="report-tipo">Tipo de Reporte</label>
86 <select class="form-control" id="report-tipo">
87 <option value="círculos">Participación en Círculos de Estudio y Asistencia</option>
88 <option value="dudas">Métricas de Resolución de Dudas por Materia</option>
89 <option value="recursos">Descargas y Calidad de Repositorio de Apuntes</option>
90 <option value="empresas">Evaluación y Desempeño de Empresas Vinculadas</option>
91 </select>
92 </div>
93 <div class="form-group">
94 <label for="report-ciclo">Semestre / Ciclo</label>
95 <select class="form-control" id="report-ciclo">
96 <option value="current">Ciclo Vigente (Semestre Actual)</option>
97 <option value="2026-1">2026-1 (Enero - Mayo)</option>
98 <option value="all">Histórico Anual</option>
99 </select>
100 </div>
101 <div class="form-group">
102 <label for="report-formato">Formato de Salida</label>
103 <select class="form-control" id="report-formato">
104 <option value="csv">Archivo CSV / Excel (.csv)</option>
105 <option value="pdf">Documento Ejecutivo (.pdf)</option>
106 </select>
107 </div>
108 <button class="btn-solid" type="submit">⬇ Descargar Reporte</button>
109 </form>
110 </section>
111
112 <section class="card">
113 <div class="section-header">
114 <strong>Gestión de Docentes</strong>
115 <span style="font-size: 0.8rem; color: #666;">Alta de cuentas de asesores docentes</span>
116 </div>
117 <form class="report-form" id="docente-form">
118 <div class="form-group">
119 <label for="doc-nombre">Nombre(s) del docente</label>
120 <input type="text" class="form-control" id="doc-nombre" required>
121 </div>
122 <div class="form-group">
123 <label for="doc-apellido">Apellido(s)</label>
124 <input type="text" class="form-control" id="doc-apellido" required>
125 </div>
126 <div class="form-group">
127 <label for="doc-email">Correo institucional</label>
128 <input type="email" class="form-control" id="doc-email" required placeholder="docente@tecmilenio.mx">
129 </div>
130 <div class="form-group">
131 <label for="doc-nomina">Número de nómina</label>
132 <input type="text" class="form-control" id="doc-nomina" required placeholder="L01234567">
133 </div>
134 <div class="form-group">
135 <label for="doc-contrasena">Contraseña inicial</label>
136 <input type="text" class="form-control" id="doc-contrasena" required placeholder="Temporal123">
137 </div>
138 <button class="btn-solid" type="submit">➕ Crear Docente</button>
139 </form>
140 <div id="docente-resultado" style="margin-top:10px;"></div>
141 </section>
142
143 <section class="card">
144 <div class="section-header">
145 <strong>Gestión de Cuentas (Alumnos y Docentes)</strong>
146 <span style="font-size: 0.8rem; color: #666;">Dar de baja al graduarse, darse de baja o al causar baja</span>
147 </div>
148 <div id="usuarios-contenido"></div>
149 </section>
150
151 <section class="card">
152 <div class="section-header">
153 <strong>Supervisión de Actividad y Validación Docente</strong>
154 </div>
155 <div class="table-responsive">
156 <table class="admin-table">
157 <thead>
158 <tr>
159 <th>Módulo</th>
160 <th>Elemento / Título</th>
161 <th>Autor / Alumno</th>
162 <th>Estado</th>
163 </tr>
164 </thead>
165 <tbody>${actividad.length ? actividad.map(renderActividad).join('') : renderFilaVacia()}</tbody>
166 </table>
167 </div>
168 </section>
169 </div>
170 `;
171
172 document.getElementById('report-form')?.addEventListener('submit', event => {
173 event.preventDefault();
174 const tipo = document.getElementById('report-tipo').value;
175 const ciclo = document.getElementById('report-ciclo').value;
176 const formato = document.getElementById('report-formato').value;
177 exportarReporte(tipo, ciclo, formato, data);
178 });
179
180 document.getElementById('docente-form')?.addEventListener('submit', async event => {
181 event.preventDefault();
182 const resultado = document.getElementById('docente-resultado');
183 const nombre = document.getElementById('doc-nombre').value.trim();
184 const apellido = document.getElementById('doc-apellido').value.trim();
185 const email = document.getElementById('doc-email').value.trim();
186 const numeroNomina = document.getElementById('doc-nomina').value.trim();
187 const contrasena = document.getElementById('doc-contrasena').value;
188
189 if (!/^[^\s@]+@(tecmilenio\.mx|servicios\.tecmilenio\.mx)$/i.test(email)) {
190 resultado.innerHTML = '<div class="admin-empty" style="color:#b91c1c;">Usa un correo institucional @tecmilenio.mx.</div>';
191 return;
192 }
193 if (contrasena.length < 8 || !/[A-Z]/.test(contrasena) || !/[a-z]/.test(contrasena) || !/\d/.test(contrasena)) {
194 resultado.innerHTML = '<div class="admin-empty" style="color:#b91c1c;">La contraseña requiere 8+ caracteres con mayúscula, minúscula y número.</div>';
195 return;
196 }
197
198 resultado.innerHTML = '<div class="admin-empty">Creando docente…</div>';
199 try {
200 await API.request('/admin/crear-docente', {
201 method: 'POST',
202 body: JSON.stringify({ nombre, apellido, email, numeroNomina, contrasena })
203 });
204 resultado.innerHTML = '<div class="admin-empty" style="color:#087527;">✅ Docente creado correctamente.</div>';
205 event.target.reset();
206 cargarUsuarios();
207 } catch (e) {
208 resultado.innerHTML = `<div class="admin-empty" style="color:#b91c1c;">${Utils.esc(e.message || 'No se pudo crear el docente.')}</div>`;
209 }
210 });
211
212 cargarUsuarios();
213 }
214
215 async function cargarUsuarios() {
216 const contenedor = document.getElementById('usuarios-contenido');
217 if (!contenedor) return;
218
219 contenedor.innerHTML = '<p class="admin-empty">Cargando cuentas…</p>';
220 let usuarios = [];
221 try {
222 usuarios = await API.request('/admin/usuarios');
223 } catch (e) {
224 contenedor.innerHTML = `<p class="admin-empty" style="color:#b91c1c;">No se pudo cargar la lista: ${Utils.esc(e.message || 'sin permisos')}</p>`;
225 return;
226 }
227
228 if (!Array.isArray(usuarios) || !usuarios.length) {
229 contenedor.innerHTML = '<p class="admin-empty">Sin cuentas registradas.</p>';
230 return;
231 }
232
233 contenedor.innerHTML = `
234 <div class="usuarios-buscar">
235 <input type="text" id="usuarios-buscar-input" class="form-control"
236 placeholder="Buscar por nombre o matrícula / nómina…" autocomplete="off">
237 </div>
238 <div class="table-responsive">
239 <table class="admin-table" id="usuarios-tabla">
240 <thead>
241 <tr>
242 <th>Nombre</th>
243 <th>Correo</th>
244 <th>Matrícula / Nómina</th>
245 <th>Tipo</th>
246 <th>Estado</th>
247 <th>Acción</th>
248 </tr>
249 </thead>
250 <tbody id="usuarios-tbody"></tbody>
251 </table>
252 </div>`;
253
254 const input = document.getElementById('usuarios-buscar-input');
255 const tbody = document.getElementById('usuarios-tbody');
256 let filtro = '';
257
258 const render = () => {
259 const q = filtro.trim().toLowerCase();
260 const filtrados = q
261 ? usuarios.filter(u => {
262 const mat = (u.matricula || u.nomina || '').toLowerCase();
263 return (u.nombre || '').toLowerCase().includes(q)
264 || (u.email || '').toLowerCase().includes(q)
265 || mat.includes(q);
266 })
267 : usuarios;
268
269 if (!filtrados.length) {
270 tbody.innerHTML = '<tr><td colspan="6"><div class="admin-empty">Sin resultados para la búsqueda.</div></td></tr>';
271 return;
272 }
273
274 tbody.innerHTML = filtrados.map(u => {
275 const activo = u.activo === true;
276 const rolLbl = u.rol === 'PROFESOR' ? 'Docente' : 'Estudiante';
277 const credencial = u.rol === 'PROFESOR' ? (u.nomina || '') : (u.matricula || '');
278 const badge = activo
279 ? '<span class="badge badge-info">Activo</span>'
280 : '<span class="badge badge-danger">Inhabilitado</span>';
281 const accion = activo
282 ? `<button class="btn-solid btn-baja" data-id="${u.id}" style="padding:5px 10px;font-size:0.8rem;">Dar de baja</button>`
283 : `<button class="btn-outline btn-reactivar" data-id="${u.id}" style="padding:5px 10px;font-size:0.8rem;">Reactivar</button>`;
284 return `
285 <tr>
286 <td><strong>${Utils.esc(u.nombre || '')}</strong></td>
287 <td>${Utils.esc(u.email || '')}</td>
288 <td>${Utils.esc(credencial)}</td>
289 <td><span class="badge badge-info">${rolLbl}</span></td>
290 <td>${badge}</td>
291 <td>${accion}</td>
292 </tr>`;
293 }).join('');
294 };
295
296 input.addEventListener('input', () => {
297 filtro = input.value;
298 render();
299 });
300
301 render();
302
303 document.querySelectorAll('#usuarios-tabla .btn-baja').forEach(btn => {
304 btn.addEventListener('click', async () => {
305 const id = btn.dataset.id;
306 if (!confirm('¿Inhabilitar esta cuenta? El usuario ya no podrá iniciar sesión.')) return;
307 try {
308 await API.request(`/admin/usuarios/${id}/inhabilitar`, { method: 'POST' });
309 Utils.toast('Cuenta inhabilitada.', 'success');
310 cargarUsuarios();
311 } catch (e) {
312 Utils.toast(e.message || 'No se pudo inhabilitar.', 'error');
313 }
314 });
315 });
316
317 document.querySelectorAll('#usuarios-tabla .btn-reactivar').forEach(btn => {
318 btn.addEventListener('click', async () => {
319 const id = btn.dataset.id;
320 try {
321 await API.request(`/admin/usuarios/${id}/reactivar`, { method: 'POST' });
322 Utils.toast('Cuenta reactivada.', 'success');
323 cargarUsuarios();
324 } catch (e) {
325 Utils.toast(e.message || 'No se pudo reactivar.', 'error');
326 }
327 });
328 });
329 }
330
331 function renderActividad(item) {
332 const fecha = item.fecha ? formatFecha(item.fecha) : '';
333 const esPublicacion = item.tipo === 'publicacion';
334 const badge = esPublicacion
335 ? '<span class="badge badge-warning">Publicación de foro</span>'
336 : '<span class="badge badge-info">Actividad</span>';
337 const modulo = item.modulo || (esPublicacion ? 'Foro Dudas' : 'General');
338 return `
339 <tr>
340 <td><strong>${Utils.esc(modulo)}</strong></td>
341 <td>${Utils.esc(item.titulo || 'Sin título')}</td>
342 <td>${Utils.esc(item.autor || '')}</td>
343 <td>${badge}${fecha ? `<span style="color:#888; font-size:0.75rem;"> · ${Utils.esc(fecha)}</span>` : ''}</td>
344 </tr>
345 `;
346 }
347
348 function renderFilaVacia() {
349 return '<tr><td colspan="4"><div class="admin-empty">Sin actividad reciente para supervisar.</div></td></tr>';
350 }
351
352 function exportarReporte(tipo, ciclo, formato, data) {
353 const etiquetas = {
354 'círculos': 'Participación en Círculos de Estudio y Asistencia',
355 'dudas': 'Métricas de Resolución de Dudas por Materia',
356 'recursos': 'Descargas y Calidad de Repositorio de Apuntes',
357 'empresas': 'Evaluación y Desempeño de Empresas Vinculadas'
358 };
359 const kpis = (data && data.kpis) || {};
360 const actividad = (data && Array.isArray(data.actividad)) ? data.actividad : [];
361
362 const filas = [];
363 filas.push(['MAPS CONNECT — REPORTE INSTITUCIONAL']);
364 filas.push(['Tipo de reporte', etiquetas[tipo] || tipo]);
365 filas.push(['Semestre / Ciclo', ciclo]);
366 filas.push(['Generado', new Date().toLocaleString()]);
367 filas.push([]);
368 filas.push(['INDICADOR', 'VALOR']);
369 filas.push(['Estudiantes activos', Number(kpis.totalEstudiantes) || 0]);
370 filas.push(['Docentes registrados', Number(kpis.totalProfesores) || 0]);
371 filas.push(['Dudas / Publicaciones', Number(kpis.totalPublicaciones) || 0]);
372 filas.push(['Círculos de Estudio activos', Number(kpis.sesionesAbiertas) || 0]);
373 filas.push(['Empresas vinculadas', Number(kpis.totalEmpresas) || 0]);
374 filas.push(['Reseñas empresariales', Number(kpis.totalResenas) || 0]);
375 filas.push(['Recursos académicos', Number(kpis.totalRecursos) || 0]);
376 filas.push([]);
377 filas.push(['ACTIVIDAD RECIENTE — Módulo', 'Elemento / Título', 'Autor', 'Estado', 'Fecha']);
378 if (actividad.length) {
379 actividad.forEach(a => {
380 filas.push([
381 a.modulo || 'General',
382 a.titulo || 'Sin título',
383 a.autor || '',
384 a.tipo === 'publicacion' ? 'Foro' : 'Actividad',
385 formatFecha(a.fecha)
386 ]);
387 });
388 } else {
389 filas.push(['Sin actividad reciente para este período.']);
390 }
391
392 const csv = filas.map(fila => fila.map(campo => {
393 const s = String(campo == null ? '' : campo);
394 return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
395 }).join(',')).join('\r\n');
396
397 const nombre = `reporte_${tipo}_${ciclo}`;
398 if (formato === 'pdf') {
399 const docu = filas.map(fila => fila.join('\t')).join('\r\n');
400 const blob = new Blob([docu], { type: 'text/plain;charset=utf-8' });
401 descargar(blob, `${nombre}.txt`);
402 Utils.toast('Documento ejecutivo generado.', 'success');
403 return;
404 }
405 const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
406 descargar(blob, `${nombre}.csv`);
407 Utils.toast('Reporte CSV generado correctamente.', 'success');
408 }
409
410 function descargar(blob, nombre) {
411 const url = URL.createObjectURL(blob);
412 const a = document.createElement('a');
413 a.href = url;
414 a.download = nombre;
415 document.body.appendChild(a);
416 a.click();
417 document.body.removeChild(a);
418 URL.revokeObjectURL(url);
419 }
420
421 function formatFecha(value) {
422 if (!value) return '';
423 const s = String(value).replace('T', ' ');
424 const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
425 if (!m) return '';
426 return `${m[3]}/${m[2]}/${m[1]}`;
427 }