// ===================================================================== // Utilidades compartidas: red, formato, iconos, avisos y el editor. // Todo lo que se interpola en HTML pasa por esc(). // ===================================================================== export const FECHA_CORTE = '2026-07-29'; /** Escapa texto antes de meterlo en una plantilla. */ export function esc(v) { if (v === null || v === undefined) return ''; return String(v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } // ---- red ------------------------------------------------------------- export async function api(ruta, opciones = {}) { const res = await fetch(`/api${ruta}`, { headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', ...opciones, body: opciones.body ? JSON.stringify(opciones.body) : undefined, }); let datos = null; try { datos = await res.json(); } catch { /* sin cuerpo */ } if (!res.ok) { const err = new Error(datos?.mensaje ?? `Error ${res.status}`); err.estado = res.status; err.codigo = datos?.error; err.campo = datos?.campo; throw err; } return datos; } // ---- formato --------------------------------------------------------- export const pesos = (n) => `$${Number(n ?? 0).toLocaleString('es-MX', { maximumFractionDigits: 0 })}`; export const num = (n) => Number(n ?? 0).toLocaleString('es-MX'); const MESES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre']; /** '2021-09-15' → '15 de septiembre de 2021' */ export function fecha(v) { if (!v) return '—'; const [a, m, d] = String(v).slice(0, 10).split('-').map(Number); if (!a || !m || !d) return esc(v); return `${d} de ${MESES[m - 1]} de ${a}`; } /** Marca de tiempo corta para la bitácora. */ export function fechaHora(v) { if (!v) return '—'; const d = new Date(v); if (Number.isNaN(d.getTime())) return esc(v); return `${d.getDate()} ${MESES[d.getMonth()].slice(0, 3)} · ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } /** Una hora de 24 h a 12 h, o null si no es una hora. */ function aDoce(h24) { const [h, m] = String(h24 ?? '').split(':').map(Number); if (!Number.isFinite(h) || !Number.isFinite(m)) return null; return { txt: `${h % 12 === 0 ? 12 : h % 12}:${String(m).padStart(2, '0')}`, sufijo: h >= 12 ? 'pm' : 'am' }; } /** * Rango de horas legible: '20:00' y '21:30' → '8:00 a 9:30 pm'. * Si falta o viene mal una de las dos, se muestra lo que sí se pueda leer en * lugar de un "12:undefined am". */ export function rangoHora(inicio, fin) { const a = aDoce(inicio); const b = aDoce(fin); if (!a && !b) return 'Sin horario'; if (!b) return `desde las ${a.txt} ${a.sufijo}`; if (!a) return `hasta las ${b.txt} ${b.sufijo}`; return a.sufijo === b.sufijo ? `${a.txt} a ${b.txt} ${b.sufijo}` : `${a.txt} ${a.sufijo} a ${b.txt} ${b.sufijo}`; } /** Traduce la acción registrada en la bitácora a algo que se entienda. */ const ACCIONES = { 'auth.enlace': 'Pidió un enlace de acceso', 'auth.entrada': 'Entró al portal', 'auth.salida': 'Cerró sesión', 'auth.canje': 'Intentó usar un enlace', 'acceso.denegado': 'Intentó abrir algo sin permiso', 'db.siembra': 'Se reconstruyó la base', }; const ENTIDADES = { oferta: 'la oferta educativa', requisitos: 'los requisitos', becas: 'las becas', clases: 'los links de clases', kb: 'las preguntas frecuentes', guiones: 'los mensajes listos', contacto: 'los datos del instituto', datos: 'los datos por confirmar', }; const VERBOS = { editar: 'Editó', crear: 'Agregó algo en', borrar: 'Quitó algo de' }; export function accionLegible(accion) { if (ACCIONES[accion]) return ACCIONES[accion]; const [seccion, verbo] = String(accion).split('.'); if (VERBOS[verbo] && ENTIDADES[seccion]) return `${VERBOS[verbo]} ${ENTIDADES[seccion]}`; return accion; } export const TONO_RVOE = { confirmado: 'var(--bien)', revisar: 'var(--atencion)', 'por-confirmar': 'var(--alerta)' }; export const TONO_ESTADO = { abierto: 'var(--alerta)', 'en-proceso': 'var(--atencion)', aclarado: 'var(--bien)' }; export const TONO_PRIORIDAD = { alta: 'var(--alerta)', media: 'var(--atencion)', baja: 'var(--gris-claro)' }; export const ETQ_RVOE = { confirmado: 'Validez confirmada', revisar: 'Por revisar', 'por-confirmar': 'Sin confirmar', }; // ---- piezas reutilizables ------------------------------------------- /** Solo se aceptan tonos que este archivo controla: una variable CSS o un * hexadecimal. Cualquier otra cosa iría a parar al atributo style. */ function tonoSeguro(tono) { const t = String(tono ?? '').trim(); return /^(var\(--[a-z-]+\)|#[0-9a-fA-F]{3,8})$/.test(t) ? t : 'var(--gris)'; } /** * Con punto es un ESTADO (el tono lleva la información y va también en el * texto); sin punto es una ETIQUETA (el tono puede ser claro, así que el * texto va en tinta para que siempre se lea). */ export function insignia(txt, tono = 'var(--gris)', { punto = false } = {}) { const t = tonoSeguro(tono); return `${punto ? '' : ''}${esc(txt)}`; } /** El color del rol va en el borde, nunca en el texto: los tonos claros * dejaban las iniciales por debajo del contraste mínimo. */ export function ficha(iniciales, tono, { chica = false } = {}) { const estilo = tono ? ` style="border-color:${tonoSeguro(tono)}"` : ''; return `${esc(iniciales ?? '??')}`; } export function vacio(titulo, texto) { return `

${esc(titulo)}

${esc(texto)}

`; } export function cargando(n = 3, alto = 96) { return `
${Array.from({ length: n }, () => `
`).join('')}
`; } export function escalonar(contenedor, retardo = 45) { if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; [...contenedor.children].forEach((hijo, i) => { hijo.classList.add('aparece'); hijo.style.animationDelay = `${Math.min(i * retardo, 300)}ms`; }); } // ---- avisos y portapapeles ------------------------------------------- let temporizador; export function brindis(texto) { const caja = document.getElementById('brindis'); const txt = document.getElementById('brindis-txt'); if (!caja || !txt) return; txt.textContent = texto; caja.dataset.visible = '1'; clearTimeout(temporizador); temporizador = setTimeout(() => { caja.dataset.visible = '0'; }, 2600); } export async function copiar(texto, etiqueta = 'Copiado') { try { await navigator.clipboard.writeText(texto); } catch { const ta = document.createElement('textarea'); ta.value = texto; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;left:-9999px;top:0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch { /* sin respaldo */ } ta.remove(); } brindis(etiqueta); } /** Delegación global: cualquier elemento con data-copiar copia su texto. */ export function activarCopiado(raiz = document) { raiz.addEventListener('click', (ev) => { const btn = ev.target.closest('[data-copiar], [data-copiar-de]'); if (!btn) return; const de = btn.dataset.copiarDe; const texto = de ? (document.getElementById(de)?.innerText ?? '') : btn.dataset.copiar; if (!texto) return; copiar(texto, btn.dataset.copiarAviso ?? 'Copiado'); }); } // ===================================================================== // Editor: una ventana con un formulario armado desde una lista de campos // ===================================================================== /** * @param {object} cfg * @param {string} cfg.titulo * @param {string} [cfg.subtitulo] * @param {Array} cfg.campos lista de campos, o {par:[a,b]} para dos en una fila * @param {object} [cfg.valores] valores iniciales por nombre de campo * @param {Function} cfg.guardar async (datos) => void · puede lanzar {campo, message} * @param {Function} [cfg.borrar] async () => void · si viene, aparece "Eliminar" * @param {string} [cfg.textoGuardar] * @param {string} [cfg.confirmarBorrado] * @returns {Promise} true si se guardó o se eliminó */ export function abrirEditor(cfg) { return new Promise((resolver) => { const dlg = document.createElement('dialog'); dlg.className = 'editor'; const lista = cfg.campos.flatMap((c) => (c.par ? c.par : [c])); dlg.innerHTML = `

${esc(cfg.titulo)}

${cfg.subtitulo ? `

${esc(cfg.subtitulo)}

` : ''}
${cfg.campos.map((c) => (c.par ? `
${c.par.map(pintarCampo).join('')}
` : pintarCampo(c))).join('')}
${cfg.borrar ? '' : ''}
`; document.body.appendChild(dlg); // Valores iniciales for (const c of lista) { const el = dlg.querySelector(`[name="${c.nombre}"]`); if (!el) continue; const v = cfg.valores?.[c.nombre]; if (c.tipo === 'casilla') el.checked = Boolean(Number(v ?? c.valorPorOmision ?? 0)); else el.value = v ?? c.valorPorOmision ?? ''; } const cajaError = dlg.querySelector('.editor__error'); const avisoError = cajaError.querySelector('.aviso'); const btnGuardar = dlg.querySelector('[data-guardar]'); function mostrarError(mensaje, campo) { avisoError.textContent = mensaje; cajaError.hidden = false; dlg.querySelectorAll('[aria-invalid]').forEach((el) => el.removeAttribute('aria-invalid')); if (campo) { const el = dlg.querySelector(`[name="${campo}"]`); if (el) { el.setAttribute('aria-invalid', 'true'); el.focus(); } } } function cerrar(resultado) { dlg.close(); dlg.remove(); resolver(resultado); } dlg.querySelectorAll('[data-cerrar]').forEach((b) => b.addEventListener('click', () => cerrar(false))); dlg.addEventListener('cancel', (ev) => { ev.preventDefault(); cerrar(false); }); dlg.querySelector('[data-borrar]')?.addEventListener('click', async () => { const texto = cfg.confirmarBorrado ?? '¿Seguro que quieres eliminarlo? Esta acción no se puede deshacer.'; // eslint-disable-next-line no-alert if (!window.confirm(texto)) return; const btn = dlg.querySelector('[data-borrar]'); btn.disabled = true; btn.textContent = 'Eliminando…'; try { await cfg.borrar(); cerrar(true); } catch (err) { btn.disabled = false; btn.textContent = 'Eliminar'; mostrarError(err.message); } }); dlg.querySelector('form').addEventListener('submit', async (ev) => { ev.preventDefault(); const datos = {}; for (const c of lista) { const el = dlg.querySelector(`[name="${c.nombre}"]`); if (!el) continue; datos[c.nombre] = c.tipo === 'casilla' ? (el.checked ? 1 : 0) : el.value; } // Validación mínima del lado del navegador; el servidor revalida todo. const faltante = lista.find((c) => c.requerido && !String(datos[c.nombre] ?? '').trim()); if (faltante) return mostrarError(`Falta llenar: ${faltante.etiqueta}.`, faltante.nombre); btnGuardar.disabled = true; const previo = btnGuardar.textContent; btnGuardar.textContent = 'Guardando…'; try { await cfg.guardar(datos); cerrar(true); } catch (err) { btnGuardar.disabled = false; btnGuardar.textContent = previo; mostrarError(err.message, err.campo); } }); dlg.showModal(); dlg.querySelector('.editor__cuerpo input, .editor__cuerpo select, .editor__cuerpo textarea')?.focus(); }); } function pintarCampo(c) { const id = `campo-${c.nombre}`; const ayuda = c.ayuda ? `${esc(c.ayuda)}` : ''; const desc = c.ayuda ? ` aria-describedby="${id}-ayuda"` : ''; const req = c.requerido ? ' aria-required="true"' : ''; if (c.tipo === 'casilla') { // La etiqueta envuelve la casilla y llega al alto mínimo de toque, así // que se puede marcar tocando el texto y no solo el cuadrito. return `
${ayuda}
`; } if (c.tipo === 'area') { return `
${ayuda}
`; } if (c.tipo === 'select') { return `
${ayuda}
`; } // Los campos de dinero son de texto con teclado numérico, no type=number: // así "$1,500" llega entero al servidor, que sabe limpiarlo. Con // type=number el navegador reinterpreta la coma como decimal y $1,500 se // guardaba como $1.50, o se descartaba el valor y quedaba en cero. const tipoHtml = { numero: 'text', url: 'url', fecha: 'date' }[c.tipo] ?? 'text'; const extra = c.tipo === 'numero' ? ' inputmode="decimal" autocomplete="off"' : ''; return `
${ayuda}
`; } // ---- iconos ---------------------------------------------------------- const TRAZOS = { inicio: '', birrete: '', documento: '', etiqueta: '', video: '', libro: '', mensaje: '', edificio: '', personas: '', reloj: '', menu: '', salir: '', copiar: '', buscar: '', lapiz: '', mas: '', abrir: '', tarjetas: '', tabla: '', alerta: '', check: '', }; export function icono(nombre) { const trazo = TRAZOS[nombre] ?? TRAZOS.check; return ``; }