// ===================================================================== // 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 = `
${atajos.map(([id, ic, titulo, nota]) => ` `).join('')}
`; const contacto = `
Lo que más se dicta por teléfono
Dirección de la sede
${esc(dir)}
WhatsApp
${esc(wa)}
Horario de atención
${esc(horario)}
`; const porConfirmar = d.datosPorConfirmar ? `
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.
` : ''; const cambios = d.cambiosRecientes?.length ? `
Últimos cambios en el portal
${d.cambiosRecientes.map((c) => `
${esc(c.actor)} — ${esc(accionLegible(c.accion).toLowerCase())}${c.detalle ? `${esc(c.detalle)}` : ''} ${fechaHora(c.creado_en)}
`).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: `
${heroe}${porConfirmar}${tarjetasAtajo}${contacto}${cambios}
`, }; } // ===================================================================== // 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.

${sinConfirmar.map((p) => `${esc(p.nombre_corto)}${esc(ETQ_RVOE[p.rvoe_estado])}`).join('')}
` : ''; const conmutador = `
`; const porConfirmar = d.porConfirmar?.length ? `
Datos pendientes de confirmar ${insignia(`${d.porConfirmar.length} abiertos`, 'var(--atencion)')}

Cada punto es un dato que hoy no se puede citar por escrito ante un interesado.

${plegable('oferta-por-confirmar')} Ver los ${d.porConfirmar.length} datos
${d.porConfirmar.map((x) => `
${esc(x.titulo)} ${esc(x.detalle)} ${insignia(x.ambito, 'var(--gris)')} ${insignia(`Prioridad ${x.prioridad}`, TONO_PRIORIDAD[x.prioridad])} ${insignia(x.estado === 'abierto' ? 'Sin empezar' : 'En proceso', TONO_ESTADO[x.estado], { punto: true })} ${ctx.permisos.has('datos.gestionar') ? ` ${x.estado === 'abierto' ? `` : ''} ` : ''}
`).join('')}
` : ''; const modalidades = `
Qué significa cada modalidad
${d.modalidades.map((m) => `
${esc(m.nombre)} — ${esc(m.detalle)}
`).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) : `
${d.programas.map((p) => tarjetaPrograma(p, puedeEditar)).join('')}
`; }; pintar(); raiz.querySelectorAll('[data-vista]').forEach((b) => b.addEventListener('click', () => { vista = b.dataset.vista; localStorage.setItem(CLAVE_VISTA, vista); raiz.querySelectorAll('[data-vista]').forEach((o) => o.setAttribute('aria-pressed', String(o === b))); pintar(); })); // Editar un programa raiz.addEventListener('click', async (ev) => { const btn = ev.target.closest('[data-editar-programa]'); if (!btn) return; const p = d.programas.find((x) => x.id === Number(btn.dataset.editarPrograma)); if (!p) return; const guardado = await abrirEditor({ titulo: `Editar ${p.nombre_corto}`, subtitulo: 'Lo que guardes aquí es lo que verá todo el equipo al informar a un interesado.', valores: { ...p, inscripcion_lista: Number(p.inscripcion_lista), inscripcion_beca: Number(p.inscripcion_beca), mensualidad_lista: Number(p.mensualidad_lista), mensualidad_beca: Number(p.mensualidad_beca), rvoe_fecha: p.rvoe_fecha ? String(p.rvoe_fecha).slice(0, 10) : '', }, campos: [ { nombre: 'nombre', etiqueta: 'Nombre completo del programa', requerido: true }, { par: [ { nombre: 'nombre_corto', etiqueta: 'Nombre corto', requerido: true, ayuda: 'El que se usa en las tablas' }, { nombre: 'duracion', etiqueta: 'Duración', requerido: true, marcador: '3 años' }, ] }, { par: [ { nombre: 'fecha_inicio', etiqueta: 'Cuándo inicia', requerido: true, marcador: 'Mayo 2026' }, { nombre: 'ciclo_semanas', etiqueta: 'Semanas por ciclo', tipo: 'numero', ayuda: 'Déjalo vacío si no aplica' }, ] }, { par: [ { nombre: 'inscripcion_lista', etiqueta: 'Inscripción sin beca', tipo: 'numero', paso: 1, ayuda: 'En pesos, solo el número' }, { nombre: 'inscripcion_beca', etiqueta: 'Inscripción con beca', tipo: 'numero', paso: 1, ayuda: 'En pesos, solo el número' }, ] }, { par: [ { nombre: 'mensualidad_lista', etiqueta: 'Mensualidad sin beca', tipo: 'numero', paso: 1, ayuda: 'En pesos, solo el número' }, { nombre: 'mensualidad_beca', etiqueta: 'Mensualidad con beca', tipo: 'numero', paso: 1, ayuda: 'En pesos, solo el número' }, ] }, { nombre: 'beneficios', etiqueta: 'Beneficios incluidos', tipo: 'area', filas: 3 }, { nombre: 'rvoe_estado', etiqueta: 'Estado de la validez oficial', tipo: 'select', opciones: [ { valor: 'confirmado', texto: 'Confirmada · se puede citar el acuerdo' }, { valor: 'revisar', texto: 'Por revisar · citar con cuidado' }, { valor: 'por-confirmar', texto: 'Sin confirmar · NO citar ningún número' }, ] }, { par: [ { nombre: 'rvoe_acuerdo', etiqueta: 'Número de acuerdo' }, { nombre: 'rvoe_fecha', etiqueta: 'Fecha del acuerdo', tipo: 'fecha' }, ] }, { nombre: 'rvoe_autoridad', etiqueta: 'Autoridad que lo otorgó' }, { nombre: 'rvoe_nota', etiqueta: 'Nota sobre la validez oficial', tipo: 'area', filas: 3, ayuda: 'Lo que el equipo tiene que saber antes de hablar de este RVOE.' }, ], guardar: (datos) => api(`/oferta/${p.id}`, { method: 'PATCH', body: datos }), }); if (guardado) { brindis('Programa actualizado'); ctx.recargar(); } }); // Horarios: agregar, editar y quitar raiz.addEventListener('click', async (ev) => { const agregar = ev.target.closest('[data-agregar-horario]'); const editar = ev.target.closest('[data-editar-horario]'); if (!agregar && !editar) return; const opcionesMod = d.modalidades.map((m) => ({ valor: String(m.id), texto: m.nombre })); const campos = [ { nombre: 'modalidad_id', etiqueta: 'Modalidad', tipo: 'select', opciones: opcionesMod }, { nombre: 'dias', etiqueta: 'Días', requerido: true, marcador: 'Lunes a jueves' }, { 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: 'nota', etiqueta: 'Nota', marcador: 'Presencial e híbrida' }, ]; if (agregar) { const programaId = Number(agregar.dataset.agregarHorario); const ok = await abrirEditor({ titulo: 'Agregar un horario', subtitulo: d.programas.find((x) => x.id === programaId)?.nombre ?? '', campos, valores: { modalidad_id: String(d.modalidades[0].id) }, textoGuardar: 'Agregar horario', guardar: (datos) => api('/horarios', { method: 'POST', body: { ...datos, programa_id: programaId } }), }); if (ok) { brindis('Horario agregado'); ctx.recargar(); } return; } const id = Number(editar.dataset.editarHorario); const prog = d.programas.find((p) => p.horarios.some((h) => h.id === id)); const h = prog?.horarios.find((x) => x.id === id); if (!h) return; const ok = await abrirEditor({ titulo: 'Editar el horario', subtitulo: `${prog.nombre_corto} · ${h.modalidad_nombre}`, campos, valores: { ...h, modalidad_id: String(h.modalidad_id) }, borrar: () => api(`/horarios/${id}`, { method: 'DELETE' }), confirmarBorrado: `¿Quitar el horario "${h.dias} ${h.hora_inicio}" de ${prog.nombre_corto}?`, guardar: (datos) => api(`/horarios/${id}`, { method: 'PATCH', body: datos }), }); if (ok) { brindis('Horario actualizado'); ctx.recargar(); } }); // Datos por confirmar raiz.addEventListener('click', async (ev) => { const btn = ev.target.closest('[data-dato]'); if (!btn) return; if (btn.dataset.estado === 'aclarado' // eslint-disable-next-line no-alert && !window.confirm('Al marcarlo como confirmado desaparece de esta lista. ¿Ya tienes el dato por escrito?')) return; btn.disabled = true; try { await api(`/datos/${btn.dataset.dato}`, { method: 'PATCH', body: { estado: btn.dataset.estado } }); brindis(btn.dataset.estado === 'aclarado' ? 'Dato marcado como confirmado' : 'Marcado en proceso'); ctx.recargar(); } catch (err) { btn.disabled = false; brindis(err.message); } }); }, }; } function tarjetaPrograma(p, puedeEditar) { const conBeca = Number(p.mensualidad_beca) < Number(p.mensualidad_lista); return `

${esc(p.nombre)}

${insignia(ETQ_RVOE[p.rvoe_estado], TONO_RVOE[p.rvoe_estado], { punto: true })}
${pesos(conBeca ? p.mensualidad_beca : p.mensualidad_lista)} ${conBeca ? `${pesos(p.mensualidad_lista)}` : ''} al mes${conBeca ? ', con la beca del 25%' : ''}
Inscripción
${pesos(p.inscripcion_beca)}${Number(p.inscripcion_lista) > Number(p.inscripcion_beca) ? ` (sin beca ${pesos(p.inscripcion_lista)})` : ''}
Duración
${esc(p.duracion)}
Inicia
${esc(p.fecha_inicio)}
${p.ciclo_semanas ? `
Ciclo
${esc(p.ciclo_semanas)} semanas
` : ''}
${plegable(`programa-${p.id}`)} Horarios, validez oficial y beneficios
Horarios ${p.horarios.length ? p.horarios.map((h) => `
${esc(h.modalidad_nombre)} ${esc(h.dias)}${h.nota ? ` · ${esc(h.nota)}` : ''} ${esc(rangoHora(h.hora_inicio, h.hora_fin))} ${puedeEditar ? `` : ''}
`).join('') : '

Sin horarios registrados.

'}
${p.beneficios ? `
Incluye

${esc(p.beneficios)}

` : ''}
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')}
` : ''}
`; } function tablaOferta(d, puedeEditar) { return `
${puedeEditar ? '' : ''} ${d.programas.map((p) => ` ${puedeEditar ? `` : ''} `).join('')}
ProgramaNivelDuraciónInicia InscripciónMensualidad HorariosValidez oficialEditar
${esc(p.nombre)}${p.beneficios ? `${esc(p.beneficios)}` : ''} ${esc(p.nivel_nombre)} ${esc(p.duracion)} ${esc(p.fecha_inicio)} ${pesos(p.inscripcion_beca)}${Number(p.inscripcion_lista) > Number(p.inscripcion_beca) ? `
${pesos(p.inscripcion_lista)}` : ''}
${pesos(p.mensualidad_beca)}${Number(p.mensualidad_lista) > Number(p.mensualidad_beca) ? `
${pesos(p.mensualidad_lista)}` : ''}
${p.horarios.map((h) => `${esc(h.modalidad_nombre)}: ${esc(h.dias)} ${esc(rangoHora(h.hora_inicio, h.hora_fin))}`).join('
') || '—'}
${insignia(ETQ_RVOE[p.rvoe_estado], TONO_RVOE[p.rvoe_estado], { punto: true })} ${p.rvoe_acuerdo ? `${esc(p.rvoe_acuerdo)}` : ''} ${btnEditar(`data-editar-programa="${p.id}"`, 'Editar')}

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 `
${esc(n.nombre)} ${n.programas.map((p) => insignia(p.nombre_corto, p.color)).join('')}
${n.requisitos.map((r) => `
${esc(r.texto)} ${r.nota ? `${esc(r.nota)}` : ''} ${puedeEditar ? `` : ''}
`).join('')}
${n.requisitos.length} ${n.requisitos.length === 1 ? 'documento' : 'documentos'} ${n.requisitos.length ? `` : ''} ${puedeEditar ? btnAgregar(`data-agregar-requisito="${n.id}"`, 'Agregar documento') : ''}
`; }).join(''); const titulacion = `
Formas de titularse

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.map((t) => `
${esc(t.nombre)}${esc(t.descripcion)} ${puedeEditar ? `` : ''}
`).join('')} ${puedeEditar ? `
${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)')}

${esc(p.nombre)}

${p.resumen ? `

${esc(p.resumen)}

` : ''}
Vigencia
${esc(p.vigencia || '—')}
Aplica a
${esc({ inscripcion: 'La inscripción', mensualidad: 'La mensualidad', ambos: 'Inscripción y mensualidad' }[p.aplica_a] ?? p.aplica_a)}
Condiciones completas
${esc(p.condiciones)}
${puedeEditar ? btnEditar(`data-editar-beca="${p.id}"`) : ''}
`).join('')}
`; const fechas = `
Cuándo inicia cada programa
${d.fechas.map((f) => ``).join('')}
ProgramaNivelIniciaDuración
${esc(f.nombre)} ${esc(f.nivel)} ${esc(f.fecha_inicio)} ${esc(f.duracion)}
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.
`; const escala = `
Cuánto baja la mensualidad por referidos
${d.escalaReferidos.map((r) => ``).join('')}
ReferidosDescuento Licenciatura y maestríaBachillerato Qué conviene ofrecer
${r.referidos} ${r.descuento}% ${pesos(r.mensualidad_licenciatura)} ${pesos(r.mensualidad_bachillerato)} ${insignia(r.conviene === 'beca' ? 'La beca del 25%' : 'Los referidos', r.conviene === 'beca' ? 'var(--atencion)' : 'var(--bien)')}

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
`; const filtros = `
${conClases.map((p) => ``).join('')}
`; const bloques = conClases.map((p) => { const suyas = d.clases.filter((c) => c.programa_id === p.id); return `
${esc(p.nivel)} ${insignia(`${suyas.length} ${suyas.length === 1 ? 'materia' : 'materias'}`, p.color)}

${esc(p.nombre)}

${plegable(`clases-programa-${p.id}`)} Ver las ${suyas.length} ${suyas.length === 1 ? 'materia y su link' : 'materias y sus links'}
${puedeEditar ? '' : ''} ${suyas.map((c) => ` ${puedeEditar ? `` : ''} `).join('')}
MateriaDía y horaDocenteGrupo Link de la claseEditar
${esc(c.materia)} ${c.notas ? `${esc(c.notas)}` : ''} ${c.activo ? '' : `${insignia('Inactiva', 'var(--gris-claro)')}`} ${esc(c.dias)}
${esc(rangoHora(c.hora_inicio, c.hora_fin))}
${esc(c.docente || '—')} ${esc(c.grupo || '—')}
${icono('video')}Entrar
${btnEditar(`data-editar-clase="${c.id}"`, 'Editar')}
${puedeEditar ? `
${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) => `
${esc(s)}
${articulos.filter((a) => a.seccion === s).map((a) => `
${esc(a.pregunta)}

${esc(a.respuesta)}

${esc(a.fuente)} ${puedeEditar ? btnEditar(`data-editar-pregunta="${a.id}"`) : ''}
`).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
${esc(g.cuerpo)}
${esc(g.canal)} ${puedeEditar ? btnEditar(`data-editar-mensaje="${g.id}"`) : ''}
`).join('')}
`, 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)}

${esc(r.nombre)}

${esc(r.descripcion)}

${d.usuarios.filter((u) => u.rol_clave === r.clave).map((u) => insignia(u.nombre, r.color)).join('')}
`).join(''); const directorio = `
Directorio interno Usuarios demo
${d.usuarios.map((u) => ``).join('')}
PersonaPuestoRolExtensiónÚltimo acceso
${ficha(u.iniciales, u.rol_color, { chica: true })}${esc(u.nombre)}${esc(u.email)} ${esc(u.puesto)} ${insignia(u.rol_nombre, u.rol_color)} ${esc(u.extension || '—')} ${u.ultimo_acceso ? fechaHora(u.ultimo_acceso) : 'Nunca'}
`; const matriz = `
Qué sección ve cada rol

Desliza la tabla hacia los lados para ver los tres roles.

${d.roles.map((r) => ``).join('')}${d.secciones.map((s) => ` ${d.roles.map((r) => { const tiene = s.permisos.some((p) => (d.porRol[r.clave] ?? []).includes(p)); return ``; }).join('')} `).join('')}
Sección${esc(r.nombre.split(' ')[0])}
${esc(s.titulo)}${tiene ? '●Sí' : '○No'}
`; const permisos = `
Los ${d.permisos.length} permisos, uno por uno
${plegable('equipo-permisos')} Ver el detalle permiso por permiso
${d.roles.map((r) => ``).join('')}${d.permisos.map((p) => ` ${d.roles.map((r) => ``).join('')} `).join('')}
Permiso${esc(r.nombre.split(' ')[0])}
${esc(p.descripcion)}${esc(p.clave)}${(d.porRol[r.clave] ?? []).includes(p.clave) ? '●Sí' : '○No'}
`; 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: `
${heroe}
${tarjetas}
${directorio}${matriz}${permisos}
`, }; } // ===================================================================== // Bitácora // ===================================================================== export async function bitacora() { const d = await api('/bitacora?limite=40'); const denegados = d.entradas.filter((e) => e.resultado === 'denegado').length; const cambios = d.entradas.filter((e) => /\.(editar|crear|borrar)$/.test(e.accion)).length; const heroe = `
Auditoría

${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}
`; const sesiones = `
Sesiones abiertas ahora ${d.sesionesTotal > d.sesionesActivas.length ? insignia(`${d.sesionesActivas.length} de ${num(d.sesionesTotal)}`, 'var(--gris)') : insignia(num(d.sesionesTotal), 'var(--gris)')}
${d.sesionesActivas.length ? `${plegable('bitacora-sesiones')} Ver las ${d.sesionesActivas.length} sesiones
${d.sesionesActivas.map((s) => ``).join('')}
PersonaRolDesdeVenceOrigen
${ficha(s.iniciales, null, { chica: true })}${esc(s.nombre)} ${esc(s.rol)} ${fechaHora(s.creada_en)} ${fechaHora(s.expira_en)} ${esc(s.ip_origen || '—')}
` : '

No hay sesiones abiertas.

'}
`; const enlaces = `
Enlaces de acceso generados
${plegable('bitacora-enlaces')} Ver los últimos ${d.enlaces.length} enlaces
${d.enlaces.map((t) => ``).join('')}
ParaGeneradoVenceEstado
${esc(t.nombre)} ${fechaHora(t.creado_en)} ${fechaHora(t.expira_en)} ${t.usado_en ? insignia('Usado', 'var(--bien)', { punto: true }) : insignia('Sin usar', 'var(--gris-claro)')}
`; const eventos = `
Últimos ${d.entradas.length} eventos
${plegable('bitacora-eventos')} Ver los ${d.entradas.length} eventos
${d.entradas.map((e) => ``).join('')}
CuándoQuiénQué hizoResultadoDetalle
${fechaHora(e.creado_en)} ${esc(e.actor)}${e.rol ? `${esc(e.rol)}` : ''} ${esc(accionLegible(e.accion))}${esc(e.accion)}${e.entidad ? ` · ${esc(e.entidad)} ${esc(e.entidad_id)}` : ''} ${e.resultado === 'ok' ? insignia('Bien', 'var(--bien)') : insignia('Denegado', 'var(--alerta)', { punto: true })} ${esc(e.detalle)}
`; return { ceja: 'Solo dirección', titulo: 'Bitácora', resumen: 'Quién entró, qué cambió del contenido y qué intentó abrir sin permiso.', cuerpo: `
${heroe}${sesiones}${enlaces}${eventos}
`, }; }