| 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 => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[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">×</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 | } |