// =====================================================================
// Las secciones del portal
// Cada vista devuelve { ceja, titulo, resumen, acciones, cuerpo, montar }.
// =====================================================================
import {
api, esc, pesos, num, fecha, fechaHora, rangoHora, accionLegible, insignia, ficha, vacio,
icono, abrirEditor, brindis, TONO_RVOE, TONO_ESTADO, TONO_PRIORIDAD, ETQ_RVOE, FECHA_CORTE,
} from './ui.mjs';
const btnEditar = (attrs, texto = 'Editar') =>
``;
const btnAgregar = (attrs, texto) =>
``;
/**
* Un bloque plegable: abierto en escritorio, cerrado en teléfono. La clave
* permite que app.mjs recuerde si la persona lo abrió, para no volver a
* plegarlo cuando la sección se repinta después de guardar un cambio.
*/
const enEscritorio = () => matchMedia('(min-width: 781px)').matches;
const plegable = (clave) => ``;
// =====================================================================
// Inicio
// =====================================================================
export async function inicio(ctx) {
const d = await api('/inicio');
const wa = d.contacto.find((c) => c.clave === 'whatsapp')?.valor ?? '';
const dir = d.contacto.find((c) => c.clave === 'direccion')?.valor ?? '';
const horario = d.contacto.find((c) => c.clave === 'horario')?.valor ?? '';
const heroe = d.promo ? `
Promoción vigente
${esc(d.promo.nombre)}: ${esc(d.promo.vigencia)}
${esc(d.promo.resumen)}
` : '';
const atajos = [
['oferta', 'birrete', 'Oferta educativa', `${d.conteos.programas} programas con costos y horarios`],
['requisitos', 'documento', 'Requisitos y titulación', 'Los documentos que se piden por nivel'],
['becas', 'etiqueta', 'Becas y fechas', 'Descuentos vigentes y cuándo inician las clases'],
['clases', 'video', 'Links de clases', `${d.conteos.clases} enlaces de Google Meet`],
['preguntas', 'libro', 'Preguntas frecuentes', `${d.conteos.preguntas} respuestas verificadas`],
['mensajes', 'mensaje', 'Mensajes listos', `${d.conteos.mensajes} textos para copiar y enviar`],
].filter(([id]) => ctx.navegacion.some((s) => s.id === id));
const tarjetasAtajo = `
Hay ${esc(d.datosPorConfirmar)} datos pendientes de confirmar (números de RVOE y fechas).
Mientras no se cierren, no se pueden citar por escrito ante un interesado.
`).join('')}
` : '';
return {
ceja: `Corte ${fecha(FECHA_CORTE)}`,
titulo: `Hola, ${ctx.usuario.nombre.split(' ')[0]}`,
resumen: 'Toda la información del instituto en un solo lugar. Elige la sección que necesites.',
cuerpo: `
`,
};
}
// =====================================================================
// Oferta educativa
// =====================================================================
const CLAVE_VISTA = 'insta.oferta.vista';
export async function oferta(ctx) {
const d = await api('/oferta');
const puedeEditar = ctx.permisos.has('oferta.editar');
const sinConfirmar = d.programas.filter((p) => p.rvoe_estado !== 'confirmado');
const vistaGuardada = localStorage.getItem(CLAVE_VISTA) === 'tabla' ? 'tabla' : 'tarjetas';
const heroe = sinConfirmar.length ? `
Antes de citar un número de acuerdo
${sinConfirmar.length} de los ${d.programas.length} programas tienen la
validez oficial sin confirmar.
El instituto opera como centro de apoyo de Centro Educativo Puebla, que es quien tiene los RVOE.
Por eso los acuerdos están a su nombre. En los programas de abajo, el número que traían las listas
viejas es incorrecto o no existe: no lo cites, avísale a dirección.
`).join('')}
`;
return {
ceja: 'Catálogo académico',
titulo: 'Oferta educativa',
resumen: 'Los ocho programas del instituto con su duración, su costo real, sus horarios y el estado de su validez oficial. Es la información que se le da a un interesado.',
acciones: conmutador,
cuerpo: `
${heroe}
${porConfirmar}
${modalidades}
`,
montar(raiz) {
const caja = raiz.querySelector('#oferta-lista');
let vista = vistaGuardada;
const pintar = () => {
caja.innerHTML = vista === 'tabla'
? tablaOferta(d, puedeEditar)
: `
Validez oficial
${p.rvoe_estado === 'confirmado' && p.rvoe_acuerdo
? `
Acuerdo ${esc(p.rvoe_acuerdo)}${p.rvoe_fecha ? `, del ${fecha(p.rvoe_fecha)}` : ''}${p.rvoe_autoridad ? `, a nombre de ${esc(p.rvoe_autoridad)}` : ''}.
`
: `
${esc(p.rvoe_nota || 'Falta registrar el número de acuerdo.')}
`}
${puedeEditar ? `
${p.actualizado_por_nombre ? `Editado por ${esc(p.actualizado_por_nombre)}` : 'Como se sembró'}
${btnAgregar(`data-agregar-horario="${p.id}"`, 'Agregar horario')}
${btnEditar(`data-editar-programa="${p.id}"`, 'Editar programa')}
Los precios con beca son los del 25% para alumnos nuevos. La cifra tachada es el precio sin beca.
`;
}
// =====================================================================
// Requisitos y titulación
// =====================================================================
export async function requisitos(ctx) {
const d = await api('/requisitos');
const puedeEditar = ctx.permisos.has('requisitos.editar');
// El titular se calcula de la lista real: si dirección edita los requisitos,
// una frase escrita a mano quedaría contradiciendo lo que está debajo.
const bach = d.niveles.find((n) => n.clave === 'bachillerato');
const otros = d.niveles.filter((n) => n.clave !== 'bachillerato');
const cuenta = (nivel) => nivel?.requisitos.length ?? 0;
const distintos = bach && otros.length && otros.every((n) => cuenta(n) !== cuenta(bach));
const heroe = `
Lo primero que hay que mirar
${distintos
? `
Cada nivel pide una lista distinta:
${cuenta(bach)} documentos en bachillerato
y ${otros.map((n) => cuenta(n)).join(' o ')} en los demás.
`
: '
Cada nivel pide su propia lista de documentos.
'}
Mandar la lista equivocada es el error más fácil de cometer por WhatsApp. Aquí está cada
lista completa, con un botón para copiarla tal cual.
`;
const niveles = d.niveles.map((n) => {
const texto = `Estos son los documentos para inscribirte:\n\n${n.requisitos.map((r) => `• ${r.texto}`).join('\n')}`;
return `
Se elige según el programa y el caso de la persona.
Para quien ya trabaja en el área de la carrera, la de experiencia profesional suele ser la más directa.
${d.titulacion.length} opciones
${btnAgregar('data-agregar-titulacion', 'Agregar forma de titulación')}
` : ''}
`;
return {
ceja: 'Documentación',
titulo: 'Requisitos y titulación',
resumen: 'Los documentos que se piden en cada nivel y las cinco formas de titularse.',
cuerpo: `
${heroe}
${niveles}
${titulacion}
`,
montar(raiz) {
if (!puedeEditar) return;
const camposReq = [
{ nombre: 'texto', etiqueta: 'Documento', requerido: true, marcador: 'Acta de nacimiento' },
{ nombre: 'nota', etiqueta: 'Aclaración', ayuda: 'Opcional. Se muestra en letra pequeña debajo.' },
{ nombre: 'orden', etiqueta: 'Posición en la lista', tipo: 'numero' },
];
raiz.addEventListener('click', async (ev) => {
const agregar = ev.target.closest('[data-agregar-requisito]');
if (agregar) {
const nivelId = Number(agregar.dataset.agregarRequisito);
const nivel = d.niveles.find((n) => n.id === nivelId);
const ok = await abrirEditor({
titulo: 'Agregar un documento',
subtitulo: nivel?.nombre ?? '',
campos: camposReq,
valores: { orden: (nivel?.requisitos.length ?? 0) + 1 },
textoGuardar: 'Agregar documento',
guardar: (datos) => api('/requisitos', { method: 'POST', body: { ...datos, nivel_id: nivelId } }),
});
if (ok) { brindis('Documento agregado'); ctx.recargar(); }
return;
}
const editar = ev.target.closest('[data-editar-requisito]');
if (editar) {
const id = Number(editar.dataset.editarRequisito);
const nivel = d.niveles.find((n) => n.requisitos.some((r) => r.id === id));
const r = nivel?.requisitos.find((x) => x.id === id);
if (!r) return;
const ok = await abrirEditor({
titulo: 'Editar el documento',
subtitulo: nivel.nombre,
campos: camposReq,
valores: r,
borrar: () => api(`/requisitos/${id}`, { method: 'DELETE' }),
confirmarBorrado: `¿Quitar "${r.texto}" de los requisitos de ${nivel.nombre.toLowerCase()}?`,
guardar: (datos) => api(`/requisitos/${id}`, { method: 'PATCH', body: datos }),
});
if (ok) { brindis('Documento actualizado'); ctx.recargar(); }
return;
}
const camposTit = [
{ nombre: 'nombre', etiqueta: 'Nombre', requerido: true, marcador: 'Por promedio' },
{ nombre: 'descripcion', etiqueta: 'En qué consiste', tipo: 'area', filas: 3 },
{ nombre: 'orden', etiqueta: 'Posición en la lista', tipo: 'numero' },
];
if (ev.target.closest('[data-agregar-titulacion]')) {
const ok = await abrirEditor({
titulo: 'Agregar una forma de titulación',
campos: camposTit,
valores: { orden: d.titulacion.length + 1 },
textoGuardar: 'Agregar',
guardar: (datos) => api('/titulacion', { method: 'POST', body: datos }),
});
if (ok) { brindis('Forma de titulación agregada'); ctx.recargar(); }
return;
}
const editarTit = ev.target.closest('[data-editar-titulacion]');
if (editarTit) {
const id = Number(editarTit.dataset.editarTitulacion);
const t = d.titulacion.find((x) => x.id === id);
if (!t) return;
const ok = await abrirEditor({
titulo: 'Editar la forma de titulación',
campos: camposTit,
valores: t,
borrar: () => api(`/titulacion/${id}`, { method: 'DELETE' }),
confirmarBorrado: `¿Quitar "${t.nombre}" de las formas de titulación?`,
guardar: (datos) => api(`/titulacion/${id}`, { method: 'PATCH', body: datos }),
});
if (ok) { brindis('Forma de titulación actualizada'); ctx.recargar(); }
}
});
},
};
}
// =====================================================================
// Becas y fechas
// =====================================================================
export async function becas(ctx) {
const d = await api('/becas');
const puedeEditar = ctx.permisos.has('becas.editar');
const activas = d.promociones.filter((p) => p.activo);
const heroe = `
La regla que más se rompe
El programa de referidos no se junta
con la beca del 25% ni con la promoción de julio.
Hay que elegir el esquema que más le convenga a la persona. Y con un solo referido casi siempre
conviene la beca: deja la mensualidad en $1,500 contra los $1,600 de un referido.
`;
const lista = `
${d.promociones.map((p) => `
${insignia(p.acumulable ? 'Se puede combinar' : 'No se combina con nada más', p.acumulable ? 'var(--bien)' : 'var(--alerta)', { punto: true })}
${p.activo ? '' : insignia('Desactivada', 'var(--gris-claro)')}
Estas fechas vienen del archivo de oferta educativa y están pendientes de confirmar.
Verifica con dirección la próxima fecha de ingreso vigente antes de comprometerla.
La inscripción y la reinscripción quedan fijas ($1,500 en licenciatura y maestría, $1,125 en
bachillerato) y no cambian con este programa. El descuento se mantiene solo mientras ese referido
siga pagando su mensualidad.
`;
return {
ceja: `${activas.length} esquemas vigentes`,
titulo: 'Becas y fechas',
resumen: 'Los descuentos que se pueden ofrecer, qué se junta con qué, y cuándo inicia cada programa.',
acciones: puedeEditar ? btnAgregar('data-agregar-beca', 'Agregar promoción') : '',
cuerpo: `
${heroe}${lista}${fechas}${escala}
`,
montar(raiz) {
if (!puedeEditar) return;
const campos = [
{ nombre: 'nombre', etiqueta: 'Nombre de la promoción', requerido: true },
{ nombre: 'resumen', etiqueta: 'La frase que se le dice al interesado', tipo: 'area', filas: 2,
ayuda: 'Una sola frase, clara y sin tecnicismos.' },
{ par: [
{ nombre: 'aplica_a', etiqueta: 'Aplica a', tipo: 'select', opciones: [
{ valor: 'inscripcion', texto: 'La inscripción' },
{ valor: 'mensualidad', texto: 'La mensualidad' },
{ valor: 'ambos', texto: 'Inscripción y mensualidad' },
] },
{ nombre: 'vigencia', etiqueta: 'Vigencia', marcador: 'Del 1 al 31 de julio de 2026' },
] },
{ nombre: 'condiciones', etiqueta: 'Condiciones completas', tipo: 'area', filas: 6 },
{ nombre: 'acumulable', etiqueta: 'Se puede juntar con otras promociones', tipo: 'casilla' },
{ nombre: 'activo', etiqueta: 'Está vigente y se puede ofrecer', tipo: 'casilla' },
];
raiz.addEventListener('click', async (ev) => {
if (ev.target.closest('[data-agregar-beca]')) {
const ok = await abrirEditor({
titulo: 'Agregar una promoción',
campos,
valores: { aplica_a: 'inscripcion', activo: 1 },
textoGuardar: 'Agregar promoción',
guardar: (datos) => api('/becas', { method: 'POST', body: datos }),
});
if (ok) { brindis('Promoción agregada'); ctx.recargar(); }
return;
}
const editar = ev.target.closest('[data-editar-beca]');
if (!editar) return;
const p = d.promociones.find((x) => x.id === Number(editar.dataset.editarBeca));
if (!p) return;
const ok = await abrirEditor({
titulo: `Editar ${p.nombre}`,
campos,
valores: p,
borrar: () => api(`/becas/${p.id}`, { method: 'DELETE' }),
confirmarBorrado: `¿Eliminar la promoción "${p.nombre}"? Si solo quieres dejar de ofrecerla, desmarca "Está vigente".`,
guardar: (datos) => api(`/becas/${p.id}`, { method: 'PATCH', body: datos }),
});
if (ok) { brindis('Promoción actualizada'); ctx.recargar(); }
});
},
};
}
// =====================================================================
// Links de clases
// =====================================================================
export async function clases(ctx) {
const d = await api('/clases');
const puedeEditar = d.puedeEditar;
const conClases = d.programas.filter((p) => d.clases.some((c) => c.programa_id === p.id));
// Sin repetir el nivel cuando ya está en el nombre: "Bachillerato (Bachillerato)".
const opcionesPrograma = d.programas.map((p) => ({
valor: String(p.id),
texto: p.nombre_corto.toLowerCase().includes(p.nivel.toLowerCase())
? p.nombre_corto
: `${p.nombre_corto} · ${p.nivel}`,
}));
const camposClase = [
{ nombre: 'programa_id', etiqueta: 'Programa', tipo: 'select', opciones: opcionesPrograma },
{ nombre: 'materia', etiqueta: 'Materia', requerido: true, marcador: 'Psicopatología' },
{ par: [
{ nombre: 'docente', etiqueta: 'Docente' },
{ nombre: 'grupo', etiqueta: 'Grupo o generación', marcador: 'Generación 2026 · en línea' },
] },
{ nombre: 'dias', etiqueta: 'Día de clase', tipo: 'select', opciones: d.dias.map((x) => ({ valor: x, texto: x })) },
{ par: [
{ nombre: 'hora_inicio', etiqueta: 'Empieza a las', marcador: '20:00', ayuda: 'Formato de 24 horas' },
{ nombre: 'hora_fin', etiqueta: 'Termina a las', marcador: '21:30' },
] },
{ nombre: 'enlace', etiqueta: 'Link de Google Meet', tipo: 'url', requerido: true,
marcador: 'https://meet.google.com/abc-defg-hij', ayuda: 'Pega la dirección completa, empezando con https://' },
{ nombre: 'notas', etiqueta: 'Nota para quien entra', ayuda: 'Opcional.' },
{ nombre: 'activo', etiqueta: 'La clase está activa', tipo: 'casilla' },
];
const heroe = `
Cómo se usa
Cada materia tiene su propio link fijo.
El link no cambia de una semana a otra: es el mismo toda la generación. Si un alumno o un
docente lo pide, se copia de aquí y se manda. ${puedeEditar
? 'Si un link cambia, edítalo aquí y todo el equipo lo ve al instante.'
: 'Si un link ya no funciona, avísale a administración para que lo corrija.'}
${d.clases.filter((c) => c.activo).length}Clases activas${conClases.length}Programas con clases
${btnAgregar(`data-agregar-clase="${p.id}"`, 'Agregar materia a este programa')}
` : ''}
`;
}).join('');
const avisoDemo = `
Datos demo
Los links de esta demostración son de ejemplo y no abren una reunión real.
${puedeEditar ? 'Edita cada materia para poner el link verdadero.' : 'Administración los sustituirá por los verdaderos.'}
`;
return {
ceja: `${d.clases.length} clases registradas`,
titulo: 'Links de clases',
resumen: 'Los enlaces de Google Meet organizados por programa, materia y horario.',
acciones: puedeEditar ? btnAgregar('data-agregar-clase', 'Agregar una clase') : '',
cuerpo: `
${heroe}
${avisoDemo}
${filtros}
${bloques || vacio('Todavía no hay clases', 'Cuando se registren los links de Meet, aparecerán aquí agrupados por programa.')}
`,
montar(raiz) {
raiz.querySelector('#filtros-clases')?.addEventListener('click', (ev) => {
const btn = ev.target.closest('.filtro');
if (!btn) return;
raiz.querySelectorAll('#filtros-clases .filtro').forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
const prog = btn.dataset.prog;
raiz.querySelectorAll('[data-bloque]').forEach((b) => {
b.hidden = Boolean(prog) && b.dataset.bloque !== prog;
});
});
if (!puedeEditar) return;
raiz.addEventListener('click', async (ev) => {
const agregar = ev.target.closest('[data-agregar-clase]');
if (agregar) {
const preseleccion = agregar.dataset.agregarClase || String(d.programas[0].id);
const ok = await abrirEditor({
titulo: 'Agregar una clase',
subtitulo: 'Queda visible para todo el equipo en cuanto la guardes.',
campos: camposClase,
valores: { programa_id: preseleccion, dias: 'Lunes', hora_inicio: '20:00', hora_fin: '21:30', activo: 1 },
textoGuardar: 'Agregar clase',
guardar: (datos) => api('/clases', { method: 'POST', body: datos }),
});
if (ok) { brindis('Clase agregada'); ctx.recargar(); }
return;
}
const editar = ev.target.closest('[data-editar-clase]');
if (!editar) return;
const c = d.clases.find((x) => x.id === Number(editar.dataset.editarClase));
if (!c) return;
const ok = await abrirEditor({
titulo: `Editar ${c.materia}`,
subtitulo: `${c.programa_nombre} · ${c.grupo || 'sin grupo'}`,
campos: camposClase,
valores: { ...c, programa_id: String(c.programa_id) },
borrar: () => api(`/clases/${c.id}`, { method: 'DELETE' }),
confirmarBorrado: `¿Eliminar la clase de ${c.materia}? Si solo quieres ocultarla, desmarca "La clase está activa".`,
guardar: (datos) => api(`/clases/${c.id}`, { method: 'PATCH', body: datos }),
});
if (ok) { brindis('Clase actualizada'); ctx.recargar(); }
});
},
};
}
// =====================================================================
// Preguntas frecuentes
// =====================================================================
export async function preguntas(ctx) {
const d = await api('/preguntas');
const puedeEditar = ctx.permisos.has('kb.editar');
const campos = (secciones) => [
{ nombre: 'seccion', etiqueta: 'Categoría', requerido: true,
ayuda: `Las que ya existen: ${secciones.join(', ')}` },
{ nombre: 'pregunta', etiqueta: 'La pregunta', requerido: true, marcador: '¿Cuánto cuesta una licenciatura?' },
{ nombre: 'respuesta', etiqueta: 'La respuesta', tipo: 'area', filas: 7, requerido: true,
ayuda: 'Escríbela como se la dirías a la persona, sin tecnicismos.' },
{ nombre: 'palabras_clave', etiqueta: 'Palabras para buscarla', ayuda: 'Separadas por espacios. Ayudan a encontrarla.' },
{ nombre: 'orden', etiqueta: 'Posición en la lista', tipo: 'numero' },
];
function pintarLista(articulos) {
if (!articulos.length) {
return vacio('No encontramos nada', 'Prueba con otra palabra: costos, beca, referidos, RVOE, en línea, requisitos, titulación.');
}
const secciones = [...new Set(articulos.map((a) => a.seccion))];
return secciones.map((s) => `
`).join('')}
`).join('');
}
const heroe = `
Para qué sirve esta sección
Aquí está la respuesta correcta, ya redactada.
Si alguien pregunta algo y no estás seguro, búscalo aquí antes de contestar. Cada respuesta
dice también lo que todavía no se puede prometer porque falta confirmarlo con dirección.
`;
return {
ceja: `${d.articulos.length} respuestas`,
titulo: 'Preguntas frecuentes',
resumen: 'Lo que más preguntan los interesados y los alumnos, con la respuesta ya lista.',
acciones: puedeEditar ? btnAgregar('data-agregar-pregunta', 'Agregar pregunta') : '',
cuerpo: `
${heroe}
${pintarLista(d.articulos)}
`,
montar(raiz) {
const entrada = raiz.querySelector('#buscar');
const caja = raiz.querySelector('#resultados');
let t;
// Cada búsqueda lleva su número: si una respuesta llega después de que
// se tecleó algo nuevo, se descarta. Sin esto se alcanzaba a ver el
// resultado de lo escrito antes.
let ultima = 0;
entrada.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(async () => {
const busq = entrada.value.trim();
const mia = ++ultima;
try {
const r = await api(`/preguntas${busq ? `?q=${encodeURIComponent(busq)}` : ''}`);
if (mia !== ultima) return;
caja.innerHTML = pintarLista(r.articulos);
} catch (err) {
if (mia !== ultima) return;
caja.innerHTML = `
${esc(err.message)}
`;
}
}, 200);
});
if (!puedeEditar) return;
raiz.addEventListener('click', async (ev) => {
if (ev.target.closest('[data-agregar-pregunta]')) {
const ok = await abrirEditor({
titulo: 'Agregar una pregunta',
campos: campos(d.secciones),
valores: { seccion: d.secciones[0] ?? '', orden: d.articulos.length + 1 },
textoGuardar: 'Agregar pregunta',
guardar: (datos) => api('/preguntas', { method: 'POST', body: datos }),
});
if (ok) { brindis('Pregunta agregada'); ctx.recargar(); }
return;
}
const editar = ev.target.closest('[data-editar-pregunta]');
if (!editar) return;
const a = d.articulos.find((x) => x.id === Number(editar.dataset.editarPregunta));
if (!a) return;
const ok = await abrirEditor({
titulo: 'Editar la pregunta',
campos: campos(d.secciones),
valores: a,
borrar: () => api(`/preguntas/${a.id}`, { method: 'DELETE' }),
confirmarBorrado: '¿Eliminar esta pregunta de la base de conocimiento?',
guardar: (datos) => api(`/preguntas/${a.id}`, { method: 'PATCH', body: datos }),
});
if (ok) { brindis('Pregunta actualizada'); ctx.recargar(); }
});
},
};
}
// =====================================================================
// Mensajes listos
// =====================================================================
export async function mensajes(ctx) {
const d = await api('/mensajes');
const puedeEditar = ctx.permisos.has('guiones.editar');
const campos = [
{ nombre: 'categoria', etiqueta: 'Categoría', requerido: true,
ayuda: `Las que ya existen: ${d.categorias.join(', ')}` },
{ nombre: 'titulo', etiqueta: 'Para qué sirve', requerido: true, marcador: 'Cerrar con la promoción de julio' },
{ nombre: 'cuerpo', etiqueta: 'El mensaje', tipo: 'area', filas: 9, requerido: true,
ayuda: 'Tal como se va a enviar. Termina siempre con una pregunta para que la conversación siga.' },
{ par: [
{ nombre: 'canal', etiqueta: 'Canal', marcador: 'whatsapp' },
{ nombre: 'orden', etiqueta: 'Posición en la lista', tipo: 'numero' },
] },
];
const heroe = `
Cómo se usan
Copiar, cambiar el nombre, enviar. Nunca once mensajes seguidos.
Un interesado recibió once mensajes casi sin pausa y sin una sola pregunta al final, y dejó de
contestar. Cada mensaje de aquí termina con una pregunta para que la conversación siga.
`;
return {
ceja: `${d.guiones.length} mensajes en ${d.categorias.length} categorías`,
titulo: 'Mensajes listos',
resumen: 'Textos ya redactados con los datos correctos adentro, para copiar y enviar por WhatsApp.',
acciones: puedeEditar ? btnAgregar('data-agregar-mensaje', 'Agregar mensaje') : '',
cuerpo: `
${heroe}
${d.categorias.map((c) => ``).join('')}
${d.guiones.map((g) => `
${esc(g.categoria)}
${esc(g.titulo)}
${plegable(`mensaje-${g.id}`)}
Ver el texto completo
`,
montar(raiz) {
raiz.querySelector('#filtros').addEventListener('click', (ev) => {
const btn = ev.target.closest('.filtro');
if (!btn) return;
raiz.querySelectorAll('#filtros .filtro').forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
const cat = btn.dataset.cat;
raiz.querySelectorAll('#lista > [data-cat]').forEach((t) => {
t.hidden = Boolean(cat) && t.dataset.cat !== cat;
});
});
if (!puedeEditar) return;
raiz.addEventListener('click', async (ev) => {
if (ev.target.closest('[data-agregar-mensaje]')) {
const ok = await abrirEditor({
titulo: 'Agregar un mensaje',
campos,
valores: { categoria: d.categorias[0] ?? '', canal: 'whatsapp', orden: d.guiones.length + 1 },
textoGuardar: 'Agregar mensaje',
guardar: (datos) => api('/mensajes', { method: 'POST', body: datos }),
});
if (ok) { brindis('Mensaje agregado'); ctx.recargar(); }
return;
}
const editar = ev.target.closest('[data-editar-mensaje]');
if (!editar) return;
const g = d.guiones.find((x) => x.id === Number(editar.dataset.editarMensaje));
if (!g) return;
const ok = await abrirEditor({
titulo: 'Editar el mensaje',
subtitulo: g.titulo,
campos,
valores: g,
borrar: () => api(`/mensajes/${g.id}`, { method: 'DELETE' }),
confirmarBorrado: `¿Eliminar el mensaje "${g.titulo}"?`,
guardar: (datos) => api(`/mensajes/${g.id}`, { method: 'PATCH', body: datos }),
});
if (ok) { brindis('Mensaje actualizado'); ctx.recargar(); }
});
},
};
}
// =====================================================================
// Datos del instituto
// =====================================================================
export async function contacto(ctx) {
const d = await api('/contacto');
const puedeEditar = ctx.permisos.has('contacto.editar');
const todos = d.grupos.flatMap((g) => g.campos);
const dir = todos.find((c) => c.clave === 'direccion')?.valor ?? '';
const wa = todos.find((c) => c.clave === 'whatsapp')?.valor ?? '';
const heroe = `
Lo que se dicta por teléfono
Av. Guadalupe Victoria 1409-d, Prados Verdes, 58110.
El volante impreso del programa de referidos trae 1409-A y CP 58114. Esa dirección no es la sede.
La correcta es esta.
`;
const grupos = d.grupos.map((g) => `
${esc(g.titulo)}
${g.campos.map((c) => `
${esc(c.etiqueta)}
${esc(c.valor)}
${puedeEditar ? `` : ''}
`).join('')}
`).join('');
return {
ceja: 'Ficha del instituto',
titulo: 'Datos del instituto',
resumen: 'Contacto, ubicación, horarios y la identidad del instituto. Es la versión que se cita ante un interesado o un proveedor.',
cuerpo: `
${heroe}
${grupos}
`,
montar(raiz) {
if (!puedeEditar) return;
raiz.addEventListener('click', async (ev) => {
const editar = ev.target.closest('[data-editar-dato]');
if (!editar) return;
const c = todos.find((x) => x.id === Number(editar.dataset.editarDato));
if (!c) return;
const ok = await abrirEditor({
titulo: `Editar ${c.etiqueta.toLowerCase()}`,
subtitulo: 'Este dato lo ve todo el equipo y se lo dicta a los interesados.',
campos: [
{ nombre: 'etiqueta', etiqueta: 'Nombre del dato', requerido: true },
{ nombre: 'valor', etiqueta: 'Contenido', tipo: 'area', filas: 4, requerido: true },
],
valores: c,
guardar: (datos) => api(`/contacto/${c.id}`, { method: 'PATCH', body: datos }),
});
if (ok) { brindis('Dato actualizado'); ctx.recargar(); }
});
},
};
}
// =====================================================================
// Equipo
// =====================================================================
export async function equipo() {
const d = await api('/equipo');
const heroe = `
Cómo funcionan los accesos
El menú y el servidor usan la misma regla.
Si una sección no aparece en el menú de alguien, tampoco la puede abrir escribiendo la dirección
a mano: el servidor la niega y el intento queda en la bitácora. El catálogo lo edita dirección;
los links de clases, dirección y administración.
`;
const tarjetas = d.roles.map((r) => `
Nivel ${esc(r.nivel)}
${insignia(`${(d.porRol[r.clave] ?? []).length} permisos`, r.color)}
`;
return {
ceja: `${d.usuarios.length} personas · ${d.roles.length} roles`,
titulo: 'Equipo',
resumen: 'Quién es quién y exactamente qué puede ver y editar cada rol.',
cuerpo: `
${cambios} ${cambios === 1 ? 'cambio' : 'cambios'} al contenido y
${denegados} ${denegados === 1 ? 'intento' : 'intentos'} de entrar
sin permiso.
Cada enlace de acceso sirve una sola vez y caduca en 15 minutos. Los tokens no se guardan en
claro: en la base solo vive su huella, así que un respaldo filtrado no permite entrar como nadie.
${num(d.sesionesTotal)}Sesiones abiertas${num(d.eventosTotal)}Eventos registrados${denegados}Denegados de los últimos ${d.entradas.length}