main
js 407 lines 20.3 KB
Raw
1 /**
2 * Directorio de Semestre Empresarial.
3 * Contrato esperado de GET /empresarial:
4 * { empresas: [{ id, nombre, sector, calificacion,
5 * totalResenas, descripcion, experienciaDestacada }] }
6 * Reseñas: GET/POST/PUT/DELETE /empresarial/{id}/resenas.
7 */
8 document.addEventListener('DOMContentLoaded', async () => {
9 Layout.init('empresarial.html');
10 Layout.setPageTitle('Semestre Empresarial');
11
12 const content = document.getElementById('main-content');
13 if (!content) return;
14
15 content.innerHTML = '<div class="empresarial-container"><p class="loading-message">Cargando empresas vinculadas…</p></div>';
16 content.addEventListener('click', event => {
17 const target = event.target.closest('button[data-action]');
18 if (!target) return;
19 if (target.dataset.action === 'experiencias' || target.dataset.action === 'resenar') {
20 const id = target.dataset.companyId;
21 if (id) abrirModalEmpresa(id, target.dataset.action === 'resenar');
22 } else if (target.dataset.action === 'requisitos') {
23 Utils.toast('Los requisitos de vinculación se habilitan cuando el backend de convenios esté integrado.', 'info');
24 }
25 });
26 await loadCompanies(content);
27 });
28
29 let empresasActuales = [];
30
31 async function loadCompanies(content, filters = {}) {
32 const params = new URLSearchParams();
33 if (filters.busqueda) params.set('busqueda', filters.busqueda);
34 if (filters.calificacionMinima) params.set('calificacionMinima', filters.calificacionMinima);
35
36 let response = {};
37 try {
38 response = await API.request(`/empresarial${params.size ? `?${params}` : ''}`);
39 } catch {
40 // El controlador todavía no expone datos; se conserva el estado vacío.
41 }
42
43 const companies = Array.isArray(response) ? response : (Array.isArray(response.empresas) ? response.empresas : []);
44 const carreraFiltrada = response && !Array.isArray(response) ? response.carreraFiltrada || '' : '';
45 empresasActuales = companies;
46 renderCompanies(content, companies, filters.sector || '', carreraFiltrada);
47 }
48
49 function renderCompanies(content, companies, sectorSeleccionado = '', carreraFiltrada = '') {
50 const sectores = [...new Set((companies || [])
51 .map(c => (c.sector || '').trim())
52 .filter(Boolean))]
53 .sort((a, b) => a.localeCompare(b, 'es'));
54 const sectorOptions = sectores
55 .map(s => `<option value="${escapeHtml(s)}"${s === sectorSeleccionado ? ' selected' : ''}>${escapeHtml(s)}</option>`)
56 .join('');
57
58 const visibles = sectorSeleccionado
59 ? companies.filter(c => (c.sector || '').trim() === sectorSeleccionado)
60 : companies;
61
62 content.innerHTML = `
63 <div class="empresarial-container">
64 <header class="module-heading"><h1>Semestre Empresarial</h1><p>Explora empresas vinculadas y experiencias compartidas por la comunidad.</p>${carreraFiltrada ? `<p class="filter-note">Mostrando empresas afines a tu carrera: <strong>${escapeHtml(carreraFiltrada)}</strong></p>` : ''}</header>
65 <form class="filter-bar" id="company-filter-form">
66 <label class="visually-hidden" for="company-search">Buscar empresa</label>
67 <input class="input-search" id="company-search" name="busqueda" type="search" placeholder="Buscar por empresa, tecnología o proyecto...">
68 <select class="select-filter" name="sector" aria-label="Filtrar por sector"><option value="">Todos los sectores</option>${sectorOptions}</select>
69 <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>
70 <button class="btn-solid" type="submit">Buscar</button>
71 </form>
72 <section id="company-results" aria-live="polite">${visibles.length ? visibles.map(renderCompany).join('') : renderEmptyCompanies()}</section>
73 </div>
74 `;
75
76 document.getElementById('company-filter-form')?.addEventListener('submit', event => {
77 event.preventDefault();
78 const formData = new FormData(event.currentTarget);
79 loadCompanies(content, Object.fromEntries(formData.entries()));
80 });
81 }
82
83 function renderCompany(company) {
84 const rating = Number(company.calificacion);
85 const hasRating = Number.isFinite(rating) && rating > 0;
86 const location = [company.ciudad, company.estado].filter(Boolean).join(', ');
87 const technologies = Array.isArray(company.tecnologias) ? company.tecnologias : [];
88 const experience = company.experienciaDestacada;
89
90 const tags = [];
91 if (company.sector) tags.push(`<span class="badge badge-blue">Sector: ${escapeHtml(company.sector)}</span>`);
92 if (company.convenioActivo) tags.push('<span class="badge badge-green">Convenio oficial activo</span>');
93 tags.push(...technologies.map(item => `<span class="badge badge-blue">${escapeHtml(item)}</span>`));
94
95 return `
96 <article class="card company-card">
97 <header class="company-header">
98 <div><h2 class="company-name">${escapeHtml(company.nombre || 'Empresa')}</h2>${location ? `<span class="company-location">${escapeHtml(location)}</span>` : ''}</div>
99 ${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>` : ''}
100 </header>
101 ${company.descripcion ? `<p class="company-desc"><strong>¿Qué hacen?</strong> ${escapeHtml(company.descripcion)}</p>` : ''}
102 ${tags.length ? `<div class="tags-group">${tags.join('')}</div>` : ''}
103 ${experience ? renderExperience(experience) : ''}
104 <footer class="company-footer"><button class="btn-clean" type="button" data-action="experiencias" data-company-id="${escapeHtml(company.id ?? '')}">Ver experiencias${Number.isFinite(Number(company.totalResenas)) ? ` (${Number(company.totalResenas)})` : ''}</button><button class="btn-solid" type="button" data-action="resenar" data-company-id="${escapeHtml(company.id ?? '')}">Compartir tu experiencia</button></footer>
105 </article>
106 `;
107 }
108
109 function renderExperience(experience) {
110 const author = [experience.autor, experience.semestre && `${experience.semestre}.º semestre`].filter(Boolean).join(' · ');
111 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>`;
112 }
113
114 function renderEmptyCompanies() {
115 return '<section class="card empty-companies"><h2>Aún no hay empresas disponibles</h2><p>Las empresas vinculadas y las experiencias de estudiantes aparecerán aquí cuando se registren.</p></section>';
116 }
117
118 function renderStars(rating) {
119 const filled = Math.round(rating);
120 return ''.repeat(Math.min(5, filled)) + ''.repeat(Math.max(0, 5 - filled));
121 }
122
123 function escapeHtml(value) {
124 return String(value ?? '').replace(/[&<>'"]/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[character]);
125 }
126
127 // ------- Modal de experiencias y reseñas -------
128
129 let modalBackdrop = null;
130 let modalEmpresaId = null;
131 let modalEmpresa = null;
132 let calificacionSeleccionada = 0;
133 let resenaEnviandose = false;
134
135 function abrirModalEmpresa(companyId, enfocarFormulario) {
136 calificacionSeleccionada = 0;
137 modalEmpresaId = String(companyId);
138 modalEmpresa = empresasActuales.find(empresa => String(empresa.id) === modalEmpresaId) || null;
139
140 if (modalBackdrop) modalBackdrop.remove();
141
142 const rating = Number(modalEmpresa?.calificacion);
143 const headerSub = Number.isFinite(rating) && rating > 0
144 ? `Calificación promedio ${rating.toFixed(1)} ★ · ${modalEmpresa.totalResenas ?? 0} reseñas`
145 : 'Aún sin calificaciones';
146
147 modalBackdrop = document.createElement('div');
148 modalBackdrop.className = 'empresa-modal-backdrop';
149 modalBackdrop.innerHTML = `
150 <div class="empresa-modal" role="dialog" aria-modal="true" aria-label="Experiencias de ${escapeHtml(modalEmpresa?.nombre || 'la empresa')}">
151 <header class="empresa-modal-header">
152 <div>
153 <h2 class="empresa-modal-title">${escapeHtml(modalEmpresa?.nombre || 'Empresa')}</h2>
154 <span class="empresa-modal-sub">${escapeHtml(headerSub)}</span>
155 </div>
156 <button class="empresa-modal-close" type="button" aria-label="Cerrar">&times;</button>
157 </header>
158 <div class="empresa-modal-body">
159 <div id="experiencias-list"><p class="loading-message">Cargando experiencias…</p></div>
160 <div id="resena-form-section"></div>
161 </div>
162 <footer class="empresa-modal-footer"><button class="btn-clean" type="button" data-modal-close>Cerrar</button></footer>
163 </div>
164 `;
165 document.body.appendChild(modalBackdrop);
166 document.body.classList.add('empresa-modal-open');
167
168 modalBackdrop.addEventListener('click', event => {
169 if (event.target === modalBackdrop || event.target.closest('[data-modal-close]')) {
170 cerrarModalEmpresa();
171 return;
172 }
173
174 const btn = event.target.closest('button[data-action="editar-resena"], button[data-action="eliminar-resena"]');
175 if (!btn) return;
176
177 if (btn.dataset.action === 'editar-resena') {
178 abrirEdicionResena();
179 return;
180 }
181
182 if (Number(btn.dataset.step) === 0) {
183 btn.dataset.step = '1';
184 btn.textContent = '¿Seguro?';
185 setTimeout(() => {
186 btn.dataset.step = '0';
187 btn.textContent = 'Eliminar';
188 }, 3500);
189 return;
190 }
191 btn.disabled = true;
192 eliminarMiResena(btn);
193 });
194
195 cargarExperiencias();
196 if (enfocarFormulario) {
197 setTimeout(() => document.getElementById('resena-form-section')?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 150);
198 }
199 }
200
201 function cerrarModalEmpresa() {
202 modalBackdrop?.remove();
203 modalBackdrop = null;
204 modalEmpresaId = null;
205 modalEmpresa = null;
206 document.body.classList.remove('empresa-modal-open');
207 }
208
209 let modalResenas = [];
210 let modalMiResena = null;
211
212 async function cargarExperiencias() {
213 const listContainer = modalBackdrop?.querySelector('#experiencias-list');
214 const formContainer = modalBackdrop?.querySelector('#resena-form-section');
215 if (!listContainer || !modalEmpresaId) return;
216
217 listContainer.innerHTML = '<p class="loading-message">Cargando experiencias…</p>';
218 formContainer.innerHTML = '';
219
220 let reviews = [];
221 try {
222 const response = await API.request(`/empresarial/${modalEmpresaId}/resenas`);
223 reviews = Array.isArray(response) ? response : [];
224 } catch {
225 listContainer.innerHTML = '<p class="loading-message">No se pudieron cargar las experiencias. Inténtalo de nuevo.</p>';
226 return;
227 }
228
229 modalResenas = reviews;
230 modalMiResena = reviews.find(review => review.miResena) || null;
231
232 listContainer.innerHTML = reviews.length
233 ? reviews.map(renderReview).join('')
234 : '<p class="empty-experiences">Aún no hay experiencias compartidas en esta empresa. Sé la primera persona en compartir la tuya.</p>';
235
236 formContainer.innerHTML = modalMiResena
237 ? `<div class="experiencias-section"><p class="my-experience-note">Ya compartiste tu experiencia en esta empresa. Puedes <button type="button" class="link-action" data-action="editar-resena">editar</button> o <button type="button" class="link-action" data-action="eliminar-resena" data-step="0">eliminar</button> tu aporte.</p><div id="resena-edit-wrap"></div></div>`
238 : renderResenaForm();
239 if (!modalMiResena) vincularFormulario('crear', null);
240 }
241
242 function renderReview(review) {
243 const destacada = review.miResena ? ' review-mine' : '';
244 const fecha = formatFecha(review.fecha);
245 const acciones = review.miResena
246 ? `<div class="review-actions"><button class="btn-clean" type="button" data-action="editar-resena">Editar</button><button class="btn-clean btn-danger" type="button" data-action="eliminar-resena" data-step="0">Eliminar</button></div>`
247 : '';
248 return `
249 <article class="review-item${destacada}">
250 <header class="review-head">
251 <div class="review-stars" aria-label="${review.calificacion} de 5 estrellas">${renderStars(review.calificacion)}</div>
252 <div class="review-meta"><strong class="review-author">${escapeHtml(review.autor || 'Estudiante')}</strong>${fecha ? `<span class="review-date">${fecha}</span>` : ''}${review.miResena ? '<span class="badge badge-green">Tu experiencia</span>' : ''}</div>
253 </header>
254 ${review.proyectoDesarrollado ? `<p class="review-field"><strong>Proyecto desarrollado:</strong> ${escapeHtml(review.proyectoDesarrollado)}</p>` : ''}
255 ${review.aprendizajes ? `<p class="review-field"><strong>¿Qué aprendió?</strong> ${escapeHtml(review.aprendizajes)}</p>` : ''}
256 ${review.recomendaciones ? `<p class="review-field"><strong>Recomendaciones:</strong> ${escapeHtml(review.recomendaciones)}</p>` : ''}
257 ${acciones}
258 </article>
259 `;
260 }
261
262 function abrirEdicionResena() {
263 const wrap = modalBackdrop?.querySelector('#resena-edit-wrap');
264 if (!wrap || !modalMiResena) return;
265 const base = modalMiResena;
266 wrap.innerHTML = renderResenaForm(base, 'editar');
267 vincularFormulario('editar', base);
268 wrap.scrollIntoView({ behavior: 'smooth', block: 'neutral' });
269 }
270
271 function cancelarEdicionResena() {
272 const wrap = modalBackdrop?.querySelector('#resena-edit-wrap');
273 if (wrap) wrap.innerHTML = '';
274 }
275
276 function renderResenaForm(valores = null, modo = 'crear') {
277 const stars = [1, 2, 3, 4, 5].map(value => `<button type="button" class="star-btn${valores?.calificacion && Number(valores.calificacion) >= value ? ' is-selected' : ''}" data-value="${value}" aria-label="${value} estrellas" aria-pressed="${valores?.calificacion && Number(valores.calificacion) >= value ? 'true' : 'false'}">★</button>`).join('');
278 const titulo = modo === 'editar' ? 'Edita tu experiencia' : 'Comparte tu experiencia';
279 const textoSubmit = modo === 'editar' ? 'Guardar cambios' : 'Compartir experiencia';
280 return `
281 <div class="experiencias-section resena-form-wrap">
282 <h3 class="resena-title">${titulo}</h3>
283 <form class="resena-form" id="${modo === 'editar' ? 'resena-form-editar' : 'resena-form'}" novalidate>
284 <div class="field-row">
285 <span class="resena-label">Tu calificación</span>
286 <div class="star-picker" role="radiogroup" aria-label="Calificación">
287 ${stars}
288 </div>
289 <span class="star-picker-label" id="star-picker-label">${valores?.calificacion ? (String(valores.calificacion) === '1' ? '1 estrella' : `${valores.calificacion} estrellas`) : 'Selecciona de 1 a 5 estrellas'}</span>
290 </div>
291 <div class="field-row">
292 <label class="resena-label" for="resena-proyecto">Proyecto desarrollado</label>
293 <textarea class="resena-input" id="resena-proyecto" rows="2" maxlength="2000" placeholder="Describe brevemente el proyecto o actividad que desarrollaste...">${escapeHtml(valores?.proyectoDesarrollado || '')}</textarea>
294 </div>
295 <div class="field-row">
296 <label class="resena-label" for="resena-aprendizajes">¿Qué aprendiste?</label>
297 <textarea class="resena-input" id="resena-aprendizajes" rows="3" maxlength="2000" placeholder="Comparte los aprendizajes más valiosos de tu experiencia...">${escapeHtml(valores?.aprendizajes || '')}</textarea>
298 </div>
299 <div class="field-row">
300 <label class="resena-label" for="resena-recomendaciones">Recomendaciones para otros estudiantes</label>
301 <textarea class="resena-input" id="resena-recomendaciones" rows="2" maxlength="2000" placeholder="Consejos para quien viva el Semestre Empresarial aquí...">${escapeHtml(valores?.recomendaciones || '')}</textarea>
302 </div>
303 <div class="field-row resena-actions">
304 ${modo === 'editar' ? '<button class="btn-clean" type="button" id="resena-cancelar">Cancelar</button>' : ''}
305 <button class="btn-solid" type="submit" id="resena-submit">${textoSubmit}</button>
306 </div>
307 </form>
308 </div>
309 `;
310 }
311
312 function vincularFormulario(modo, valores) {
313 const formId = modo === 'editar' ? '#resena-form-editar' : '#resena-form';
314 const form = modalBackdrop?.querySelector(formId);
315 if (!form) return;
316
317 if (modo === 'editar') {
318 calificacionSeleccionada = Number(valores?.calificacion) || 0;
319 } else {
320 calificacionSeleccionada = 0;
321 }
322
323 form.querySelectorAll('.star-btn').forEach(btn => {
324 btn.addEventListener('click', () => {
325 calificacionSeleccionada = Number(btn.dataset.value);
326 form.querySelectorAll('.star-btn').forEach(star => {
327 const activo = Number(star.dataset.value) <= calificacionSeleccionada;
328 star.classList.toggle('is-selected', activo);
329 star.setAttribute('aria-pressed', String(activo));
330 });
331 const label = form.querySelector('#star-picker-label');
332 if (label) {
333 label.textContent = calificacionSeleccionada === 1
334 ? '1 estrella'
335 : `${calificacionSeleccionada} estrellas`;
336 }
337 });
338 });
339
340 form.querySelector('#resena-cancelar')?.addEventListener('click', () => cancelarEdicionResena());
341
342 form.addEventListener('submit', async event => {
343 event.preventDefault();
344 if (resenaEnviandose) return;
345 if (!calificacionSeleccionada) {
346 Utils.toast('Selecciona una calificación de 1 a 5 estrellas.', 'error');
347 return;
348 }
349
350 resenaEnviandose = true;
351 const submitBtn = form.querySelector('#resena-submit');
352 const originalText = submitBtn.textContent;
353 submitBtn.disabled = true;
354
355 const body = {
356 calificacion: calificacionSeleccionada,
357 proyectoDesarrollado: form.querySelector('#resena-proyecto').value.trim(),
358 aprendizajes: form.querySelector('#resena-aprendizajes').value.trim(),
359 recomendaciones: form.querySelector('#resena-recomendaciones').value.trim()
360 };
361
362 try {
363 await API.request(`/empresarial/${modalEmpresaId}/resenas`, {
364 method: modo === 'editar' ? 'PUT' : 'POST',
365 body: JSON.stringify(body)
366 });
367 Utils.toast(modo === 'editar'
368 ? 'Tu experiencia fue actualizada correctamente.'
369 : 'Experiencia compartida correctamente. Gracias por aportar a la comunidad.', 'success');
370 await cargarExperiencias();
371 recargarEmpresas();
372 } catch (error) {
373 Utils.toast(error.message || 'No se pudo guardar tu experiencia. Inténtalo de nuevo.', 'error');
374 } finally {
375 submitBtn.disabled = false;
376 submitBtn.textContent = originalText;
377 resenaEnviandose = false;
378 }
379 });
380 }
381
382 async function eliminarMiResena(btn) {
383 try {
384 await API.request(`/empresarial/${modalEmpresaId}/resenas`, { method: 'DELETE' });
385 Utils.toast('Tu experiencia fue eliminada.', 'success');
386 await cargarExperiencias();
387 recargarEmpresas();
388 } catch (error) {
389 btn.disabled = false;
390 btn.dataset.step = '0';
391 btn.textContent = 'Eliminar';
392 Utils.toast(error.message || 'No se pudo eliminar tu experiencia.', 'error');
393 }
394 }
395
396 async function recargarEmpresas() {
397 const content = document.getElementById('main-content');
398 if (content) await loadCompanies(content);
399 }
400
401 function formatFecha(value) {
402 if (!value) return '';
403 const cleaned = String(value).replace('T', ' ');
404 const match = cleaned.match(/^(\d{4})-(\d{2})-(\d{2})/);
405 if (!match) return '';
406 return `${match[3]}/${match[2]}/${match[1]}`;
407 }