);
}
// Agrupación visual por burst de WhatsApp (vista, no cambia entrega):
// `groupBursts`/`itemMessageCount`/`FRENO_LABEL` vienen de console/burst.js
// (lógica pura sin JSX, testeada en tests/front/burst_grouping.test.mjs —
// espeja compute_segment en src/orchestrator/guion.py:130-158).
function OgItemList({ items, loc, empty, over, dnd, onRename, onEdit, onDelete, unitReadonly }){
const zoneOver = over && over.loc===loc;
// La agrupación solo tiene sentido sobre el guion real de una unidad: la
// bandeja no tiene orden de entrega (ítems sin ubicar), y las unidades
// importadas no traen esperaRespuesta/textoComoCaption (ver Fase A).
const canGroup = loc!==TRAY_LOC && !unitReadonly;
const bursts = canGroup ? groupBursts(items) : null;
const renderItem = (it, i) => (
onRename(loc,it.id,patch)} onEdit={()=>onEdit(loc,it)} onDelete={()=>onDelete(loc,it.id)} />
);
let body;
if (bursts){
let idx = 0;
body = bursts.map((g,gi)=>{
const rendered = g.items.map((it)=>renderItem(it, idx++));
const n = g.items.reduce((s,it)=>s+itemMessageCount(it), 0);
return (
);
}
// ---------------- MODO TEXTO ----------------
if(mode==='texto'){
return (
CancelarGuardar en borrador>}>
{!editing && }
Espera libre El guion frena después de este paso y espera cualquier mensaje del alumno para seguir — sin pregunta, sin evaluación. Útil para bienvenidas o cierres parciales.>}>
);
}
// ---------------- MODO CUESTIONARIO ----------------
if(mode==='cuestionario'){
const canSave = tipoPregunta==='abierta'
? (q.trim() && rubrica.trim())
: (q.trim() && opts.filter(o=>o.trim()).length>=2);
return (
CancelarGuardar en borrador>}>
{!editing && }
{s3Key ? 'Reemplazar archivo' : 'Elegir o arrastrar archivo'}
{rules.types.map(t=>{t})}
{uploadPct>0 && uploadPct<100 && (
Subiendo… {uploadPct}%
)}
{s3Key && uploadPct>=100 && !uploadErr && (
Archivo subido ({s3Key.split('/').pop()})
)}
{compressionStatus && (
)}
{uploadErr && (
{uploadErr}
)}
>
) : (
<>
setS3Key(e.target.value)} placeholder="curso/unidad/asset-….ext" />
>
)}
Texto del paso opcionalLo que se le enseña al alumno junto con el adjunto. Con "Usar como caption" activo, este texto viaja pegado al adjunto en un solo mensaje en vez de mandarse aparte.>}>
Caption del adjunto opcionalTexto que acompaña al adjunto cuando se envía por WhatsApp. Máximo 1024 caracteres. No usar junto con "Usar el texto del paso como caption" — son dos fuentes en conflicto.>}>
{editType!=='audio' && (
Usar el texto del paso como caption En vez de un mensaje de texto y después el adjunto por separado, se manda UN solo mensaje: el adjunto con el texto del paso como caption. Requiere texto ≤1024 caracteres y sin caption propio (elegí una sola fuente).>}>
{textoComoCaption && text.trim() && caption.trim() && (
Tiene texto Y caption propio: al publicar se va a rechazar, dejá solo uno.
)}
{textoComoCaption && text.length>1024 && (
El texto tiene {text.length} caracteres, supera el límite de 1024 para caption.
)}
)}
Espera libre El guion frena después de este paso y espera cualquier mensaje del alumno para seguir — sin pregunta, sin evaluación.>}>
);
}
function CourseOrganizer({ course, actions }){
const cid = course.id;
const locked = !!course.published; // curso ya publicado → estructura de unidades publicadas bloqueada
const nuevas = new Set(course.nuevasUnitIds||[]); // unidades del borrador aún no publicadas
const unitLocked = (u)=> locked && !nuevas.has(u.id); // unidad ya publicada: no se borra ni reordena
const units = course.draft.units;
const tray = course.draft.tray || { items:[] };
const [open,setOpen]=useState(()=>new Set(units.map(u=>u.id)));
const [over,setOver]=useState(null);
const [uover,setUover]=useState(null);
const [modal,setModal]=useState(null); // {type:'add'|'edit'|'del-unit', loc, item?}
const dragRef=useRef(null);
const isOpen=(id)=>open.has(id);
const toggle=(id)=>setOpen(s=>{ const n=new Set(s); n.has(id)?n.delete(id):n.add(id); return n; });
const allOpen = units.length>0 && units.every(u=>open.has(u.id));
const toggleAll = ()=>setOpen(()=> allOpen ? new Set() : new Set(units.map(u=>u.id)));
const dnd = {
startItem:(loc,id)=>{ dragRef.current={mode:'item',loc,id}; },
end:()=>{ dragRef.current=null; setOver(null); setUover(null); },
onOver:(loc,index)=>{ const d=dragRef.current; if(d&&d.mode==='item') setOver({loc,index}); },
onDrop:(toLoc,toIndex)=>{
const d=dragRef.current; if(!d||d.mode!=='item'){ setOver(null); return; }
const fromCont = d.loc===TRAY_LOC ? tray : units.find(u=>u.id===d.loc);
const fromIx = fromCont ? fromCont.items.findIndex(x=>x.id===d.id) : -1;
if(fromIx>=0) actions.moveItem(cid, d.loc, fromIx, toLoc, toIndex);
dragRef.current=null; setOver(null);
},
};
const startUnit=(id)=>{ dragRef.current={mode:'unit',id}; };
const onUnitOver=(i)=>{ const d=dragRef.current; if(d&&d.mode==='unit') setUover(i); };
const onUnitDrop=(toIndex)=>{
const d=dragRef.current; if(!d||d.mode!=='unit'){ setUover(null); return; }
// Camino A: en un curso publicado no se reordenan unidades (correría el progreso).
// Dejamos que el intento ocurra y damos un rechazo legible (defensa en el front;
// el backend lo rechaza igual con CaminoAError).
if(locked){ dragRef.current=null; setUover(null); actions.notify(UNIT_LOCK_MSG); return; }
const idx=units.findIndex(u=>u.id===d.id);
if(idx>=0){ const to = idx actions.renameItem(cid, loc, id, patch);
const delItem=(loc,id)=> actions.deleteItem(cid, loc, id);
const editItem=(loc,item)=> setModal({ type:'edit', loc, item });
const addContent=(loc)=> setModal({ type:'add', loc });
const trayCount=tray.items.length;
return (
{/* IZQUIERDA · unidades */}
Unidades del curso{units.length}
{units.length>0 && }
{locked && (
Este curso ya está publicado. Podés agregar unidades nuevas al final y editar el contenido de las existentes, pero no reordenar ni borrar las ya publicadas: eso correría el progreso de los alumnos.
)}
{units.map((u,ui)=>{
const cnt=A8.countUnitContent(u);
const uLock = unitLocked(u);
const uRO = !!u.readonly; // unidad importada del formato original: solo lectura en v1
return (