feat: add dynamic dashboards for academic modules
Daniel Silva committed
Aug 26, 2026 at 22:59 UTC
e55aa745587b4472e42e445350d260d7e5f4cb4d
9 files changed
+419
-9
frontend/css/circulos.css
new
+41
@@ -0,0 +1,41 @@
1
+/* MAPS Connect - Círculos de estudio */
2
+
3
+.circulos-container { width: 100%; max-width: 1050px; margin: 0 auto; }
4
+.circulos-container .card, .circulos-container .btn, .circulos-container .badge, .circulos-container input { border-radius: 0; }
5
+.top-action-bar { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
6
+.search-input { flex: 1; min-width: 250px; padding: 0.625rem 0.75rem; border: 1px solid var(--gray-300); background: white; font: inherit; font-size: 0.9rem; }
7
+.search-input:focus { outline: none; border-color: var(--sidebar-bg); }
8
+.circulos-container .btn-solid { display: inline-block; border: 1px solid var(--sidebar-bg); background: var(--sidebar-bg); padding: 0.55rem 1rem; color: white; font: inherit; font-size: 0.9rem; font-weight: 700; cursor: pointer; text-align: center; }
9
+.circulos-container .btn-solid:hover { background: #043b10; color: white; text-decoration: none; }
10
+.circulos-container .btn-clean { display: inline-block; border: 1px solid var(--sidebar-bg); background: transparent; padding: 0.45rem 0.875rem; color: var(--sidebar-bg); font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer; text-align: center; }
11
+.circulos-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); gap: 1.25rem; }
12
+.section-title { margin-bottom: 0.875rem; font-size: 1.05rem; }
13
+.session-card { border-left: 4px solid var(--sidebar-bg); }
14
+.session-card.live-session { border-left-color: var(--danger-color); }
15
+.session-header, .session-footer { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; }
16
+.session-header { margin-bottom: 0.5rem; }
17
+.session-title { font-size: 1.15rem; }
18
+.session-info, .session-desc, .attendees-count, .group-description { color: var(--gray-600); }
19
+.session-info, .attendees-count { font-size: 0.85rem; }
20
+.session-desc { margin: 0.625rem 0 0.875rem; font-size: 0.9rem; line-height: 1.4; }
21
+.session-footer { align-items: center; border-top: 1px solid var(--gray-200); padding-top: 0.75rem; flex-wrap: wrap; }
22
+.session-actions, .badges-wrap { display: flex; flex-wrap: wrap; gap: 0.5rem; }
23
+.badge-live { border: 1px solid #fecaca; background: #fee2e2; color: #991b1b; }
24
+.badge-materia { border: 1px solid #c8e6c9; background: #e8f5e9; color: var(--sidebar-bg); }
25
+.badge-time { border: 1px solid #bae6fd; background: #e0f2fe; color: #0369a1; }
26
+.group-item { border-bottom: 1px solid var(--gray-200); padding: 0.75rem 0; }
27
+.group-item:first-child { padding-top: 0; }
28
+.group-item:last-child { border-bottom: 0; padding-bottom: 0; }
29
+.group-name { display: block; color: var(--gray-900); font-size: 0.95rem; font-weight: 700; }
30
+.group-description { display: block; margin-top: 0.125rem; font-size: 0.8rem; }
31
+.group-meta { display: flex; justify-content: space-between; gap: 0.5rem; margin-top: 0.375rem; color: var(--gray-600); font-size: 0.8rem; }
32
+.group-link { border: 0; background: transparent; color: var(--sidebar-bg); font: inherit; font-size: 0.8rem; font-weight: 700; cursor: pointer; }
33
+.rules-card { border-top: 3px solid var(--sidebar-bg); }
34
+.rules-card h2 { font-size: 0.95rem; }
35
+.rules-card p, .empty-circles p { margin-top: 0.375rem; color: var(--gray-600); font-size: 0.85rem; line-height: 1.4; }
36
+.empty-circles { border-left: 4px solid var(--gray-300); text-align: center; }
37
+.empty-circles h2 { font-size: 1.05rem; }
38
+.loading-message { color: var(--gray-600); }
39
+
40
+@media (max-width: 850px) { .circulos-grid { grid-template-columns: 1fr; } }
41
+@media (max-width: 560px) { .top-action-bar { align-items: stretch; flex-direction: column; } .search-input, .top-action-bar .btn-solid { width: 100%; } .session-footer { align-items: flex-start; flex-direction: column; } }
frontend/css/empresarial.css
new
+35
@@ -0,0 +1,35 @@
1
+/* MAPS Connect - Directorio de Semestre Empresarial */
2
+
3
+.empresarial-container { width: 100%; max-width: 1050px; margin: 0 auto; }
4
+.empresarial-container .card, .empresarial-container .btn, .empresarial-container .badge, .empresarial-container input, .empresarial-container select { border-radius: 0; }
5
+.filter-bar { display: grid; grid-template-columns: 2fr 1fr 1fr auto; gap: 0.625rem; margin-bottom: 1.5rem; }
6
+.input-search, .select-filter { min-width: 0; padding: 0.625rem 0.75rem; border: 1px solid var(--gray-300); background: white; font: inherit; font-size: 0.9rem; }
7
+.input-search:focus, .select-filter:focus { outline: none; border-color: var(--sidebar-bg); }
8
+.company-card { margin-bottom: 1.25rem; padding: 1.25rem; }
9
+.company-header, .company-footer { display: flex; justify-content: space-between; gap: 1rem; }
10
+.company-header { align-items: flex-start; margin-bottom: 0.75rem; }
11
+.company-name { font-size: 1.2rem; }
12
+.company-location, .rating-count { color: var(--gray-600); font-size: 0.85rem; }
13
+.rating-box { flex-shrink: 0; text-align: right; }
14
+.stars { color: var(--warning-color); font-weight: 700; letter-spacing: 1px; }
15
+.rating-number { margin-left: 0.25rem; }
16
+.rating-count { display: block; font-size: 0.8rem; }
17
+.company-desc { margin-bottom: 0.75rem; color: var(--gray-600); font-size: 0.9rem; line-height: 1.5; }
18
+.tags-group { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-bottom: 0.875rem; }
19
+.badge-green { border: 1px solid #c8e6c9; background: #e8f5e9; color: var(--sidebar-bg); }
20
+.badge-blue { border: 1px solid #bae6fd; background: #e0f2fe; color: #0284c7; }
21
+.experience-box { margin-top: 0.875rem; border-left: 3px solid var(--sidebar-bg); background: var(--gray-50); padding: 0.75rem 0.875rem; }
22
+.experience-author { display: block; margin-bottom: 0.25rem; font-size: 0.85rem; }
23
+.experience-text { color: var(--gray-600); font-size: 0.88rem; line-height: 1.4; }
24
+.company-footer { justify-content: flex-end; margin-top: 1rem; border-top: 1px solid var(--gray-200); padding-top: 0.875rem; }
25
+.empresarial-container .btn-solid { display: inline-block; border: 1px solid var(--sidebar-bg); background: var(--sidebar-bg); padding: 0.5rem 1rem; color: white; font: inherit; font-size: 0.9rem; font-weight: 700; cursor: pointer; text-align: center; }
26
+.empresarial-container .btn-solid:hover { background: #043b10; color: white; text-decoration: none; }
27
+.empresarial-container .btn-clean { display: inline-block; border: 1px solid var(--sidebar-bg); background: transparent; padding: 0.45rem 0.875rem; color: var(--sidebar-bg); font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer; text-align: center; }
28
+.empresarial-container .btn-clean:hover { background: #f0fdf4; text-decoration: none; }
29
+.empty-companies { border-left: 4px solid var(--gray-300); text-align: center; }
30
+.empty-companies h1 { font-size: 1.1rem; }
31
+.empty-companies p { margin: 0.5rem 0 0; color: var(--gray-600); }
32
+.loading-message { color: var(--gray-600); }
33
+
34
+@media (max-width: 768px) { .filter-bar { grid-template-columns: 1fr; } }
35
+@media (max-width: 560px) { .company-header, .company-footer { align-items: flex-start; flex-direction: column; } .rating-box { text-align: left; } .company-footer .btn-solid, .company-footer .btn-clean { width: 100%; } }
frontend/css/inicio.css
new
+58
@@ -0,0 +1,58 @@
1
+/* MAPS Connect - Panel de inicio */
2
+
3
+.inicio-container { max-width: 1100px; margin: 0 auto; }
4
+.loading-message { color: var(--gray-600); }
5
+.inicio-container .card, .inicio-container .btn, .inicio-container .badge, .inicio-container input, .inicio-container select { border-radius: 0; }
6
+.maps-banner { border-left: 4px solid var(--sidebar-bg); }
7
+.banner-content, .post-header, .post-footer, .create-post-actions { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
8
+.banner-content { align-items: flex-start; flex-wrap: wrap; }
9
+.banner-content h1 { color: var(--sidebar-bg); font-size: 1.3rem; }
10
+.banner-content p, .post-body, .side-card p { color: var(--gray-600); }
11
+.progress-container { margin-top: 1rem; }
12
+.progress-labels { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: 0.375rem; color: var(--gray-600); font-size: 0.8rem; font-weight: 600; }
13
+.progress-bar { height: 0.5rem; background: var(--gray-200); }
14
+.progress-fill { width: 50%; height: 100%; background: var(--sidebar-bg); }
15
+.inicio-layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); gap: 1.25rem; }
16
+.create-post input, .create-post select { width: 100%; padding: 0.75rem; border: 1px solid var(--gray-300); background: white; font: inherit; }
17
+.create-post input { margin-bottom: 0.75rem; }
18
+.create-post-actions select { max-width: 60%; }
19
+.btn-solid { display: inline-block; padding: 0.55rem 1rem; border: 1px solid var(--sidebar-bg); background: var(--sidebar-bg); color: white; font: inherit; font-size: 0.9rem; font-weight: 700; cursor: pointer; text-align: center; }
20
+.btn-solid:hover, .btn-solid:focus-visible { background: #043b10; color: white; text-decoration: none; }
21
+.post-card { padding: 1.125rem; }
22
+.post-header { align-items: flex-start; margin-bottom: 0.75rem; }
23
+.user-block, .badges-wrap, .action-links { display: flex; align-items: center; gap: 0.625rem; }
24
+.badges-wrap, .action-links { flex-wrap: wrap; }
25
+.avatar { display: grid; width: 2.375rem; height: 2.375rem; place-items: center; background: var(--sidebar-bg); color: white; font-size: 0.85rem; font-weight: 700; }
26
+.avatar.prof { background: #0284c7; }
27
+.user-data strong, .user-data span { display: block; }
28
+.user-data strong { font-size: 0.9rem; }
29
+.user-data span, .post-status { color: var(--gray-600); font-size: 0.75rem; }
30
+.badge-materia { border: 1px solid #c8e6c9; background: #e8f5e9; color: var(--sidebar-bg); }
31
+.badge-interacted { border: 1px solid #fde68a; background: #fef3c7; color: #92400e; }
32
+.badge-solved { border: 1px solid #bbf7d0; background: #dcfce7; color: #166534; }
33
+.badge-verified { border: 1px solid #bae6fd; background: #e0f2fe; color: #0369a1; }
34
+.post-title { font-size: 1.05rem; }
35
+.post-body { margin-bottom: 0.75rem; font-size: 0.9rem; }
36
+.embed-box { margin: 0.75rem 0; border-left: 3px solid var(--sidebar-bg); background: var(--gray-50); padding: 0.625rem 0.75rem; font-size: 0.85rem; }
37
+.embed-box p { margin-top: 0.25rem; }
38
+.prof-box { border-left-color: #0284c7; background: #f0f9ff; }
39
+.post-footer { margin-top: 0.75rem; border-top: 1px solid var(--gray-200); padding-top: 0.625rem; }
40
+.btn-action { border: 0; background: transparent; color: var(--gray-600); font: inherit; font-size: 0.82rem; font-weight: 600; cursor: pointer; }
41
+.btn-action:hover, .btn-action.is-active { color: var(--sidebar-bg); text-decoration: none; }
42
+.side-card { border-top: 3px solid var(--sidebar-bg); }
43
+.side-card.prof-card { border-top-color: #0284c7; }
44
+.side-card h3 { margin-top: 0.5rem; font-size: 0.95rem; }
45
+.side-card p { margin: 0.5rem 0 0.75rem; font-size: 0.85rem; }
46
+.side-card .btn-solid, .btn-clean { width: 100%; }
47
+.btn-clean { display: inline-block; border: 1px solid var(--sidebar-bg); background: transparent; padding: 0.45rem 0.75rem; color: var(--sidebar-bg); font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer; text-align: center; }
48
+.side-link { color: var(--sidebar-bg); font-size: 0.85rem; font-weight: 700; }
49
+.empty-state { border-left: 4px solid var(--gray-300); text-align: center; }
50
+.empty-state h2 { font-size: 1.05rem; }
51
+.empty-state p { margin: 0.5rem 0 1rem; color: var(--gray-600); }
52
+
53
+@media (max-width: 850px) { .inicio-layout { grid-template-columns: 1fr; } }
54
+@media (max-width: 560px) {
55
+ .post-header, .post-footer, .create-post-actions { align-items: flex-start; flex-direction: column; }
56
+ .create-post-actions select, .create-post-actions .btn-solid { max-width: none; width: 100%; }
57
+ .progress-labels { flex-direction: column; gap: 0.125rem; }
58
+}
frontend/js/pages/circulos.js
+75
-1
@@ -1,4 +1,78 @@
1
-document.addEventListener('DOMContentLoaded', () => {
1
+/**
2
+ * Círculos de estudio.
3
+ * Contrato esperado de GET /circulos:
4
+ * { sesiones, misGrupos } con sesiones [{ id, titulo, materia, fecha, enVivo,
5
+ * moderador, plataforma, descripcion, asistentes }] y misGrupos [{ id, nombre,
6
+ * descripcion, miembros }].
7
+ */
8
+document.addEventListener('DOMContentLoaded', async () => {
9
Layout.init('circulos.html');
10
Layout.setPageTitle('Círculos de Estudio');
11
+
12
+ const content = document.getElementById('main-content');
13
+ if (!content) return;
14
+ content.innerHTML = '<div class="circulos-container"><p class="loading-message">Cargando círculos de estudio…</p></div>';
15
+ await loadCircles(content);
16
});
17
+
18
+async function loadCircles(content, search = '') {
19
+ let response = {};
20
+ try {
21
+ response = await API.request(`/circulos${search ? `?busqueda=${encodeURIComponent(search)}` : ''}`);
22
+ } catch {
23
+ // El endpoint se conectará cuando el módulo esté disponible en backend.
24
+ }
25
+
26
+ const sessions = Array.isArray(response.sesiones) ? response.sesiones : [];
27
+ const groups = Array.isArray(response.misGrupos) ? response.misGrupos : [];
28
+ renderCircles(content, sessions, groups);
29
+}
30
+
31
+function renderCircles(content, sessions, groups) {
32
+ content.innerHTML = `
33
+ <div class="circulos-container">
34
+ <form class="top-action-bar" id="circle-search-form">
35
+ <label class="visually-hidden" for="circle-search">Buscar círculo o sesión</label>
36
+ <input class="search-input" id="circle-search" name="busqueda" type="search" placeholder="Buscar círculo o sesión por materia...">
37
+ <button class="btn-solid" type="button" id="create-circle">+ Crear nuevo círculo</button>
38
+ </form>
39
+ <div class="circulos-grid">
40
+ <section><h1 class="section-title">Próximas sesiones de repaso</h1>${sessions.length ? sessions.map(renderSession).join('') : renderEmptySessions()}</section>
41
+ <aside><h1 class="section-title">Tus grupos permanentes</h1>${groups.length ? `<section class="card">${groups.map(renderGroup).join('')}</section>` : ''}<section class="card rules-card"><h2>Normas del círculo</h2><p>Los círculos son espacios colaborativos. Comparte material libre de plagio y mantén el respeto en las salas de videollamada.</p></section></aside>
42
+ </div>
43
+ </div>
44
+ `;
45
+
46
+ document.getElementById('circle-search-form')?.addEventListener('submit', event => {
47
+ event.preventDefault();
48
+ loadCircles(content, new FormData(event.currentTarget).get('busqueda')?.trim());
49
+ });
50
+ document.getElementById('create-circle')?.addEventListener('click', () => {
51
+ // La pantalla de creación se conectará cuando esté disponible el endpoint POST /circulos.
52
+ });
53
+}
54
+
55
+function renderSession(session) {
56
+ const live = Boolean(session.enVivo);
57
+ return `
58
+ <article class="card session-card ${live ? 'live-session' : ''}">
59
+ <div class="session-header"><div class="badges-wrap">${live ? '<span class="badge badge-live">● En vivo ahora</span>' : session.fecha ? `<span class="badge badge-time">${escapeHtml(session.fecha)}</span>` : ''}${session.materia ? `<span class="badge badge-materia">${escapeHtml(session.materia)}</span>` : ''}</div>${session.sala ? `<span class="attendees-count">${escapeHtml(session.sala)}</span>` : ''}</div>
60
+ <h2 class="session-title">${escapeHtml(session.titulo || 'Sesión de repaso')}</h2>
61
+ ${(session.moderador || session.plataforma) ? `<p class="session-info">${session.moderador ? `Moderador: <strong>${escapeHtml(session.moderador)}</strong>` : ''}${session.moderador && session.plataforma ? ' · ' : ''}${session.plataforma ? `Plataforma: <strong>${escapeHtml(session.plataforma)}</strong>` : ''}</p>` : ''}
62
+ ${session.descripcion ? `<p class="session-desc">${escapeHtml(session.descripcion)}</p>` : ''}
63
+ <div class="session-footer">${Number.isFinite(Number(session.asistentes)) ? `<span class="attendees-count">👥 ${Number(session.asistentes)} ${live ? 'estudiantes conectados' : 'confirmados'}</span>` : ''}<div class="session-actions">${session.materialDisponible ? '<button class="btn-clean" type="button">Ver material de apoyo</button>' : ''}<button class="btn-solid" type="button">${live ? 'Entrar a sala' : 'Confirmar asistencia'}</button></div></div>
64
+ </article>
65
+ `;
66
+}
67
+
68
+function renderGroup(group) {
69
+ return `<article class="group-item"><span class="group-name">${escapeHtml(group.nombre || 'Grupo de estudio')}</span>${group.descripcion ? `<span class="group-description">${escapeHtml(group.descripcion)}</span>` : ''}<div class="group-meta">${Number.isFinite(Number(group.miembros)) ? `<span>${Number(group.miembros)} miembros</span>` : '<span></span>'}<button class="group-link" type="button">Ver grupo →</button></div></article>`;
70
+}
71
+
72
+function renderEmptySessions() {
73
+ return '<section class="card empty-circles"><h2>Aún no hay sesiones programadas</h2><p>Las sesiones de repaso y los círculos disponibles aparecerán aquí cuando sean creados.</p></section>';
74
+}
75
+
76
+function escapeHtml(value) {
77
+ return String(value ?? '').replace(/[&<>'"]/g, character => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]);
78
+}
frontend/js/pages/empresarial.js
+95
-1
@@ -1,4 +1,98 @@
1
-document.addEventListener('DOMContentLoaded', () => {
1
+/**
2
+ * Directorio de Semestre Empresarial.
3
+ * Contrato esperado de GET /empresarial:
4
+ * { empresas: [{ id, nombre, ciudad, estado, modalidad, calificacion,
5
+ * totalResenas, descripcion, convenioActivo, tecnologias, experienciaDestacada }] }
6
+ */
7
+document.addEventListener('DOMContentLoaded', async () => {
8
Layout.init('empresarial.html');
9
Layout.setPageTitle('Semestre Empresarial');
10
+
11
+ const content = document.getElementById('main-content');
12
+ if (!content) return;
13
+
14
+ content.innerHTML = '<div class="empresarial-container"><p class="loading-message">Cargando empresas vinculadas…</p></div>';
15
+ await loadCompanies(content);
16
});
17
+
18
+async function loadCompanies(content, filters = {}) {
19
+ const params = new URLSearchParams();
20
+ if (filters.busqueda) params.set('busqueda', filters.busqueda);
21
+ if (filters.modalidad) params.set('modalidad', filters.modalidad);
22
+ if (filters.calificacionMinima) params.set('calificacionMinima', filters.calificacionMinima);
23
+
24
+ let response = {};
25
+ try {
26
+ response = await API.request(`/empresarial${params.size ? `?${params}` : ''}`);
27
+ } catch {
28
+ // El controlador todavía no expone datos; se conserva el estado vacío.
29
+ }
30
+
31
+ const companies = Array.isArray(response) ? response : (Array.isArray(response.empresas) ? response.empresas : []);
32
+ renderCompanies(content, companies);
33
+}
34
+
35
+function renderCompanies(content, companies) {
36
+ content.innerHTML = `
37
+ <div class="empresarial-container">
38
+ <form class="filter-bar" id="company-filter-form">
39
+ <label class="visually-hidden" for="company-search">Buscar empresa</label>
40
+ <input class="input-search" id="company-search" name="busqueda" type="search" placeholder="Buscar por empresa, tecnología o proyecto...">
41
+ <select class="select-filter" name="modalidad" aria-label="Filtrar por modalidad"><option value="">Todas las modalidades</option><option value="HIBRIDO">Híbrido</option><option value="REMOTO">Remoto</option><option value="PRESENCIAL">Presencial</option></select>
42
+ <select class="select-filter" name="calificacionMinima" aria-label="Filtrar por calificación"><option value="">Calificación: todas</option><option value="4.5">4.5 estrellas o más</option><option value="4.0">4.0 estrellas o más</option></select>
43
+ <button class="btn-solid" type="submit">Buscar</button>
44
+ </form>
45
+ <section id="company-results" aria-live="polite">${companies.length ? companies.map(renderCompany).join('') : renderEmptyCompanies()}</section>
46
+ </div>
47
+ `;
48
+
49
+ document.getElementById('company-filter-form')?.addEventListener('submit', event => {
50
+ event.preventDefault();
51
+ const formData = new FormData(event.currentTarget);
52
+ loadCompanies(content, Object.fromEntries(formData.entries()));
53
+ });
54
+}
55
+
56
+function renderCompany(company) {
57
+ const rating = Number(company.calificacion);
58
+ const hasRating = Number.isFinite(rating) && rating > 0;
59
+ const location = [company.ciudad, company.estado].filter(Boolean).join(', ');
60
+ const locationAndMode = [location, company.modalidad && `Modalidad: ${formatMode(company.modalidad)}`].filter(Boolean).join(' · ');
61
+ const technologies = Array.isArray(company.tecnologias) ? company.tecnologias : [];
62
+ const experience = company.experienciaDestacada;
63
+
64
+ return `
65
+ <article class="card company-card">
66
+ <header class="company-header">
67
+ <div><h1 class="company-name">${escapeHtml(company.nombre || 'Empresa')}</h1>${locationAndMode ? `<span class="company-location">${escapeHtml(locationAndMode)}</span>` : ''}</div>
68
+ ${hasRating ? `<div class="rating-box"><span class="stars" aria-label="${rating} de 5 estrellas">${renderStars(rating)}</span><strong class="rating-number">${rating.toFixed(1)}</strong>${Number.isFinite(Number(company.totalResenas)) ? `<span class="rating-count">(${Number(company.totalResenas)} reseñas)</span>` : ''}</div>` : ''}
69
+ </header>
70
+ ${company.descripcion ? `<p class="company-desc"><strong>¿Qué hacen?</strong> ${escapeHtml(company.descripcion)}</p>` : ''}
71
+ ${(company.convenioActivo || technologies.length) ? `<div class="tags-group">${company.convenioActivo ? '<span class="badge badge-green">Convenio oficial activo</span>' : ''}${technologies.map(item => `<span class="badge badge-blue">${escapeHtml(item)}</span>`).join('')}</div>` : ''}
72
+ ${experience ? renderExperience(experience) : ''}
73
+ <footer class="company-footer"><button class="btn-clean" type="button" data-company-id="${escapeHtml(company.id ?? '')}">Ver experiencias${Number.isFinite(Number(company.totalResenas)) ? ` (${Number(company.totalResenas)})` : ''}</button><button class="btn-solid" type="button" data-company-id="${escapeHtml(company.id ?? '')}">Ver requisitos de vinculación</button></footer>
74
+ </article>
75
+ `;
76
+}
77
+
78
+function renderExperience(experience) {
79
+ const author = [experience.autor, experience.semestre && `${experience.semestre}.º semestre`].filter(Boolean).join(' · ');
80
+ return `<div class="experience-box">${author ? `<strong class="experience-author">${escapeHtml(author)}</strong>` : ''}${experience.texto ? `<p class="experience-text"><em>“${escapeHtml(experience.texto)}”</em></p>` : ''}</div>`;
81
+}
82
+
83
+function renderEmptyCompanies() {
84
+ return '<section class="card empty-companies"><h1>Aún no hay empresas disponibles</h1><p>Las empresas vinculadas y las experiencias de estudiantes aparecerán aquí cuando se registren.</p></section>';
85
+}
86
+
87
+function renderStars(rating) {
88
+ const filled = Math.round(rating);
89
+ return '★'.repeat(Math.min(5, filled)) + '☆'.repeat(Math.max(0, 5 - filled));
90
+}
91
+
92
+function formatMode(mode) {
93
+ return String(mode).toLowerCase().replace(/^./, character => character.toUpperCase());
94
+}
95
+
96
+function escapeHtml(value) {
97
+ return String(value ?? '').replace(/[&<>'"]/g, character => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]);
98
+}
frontend/js/pages/inicio.js
+111
-1
@@ -1,4 +1,114 @@
1
-document.addEventListener('DOMContentLoaded', () => {
1
+/**
2
+ * Panel de inicio.
3
+ * Contrato esperado de GET /inicio:
4
+ * { perfil, materias, publicaciones, circuloActual, asesoria, empresaDestacada }
5
+ */
6
+document.addEventListener('DOMContentLoaded', async () => {
7
Layout.init('inicio.html');
8
Layout.setPageTitle('Inicio');
9
+
10
+ const content = document.getElementById('main-content');
11
+ if (!content) return;
12
+
13
+ content.innerHTML = '<div class="inicio-container"><p class="loading-message">Cargando tu información académica…</p></div>';
14
+
15
+ let dashboard = {};
16
+ try {
17
+ dashboard = await API.request('/inicio');
18
+ } catch {
19
+ // El endpoint se implementará en el backend. La UI mantiene estados vacíos.
20
+ }
21
+
22
+ renderDashboard(content, dashboard || {});
23
});
24
+
25
+function renderDashboard(content, data) {
26
+ const perfil = data.perfil || null;
27
+ const materias = Array.isArray(data.materias) ? data.materias : [];
28
+ const publicaciones = Array.isArray(data.publicaciones) ? data.publicaciones : [];
29
+ const accesos = renderAccessCards(data);
30
+
31
+ content.innerHTML = `
32
+ <div class="inicio-container">
33
+ ${perfil ? renderBanner(perfil) : ''}
34
+ <div class="inicio-layout">
35
+ <section class="feed-column" aria-label="Actividad reciente">
36
+ ${materias.length ? renderQuickQuestion(materias) : ''}
37
+ ${publicaciones.length ? publicaciones.map(renderPost).join('') : renderEmptyFeed()}
38
+ </section>
39
+ ${accesos ? `<aside class="sidebar-section" aria-label="Accesos directos">${accesos}</aside>` : ''}
40
+ </div>
41
+ </div>
42
+ `;
43
+
44
+ document.getElementById('quick-question-form')?.addEventListener('submit', event => {
45
+ event.preventDefault();
46
+ window.location.href = 'foro.html';
47
+ });
48
+}
49
+
50
+function renderBanner(perfil) {
51
+ const semestre = Number(perfil.semestre) || 0;
52
+ const totalSemestres = Number(perfil.totalSemestres) || 0;
53
+ const avance = totalSemestres ? Math.min(100, Math.round((semestre / totalSemestres) * 100)) : 0;
54
+
55
+ return `
56
+ <section class="card maps-banner" aria-labelledby="maps-title">
57
+ <div class="banner-content">
58
+ <div>
59
+ <h1 id="maps-title">${escapeHtml(perfil.carrera || 'Mi trayectoria MAPS')}</h1>
60
+ <p>${perfil.certificado ? `Certificado activo: <strong>${escapeHtml(perfil.certificado)}</strong>` : 'Sin certificado activo'}${semestre ? ` · Semestre vigente: <strong>${semestre}.º</strong>` : ''}</p>
61
+ </div>
62
+ <span class="badge badge-materia">Plan MAPS oficial</span>
63
+ </div>
64
+ ${totalSemestres ? `<div class="progress-container"><div class="progress-labels"><span>Avance de carrera</span><span>Semestre ${semestre} de ${totalSemestres} · ${avance} %</span></div><div class="progress-bar" role="progressbar" aria-label="Avance de carrera" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${avance}"><div class="progress-fill" style="width: ${avance}%"></div></div></div>` : ''}
65
+ </section>
66
+ `;
67
+}
68
+
69
+function renderQuickQuestion(materias) {
70
+ const options = materias.map(materia => `<option value="${escapeHtml(materia.id ?? '')}">${escapeHtml(materia.nombre || materia)}</option>`).join('');
71
+ return `
72
+ <form class="card create-post" id="quick-question-form">
73
+ <label class="visually-hidden" for="quick-question">Nueva duda académica</label>
74
+ <input id="quick-question" name="question" type="text" maxlength="180" placeholder="¿Tienes una duda académica? Pregunta a tu comunidad..." required>
75
+ <div class="create-post-actions"><select aria-label="Materia de la duda"><option value="">Seleccionar materia...</option>${options}</select><button class="btn-solid" type="submit">Publicar duda</button></div>
76
+ </form>
77
+ `;
78
+}
79
+
80
+function renderPost(post) {
81
+ const author = post.autor || {};
82
+ const initials = (author.iniciales || author.nombre || '?').trim().split(/\s+/).map(word => word[0]).slice(0, 2).join('').toUpperCase();
83
+ const tags = [post.materia && `<span class="badge badge-materia">${escapeHtml(post.materia)}</span>`, post.solucionAceptada && '<span class="badge badge-solved">✓ Solución aceptada</span>', post.verificadoDocente && '<span class="badge badge-verified">Verificado por docente</span>'].filter(Boolean).join('');
84
+
85
+ return `
86
+ <article class="card post-card">
87
+ <div class="post-header"><div class="user-block"><div class="avatar ${post.verificadoDocente ? 'prof' : ''}">${escapeHtml(initials)}</div><div class="user-data"><strong>${escapeHtml(author.nombre || 'Usuario')}</strong><span>${escapeHtml(post.fechaRelativa || '')}</span></div></div><div class="badges-wrap">${tags}</div></div>
88
+ <h2 class="post-title">${escapeHtml(post.titulo || '')}</h2>
89
+ ${post.contenido ? `<p class="post-body">${escapeHtml(post.contenido)}</p>` : ''}
90
+ <div class="post-footer"><div class="action-links"><a class="btn-action" href="foro.html">💬 ${Number(post.respuestas) || 0} respuestas</a></div><span class="post-status">${post.solucionAceptada ? 'Resuelto' : ''}</span></div>
91
+ </article>
92
+ `;
93
+}
94
+
95
+function renderAccessCards(data) {
96
+ const cards = [];
97
+ const circulo = data.circuloActual;
98
+ const asesoria = data.asesoria;
99
+ const empresa = data.empresaDestacada;
100
+
101
+ if (circulo) cards.push(`<section class="card side-card"><span class="badge badge-materia">${escapeHtml(circulo.fecha || 'Próximamente')}</span><h3>${escapeHtml(circulo.nombre || 'Círculo de estudio')}</h3>${circulo.descripcion ? `<p>${escapeHtml(circulo.descripcion)}</p>` : ''}<a class="btn-solid" href="circulos.html">Ver círculo</a></section>`);
102
+ if (asesoria) cards.push(`<section class="card side-card prof-card"><span class="badge badge-verified">Asesoría disponible</span><h3>${escapeHtml(asesoria.docente || 'Docente')}</h3>${asesoria.horario ? `<p>${escapeHtml(asesoria.horario)}</p>` : ''}<a class="btn-clean" href="mensajes.html">Enviar mensaje privado</a></section>`);
103
+ if (empresa) cards.push(`<section class="card side-card"><h3>Radar Semestre Empresarial</h3><p><strong>${escapeHtml(empresa.nombre || '')}${empresa.calificacion ? ` · ${escapeHtml(empresa.calificacion)} ★` : ''}</strong>${empresa.resena ? `<br>${escapeHtml(empresa.resena)}` : ''}</p><a class="side-link" href="empresarial.html">Ver directorio de empresas →</a></section>`);
104
+
105
+ return cards.join('');
106
+}
107
+
108
+function renderEmptyFeed() {
109
+ return '<section class="card empty-state"><h2>Aún no hay actividad</h2><p>Las dudas, avisos y recursos de tu comunidad aparecerán aquí cuando estén disponibles.</p><a class="side-link" href="foro.html">Ir al foro →</a></section>';
110
+}
111
+
112
+function escapeHtml(value) {
113
+ return String(value ?? '').replace(/[&<>'"]/g, character => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]);
114
+}
frontend/pages/circulos.html
+1
@@ -7,6 +7,7 @@
7
<link rel="stylesheet" href="../css/base.css">
8
<link rel="stylesheet" href="../css/layout.css">
9
<link rel="stylesheet" href="../css/components.css">
10
+ <link rel="stylesheet" href="../css/circulos.css">
11
</head>
12
<body>
13
<div class="app-layout">
frontend/pages/empresarial.html
+1
@@ -7,6 +7,7 @@
7
<link rel="stylesheet" href="../css/base.css">
8
<link rel="stylesheet" href="../css/layout.css">
9
<link rel="stylesheet" href="../css/components.css">
10
+ <link rel="stylesheet" href="../css/empresarial.css">
11
</head>
12
<body>
13
<div class="app-layout">
frontend/pages/inicio.html
+2
-6
@@ -7,6 +7,7 @@
7
<link rel="stylesheet" href="../css/base.css">
8
<link rel="stylesheet" href="../css/layout.css">
9
<link rel="stylesheet" href="../css/components.css">
10
+ <link rel="stylesheet" href="../css/inicio.css">
11
</head>
12
<body>
13
@@ -15,12 +16,7 @@
16
<aside class="sidebar" id="sidebar"></aside>
17
<div class="main-wrapper">
18
<header class="topbar" id="topbar"></header>
18
- <main class="main-content" id="main-content">
19
-
20
- hola
21
-
22
-
23
- </main>
19
+ <main class="main-content" id="main-content"></main>
20
</div>
21
</div>
22
<script src="../js/api.js"></script>