| 1 | /** |
| 2 | * Mi Ruta MAPS - Certificados del modelo académico. |
| 3 | * Contrato esperado de GET /estudiantes/ruta: |
| 4 | * { estudiante: { id, matricula, semestre, carrera }, |
| 5 | * ruta: [{ id, nombre, descripcion, semestreMin, semestreMax, materias[], enRuta }], |
| 6 | * catalogo: [ { id, nombre, descripcion, semestreMin, semestreMax, materias[], enRuta } ] } |
| 7 | * ASIGNACION: POST /certificados/asignar { idEstudiante, idCertificado }. |
| 8 | */ |
| 9 | document.addEventListener('DOMContentLoaded', async () => { |
| 10 | Layout.init('certificados.html'); |
| 11 | Layout.setPageTitle('Mi Ruta MAPS'); |
| 12 | |
| 13 | const content = document.getElementById('main-content'); |
| 14 | if (!content) return; |
| 15 | |
| 16 | const user = Auth.getUser(); |
| 17 | if (!user || user.rol === 'PROFESOR') { |
| 18 | renderVistaProfesor(content); |
| 19 | return; |
| 20 | } |
| 21 | |
| 22 | content.innerHTML = '<div class="ruta-container"><p class="loading-message">Cargando tu ruta MAPS…</p></div>'; |
| 23 | content.addEventListener('click', handlerAgregar); |
| 24 | |
| 25 | let data = {}; |
| 26 | try { |
| 27 | data = await API.request('/estudiantes/ruta'); |
| 28 | } catch (error) { |
| 29 | const sinPerfil = /completa tu perfil|perfil de estudiante aún no está completo/i.test(error?.message || ''); |
| 30 | renderError(content, sinPerfil); |
| 31 | return; |
| 32 | } |
| 33 | |
| 34 | renderRuta(content, data || {}); |
| 35 | }); |
| 36 | |
| 37 | function renderVistaProfesor(content) { |
| 38 | content.innerHTML = ` |
| 39 | <div class="ruta-container"> |
| 40 | <header class="module-heading"><h1>Mi Ruta MAPS</h1><p>Panel de la trayectoria de certificados de los estudiantes.</p></header> |
| 41 | <section class="card empty-state-box"> |
| 42 | <h2>Vista exclusiva para estudiantes</h2> |
| 43 | <p>La ruta MAPS agrupa los certificados y sus materias por semestre sugerido. Como docente, puedes orientar a tus estudiantes desde tu vista de docencia.</p> |
| 44 | <a class="btn-clean" href="inicio.html">Volver a inicio</a> |
| 45 | </section> |
| 46 | </div> |
| 47 | `; |
| 48 | } |
| 49 | |
| 50 | function renderError(content, sinPerfil) { |
| 51 | content.innerHTML = ` |
| 52 | <div class="ruta-container"> |
| 53 | <header class="module-heading"><h1>Mi Ruta MAPS</h1><p>Explora y arma la ruta de certificados de tu carrera.</p></header> |
| 54 | <section class="card empty-state-box"> |
| 55 | <h2>${sinPerfil ? 'Completa tu perfil de estudiante' : 'No se pudo cargar tu ruta'}</h2> |
| 56 | <p>${sinPerfil |
| 57 | ? 'Para ver tu ruta MAPS primero debes completar tu perfil de estudiante y elegir tu carrera.' |
| 58 | : 'Ocurrió un problema al consultar tu ruta. Inténtalo de nuevo en un momento.'}</p> |
| 59 | ${sinPerfil ? '<a class="btn-solid" href="onboarding.html">Completar mi perfil</a>' : ''} |
| 60 | </section> |
| 61 | </div> |
| 62 | `; |
| 63 | } |
| 64 | |
| 65 | let estadoRuta = { estudiante: null, ruta: [], catalogo: [] }; |
| 66 | |
| 67 | function renderRuta(content, data) { |
| 68 | estadoRuta = { |
| 69 | estudiante: data.estudiante || null, |
| 70 | ruta: Array.isArray(data.ruta) ? data.ruta : [], |
| 71 | catalogo: Array.isArray(data.catalogo) ? data.catalogo : [] |
| 72 | }; |
| 73 | |
| 74 | const estudiante = estadoRuta.estudiante; |
| 75 | const semestreActual = Number(estudiante?.semestre) || 0; |
| 76 | const maxCertificados = 3; |
| 77 | const enRuta = estadoRuta.ruta.length; |
| 78 | |
| 79 | content.innerHTML = ` |
| 80 | <div class="ruta-container"> |
| 81 | <header class="module-heading"> |
| 82 | <h1>Mi Ruta MAPS</h1> |
| 83 | <p>Tu plan de certificados ${estudiante?.carrera ? `en <strong>${escapeHtml(estudiante.carrera)}</strong>` : ''}, ordenado por el semestre sugerido de sus materias.</p> |
| 84 | </header> |
| 85 | ${estudiante ? renderBanner(estudiante, semestreActual, enRuta, maxCertificados) : ''} |
| 86 | <section id="ruta-section" class="ruta-section" aria-labelledby="ruta-title"> |
| 87 | <h2 class="ruta-section-title" id="ruta-title">Tu ruta ${enRuta ? `<small>${enRuta} de ${maxCertificados} certificados</small>` : ''}</h2> |
| 88 | ${enRuta ? estadoRuta.ruta.map(renderRutaBlock).join('') : renderRutaVacia()} |
| 89 | </section> |
| 90 | <section class="ruta-section" aria-labelledby="catalogo-title"> |
| 91 | <h2 class="ruta-section-title" id="catalogo-title">Catálogo de certificados</h2> |
| 92 | ${estadoRuta.catalogo.map(cert => renderCatalogoBlock(cert, semestreActual, enRuta, maxCertificados)).join('')} |
| 93 | </section> |
| 94 | <p class="ruta-footer-note">Sugerencia: arma tu ruta con hasta ${maxCertificados} certificados según los intereses de tu carrera.</p> |
| 95 | </div> |
| 96 | `; |
| 97 | } |
| 98 | |
| 99 | function renderBanner(estudiante, semestreActual, enRuta, maxCertificados) { |
| 100 | const bloque = semestreActual |
| 101 | ? `Semestre vigente: <strong>${semestreActual}°</strong>` |
| 102 | : 'Sin semestre asignado'; |
| 103 | return ` |
| 104 | <section class="card ruta-banner" aria-labelledby="ruta-banner-title"> |
| 105 | <h2 id="ruta-banner-title">${escapeHtml(estudiante.matricula || 'Estudiante')}</h2> |
| 106 | <p>${bloque} • Certificados seleccionados: <strong>${enRuta} de ${maxCertificados}</strong></p> |
| 107 | </section> |
| 108 | `; |
| 109 | } |
| 110 | |
| 111 | function renderRutaVacia() { |
| 112 | return ` |
| 113 | <section class="card empty-state-box"> |
| 114 | <h2>Aún no tienes certificados en tu ruta</h2> |
| 115 | <p>Explora el catálogo de abajo y añade hasta 3 certificados relacionados con tu carrera. Seguirán su semestre sugerido en el plan de estudios.</p> |
| 116 | </section> |
| 117 | `; |
| 118 | } |
| 119 | |
| 120 | function renderRutaBlock(cert) { |
| 121 | return renderCertificadoCard(cert, true); |
| 122 | } |
| 123 | |
| 124 | function renderCatalogoBlock(cert, semestreActual, enRuta, maxCertificados) { |
| 125 | const lleno = enRuta >= maxCertificados && !cert.enRuta; |
| 126 | return renderCertificadoCard(cert, false, lleno); |
| 127 | } |
| 128 | |
| 129 | function renderCertificadoCard(cert, enRuta, lleno) { |
| 130 | const rango = rangoSemestres(cert); |
| 131 | const estado = estadoCertificado(cert, enRuta); |
| 132 | const materias = Array.isArray(cert.materias) ? cert.materias : []; |
| 133 | const chips = materias.map(materia => |
| 134 | `<span class="materia-chip">${escapeHtml(materia.nombre || 'Materia')}${Number.isFinite(Number(materia.semestre)) ? ` · S.${materia.semestre}` : ''}</span>` |
| 135 | ).join(''); |
| 136 | |
| 137 | const cuerpo = ` |
| 138 | <div class="ruta-block-head"> |
| 139 | <div> |
| 140 | <h3 class="ruta-block-title">${escapeHtml(cert.nombre || 'Certificado')}</h3> |
| 141 | ${rango ? `<span class="ruta-block-range">${escapeHtml(rango)}</span>` : ''} |
| 142 | </div> |
| 143 | <div class="ruta-badges">${estado.badges}</div> |
| 144 | </div> |
| 145 | ${cert.descripcion ? `<p class="ruta-block-desc">${escapeHtml(cert.descripcion)}</p>` : ''} |
| 146 | ${chips ? `<div class="ruta-materias">${chips}</div>` : ''} |
| 147 | `; |
| 148 | |
| 149 | const acciones = enRuta |
| 150 | ? `<div class="ruta-actions"><button class="btn-clean" type="button" disabled>En tu ruta</button></div>` |
| 151 | : `<div class="ruta-actions"><button class="btn-solid" type="button" data-action="agregar" data-cert-id="${escapeHtml(cert.id ?? '')}"${lleno ? ' disabled' : ''}>${lleno ? 'Ruta completa' : 'Añadir a mi ruta'}</button></div>`; |
| 152 | |
| 153 | return ` |
| 154 | <article class="card ruta-block"> |
| 155 | ${cuerpo} |
| 156 | ${acciones} |
| 157 | </article> |
| 158 | `; |
| 159 | } |
| 160 | |
| 161 | function rangoSemestres(cert) { |
| 162 | const min = Number.isFinite(Number(cert.semestreMin)) ? Number(cert.semestreMin) : null; |
| 163 | const max = Number.isFinite(Number(cert.semestreMax)) ? Number(cert.semestreMax) : null; |
| 164 | if (!min && !max) return ''; |
| 165 | if (!min || !max) return `Semestre ${min || max}`; |
| 166 | return min === max ? `Semestre ${min}` : `Semestres ${min} – ${max}`; |
| 167 | } |
| 168 | |
| 169 | function estadoCertificado(cert, enRuta) { |
| 170 | const semestreActual = Number(estadoRuta.estudiante?.semestre) || 0; |
| 171 | const min = Number.isFinite(Number(cert.semestreMin)) ? Number(cert.semestreMin) : null; |
| 172 | const max = Number.isFinite(Number(cert.semestreMax)) ? Number(cert.semestreMax) : null; |
| 173 | |
| 174 | let badge = ''; |
| 175 | if (enRuta) { |
| 176 | badge = '<span class="badge-ruta">En tu ruta</span>'; |
| 177 | } |
| 178 | |
| 179 | if (min == null && max == null) { |
| 180 | badge += ' <span class="badge-proximo">Sin semestre asociado</span>'; |
| 181 | return { badges: badge }; |
| 182 | } |
| 183 | |
| 184 | if (max != null && semestreActual > max) { |
| 185 | badge += ' <span class="badge-cursado">Cursado</span>'; |
| 186 | } else if (semestreActual >= (min ?? 1) && (max == null || semestreActual <= max)) { |
| 187 | badge += ' <span class="badge-encurso">En curso</span>'; |
| 188 | } else { |
| 189 | badge += ' <span class="badge-proximo">Próximo</span>'; |
| 190 | } |
| 191 | return { badges: badge }; |
| 192 | } |
| 193 | |
| 194 | async function handlerAgregar(event) { |
| 195 | const btn = event.target.closest('button[data-action="agregar"]'); |
| 196 | if (!btn) return; |
| 197 | |
| 198 | const estudianteId = estadoRuta.estudiante?.id; |
| 199 | const certificadoId = btn.dataset.certId; |
| 200 | if (!estudianteId || !certificadoId) return; |
| 201 | |
| 202 | btn.disabled = true; |
| 203 | try { |
| 204 | await API.request('/certificados/asignar', { |
| 205 | method: 'POST', |
| 206 | body: JSON.stringify({ idEstudiante: Number(estudianteId), idCertificado: Number(certificadoId) }) |
| 207 | }); |
| 208 | Utils.toast('Certificado añadido a tu ruta MAPS.', 'success'); |
| 209 | const content = document.getElementById('main-content'); |
| 210 | if (content) { |
| 211 | content.innerHTML = '<div class="ruta-container"><p class="loading-message">Actualizando tu ruta…</p></div>'; |
| 212 | const data = await API.request('/estudiantes/ruta'); |
| 213 | renderRuta(content, data || {}); |
| 214 | } |
| 215 | } catch (error) { |
| 216 | btn.disabled = false; |
| 217 | Utils.toast(error.message || 'No se pudo añadir el certificado.', 'error'); |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | function escapeHtml(value) { |
| 222 | return String(value ?? '').replace(/[&<>'"]/g, character => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); |
| 223 | } |