// ============ AcademIA8 · Consola — pantallas ============
// util fechas legibles
function fmtDate(s){
if(!s) return '—';
const [y,m,d] = s.split('-');
const M = ['ene','feb','mar','abr','may','jun','jul','ago','sep','oct','nov','dic'];
return `${d} ${M[+m-1]} ${y}`;
}
/* ===================================================================
1 · LISTA DE CURSOS
=================================================================== */
function CourseList({ courses, actions, loadError, onRetry }){
const [q, setQ] = useState('');
const [filter, setFilter] = useState('todos');
const statusOf = (c)=> A8.estadoToStatus(c.derivedEstado);
const counts = {
todos: courses.length,
dirty: courses.filter(c=>statusOf(c)==='published-dirty').length,
draft: courses.filter(c=>statusOf(c)==='draft-only').length,
pub: courses.filter(c=>statusOf(c)==='published-clean').length,
};
let shown = courses.filter(c=>{
const nm = (c.meta_draft?.name||'');
return nm.toLowerCase().includes(q.toLowerCase());
});
if (filter==='dirty') shown = shown.filter(c=>statusOf(c)==='published-dirty');
if (filter==='draft') shown = shown.filter(c=>statusOf(c)==='draft-only');
if (filter==='pub') shown = shown.filter(c=>statusOf(c)==='published-clean');
shown = shown.slice().sort((a,b)=> (a.meta_draft?.name||'').localeCompare(b.meta_draft?.name||''));
return (
Autoría de contenido
Cursos WhatsApp
Crear curso
{loadError && (
No se pudo cargar la lista de cursos: {loadError}
Reintentar
)}
{shown.length===0 ? (
No hay cursos que coincidan
Probá con otro filtro o creá un curso nuevo para empezar a trabajar en su borrador.
) : (
{shown.map(c=>{
const st = statusOf(c);
const name = c.meta_draft?.name || 'Sin nombre';
const desc = c.meta_draft?.description || '';
const nUnits = c._loaded && c.draft ? c.draft.units.length : null;
return (
actions.openCourse(c.id)}>
{name}
{desc &&
{desc}
}
{nUnits===null ? 'Abrir para ver el borrador' : `${nUnits} ${nUnits===1?'unidad':'unidades'} en el borrador`}
);
})}
)}
);
}
/* ===================================================================
2 · EDITOR DE CURSO
=================================================================== */
function CourseEditor({ course, view='borrador', actions }){
const [modal, setModal] = useState(null); // {type, data}
const [tab, setTab] = useState('curso'); // 'curso' | 'alumnos' | 'rag'
const estado = course.derivedEstado; // solo-borrador | publicado-limpio | publicado-con-cambios
const meta = course.meta_draft;
const hasChanges = estado==='solo-borrador' || estado==='publicado-con-cambios';
// Vista PUBLICADO: snapshot estrictamente solo-lectura de lo que ven los alumnos.
if (view === 'publicado'){
return ;
}
return (
{/* volver */}
Volver a cursos
{/* encabezado */}
{meta?.name || 'Sin nombre'}
setModal({type:'duplicate'})}>Duplicar
{/* pestañas del curso */}
setTab('curso')}>
Curso
{window.FEATURES.enrollment && (
setTab('alumnos')}>
Alumnos
)}
{window.FEATURES.rag && (
setTab('rag')}>
Conocimiento del agente
)}
{tab==='rag' ? (
) : tab==='alumnos' ? (
) : (
<>
{/* METADATA */}
Metadata del curso
setModal({type:'meta'})}>Editar metadata
{window.FEATURES.reminders && } icon="clock" />}
{window.FEATURES.postAccess && } icon="chat" />}
{/* CONTENIDO · organizador del borrador */}
{/* ---- publicar (según estado derivado del backend) ---- */}
{hasChanges && (
{estado==='publicado-con-cambios' ? 'Tenés cambios sin publicar' : 'Este curso todavía no se publicó'}
{estado==='publicado-con-cambios'
? 'El alumno sigue viendo la última versión publicada. Tus ediciones no le llegan hasta que publiques.'
: 'Nada de lo que ves acá es visible para el alumno hasta la primera publicación.'}
actions.openPublish(course.id)}>Ver diferencias
actions.openPublish(course.id)}>Publicar
)}
{estado==='publicado-limpio' && (
Borrador y publicado están sincronizados: el alumno ve exactamente este contenido.
)}
{/* ---- borrar curso (permitido solo si no está publicado; el backend lo valida) ---- */}
{!course.published && (
Borrar este curso
Se elimina el borrador de forma definitiva. No se puede borrar un curso ya publicado.
setModal({type:'del-course'})}>Borrar curso
)}
>
)}
{/* ---- modales ---- */}
{modal?.type==='meta' && (
setModal(null)}
onSave={(m)=>{ actions.updateMeta(course.id, m); setModal(null); }} />
)}
{modal?.type==='del-course' && (
setModal(null)}
onConfirm={()=>{ actions.deleteCourse(course.id); setModal(null); }} />
)}
{modal?.type==='duplicate' && (
setModal(null)}
onSave={async (destino, titulo)=>{
const ok = await actions.duplicateCourse(course.id, destino, titulo);
if (ok) setModal(null);
}} />
)}
);
}
/* ---------- Modal: duplicar curso ---------- */
function DuplicateCourseForm({ course, onClose, onSave }){
const [destino, setDestino] = useState('');
const [titulo, setTitulo] = useState((course.meta_draft?.name || '') + ' (copia)');
const [busy, setBusy] = useState(false);
const slugOk = /^[a-z0-9-]+$/.test(destino.trim());
const ok = slugOk && titulo.trim();
const submit = async ()=>{
if (!ok || busy) return;
setBusy(true);
try { await onSave(destino.trim(), titulo.trim()); }
finally { setBusy(false); }
};
return (
Cancelar
{busy?'Duplicando…':'Duplicar'} >}>
setDestino(e.target.value)} placeholder="fundamentos-ia-v2" />
setTitulo(e.target.value)} placeholder="Título del curso nuevo" />
);
}
/* ---------- Vista PUBLICADO (solo lectura: lo que ven los alumnos hoy) ---------- */
function PublishedReadOnly({ course, onBack, estado }){
const pub = course.publishedFront;
const [expanded, setExpanded] = useState(()=>new Set());
const toggleExp = (id)=>setExpanded(s=>{ const n=new Set(s); n.has(id)?n.delete(id):n.add(id); return n; });
if (!pub){
return (
Volver a cursos
Cargando lo publicado…
Trayendo el snapshot que ven los alumnos.
);
}
const meta = pub.meta_draft;
const units = pub.draft.units;
const allOpen = units.length>0 && units.every(u=>expanded.has(u.id));
const toggleAll = ()=>setExpanded(()=> allOpen ? new Set() : new Set(units.map(u=>u.id)));
return (
Volver a cursos
{meta?.name || 'Sin nombre'}
{/* indicador claro de solo-lectura */}
Estás viendo lo publicado : exactamente lo que los alumnos reciben hoy por WhatsApp. Es de solo lectura . Cambiá a Borrador en el selector de arriba para editar.
{/* METADATA (solo lectura) */}
Metadata del curso
{/* UNIDADES (solo lectura, sin bandeja, sin acciones) */}
Unidades
{units.length} en orden
{units.length>0 &&
{allOpen?'Contraer todas':'Expandir todas'} }
{units.length===0 ? (
Sin unidades publicadas
Este curso todavía no tiene contenido visible para el alumno.
) : (
{units.map((u,i)=>{
const cnt = A8.countUnitContent(u);
const isOpen = expanded.has(u.id);
return (
toggleExp(u.id)}>
{i+1}
toggleExp(u.id)}>
{u.title}
{cnt.frags} {cnt.frags===1?'contenido':'contenidos'}
{cnt.chks} {cnt.chks===1?'cuestionario':'cuestionarios'}
{isOpen &&
}
);
})}
)}
);
}
/* ---------- Confirmar borrado de curso (exige escribir "confirmar") ---------- */
function ConfirmDeleteCourse({ course, onClose, onConfirm }){
const [txt, setTxt] = useState('');
const name = course.meta_draft?.name || course.meta_pub?.name || 'Sin nombre';
const ok = txt.trim().toLowerCase() === 'confirmar';
return (
Cancelar
{ if(ok) onConfirm(); }}>Borrar “{name}”
>}>
Vas a borrar “{name}” con su borrador{course.published?', su versión publicada':''} y todas sus unidades, fragmentos y cuestionarios.
Para confirmar, escribí confirmar abajo
setTxt(e.target.value)}
onKeyDown={e=>{ if(e.key==='Enter' && ok) onConfirm(); }} />
);
}
/* ---------- Pestaña: Alumnos (datos reales, arq_05-adyacente) ---------- */
function AlumnosPanel({ course }){
const [rows, setRows] = useState(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [addOpen, setAddOpen] = useState(false);
const [busyId, setBusyId] = useState(null);
const load = async ()=>{
setLoading(true); setErr(null);
try { setRows(await A8Api.listAlumnos(course.id)); }
catch(e){ setErr(e.message || String(e)); }
finally { setLoading(false); }
};
useEffect(()=>{ load(); }, [course.id]);
const toggle = async (row)=>{
setBusyId(row.alumno_id);
try {
if (row.estado === 'activa') await A8Api.pausarAlumno(course.id, row.alumno_id);
else await A8Api.reactivarAlumno(course.id, row.alumno_id);
await load();
} catch(e){ setErr(e.message || String(e)); }
finally { setBusyId(null); }
};
return (
Los alumnos inscriptos reciben el contenido del curso por WhatsApp. Pausar corta la
entrega y los recordatorios sin perder el progreso; reactivar retoma donde quedó.
Alumnos inscriptos
{rows ? rows.length : '—'}
setAddOpen(true)}>Inscribir alumno
{err && (
{err}
)}
{loading ? (
) : rows && rows.length>0 ? (
{rows.map(r=>(
{r.telefono_enmascarado} {r.alumno_id}
{r.estado==='activa'?'Activa':'Pausada'}
toggle(r)}>
{busyId===r.alumno_id ? '…' : (r.estado==='activa'?'Pausar':'Reactivar')}
Unidad {r.unidad_actual ?? '—'} · máx. desbloqueada {r.posicion_maxima_desbloqueada ?? '—'} · desde {fmtDate(r.fecha_inicio ? r.fecha_inicio.slice(0,10) : null)}
))}
) : (
Todavía no hay alumnos inscriptos. Inscribí el primero para que empiece a recibir el contenido por WhatsApp.
)}
{addOpen && (
setAddOpen(false)}
onSave={async (phone)=>{
await A8Api.inscribirAlumno(course.id, phone);
setAddOpen(false);
await load();
}} />
)}
);
}
/* ---------- Form: inscribir alumno (mismo camino que el CLI de enrolamiento) ---------- */
function AddStudentForm({ onClose, onSave }){
const [phone, setPhone] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
const ok = phone.trim().length >= 8;
const submit = async ()=>{
if (!ok || busy) return;
setBusy(true); setErr(null);
try { await onSave(phone.trim()); }
catch(e){ setErr(e.message || String(e)); }
finally { setBusy(false); }
};
return (
Cancelar {busy?'Inscribiendo…':'Inscribir'} >}>
{err && {err}
}
setPhone(e.target.value)} placeholder="+5491112345678" onKeyDown={e=>{ if(e.key==='Enter'&&ok) submit(); }} />
);
}
function MetaField({ k, v, icon, long, changed, full }){
return (
{icon && }{k}{changed && editado }
{v}
);
}
/* ---------- Panel desplegable de una unidad (preview de contenido, solo lectura) ---------- */
function UnitPreview({ u }){
const items = u.items || [];
const frags = items.filter(i=>i.kind==='frag');
const chks = items.filter(i=>i.kind==='chk');
return (
Contenido {frags.length}
{frags.length===0
?
Sin contenido
: frags.map(fr=>{
const t = (fr.text||'').trim();
const label = fr.title || (t ? (t.length>70 ? t.slice(0,70)+'…' : t) : '(texto vacío)');
return (
{label}
);
})}
Cuestionarios {chks.length}
{chks.length===0
?
Sin cuestionarios
: chks.map(c=>(
{c.question}
{c.options.length>0 && {c.options.length} opc. }
))}
);
}
/* ---------- Form: metadata del curso ---------- */
function CourseMetaForm({ meta, cursoId, onClose, onSave }){
const [f, setF] = useState({ ...meta });
const up = (k)=>(e)=>setF({ ...f, [k]:e.target.value });
const mediaOn = window.FEATURES.mediaNonText;
// Media real adjunta a la bienvenida (video, uploader v2) — mismo mecanismo
// que en el organizador de contenido: presigned PUT + progreso via XHR.
const [uploadPct,setUploadPct] = useState(f.welcomeMediaS3Key ? 100 : 0);
const [uploadErr,setUploadErr] = useState(null);
const [compressionStatus,setCompressionStatus] = useState(null);
const [compressionInfo,setCompressionInfo] = useState(null);
const welcomeFileRef = useRef(null);
async function uploadWelcomeFile(file){
const c = classify(file);
if (!c.ok) { setUploadErr(c.reason); return; }
setUploadErr(null); setUploadPct(0); setCompressionStatus(null); setCompressionInfo(null);
try {
const { upload_url, s3_key, tipo, content_type } = await A8Api.getMediaUploadUrl(cursoId, {
filename: file.name, sizeBytes: file.size, unidadId: null,
});
await A8Api.uploadFileToS3(upload_url, file, content_type, setUploadPct);
setF(prev=>({ ...prev, welcomeMediaS3Key: s3_key, welcomeMediaTipo: tipo }));
setUploadPct(100);
if(tipo==='video' && file.size > VIDEO_COMPRESS_THRESHOLD_MB*MB){
setCompressionStatus('procesando');
A8Api.pollMediaStatus(cursoId, s3_key, (st)=>{ setCompressionStatus(st.status); setCompressionInfo(st); });
}
} catch(e){
setUploadErr((e&&e.message)||'Error al subir');
}
}
return (
Cancelar onSave(f)}>Guardar en borrador >}>
{mediaOn && (
Video (u otro adjunto) de bienvenida opcional >}
hint={f.welcomeComoCaption
? "Se fusiona con el texto de bienvenida en un solo mensaje (el texto viaja como caption)."
: "Se manda como adjunto real de WhatsApp, después del texto de bienvenida (dos mensajes)."}>
{ if(e.target.files[0]) uploadWelcomeFile(e.target.files[0]); e.target.value=''; }} />
welcomeFileRef.current&&welcomeFileRef.current.click()}>
{f.welcomeMediaS3Key ? 'Reemplazar archivo' : 'Elegir o arrastrar archivo'}
{uploadPct>0 && uploadPct<100 && (
)}
{f.welcomeMediaS3Key && uploadPct>=100 && !uploadErr && (
Archivo subido ({f.welcomeMediaS3Key.split('/').pop()})
)}
{compressionStatus && (
)}
{uploadErr && (
{uploadErr}
)}
{f.welcomeMediaS3Key && f.welcomeMediaTipo!=='audio' && (
setF({ ...f, welcomeComoCaption:e.target.checked })} />
Usar el texto de bienvenida como caption (un solo mensaje)
)}
{f.welcomeComoCaption && f.welcome && f.welcome.length>1024 && (
El texto de bienvenida tiene {f.welcome.length} caracteres, supera el límite de 1024 para caption.
)}
)}
{mediaOn && f.welcomeMediaS3Key && (
Frenar acá y esperar cualquier respuesta antes de seguir
Siempre activo en la bienvenida: el guion nunca arranca hasta que el alumno responda algo.
)}
Diaria · obligatoria
{window.FEATURES.reminders && (
setF({ ...f, reminders:rs })} />
)}
{window.FEATURES.postAccess && (
setF({ ...f, postAccess:pa })} />
)}
);
}
/* ---------- Recordatorios: display + editor ---------- */
function RemindersDisplay({ list }){
if (!list || !list.length) return Sin recordatorios ;
const sorted = list.slice().sort((a,b)=>a.days-b.days);
return (
{sorted.map((r,i)=>{
const t = A8.reminderTpl(r.templateId);
return (
Tras {r.days} {r.days===1?'día':'días'}
{t?.label || '— sin mensaje'}
);
})}
);
}
function RemindersEditor({ value, onChange }){
const list = value;
const setRow = (i, patch)=>{ const n=list.map((r,j)=>j===i?{...r,...patch}:r); onChange(n); };
const addRow = ()=>{ if(list.length>=6) return; onChange([...list, { id:A8.uid('r'), days:(list.length?Math.max(...list.map(r=>r.days))+7:3), templateId:'t1' }]); };
const delRow = (i)=> onChange(list.filter((_,j)=>j!==i));
return (
{list.length===0 &&
Todavía no hay recordatorios. El alumno no recibe ningún aviso.
}
{list.map((r,i)=>{
return (
);
})}
{list.length<6 && (
Agregar recordatorio
)}
);
}
/* ---------- Acceso al asistente tras finalizar: display + editor ---------- */
function PostAccessDisplay({ pa }){
const p = A8.normPostAccess(pa);
const icon = p.mode==='always' ? 'infinity' : p.mode==='limited' ? 'clock' : 'x';
return (
{A8.fmtPostAccess(pa)}
);
}
function PostAccessEditor({ value, onChange }){
const p = A8.normPostAccess(value);
const set = (patch)=>onChange({ ...p, ...patch });
return (
{A8.POST_ACCESS_MODES.map(m=>(
set({ mode:m.id })}>
{m.label}
))}
{p.mode==='limited' && (
)}
{p.mode==='none' && 'Al emitir el certificado, la conversación queda cerrada. El alumno ya no puede escribirle al asistente.'}
{p.mode==='limited' && `El alumno puede seguir consultando al asistente durante ${p.days} ${p.days===1?'día':'días'} después de recibir el certificado. Después se cierra automáticamente.`}
{p.mode==='always' && 'El alumno conserva el acceso al asistente de forma indefinida, incluso después de certificarse.'}
);
}
/* ---------- Form: unidad (crear / renombrar) ---------- */
function UnitForm({ initial, onClose, onSave }){
const [title, setTitle] = useState(initial || '');
const editing = initial!==undefined;
return (
Cancelar onSave(title.trim())}>{editing?'Guardar':'Agregar unidad'} >}>
setTitle(e.target.value)} onKeyDown={e=>{ if(e.key==='Enter'&&title.trim()) onSave(title.trim()); }} placeholder="Nombre de la unidad" />
);
}
window.Screens = { CourseList, CourseEditor, UnitForm, fmtDate };
Object.assign(window, { CourseList, CourseEditor, UnitForm, CourseMetaForm, MetaField, UnitPreview, RemindersDisplay, RemindersEditor, fmtDate });