// ============ 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}
)}
setQ(e.target.value)} />
{[['todos','Todos'],['dirty','Con cambios'],['draft','Solo borrador'],['pub','Publicados']].map(([k,l])=>( ))}
{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 */} {/* encabezado */}
Editor de curso

{meta?.name || 'Sin nombre'}

setModal({type:'duplicate'})}>Duplicar
{/* pestañas del curso */}
{window.FEATURES.enrollment && ( )} {window.FEATURES.rag && ( )}
{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 (

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 (
Vista publicada

{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 && }
{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 (
{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.

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 ? (

Cargando alumnos…

) : 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 (
CancelaronSave(f)}>Guardar en borrador}>