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]>
1365 lines
70 KiB
JavaScript
1365 lines
70 KiB
JavaScript
// =====================================================================
|
|
// 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') =>
|
|
`<button class="boton boton--fantasma boton--chico" type="button" ${attrs}>${icono('lapiz')}${esc(texto)}</button>`;
|
|
|
|
const btnAgregar = (attrs, texto) =>
|
|
`<button class="boton boton--chico" type="button" ${attrs}>${icono('mas')}${esc(texto)}</button>`;
|
|
|
|
/**
|
|
* 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) => `<details class="plegable" data-plegable="${esc(clave)}"${enEscritorio() ? ' open' : ''}>`;
|
|
|
|
// =====================================================================
|
|
// 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 ? `
|
|
<section class="respuesta">
|
|
<span class="ceja">Promoción vigente</span>
|
|
<h2>${esc(d.promo.nombre)}: <span class="marcado"><span>${esc(d.promo.vigencia)}</span></span></h2>
|
|
<p>${esc(d.promo.resumen)}</p>
|
|
<div class="respuesta__acciones">
|
|
<button class="boton boton--marca" type="button" data-ir="becas">Ver becas y fechas</button>
|
|
<button class="boton" type="button" data-ir="oferta">Ver la oferta educativa</button>
|
|
</div>
|
|
</section>` : '';
|
|
|
|
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 = `
|
|
<div class="rejilla rejilla--3">
|
|
${atajos.map(([id, ic, titulo, nota]) => `
|
|
<button class="placa" type="button" data-ir="${id}"
|
|
style="text-align:left;cursor:pointer;font:inherit;color:inherit;display:grid;gap:8px;align-content:start">
|
|
<span style="color:var(--azul-profundo);display:flex">${icono(ic)}</span>
|
|
<b style="font-family:var(--serif);font-size:19px;color:var(--tinta)">${esc(titulo)}</b>
|
|
<span class="mini tenue">${esc(nota)}</span>
|
|
</button>`).join('')}
|
|
</div>`;
|
|
|
|
const contacto = `
|
|
<article class="placa">
|
|
<div class="placa__ceja"><span class="ceja">Lo que más se dicta por teléfono</span></div>
|
|
<dl class="detalles">
|
|
<div class="detalle"><dt>Dirección de la sede</dt><dd>${esc(dir)}</dd></div>
|
|
<div class="detalle"><dt>WhatsApp</dt><dd><span class="dato">${esc(wa)}</span></dd></div>
|
|
<div class="detalle"><dt>Horario de atención</dt><dd>${esc(horario)}</dd></div>
|
|
</dl>
|
|
<div class="fila" style="margin-top:14px">
|
|
<button class="boton boton--fantasma boton--chico" type="button" data-copiar="${esc(dir)}" data-copiar-aviso="Dirección copiada">${icono('copiar')}Copiar dirección</button>
|
|
<button class="boton boton--fantasma boton--chico" type="button" data-copiar="${esc(wa)}" data-copiar-aviso="WhatsApp copiado">${icono('copiar')}Copiar WhatsApp</button>
|
|
</div>
|
|
</article>`;
|
|
|
|
const porConfirmar = d.datosPorConfirmar ? `
|
|
<div class="aviso aviso--atencion">
|
|
Hay <b>${esc(d.datosPorConfirmar)} datos pendientes de confirmar</b> (números de RVOE y fechas).
|
|
Mientras no se cierren, no se pueden citar por escrito ante un interesado.
|
|
<div style="margin-top:12px"><button class="boton boton--fantasma boton--chico" type="button" data-ir="oferta">Verlos en la oferta educativa</button></div>
|
|
</div>` : '';
|
|
|
|
const cambios = d.cambiosRecientes?.length ? `
|
|
<article class="placa">
|
|
<div class="placa__ceja"><span class="ceja">Últimos cambios en el portal</span></div>
|
|
${d.cambiosRecientes.map((c) => `<div class="horario">
|
|
<span class="crece"><b>${esc(c.actor)}</b> — ${esc(accionLegible(c.accion).toLowerCase())}${c.detalle ? `<span class="mini tenue" style="display:block">${esc(c.detalle)}</span>` : ''}</span>
|
|
<span class="horario__hora mini tenue nowrap">${fechaHora(c.creado_en)}</span>
|
|
</div>`).join('')}
|
|
</article>` : '';
|
|
|
|
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: `<div class="pila pila--ancha">${heroe}${porConfirmar}${tarjetasAtajo}${contacto}${cambios}</div>`,
|
|
};
|
|
}
|
|
|
|
// =====================================================================
|
|
// 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 ? `
|
|
<section class="respuesta">
|
|
<span class="ceja">Antes de citar un número de acuerdo</span>
|
|
<h2>${sinConfirmar.length} de los ${d.programas.length} programas tienen la
|
|
<span class="marcado"><span>validez oficial sin confirmar</span></span>.</h2>
|
|
<p>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.</p>
|
|
<div class="respuesta__fila">
|
|
${sinConfirmar.map((p) => `<span class="respuesta__cifra"><b style="font-size:19px;font-family:var(--serif)">${esc(p.nombre_corto)}</b><span>${esc(ETQ_RVOE[p.rvoe_estado])}</span></span>`).join('')}
|
|
</div>
|
|
</section>` : '';
|
|
|
|
const conmutador = `
|
|
<div class="vista-alterna" role="group" aria-label="Cómo ver la oferta">
|
|
<button type="button" data-vista="tarjetas" aria-pressed="${vistaGuardada === 'tarjetas'}">${icono('tarjetas')}Tarjetas</button>
|
|
<button type="button" data-vista="tabla" aria-pressed="${vistaGuardada === 'tabla'}">${icono('tabla')}Tabla</button>
|
|
</div>`;
|
|
|
|
const porConfirmar = d.porConfirmar?.length ? `
|
|
<article class="placa placa--acento" style="--acento:var(--atencion)">
|
|
<div class="placa__ceja">
|
|
<span class="ceja">Datos pendientes de confirmar</span>
|
|
${insignia(`${d.porConfirmar.length} abiertos`, 'var(--atencion)')}
|
|
</div>
|
|
<p class="tenue mini" style="margin-bottom:14px">
|
|
Cada punto es un dato que hoy no se puede citar por escrito ante un interesado.
|
|
</p>
|
|
${plegable('oferta-por-confirmar')}
|
|
<summary>Ver los ${d.porConfirmar.length} datos</summary>
|
|
<div class="plegable__cuerpo" style="gap:0">
|
|
${d.porConfirmar.map((x) => `
|
|
<div class="horario" style="align-items:flex-start;gap:12px">
|
|
<span class="horario__punto" style="background:${TONO_PRIORIDAD[x.prioridad]};margin-top:7px"></span>
|
|
<span class="crece">
|
|
<b style="display:block">${esc(x.titulo)}</b>
|
|
<span class="mini tenue" style="display:block;margin:4px 0 7px">${esc(x.detalle)}</span>
|
|
<span class="etiquetas">
|
|
${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 })}
|
|
</span>
|
|
</span>
|
|
${ctx.permisos.has('datos.gestionar') ? `<span class="fila" style="flex:none;gap:7px">
|
|
${x.estado === 'abierto' ? `<button class="boton boton--fantasma boton--chico" type="button" data-dato="${x.id}" data-estado="en-proceso">En proceso</button>` : ''}
|
|
<button class="boton boton--chico" type="button" data-dato="${x.id}" data-estado="aclarado">Ya está confirmado</button>
|
|
</span>` : ''}
|
|
</div>`).join('')}
|
|
</div>
|
|
</details>
|
|
</article>` : '';
|
|
|
|
const modalidades = `
|
|
<article class="placa">
|
|
<div class="placa__ceja"><span class="ceja">Qué significa cada modalidad</span></div>
|
|
${d.modalidades.map((m) => `<div class="horario">
|
|
<span class="horario__punto" style="background:${esc(m.color)}"></span>
|
|
<span class="crece"><b>${esc(m.nombre)}</b> — <span class="tenue">${esc(m.detalle)}</span></span>
|
|
</div>`).join('')}
|
|
</article>`;
|
|
|
|
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: `<div class="pila pila--ancha">
|
|
${heroe}
|
|
<div id="oferta-lista"></div>
|
|
${porConfirmar}
|
|
${modalidades}
|
|
</div>`,
|
|
montar(raiz) {
|
|
const caja = raiz.querySelector('#oferta-lista');
|
|
let vista = vistaGuardada;
|
|
|
|
const pintar = () => {
|
|
caja.innerHTML = vista === 'tabla'
|
|
? tablaOferta(d, puedeEditar)
|
|
: `<div class="rejilla rejilla--fichas rejilla--arriba">${d.programas.map((p) => tarjetaPrograma(p, puedeEditar)).join('')}</div>`;
|
|
};
|
|
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 `
|
|
<article class="placa placa--acento programa" style="--acento:${esc(p.color)}">
|
|
<div class="programa__cabeza">
|
|
<h3>${esc(p.nombre)}</h3>
|
|
${insignia(ETQ_RVOE[p.rvoe_estado], TONO_RVOE[p.rvoe_estado], { punto: true })}
|
|
</div>
|
|
|
|
<div class="precio">
|
|
<b>${pesos(conBeca ? p.mensualidad_beca : p.mensualidad_lista)}</b>
|
|
${conBeca ? `<s>${pesos(p.mensualidad_lista)}</s>` : ''}
|
|
<small>al mes${conBeca ? ', con la beca del 25%' : ''}</small>
|
|
</div>
|
|
|
|
<dl class="detalles">
|
|
<div class="detalle"><dt>Inscripción</dt><dd><span class="dato">${pesos(p.inscripcion_beca)}</span>${Number(p.inscripcion_lista) > Number(p.inscripcion_beca) ? ` <span class="tenue mini">(sin beca ${pesos(p.inscripcion_lista)})</span>` : ''}</dd></div>
|
|
<div class="detalle"><dt>Duración</dt><dd>${esc(p.duracion)}</dd></div>
|
|
<div class="detalle"><dt>Inicia</dt><dd>${esc(p.fecha_inicio)}</dd></div>
|
|
${p.ciclo_semanas ? `<div class="detalle"><dt>Ciclo</dt><dd>${esc(p.ciclo_semanas)} semanas</dd></div>` : ''}
|
|
</dl>
|
|
|
|
${plegable(`programa-${p.id}`)}
|
|
<summary>Horarios, validez oficial y beneficios</summary>
|
|
<div class="plegable__cuerpo">
|
|
<div>
|
|
<span class="ceja" style="display:block;margin-bottom:6px">Horarios</span>
|
|
${p.horarios.length ? p.horarios.map((h) => `<div class="horario">
|
|
<span class="horario__punto" style="background:${esc(h.modalidad_color)}"></span>
|
|
<span class="crece"><b>${esc(h.modalidad_nombre)}</b> <span class="tenue">${esc(h.dias)}${h.nota ? ` · ${esc(h.nota)}` : ''}</span></span>
|
|
<span class="horario__hora nowrap">${esc(rangoHora(h.hora_inicio, h.hora_fin))}</span>
|
|
${puedeEditar ? `<button class="boton boton--fantasma boton--chico" type="button" data-editar-horario="${h.id}" aria-label="Editar el horario de ${esc(h.modalidad_nombre)} de ${esc(p.nombre_corto)}">${icono('lapiz')}</button>` : ''}
|
|
</div>`).join('') : '<p class="tenue mini">Sin horarios registrados.</p>'}
|
|
</div>
|
|
|
|
${p.beneficios ? `<div>
|
|
<span class="ceja" style="display:block;margin-bottom:4px">Incluye</span>
|
|
<p class="respeta-renglones">${esc(p.beneficios)}</p>
|
|
</div>` : ''}
|
|
|
|
<div>
|
|
<span class="ceja" style="display:block;margin-bottom:4px">Validez oficial</span>
|
|
${p.rvoe_estado === 'confirmado' && p.rvoe_acuerdo
|
|
? `<p>Acuerdo <span class="dato">${esc(p.rvoe_acuerdo)}</span>${p.rvoe_fecha ? `, del ${fecha(p.rvoe_fecha)}` : ''}${p.rvoe_autoridad ? `, a nombre de ${esc(p.rvoe_autoridad)}` : ''}.</p>`
|
|
: `<p class="respeta-renglones" style="color:${TONO_RVOE[p.rvoe_estado]}">${esc(p.rvoe_nota || 'Falta registrar el número de acuerdo.')}</p>`}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
${puedeEditar ? `<div class="acciones-edicion">
|
|
<span class="ceja">${p.actualizado_por_nombre ? `Editado por ${esc(p.actualizado_por_nombre)}` : 'Como se sembró'}</span>
|
|
${btnAgregar(`data-agregar-horario="${p.id}"`, 'Agregar horario')}
|
|
${btnEditar(`data-editar-programa="${p.id}"`, 'Editar programa')}
|
|
</div>` : ''}
|
|
</article>`;
|
|
}
|
|
|
|
function tablaOferta(d, puedeEditar) {
|
|
return `
|
|
<article class="placa">
|
|
<div class="envoltura-tabla">
|
|
<table class="tabla tabla--apila">
|
|
<thead><tr>
|
|
<th>Programa</th><th>Nivel</th><th>Duración</th><th>Inicia</th>
|
|
<th class="tabla__num">Inscripción</th><th class="tabla__num">Mensualidad</th>
|
|
<th>Horarios</th><th>Validez oficial</th>
|
|
${puedeEditar ? '<th class="tabla__acciones">Editar</th>' : ''}
|
|
</tr></thead>
|
|
<tbody>
|
|
${d.programas.map((p) => `<tr>
|
|
<td data-col="Programa"><b>${esc(p.nombre)}</b>${p.beneficios ? `<span class="mini tenue" style="display:block;margin-top:3px">${esc(p.beneficios)}</span>` : ''}</td>
|
|
<td data-col="Nivel">${esc(p.nivel_nombre)}</td>
|
|
<td data-col="Duración">${esc(p.duracion)}</td>
|
|
<td data-col="Inicia">${esc(p.fecha_inicio)}</td>
|
|
<td data-col="Inscripción" class="tabla__num">${pesos(p.inscripcion_beca)}${Number(p.inscripcion_lista) > Number(p.inscripcion_beca) ? `<br><s class="tenue mini">${pesos(p.inscripcion_lista)}</s>` : ''}</td>
|
|
<td data-col="Mensualidad" class="tabla__num">${pesos(p.mensualidad_beca)}${Number(p.mensualidad_lista) > Number(p.mensualidad_beca) ? `<br><s class="tenue mini">${pesos(p.mensualidad_lista)}</s>` : ''}</td>
|
|
<td data-col="Horarios">${p.horarios.map((h) => `${esc(h.modalidad_nombre)}: ${esc(h.dias)} ${esc(rangoHora(h.hora_inicio, h.hora_fin))}`).join('<br>') || '—'}</td>
|
|
<td data-col="Validez oficial">
|
|
${insignia(ETQ_RVOE[p.rvoe_estado], TONO_RVOE[p.rvoe_estado], { punto: true })}
|
|
${p.rvoe_acuerdo ? `<span class="dato mini" style="display:block;margin-top:4px">${esc(p.rvoe_acuerdo)}</span>` : ''}
|
|
</td>
|
|
${puedeEditar ? `<td data-col="Editar" class="tabla__acciones">${btnEditar(`data-editar-programa="${p.id}"`, 'Editar')}</td>` : ''}
|
|
</tr>`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="mini tenue" style="margin-top:14px">
|
|
Los precios con beca son los del 25% para alumnos nuevos. La cifra tachada es el precio sin beca.
|
|
</p>
|
|
</article>`;
|
|
}
|
|
|
|
// =====================================================================
|
|
// 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 = `
|
|
<section class="respuesta">
|
|
<span class="ceja">Lo primero que hay que mirar</span>
|
|
${distintos
|
|
? `<h2>Cada nivel pide una lista distinta:
|
|
<span class="marcado"><span>${cuenta(bach)} documentos en bachillerato</span></span>
|
|
y ${otros.map((n) => cuenta(n)).join(' o ')} en los demás.</h2>`
|
|
: '<h2>Cada nivel pide <span class="marcado"><span>su propia lista</span></span> de documentos.</h2>'}
|
|
<p>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.</p>
|
|
</section>`;
|
|
|
|
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 `
|
|
<article class="placa">
|
|
<div class="placa__ceja">
|
|
<span class="ceja">${esc(n.nombre)}</span>
|
|
<span class="etiquetas">${n.programas.map((p) => insignia(p.nombre_corto, p.color)).join('')}</span>
|
|
</div>
|
|
${n.requisitos.map((r) => `<div class="horario" style="align-items:flex-start">
|
|
<span class="horario__punto" style="background:var(--azul-panel);margin-top:8px"></span>
|
|
<span class="crece">
|
|
<b>${esc(r.texto)}</b>
|
|
${r.nota ? `<span class="mini tenue" style="display:block">${esc(r.nota)}</span>` : ''}
|
|
</span>
|
|
${puedeEditar ? `<button class="boton boton--fantasma boton--chico" type="button" data-editar-requisito="${r.id}" aria-label="Editar ${esc(r.texto)}">${icono('lapiz')}</button>` : ''}
|
|
</div>`).join('')}
|
|
<div class="acciones-edicion">
|
|
<span class="ceja">${n.requisitos.length} ${n.requisitos.length === 1 ? 'documento' : 'documentos'}</span>
|
|
${n.requisitos.length
|
|
? `<button class="boton boton--fantasma boton--chico" type="button" data-copiar="${esc(texto)}" data-copiar-aviso="Lista de ${esc(n.nombre.toLowerCase())} copiada">${icono('copiar')}Copiar la lista</button>`
|
|
: ''}
|
|
${puedeEditar ? btnAgregar(`data-agregar-requisito="${n.id}"`, 'Agregar documento') : ''}
|
|
</div>
|
|
</article>`;
|
|
}).join('');
|
|
|
|
const titulacion = `
|
|
<article class="placa">
|
|
<div class="placa__ceja"><span class="ceja">Formas de titularse</span></div>
|
|
<p class="tenue" style="margin-bottom:14px">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.</p>
|
|
${d.titulacion.map((t) => `<div class="horario" style="align-items:flex-start">
|
|
<span class="horario__punto" style="background:#7E57C2;margin-top:8px"></span>
|
|
<span class="crece"><b>${esc(t.nombre)}</b><span class="mini tenue" style="display:block">${esc(t.descripcion)}</span></span>
|
|
${puedeEditar ? `<button class="boton boton--fantasma boton--chico" type="button" data-editar-titulacion="${t.id}" aria-label="Editar ${esc(t.nombre)}">${icono('lapiz')}</button>` : ''}
|
|
</div>`).join('')}
|
|
${puedeEditar ? `<div class="acciones-edicion">
|
|
<span class="ceja">${d.titulacion.length} opciones</span>
|
|
${btnAgregar('data-agregar-titulacion', 'Agregar forma de titulación')}
|
|
</div>` : ''}
|
|
</article>`;
|
|
|
|
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: `<div class="pila pila--ancha">${heroe}<div class="rejilla rejilla--2 rejilla--arriba">${niveles}</div>${titulacion}</div>`,
|
|
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 = `
|
|
<section class="respuesta">
|
|
<span class="ceja">La regla que más se rompe</span>
|
|
<h2>El programa de referidos <span class="marcado"><span>no se junta</span></span>
|
|
con la beca del 25% ni con la promoción de julio.</h2>
|
|
<p>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.</p>
|
|
</section>`;
|
|
|
|
const lista = `
|
|
<div class="rejilla rejilla--2 rejilla--arriba">
|
|
${d.promociones.map((p) => `
|
|
<article class="placa placa--acento" style="--acento:${p.activo ? 'var(--azul)' : 'var(--gris-claro)'}">
|
|
<div class="placa__ceja">
|
|
${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)')}
|
|
</div>
|
|
<h3>${esc(p.nombre)}</h3>
|
|
${p.resumen ? `<p style="margin-top:8px">${esc(p.resumen)}</p>` : ''}
|
|
<dl class="detalles" style="margin-top:12px">
|
|
<div class="detalle"><dt>Vigencia</dt><dd>${esc(p.vigencia || '—')}</dd></div>
|
|
<div class="detalle"><dt>Aplica a</dt><dd>${esc({ inscripcion: 'La inscripción', mensualidad: 'La mensualidad', ambos: 'Inscripción y mensualidad' }[p.aplica_a] ?? p.aplica_a)}</dd></div>
|
|
</dl>
|
|
<details class="acordeon" style="margin-top:6px">
|
|
<summary>Condiciones completas</summary>
|
|
<div class="acordeon__cuerpo respeta-renglones">${esc(p.condiciones)}</div>
|
|
</details>
|
|
<div class="acciones-edicion">
|
|
<span class="ceja"></span>
|
|
<button class="boton boton--fantasma boton--chico" type="button" data-copiar="${esc(p.resumen || p.nombre)}" data-copiar-aviso="Copiado">${icono('copiar')}Copiar</button>
|
|
${puedeEditar ? btnEditar(`data-editar-beca="${p.id}"`) : ''}
|
|
</div>
|
|
</article>`).join('')}
|
|
</div>`;
|
|
|
|
const fechas = `
|
|
<article class="placa">
|
|
<div class="placa__ceja"><span class="ceja">Cuándo inicia cada programa</span></div>
|
|
<div class="envoltura-tabla">
|
|
<table class="tabla tabla--apila">
|
|
<thead><tr><th>Programa</th><th>Nivel</th><th>Inicia</th><th>Duración</th></tr></thead>
|
|
<tbody>${d.fechas.map((f) => `<tr>
|
|
<td data-col="Programa"><b>${esc(f.nombre)}</b></td>
|
|
<td data-col="Nivel">${esc(f.nivel)}</td>
|
|
<td data-col="Inicia"><b>${esc(f.fecha_inicio)}</b></td>
|
|
<td data-col="Duración">${esc(f.duracion)}</td>
|
|
</tr>`).join('')}</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="aviso aviso--atencion" style="margin-top:16px">
|
|
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.
|
|
</div>
|
|
</article>`;
|
|
|
|
const escala = `
|
|
<article class="placa">
|
|
<div class="placa__ceja"><span class="ceja">Cuánto baja la mensualidad por referidos</span></div>
|
|
<div class="envoltura-tabla">
|
|
<table class="tabla tabla--apila">
|
|
<thead><tr>
|
|
<th>Referidos</th><th>Descuento</th>
|
|
<th class="tabla__num">Licenciatura y maestría</th><th class="tabla__num">Bachillerato</th>
|
|
<th>Qué conviene ofrecer</th>
|
|
</tr></thead>
|
|
<tbody>${d.escalaReferidos.map((r) => `<tr>
|
|
<td data-col="Referidos"><span class="dato">${r.referidos}</span></td>
|
|
<td data-col="Descuento"><span class="dato">${r.descuento}%</span></td>
|
|
<td data-col="Lic. y maestría" class="tabla__num">${pesos(r.mensualidad_licenciatura)}</td>
|
|
<td data-col="Bachillerato" class="tabla__num">${pesos(r.mensualidad_bachillerato)}</td>
|
|
<td data-col="Qué conviene">${insignia(r.conviene === 'beca' ? 'La beca del 25%' : 'Los referidos', r.conviene === 'beca' ? 'var(--atencion)' : 'var(--bien)')}</td>
|
|
</tr>`).join('')}</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="mini tenue" style="margin-top:14px">
|
|
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.
|
|
</p>
|
|
</article>`;
|
|
|
|
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: `<div class="pila pila--ancha">${heroe}${lista}${fechas}${escala}</div>`,
|
|
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 = `
|
|
<section class="respuesta">
|
|
<span class="ceja">Cómo se usa</span>
|
|
<h2>Cada materia tiene <span class="marcado"><span>su propio link fijo</span></span>.</h2>
|
|
<p>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.'}</p>
|
|
<div class="respuesta__fila">
|
|
<span class="respuesta__cifra"><b>${d.clases.filter((c) => c.activo).length}</b><span>Clases activas</span></span>
|
|
<span class="respuesta__cifra"><b>${conClases.length}</b><span>Programas con clases</span></span>
|
|
</div>
|
|
</section>`;
|
|
|
|
const filtros = `
|
|
<div class="filtros" id="filtros-clases">
|
|
<button class="filtro" type="button" data-prog="" aria-pressed="true">Todos los programas</button>
|
|
${conClases.map((p) => `<button class="filtro" type="button" data-prog="${p.id}" aria-pressed="false">${esc(p.nombre_corto)}</button>`).join('')}
|
|
</div>`;
|
|
|
|
const bloques = conClases.map((p) => {
|
|
const suyas = d.clases.filter((c) => c.programa_id === p.id);
|
|
return `
|
|
<article class="placa placa--acento" data-bloque="${p.id}" style="--acento:${esc(p.color)}">
|
|
<div class="placa__ceja">
|
|
<span class="ceja">${esc(p.nivel)}</span>
|
|
${insignia(`${suyas.length} ${suyas.length === 1 ? 'materia' : 'materias'}`, p.color)}
|
|
</div>
|
|
<h3 style="margin-bottom:14px">${esc(p.nombre)}</h3>
|
|
${plegable(`clases-programa-${p.id}`)}
|
|
<summary>Ver las ${suyas.length} ${suyas.length === 1 ? 'materia y su link' : 'materias y sus links'}</summary>
|
|
<div class="plegable__cuerpo">
|
|
<div class="envoltura-tabla">
|
|
<table class="tabla tabla--apila tabla--clases">
|
|
<thead><tr>
|
|
<th>Materia</th><th>Día y hora</th><th>Docente</th><th>Grupo</th>
|
|
<th>Link de la clase</th>${puedeEditar ? '<th class="tabla__acciones">Editar</th>' : ''}
|
|
</tr></thead>
|
|
<tbody>
|
|
${suyas.map((c) => `<tr${c.activo ? '' : ' class="clase-inactiva"'}>
|
|
<td data-col="Materia"><b>${esc(c.materia)}</b>
|
|
${c.notas ? `<span class="mini tenue" style="display:block;margin-top:3px">${esc(c.notas)}</span>` : ''}
|
|
${c.activo ? '' : `<span style="display:block;margin-top:5px">${insignia('Inactiva', 'var(--gris-claro)')}</span>`}
|
|
</td>
|
|
<td data-col="Día y hora"><b>${esc(c.dias)}</b><br><span class="dato mini nowrap">${esc(rangoHora(c.hora_inicio, c.hora_fin))}</span></td>
|
|
<td data-col="Docente">${esc(c.docente || '—')}</td>
|
|
<td data-col="Grupo"><span class="mini tenue">${esc(c.grupo || '—')}</span></td>
|
|
<td data-col="Link">
|
|
<div class="fila fila--junta">
|
|
<a class="clase-enlace" href="${esc(c.enlace)}" target="_blank" rel="noopener noreferrer"
|
|
aria-label="Entrar a la clase de ${esc(c.materia)}">${icono('video')}Entrar</a>
|
|
<button class="boton boton--fantasma boton--chico" type="button"
|
|
aria-label="Copiar el link de ${esc(c.materia)}"
|
|
data-copiar="${esc(c.enlace)}" data-copiar-aviso="Link de ${esc(c.materia)} copiado">${icono('copiar')}</button>
|
|
</div>
|
|
</td>
|
|
${puedeEditar ? `<td data-col="Editar" class="tabla__acciones">${btnEditar(`data-editar-clase="${c.id}"`, 'Editar')}</td>` : ''}
|
|
</tr>`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
${puedeEditar ? `<div class="acciones-edicion">
|
|
<span class="ceja"></span>
|
|
${btnAgregar(`data-agregar-clase="${p.id}"`, 'Agregar materia a este programa')}
|
|
</div>` : ''}
|
|
</article>`;
|
|
}).join('');
|
|
|
|
const avisoDemo = `
|
|
<div class="aviso aviso--nota">
|
|
<span class="tira-demo" style="margin-right:8px">Datos demo</span>
|
|
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.'}
|
|
</div>`;
|
|
|
|
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: `<div class="pila pila--ancha">
|
|
${heroe}
|
|
${avisoDemo}
|
|
<div>${filtros}<div class="pila" id="lista-clases">${bloques || vacio('Todavía no hay clases', 'Cuando se registren los links de Meet, aparecerán aquí agrupados por programa.')}</div></div>
|
|
</div>`,
|
|
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) => `
|
|
<article class="placa">
|
|
<div class="placa__ceja"><span class="ceja">${esc(s)}</span></div>
|
|
${articulos.filter((a) => a.seccion === s).map((a) => `
|
|
<details class="acordeon">
|
|
<summary>${esc(a.pregunta)}</summary>
|
|
<div class="acordeon__cuerpo">
|
|
<p class="respeta-renglones">${esc(a.respuesta)}</p>
|
|
<div class="acciones-edicion">
|
|
<span class="ceja">${esc(a.fuente)}</span>
|
|
<button class="boton boton--fantasma boton--chico" type="button" data-copiar="${esc(a.respuesta)}" data-copiar-aviso="Respuesta copiada">${icono('copiar')}Copiar respuesta</button>
|
|
${puedeEditar ? btnEditar(`data-editar-pregunta="${a.id}"`) : ''}
|
|
</div>
|
|
</div>
|
|
</details>`).join('')}
|
|
</article>`).join('');
|
|
}
|
|
|
|
const heroe = `
|
|
<section class="respuesta">
|
|
<span class="ceja">Para qué sirve esta sección</span>
|
|
<h2>Aquí está <span class="marcado"><span>la respuesta correcta</span></span>, ya redactada.</h2>
|
|
<p>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.</p>
|
|
</section>`;
|
|
|
|
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: `<div class="pila pila--ancha">
|
|
${heroe}
|
|
<div class="campo">
|
|
<label for="buscar">Buscar una pregunta</label>
|
|
<input type="search" id="buscar" placeholder="beca, RVOE, en línea, referidos, titulación…" autocomplete="off">
|
|
</div>
|
|
<div class="pila" id="resultados">${pintarLista(d.articulos)}</div>
|
|
</div>`,
|
|
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 = `<div class="aviso aviso--error">${esc(err.message)}</div>`;
|
|
}
|
|
}, 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 = `
|
|
<section class="respuesta">
|
|
<span class="ceja">Cómo se usan</span>
|
|
<h2>Copiar, cambiar el nombre, enviar. <span class="marcado"><span>Nunca once mensajes seguidos.</span></span></h2>
|
|
<p>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.</p>
|
|
</section>`;
|
|
|
|
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: `<div class="pila pila--ancha">
|
|
${heroe}
|
|
<div>
|
|
<div class="filtros" id="filtros">
|
|
<button class="filtro" type="button" data-cat="" aria-pressed="true">Todas</button>
|
|
${d.categorias.map((c) => `<button class="filtro" type="button" data-cat="${esc(c)}" aria-pressed="false">${esc(c)}</button>`).join('')}
|
|
</div>
|
|
<div class="rejilla rejilla--2 rejilla--arriba" id="lista">
|
|
${d.guiones.map((g) => `
|
|
<article class="placa" data-cat="${esc(g.categoria)}">
|
|
<div class="placa__ceja"><span class="ceja">${esc(g.categoria)}</span></div>
|
|
<h4>${esc(g.titulo)}</h4>
|
|
${plegable(`mensaje-${g.id}`)}
|
|
<summary>Ver el texto completo</summary>
|
|
<div class="plegable__cuerpo">
|
|
<div class="mensaje-listo">${esc(g.cuerpo)}</div>
|
|
</div>
|
|
</details>
|
|
<div class="acciones-edicion">
|
|
<span class="ceja">${esc(g.canal)}</span>
|
|
<button class="boton boton--chico" type="button" data-copiar="${esc(g.cuerpo)}" data-copiar-aviso="Mensaje copiado">${icono('copiar')}Copiar mensaje</button>
|
|
${puedeEditar ? btnEditar(`data-editar-mensaje="${g.id}"`) : ''}
|
|
</div>
|
|
</article>`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>`,
|
|
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 = `
|
|
<section class="respuesta">
|
|
<span class="ceja">Lo que se dicta por teléfono</span>
|
|
<h2>Av. Guadalupe Victoria <span class="marcado"><span>1409-d</span></span>, Prados Verdes, 58110.</h2>
|
|
<p>El volante impreso del programa de referidos trae 1409-A y CP 58114. Esa dirección no es la sede.
|
|
La correcta es esta.</p>
|
|
<div class="respuesta__acciones">
|
|
<button class="boton boton--marca" type="button" data-copiar="${esc(dir)}" data-copiar-aviso="Dirección copiada">Copiar dirección</button>
|
|
<button class="boton" type="button" data-copiar="${esc(wa)}" data-copiar-aviso="WhatsApp copiado">Copiar WhatsApp</button>
|
|
</div>
|
|
</section>`;
|
|
|
|
const grupos = d.grupos.map((g) => `
|
|
<article class="placa">
|
|
<div class="placa__ceja"><span class="ceja">${esc(g.titulo)}</span></div>
|
|
${g.campos.map((c) => `
|
|
<div style="padding:13px 0;border-bottom:1px solid var(--linea-suave)">
|
|
<div class="fila fila--sep" style="align-items:flex-start">
|
|
<span class="crece">
|
|
<span class="ceja" style="display:block;margin-bottom:4px">${esc(c.etiqueta)}</span>
|
|
<p class="respeta-renglones">${esc(c.valor)}</p>
|
|
</span>
|
|
<span class="fila" style="flex:none;gap:7px">
|
|
<button class="boton boton--fantasma boton--chico" type="button" aria-label="Copiar ${esc(c.etiqueta)}" data-copiar="${esc(c.valor)}" data-copiar-aviso="${esc(c.etiqueta)} copiado">${icono('copiar')}</button>
|
|
${puedeEditar ? `<button class="boton boton--fantasma boton--chico" type="button" data-editar-dato="${c.id}" aria-label="Editar ${esc(c.etiqueta)}">${icono('lapiz')}</button>` : ''}
|
|
</span>
|
|
</div>
|
|
</div>`).join('')}
|
|
</article>`).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: `<div class="pila pila--ancha">${heroe}<div class="rejilla rejilla--2 rejilla--arriba">${grupos}</div></div>`,
|
|
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 = `
|
|
<section class="respuesta">
|
|
<span class="ceja">Cómo funcionan los accesos</span>
|
|
<h2>El menú y el servidor usan <span class="marcado"><span>la misma regla</span></span>.</h2>
|
|
<p>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.</p>
|
|
</section>`;
|
|
|
|
const tarjetas = d.roles.map((r) => `
|
|
<article class="placa placa--acento" style="--acento:${esc(r.color)}">
|
|
<div class="placa__ceja">
|
|
<span class="ceja">Nivel ${esc(r.nivel)}</span>
|
|
${insignia(`${(d.porRol[r.clave] ?? []).length} permisos`, r.color)}
|
|
</div>
|
|
<h3>${esc(r.nombre)}</h3>
|
|
<p class="tenue" style="margin-top:8px">${esc(r.descripcion)}</p>
|
|
<div class="etiquetas" style="margin-top:12px">
|
|
${d.usuarios.filter((u) => u.rol_clave === r.clave).map((u) => insignia(u.nombre, r.color)).join('')}
|
|
</div>
|
|
</article>`).join('');
|
|
|
|
const directorio = `
|
|
<article class="placa">
|
|
<div class="placa__ceja">
|
|
<span class="ceja">Directorio interno</span>
|
|
<span class="tira-demo">Usuarios demo</span>
|
|
</div>
|
|
<div class="envoltura-tabla">
|
|
<table class="tabla tabla--apila">
|
|
<thead><tr><th>Persona</th><th>Puesto</th><th>Rol</th><th>Extensión</th><th>Último acceso</th></tr></thead>
|
|
<tbody>${d.usuarios.map((u) => `<tr>
|
|
<td data-col="Persona"><span class="fila" style="gap:10px">${ficha(u.iniciales, u.rol_color, { chica: true })}<span><b>${esc(u.nombre)}</b><span class="mini tenue" style="display:block">${esc(u.email)}</span></span></span></td>
|
|
<td data-col="Puesto">${esc(u.puesto)}</td>
|
|
<td data-col="Rol">${insignia(u.rol_nombre, u.rol_color)}</td>
|
|
<td data-col="Extensión"><span class="dato">${esc(u.extension || '—')}</span></td>
|
|
<td data-col="Último acceso"><span class="mini tenue">${u.ultimo_acceso ? fechaHora(u.ultimo_acceso) : 'Nunca'}</span></td>
|
|
</tr>`).join('')}</tbody>
|
|
</table>
|
|
</div>
|
|
</article>`;
|
|
|
|
const matriz = `
|
|
<article class="placa">
|
|
<div class="placa__ceja"><span class="ceja">Qué sección ve cada rol</span></div>
|
|
<p class="mini tenue solo-movil" style="margin-bottom:10px">Desliza la tabla hacia los lados para ver los tres roles.</p>
|
|
<div class="envoltura-tabla">
|
|
<table class="matriz">
|
|
<thead><tr><th>Sección</th>${d.roles.map((r) => `<th>${esc(r.nombre.split(' ')[0])}</th>`).join('')}</tr></thead>
|
|
<tbody>${d.secciones.map((s) => `<tr>
|
|
<td>${esc(s.titulo)}</td>
|
|
${d.roles.map((r) => {
|
|
const tiene = s.permisos.some((p) => (d.porRol[r.clave] ?? []).includes(p));
|
|
return `<td>${tiene ? '<span class="marca-si" title="Sí puede">●</span><span class="solo-lectores">Sí</span>' : '<span class="marca-no" title="No puede">○</span><span class="solo-lectores">No</span>'}</td>`;
|
|
}).join('')}
|
|
</tr>`).join('')}</tbody>
|
|
</table>
|
|
</div>
|
|
</article>`;
|
|
|
|
const permisos = `
|
|
<article class="placa">
|
|
<div class="placa__ceja"><span class="ceja">Los ${d.permisos.length} permisos, uno por uno</span></div>
|
|
${plegable('equipo-permisos')}
|
|
<summary>Ver el detalle permiso por permiso</summary>
|
|
<div class="plegable__cuerpo">
|
|
<div class="envoltura-tabla">
|
|
<table class="tabla tabla--apila">
|
|
<thead><tr><th>Permiso</th>${d.roles.map((r) => `<th>${esc(r.nombre.split(' ')[0])}</th>`).join('')}</tr></thead>
|
|
<tbody>${d.permisos.map((p) => `<tr>
|
|
<td data-col="Permiso"><b>${esc(p.descripcion)}</b><span class="dato mini tenue" style="display:block">${esc(p.clave)}</span></td>
|
|
${d.roles.map((r) => `<td data-col="${esc(r.nombre)}" style="text-align:center">${(d.porRol[r.clave] ?? []).includes(p.clave)
|
|
? '<span class="marca-si">●</span><span class="solo-lectores">Sí</span>' : '<span class="marca-no">○</span><span class="solo-lectores">No</span>'}</td>`).join('')}
|
|
</tr>`).join('')}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</article>`;
|
|
|
|
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: `<div class="pila pila--ancha">${heroe}<div class="rejilla rejilla--3">${tarjetas}</div>${directorio}${matriz}${permisos}</div>`,
|
|
};
|
|
}
|
|
|
|
// =====================================================================
|
|
// 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 = `
|
|
<section class="respuesta">
|
|
<span class="ceja">Auditoría</span>
|
|
<h2>${cambios} ${cambios === 1 ? 'cambio' : 'cambios'} al contenido y
|
|
${denegados} ${denegados === 1 ? 'intento' : 'intentos'} de entrar
|
|
<span class="marcado"><span>sin permiso</span></span>.</h2>
|
|
<p>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.</p>
|
|
<div class="respuesta__fila">
|
|
<span class="respuesta__cifra"><b>${num(d.sesionesTotal)}</b><span>Sesiones abiertas</span></span>
|
|
<span class="respuesta__cifra"><b>${num(d.eventosTotal)}</b><span>Eventos registrados</span></span>
|
|
<span class="respuesta__cifra"><b>${denegados}</b><span>Denegados de los últimos ${d.entradas.length}</span></span>
|
|
</div>
|
|
</section>`;
|
|
|
|
const sesiones = `
|
|
<article class="placa">
|
|
<div class="placa__ceja">
|
|
<span class="ceja">Sesiones abiertas ahora</span>
|
|
${d.sesionesTotal > d.sesionesActivas.length
|
|
? insignia(`${d.sesionesActivas.length} de ${num(d.sesionesTotal)}`, 'var(--gris)')
|
|
: insignia(num(d.sesionesTotal), 'var(--gris)')}
|
|
</div>
|
|
${d.sesionesActivas.length ? `${plegable('bitacora-sesiones')}
|
|
<summary>Ver las ${d.sesionesActivas.length} sesiones</summary>
|
|
<div class="plegable__cuerpo"><div class="envoltura-tabla">
|
|
<table class="tabla tabla--apila">
|
|
<thead><tr><th>Persona</th><th>Rol</th><th>Desde</th><th>Vence</th><th>Origen</th></tr></thead>
|
|
<tbody>${d.sesionesActivas.map((s) => `<tr>
|
|
<td data-col="Persona"><span class="fila" style="gap:10px">${ficha(s.iniciales, null, { chica: true })}<b>${esc(s.nombre)}</b></span></td>
|
|
<td data-col="Rol"><span class="mini tenue">${esc(s.rol)}</span></td>
|
|
<td data-col="Desde"><span class="dato mini">${fechaHora(s.creada_en)}</span></td>
|
|
<td data-col="Vence"><span class="dato mini">${fechaHora(s.expira_en)}</span></td>
|
|
<td data-col="Origen"><span class="dato mini">${esc(s.ip_origen || '—')}</span></td>
|
|
</tr>`).join('')}</tbody>
|
|
</table>
|
|
</div></div></details>` : '<p class="tenue">No hay sesiones abiertas.</p>'}
|
|
</article>`;
|
|
|
|
const enlaces = `
|
|
<article class="placa">
|
|
<div class="placa__ceja"><span class="ceja">Enlaces de acceso generados</span></div>
|
|
${plegable('bitacora-enlaces')}
|
|
<summary>Ver los últimos ${d.enlaces.length} enlaces</summary>
|
|
<div class="plegable__cuerpo"><div class="envoltura-tabla">
|
|
<table class="tabla tabla--apila">
|
|
<thead><tr><th>Para</th><th>Generado</th><th>Vence</th><th>Estado</th></tr></thead>
|
|
<tbody>${d.enlaces.map((t) => `<tr>
|
|
<td data-col="Para"><b>${esc(t.nombre)}</b></td>
|
|
<td data-col="Generado"><span class="dato mini">${fechaHora(t.creado_en)}</span></td>
|
|
<td data-col="Vence"><span class="dato mini">${fechaHora(t.expira_en)}</span></td>
|
|
<td data-col="Estado">${t.usado_en ? insignia('Usado', 'var(--bien)', { punto: true }) : insignia('Sin usar', 'var(--gris-claro)')}</td>
|
|
</tr>`).join('')}</tbody>
|
|
</table>
|
|
</div></div></details>
|
|
</article>`;
|
|
|
|
const eventos = `
|
|
<article class="placa">
|
|
<div class="placa__ceja"><span class="ceja">Últimos ${d.entradas.length} eventos</span></div>
|
|
${plegable('bitacora-eventos')}
|
|
<summary>Ver los ${d.entradas.length} eventos</summary>
|
|
<div class="plegable__cuerpo">
|
|
<div class="envoltura-tabla">
|
|
<table class="tabla tabla--apila">
|
|
<thead><tr><th>Cuándo</th><th>Quién</th><th>Qué hizo</th><th>Resultado</th><th>Detalle</th></tr></thead>
|
|
<tbody>${d.entradas.map((e) => `<tr>
|
|
<td data-col="Cuándo"><span class="dato mini">${fechaHora(e.creado_en)}</span></td>
|
|
<td data-col="Quién"><b>${esc(e.actor)}</b>${e.rol ? `<span class="mini tenue" style="display:block">${esc(e.rol)}</span>` : ''}</td>
|
|
<td data-col="Qué hizo"><b>${esc(accionLegible(e.accion))}</b><span class="dato mini tenue" style="display:block">${esc(e.accion)}${e.entidad ? ` · ${esc(e.entidad)} ${esc(e.entidad_id)}` : ''}</span></td>
|
|
<td data-col="Resultado">${e.resultado === 'ok' ? insignia('Bien', 'var(--bien)') : insignia('Denegado', 'var(--alerta)', { punto: true })}</td>
|
|
<td data-col="Detalle"><span class="mini tenue">${esc(e.detalle)}</span></td>
|
|
</tr>`).join('')}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</article>`;
|
|
|
|
return {
|
|
ceja: 'Solo dirección',
|
|
titulo: 'Bitácora',
|
|
resumen: 'Quién entró, qué cambió del contenido y qué intentó abrir sin permiso.',
|
|
cuerpo: `<div class="pila pila--ancha">${heroe}${sesiones}${enlaces}${eventos}</div>`,
|
|
};
|
|
}
|