Portal interno INSTA: base de conocimiento con Magic Login y permisos por rol

Base de conocimiento para el personal del Instituto Santiago Tapia: oferta
educativa (en tarjetas y en tabla), requisitos y titulacion, becas y fechas
de inscripcion, links de clases de Google Meet, preguntas frecuentes,
mensajes listos y datos del instituto.

Acceso con Magic Login: enlace de un solo uso, 15 minutos de vigencia, y en
la base solo se guarda el SHA-256 del token. El canje es atomico, asi que dos
canjes simultaneos del mismo enlace no pueden crear dos sesiones.

Tres roles con 19 permisos. El catalogo lo edita direccion; los links de
clases, direccion y administracion; el resto del equipo consulta. El menu se
pinta con lo que el servidor autoriza y cada endpoint exige la misma clave,
asi que no hay pantallas alcanzables escribiendo la direccion a mano.

Toda edicion pasa por una lista blanca de campos validados y queda en la
bitacora. Un precio vacio no se guarda como cero, un link que no es una
direccion se rechaza, y no se puede marcar una validez oficial como
confirmada sin numero de acuerdo.

Pensado para leerse comodo: texto de 17 px, contraste AA, areas de toque de
44 px, tema claro y bloques plegados en telefono.

La oferta, los costos, las becas, los requisitos y el contacto son los reales
del instituto, tomados del CSV de oferta educativa y de la base de
conocimiento. Los usuarios de acceso y los links de Meet son de demostracion.

Verificado: 103 comprobaciones de API y 51 pruebas en WebKit escritorio y
iPhone 13.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Uriel Jareth
2026-07-29 23:20:34 -06:00
co-authored by Claude Opus 5
commit 0b9c9c1dc2
34 changed files with 8307 additions and 0 deletions
+400
View File
@@ -0,0 +1,400 @@
// =====================================================================
// 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 `<span class="insignia${punto ? ' insignia--fuerte' : ''}" style="--tono:${t}">${punto ? '<span class="insignia__punto"></span>' : ''}${esc(txt)}</span>`;
}
/** 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 `<span class="ficha${chica ? ' ficha--chica' : ''}"${estilo}>${esc(iniciales ?? '??')}</span>`;
}
export function vacio(titulo, texto) {
return `<div class="vacio"><h3>${esc(titulo)}</h3><p>${esc(texto)}</p></div>`;
}
export function cargando(n = 3, alto = 96) {
return `<div class="cargando">${Array.from({ length: n }, () => `<div class="hueso" style="height:${alto}px"></div>`).join('')}</div>`;
}
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<boolean>} 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 = `
<form method="dialog" novalidate>
<div class="editor__cabeza">
<div>
<h2>${esc(cfg.titulo)}</h2>
${cfg.subtitulo ? `<p>${esc(cfg.subtitulo)}</p>` : ''}
</div>
<button type="button" class="editor__cerrar" data-cerrar aria-label="Cerrar sin guardar">&times;</button>
</div>
<div class="editor__error" hidden><div class="aviso aviso--error" role="alert"></div></div>
<div class="editor__cuerpo">
${cfg.campos.map((c) => (c.par
? `<div class="editor__par">${c.par.map(pintarCampo).join('')}</div>`
: pintarCampo(c))).join('')}
</div>
<div class="editor__pie">
${cfg.borrar ? '<button type="button" class="boton boton--peligro" data-borrar>Eliminar</button>' : ''}
<button type="button" class="boton boton--fantasma" data-cerrar>Cancelar</button>
<button type="submit" class="boton" data-guardar>${esc(cfg.textoGuardar ?? 'Guardar cambios')}</button>
</div>
</form>`;
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 ? `<span class="campo__ayuda" id="${id}-ayuda">${esc(c.ayuda)}</span>` : '';
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 `<div class="campo">
<label class="campo-casilla" for="${id}">
<input type="checkbox" name="${esc(c.nombre)}" id="${id}"${desc}>
<span>${esc(c.etiqueta)}</span>
</label>${ayuda}
</div>`;
}
if (c.tipo === 'area') {
return `<div class="campo">
<label for="${id}">${esc(c.etiqueta)}</label>
${ayuda}
<textarea name="${esc(c.nombre)}" id="${id}"${req}${desc} rows="${c.filas ?? 6}"></textarea>
</div>`;
}
if (c.tipo === 'select') {
return `<div class="campo">
<label for="${id}">${esc(c.etiqueta)}</label>
${ayuda}
<select name="${esc(c.nombre)}" id="${id}"${req}${desc}>
${(c.opciones ?? []).map((o) => `<option value="${esc(o.valor)}">${esc(o.texto)}</option>`).join('')}
</select>
</div>`;
}
// 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 `<div class="campo">
<label for="${id}">${esc(c.etiqueta)}</label>
${ayuda}
<input type="${tipoHtml}" name="${esc(c.nombre)}" id="${id}"
${c.marcador ? `placeholder="${esc(c.marcador)}"` : ''}${req}${desc}${extra}
${c.tipo === 'url' ? 'inputmode="url" autocapitalize="off" spellcheck="false"' : ''}>
</div>`;
}
// ---- iconos ----------------------------------------------------------
const TRAZOS = {
inicio: '<path d="M3 10.5 10 4l7 6.5"/><path d="M5 9.5V16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V9.5"/>',
birrete: '<path d="M10 4 2.5 7.5 10 11l7.5-3.5L10 4Z"/><path d="M5.5 9v4c0 1.4 2 2.5 4.5 2.5s4.5-1.1 4.5-2.5V9"/>',
documento: '<path d="M11.5 2.5H6a1.5 1.5 0 0 0-1.5 1.5v12A1.5 1.5 0 0 0 6 17.5h8a1.5 1.5 0 0 0 1.5-1.5V6.5l-4-4Z"/><path d="M11.5 2.5v4h4M7.5 11h5M7.5 14h3"/>',
etiqueta: '<path d="M10.5 3H16a1 1 0 0 1 1 1v5.5a1 1 0 0 1-.3.7l-6.5 6.5a1 1 0 0 1-1.4 0l-5.5-5.5a1 1 0 0 1 0-1.4l6.5-6.5a1 1 0 0 1 .7-.3Z"/><circle cx="13" cy="7" r="1.1"/>',
video: '<rect x="2.5" y="5" width="10.5" height="10" rx="2"/><path d="m13 9 4.5-2.5v7L13 11z"/>',
libro: '<path d="M3.5 4.5c2.5-1 4.5-1 6.5.5 2-1.5 4-1.5 6.5-.5v10c-2.5-1-4.5-1-6.5.5-2-1.5-4-1.5-6.5-.5v-10Z"/><path d="M10 5v10"/>',
mensaje: '<path d="M17 10.5c0 3.3-3.1 6-7 6a8 8 0 0 1-2.3-.3L4 17.5l1-2.7A5.7 5.7 0 0 1 3 10.5c0-3.3 3.1-6 7-6s7 2.7 7 6Z"/>',
edificio: '<path d="M4.5 17.5V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v13.5M12.5 8h2.5a1 1 0 0 1 1 1v8.5M3 17.5h14"/><path d="M7 6.5h2M7 9.5h2M7 12.5h2"/>',
personas: '<circle cx="7.5" cy="7" r="2.6"/><path d="M2.8 16.5c0-2.6 2.1-4.6 4.7-4.6s4.7 2 4.7 4.6"/><path d="M13.2 5.1a2.6 2.6 0 0 1 0 5M14 12.2c1.9.4 3.2 2.1 3.2 4.3"/>',
reloj: '<circle cx="10" cy="10" r="7"/><path d="M10 6v4.3l3 1.8"/>',
menu: '<path d="M3 6h14M3 10h14M3 14h14"/>',
salir: '<path d="M8 17H5a1.5 1.5 0 0 1-1.5-1.5v-11A1.5 1.5 0 0 1 5 3h3"/><path d="M12.5 13.5 16 10l-3.5-3.5M7.5 10H16"/>',
copiar: '<rect x="7" y="7" width="9.5" height="9.5" rx="1.5"/><path d="M13 4.5H5a1.5 1.5 0 0 0-1.5 1.5v8"/>',
buscar: '<circle cx="9" cy="9" r="5.3"/><path d="m13 13 4 4"/>',
lapiz: '<path d="M13.6 3.9a1.6 1.6 0 0 1 2.3 2.3L7 15l-3.5 1L4.5 12.5Z"/><path d="m12 5.5 2.5 2.5"/>',
mas: '<path d="M10 4v12M4 10h12"/>',
abrir: '<path d="M11 3h6v6"/><path d="M17 3 9.5 10.5"/><path d="M15 12v3.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 3 15.5v-9A1.5 1.5 0 0 1 4.5 5H8"/>',
tarjetas: '<rect x="3" y="3.5" width="6" height="6" rx="1.4"/><rect x="11" y="3.5" width="6" height="6" rx="1.4"/><rect x="3" y="10.5" width="6" height="6" rx="1.4"/><rect x="11" y="10.5" width="6" height="6" rx="1.4"/>',
tabla: '<rect x="3" y="4" width="14" height="12" rx="1.5"/><path d="M3 8h14M3 12h14M8 8v8"/>',
alerta: '<path d="M10 3.5 17 16H3l7-12.5Z"/><path d="M10 8v3.6M10 14h.01"/>',
check: '<circle cx="10" cy="10" r="7"/><path d="m6.8 10.2 2.2 2.2 4.2-4.6"/>',
};
export function icono(nombre) {
const trazo = TRAZOS[nombre] ?? TRAZOS.check;
return `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${trazo}</svg>`;
}