// =====================================================================
// 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 `
`;
}
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 = `
`;
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 `