| 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 | } |