import { DragDropProvider } from '@dnd-kit/react' import { isSortable, useSortable } from '@dnd-kit/react/sortable' import { useParams } from '@tanstack/react-router' import { Plus, GripVertical, ChevronDown, ChevronRight, Edit3, Trash2, Clock, } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api' import type { FocusEvent, KeyboardEvent, ReactNode } from 'react' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' import { Input } from '@/components/ui/input' import { useSubject, useUpdateSubjectContenido } from '@/data/hooks/useSubjects' import { cn } from '@/lib/utils' // import { toast } from 'sonner'; export interface Tema { id: string nombre: string descripcion?: string horasEstimadas?: number } export interface UnidadTematica { id: string nombre: string numero: number temas: Array } function createClientId(prefix: string) { try { const c = (globalThis as any).crypto if (c && typeof c.randomUUID === 'function') return `${prefix}-${c.randomUUID()}` } catch { // ignore } return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}` } function arrayMove(array: Array, fromIndex: number, toIndex: number) { const next = array.slice() const startIndex = fromIndex < 0 ? next.length + fromIndex : fromIndex if (startIndex < 0 || startIndex >= next.length) return next const endIndex = toIndex < 0 ? next.length + toIndex : toIndex const [item] = next.splice(startIndex, 1) next.splice(endIndex, 0, item) return next } function renumberUnidades(unidades: Array) { return unidades.map((u, idx) => ({ ...u, numero: idx + 1 })) } function InsertUnidadOverlay({ onInsert, position, }: { onInsert: () => void position: 'top' | 'bottom' }) { return (
) } function SortableUnidad({ id, index, registerContainer, children, }: { id: string index: number registerContainer: (el: HTMLDivElement | null) => void children: (args: { handleRef: (el: HTMLElement | null) => void }) => ReactNode }) { const { ref, handleRef, isDragSource, isDropTarget } = useSortable({ id, index, }) return (
{ ref(el) registerContainer(el) }} className={cn( 'group relative', isDragSource && 'opacity-80', isDropTarget && 'ring-primary/20 ring-2', )} > {children({ handleRef })}
) } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function coerceNumber(value: unknown): number | undefined { if (typeof value === 'number' && Number.isFinite(value)) return value if (typeof value === 'string') { const trimmed = value.trim() if (!trimmed) return undefined const parsed = Number(trimmed) return Number.isFinite(parsed) ? parsed : undefined } return undefined } function coerceString(value: unknown): string | undefined { if (typeof value === 'string') return value return undefined } function mapTemaValue(value: unknown): ContenidoTemaApi | null { if (typeof value === 'string') { const trimmed = value.trim() return trimmed ? trimmed : null } if (isRecord(value)) { const nombre = coerceString(value.nombre) if (!nombre) return null const horasEstimadas = coerceNumber(value.horasEstimadas) const descripcion = coerceString(value.descripcion) return { ...value, nombre, horasEstimadas, descripcion, } } return null } function mapContenidoItem(value: unknown, index: number): ContenidoApi | null { if (!isRecord(value)) return null const unidad = coerceNumber(value.unidad) ?? index + 1 const titulo = coerceString(value.titulo) ?? 'Sin título' let temas: Array = [] if (Array.isArray(value.temas)) { temas = value.temas .map(mapTemaValue) .filter((x): x is ContenidoTemaApi => x !== null) } return { ...value, unidad, titulo, temas, } } function mapContenidoTematicoFromDb(value: unknown): Array { if (typeof value === 'string') { try { return mapContenidoTematicoFromDb(JSON.parse(value)) } catch { return [] } } if (Array.isArray(value)) { return value .map((item, idx) => mapContenidoItem(item, idx)) .filter((x): x is ContenidoApi => x !== null) } if (isRecord(value)) { if (Array.isArray(value.contenido_tematico)) { return mapContenidoTematicoFromDb(value.contenido_tematico) } if (Array.isArray(value.unidades)) { return mapContenidoTematicoFromDb(value.unidades) } } return [] } function serializeUnidadesToApi( unidades: Array, ): Array { return unidades .slice() .sort((a, b) => a.numero - b.numero) .map((u, idx) => ({ unidad: u.numero || idx + 1, titulo: u.nombre || 'Sin título', temas: u.temas.map((t) => ({ nombre: t.nombre || 'Tema', horasEstimadas: t.horasEstimadas ?? 0, descripcion: t.descripcion, })), })) } // Props del componente export function ContenidoTematico() { const updateContenido = useUpdateSubjectContenido() const { asignaturaId } = useParams({ from: '/planes/$planId/asignaturas/$asignaturaId', }) const { data: data, isLoading: isLoading } = useSubject(asignaturaId) const [unidades, setUnidades] = useState>([]) const [expandedUnits, setExpandedUnits] = useState>(new Set()) const unitContainerRefs = useRef>(new Map()) const unitTitleInputRef = useRef(null) const temaNombreInputElRef = useRef(null) const [pendingScrollUnitId, setPendingScrollUnitId] = useState( null, ) const cancelNextBlurRef = useRef(false) const [deleteDialog, setDeleteDialog] = useState<{ type: 'unidad' | 'tema' id: string parentId?: string } | null>(null) const [editingUnit, setEditingUnit] = useState(null) const [unitDraftNombre, setUnitDraftNombre] = useState('') const [unitOriginalNombre, setUnitOriginalNombre] = useState('') const [editingTema, setEditingTema] = useState<{ unitId: string temaId: string } | null>(null) const [temaDraftNombre, setTemaDraftNombre] = useState('') const [temaOriginalNombre, setTemaOriginalNombre] = useState('') const [temaDraftHoras, setTemaDraftHoras] = useState('') const [temaOriginalHoras, setTemaOriginalHoras] = useState(0) const didInitExpandedUnitsRef = useRef(false) const unidadesRef = useRef>([]) useEffect(() => { unidadesRef.current = unidades }, [unidades]) const persistUnidades = async (nextUnidades: Array) => { // A partir del primer guardado, ya respetamos lo que el usuario deje expandido. didInitExpandedUnitsRef.current = true const payload = serializeUnidadesToApi(nextUnidades) await updateContenido.mutateAsync({ subjectId: asignaturaId, unidades: payload, }) } const beginEditUnit = (unitId: string) => { const unit = unidades.find((u) => u.id === unitId) const nombre = unit?.nombre ?? '' setEditingUnit(unitId) setUnitDraftNombre(nombre) setUnitOriginalNombre(nombre) setExpandedUnits((prev) => { const next = new Set(prev) next.add(unitId) return next }) } const commitEditUnit = () => { if (!editingUnit) return const next = unidades.map((u) => u.id === editingUnit ? { ...u, nombre: unitDraftNombre } : u, ) setUnidades(next) setEditingUnit(null) void persistUnidades(next) } const cancelEditUnit = () => { setEditingUnit(null) setUnitDraftNombre(unitOriginalNombre) } const beginEditTema = (unitId: string, temaId: string) => { const unit = unidades.find((u) => u.id === unitId) const tema = unit?.temas.find((t) => t.id === temaId) const nombre = tema?.nombre ?? '' const horas = tema?.horasEstimadas ?? 0 setEditingTema({ unitId, temaId }) setTemaDraftNombre(nombre) setTemaOriginalNombre(nombre) setTemaDraftHoras(String(horas)) setTemaOriginalHoras(horas) setExpandedUnits((prev) => { const next = new Set(prev) next.add(unitId) return next }) } const parseHorasEstimadas = (raw: string): number => { const normalized = raw.trim().replace(',', '.') const parsed = Number.parseFloat(normalized) if (!Number.isFinite(parsed)) return 0 return parsed } const commitEditTema = () => { if (!editingTema) return const horasEstimadas = parseHorasEstimadas(temaDraftHoras) const next = unidades.map((u) => { if (u.id !== editingTema.unitId) return u return { ...u, temas: u.temas.map((t) => t.id === editingTema.temaId ? { ...t, nombre: temaDraftNombre, horasEstimadas } : t, ), } }) setUnidades(next) setEditingTema(null) void persistUnidades(next) } const cancelEditTema = () => { setEditingTema(null) setTemaDraftNombre(temaOriginalNombre) setTemaDraftHoras(String(temaOriginalHoras)) } const handleTemaEditorBlurCapture = (e: FocusEvent) => { if (cancelNextBlurRef.current) { cancelNextBlurRef.current = false return } const nextFocus = e.relatedTarget as Node | null if (nextFocus && e.currentTarget.contains(nextFocus)) return commitEditTema() } const handleTemaEditorKeyDownCapture = (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() if (e.target instanceof HTMLElement) e.target.blur() return } if (e.key === 'Escape') { e.preventDefault() cancelNextBlurRef.current = true cancelEditTema() if (e.target instanceof HTMLElement) e.target.blur() } } useEffect(() => { const contenido = mapContenidoTematicoFromDb( data ? data.contenido_tematico : undefined, ) // 1. EL ESCUDO: Comparamos si nuestro estado local ya tiene esta info exacta // (Esto ocurre justo después de arrastrar, ya que actualizamos la UI antes que la BD) const currentPayload = JSON.stringify( serializeUnidadesToApi(unidadesRef.current), ) // Normalizamos la data de la BD para que tenga exactamente la misma forma que el payload const incomingPayload = JSON.stringify( contenido.map((u, idx) => ({ unidad: u.unidad || idx + 1, titulo: u.titulo || 'Sin título', temas: Array.isArray(u.temas) ? u.temas.map((t) => { if (typeof t === 'string') { return { nombre: t, horasEstimadas: 0, descripcion: undefined, } } return { nombre: t.nombre || 'Tema', horasEstimadas: t.horasEstimadas ?? 0, descripcion: t.descripcion, } }) : [], })), ) // Si los datos son idénticos, abortamos el useEffect. // ¡Nuestros IDs locales se salvan y no hay parpadeos! if (currentPayload === incomingPayload && unidadesRef.current.length > 0) { return } // 2. Si llegamos aquí, es la carga inicial o alguien más editó la BD desde otro lado. // Reciclamos IDs buscando por CONTENIDO (nombre), NUNCA POR ÍNDICE. const prevUnidades = [...unidadesRef.current] const transformed = contenido.map((u, idx) => { const dbTitulo = u.titulo || 'Sin título' // Buscamos si ya existe una unidad con este mismo título const existingUnitIndex = prevUnidades.findIndex( (prev) => prev.nombre === dbTitulo, ) let unidadId let existingUnit = null if (existingUnitIndex !== -1) { existingUnit = prevUnidades[existingUnitIndex] unidadId = existingUnit.id prevUnidades.splice(existingUnitIndex, 1) // Lo sacamos de la lista para no repetirlo } else { unidadId = createClientId(`u-${u.unidad || idx + 1}`) } return { id: unidadId, numero: u.unidad || idx + 1, nombre: dbTitulo, temas: Array.isArray(u.temas) ? u.temas.map((t: any, tidx: number) => { const dbTemaNombre = typeof t === 'string' ? t : t?.nombre || 'Tema' // Reciclamos subtemas por nombre también const existingTema = existingUnit?.temas.find( (prevT) => prevT.nombre === dbTemaNombre, ) const temaId = existingTema ? existingTema.id : createClientId(`t-${u.unidad || idx + 1}-${tidx + 1}`) return { id: temaId, nombre: dbTemaNombre, horasEstimadas: coerceNumber( typeof t === 'string' ? undefined : t?.horasEstimadas, ) ?? 0, } }) : [], } }) setUnidades(transformed) setExpandedUnits((prev) => { const validIds = new Set(transformed.map((u) => u.id)) const filtered = new Set( Array.from(prev).filter((id) => validIds.has(id)), ) // Expandir la primera unidad solo una vez al llegar a la ruta. // Luego, no auto-expandimos de nuevo (aunque `data` cambie). if (!didInitExpandedUnitsRef.current && transformed.length > 0) { return filtered.size > 0 ? filtered : new Set([transformed[0].id]) } return filtered }) }, [data]) useEffect(() => { if (!editingUnit) return // Foco controlado (evitamos autoFocus por lint/a11y) setTimeout(() => unitTitleInputRef.current?.focus(), 0) }, [editingUnit]) useEffect(() => { if (!editingTema) return setTimeout(() => temaNombreInputElRef.current?.focus(), 0) }, [editingTema]) useEffect(() => { if (!pendingScrollUnitId) return const el = unitContainerRefs.current.get(pendingScrollUnitId) if (!el) return el.scrollIntoView({ behavior: 'smooth', block: 'center' }) setPendingScrollUnitId(null) }, [pendingScrollUnitId, unidades.length]) if (isLoading) return
Cargando contenido...
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos) const totalHoras = unidades.reduce( (acc, u) => acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas ?? 0), 0), 0, ) // --- Lógica de Unidades --- const toggleUnit = (id: string) => { const newExpanded = new Set(expandedUnits) newExpanded.has(id) ? newExpanded.delete(id) : newExpanded.add(id) setExpandedUnits(newExpanded) } const insertUnidadAt = (insertIndex: number) => { const newId = createClientId('u') const newUnidad: UnidadTematica = { id: newId, nombre: 'Nueva Unidad', numero: 0, temas: [], } const clampedIndex = Math.max(0, Math.min(insertIndex, unidades.length)) const next = renumberUnidades([ ...unidades.slice(0, clampedIndex), newUnidad, ...unidades.slice(clampedIndex), ]) setUnidades(next) setExpandedUnits((prev) => { const n = new Set(prev) n.add(newId) return n }) setPendingScrollUnitId(newId) setEditingUnit(newId) setUnitDraftNombre(newUnidad.nombre) setUnitOriginalNombre(newUnidad.nombre) void persistUnidades(next) } const handleReorderEnd = (event: any) => { if (event?.canceled) return const source = event?.operation?.source if (!source) return // Type-guard nativo de dnd-kit para asegurar que el elemento tiene metadata de orden if (!isSortable(source)) return // Extraemos las posiciones exactas calculadas por dnd-kit const { initialIndex, index } = source.sortable // Si lo soltó en la misma posición de la que salió, cancelamos if (initialIndex === index) return setUnidades((prev) => { // Hacemos el movimiento usando los índices directos const moved = arrayMove(prev, initialIndex, index) const next = renumberUnidades(moved) // Disparamos la persistencia hacia Supabase void persistUnidades(next).catch((err) => { console.error('No se pudo guardar el orden de unidades', err) }) return next }) } // --- Lógica de Temas --- const addTema = (unidadId: string) => { const unit = unidades.find((u) => u.id === unidadId) const unitNumero = unit?.numero ?? 0 const newTemaIndex = (unit?.temas.length ?? 0) + 1 const newTemaId = `t-${unitNumero}-${newTemaIndex}` const newTema: Tema = { id: newTemaId, nombre: 'Nuevo tema', horasEstimadas: 2, } const next = unidades.map((u) => u.id === unidadId ? { ...u, temas: [...u.temas, newTema] } : u, ) setUnidades(next) // Expandir unidad y poner el subtema en edición con foco en el nombre setExpandedUnits((prev) => { const n = new Set(prev) n.add(unidadId) return n }) setEditingTema({ unitId: unidadId, temaId: newTemaId }) setTemaDraftNombre(newTema.nombre) setTemaOriginalNombre(newTema.nombre) setTemaDraftHoras(String(newTema.horasEstimadas ?? 0)) setTemaOriginalHoras(newTema.horasEstimadas ?? 0) } const handleDelete = () => { if (!deleteDialog) return let next: Array = unidades if (deleteDialog.type === 'unidad') { next = unidades .filter((u) => u.id !== deleteDialog.id) .map((u, i) => ({ ...u, numero: i + 1 })) } else if (deleteDialog.parentId) { next = unidades.map((u) => u.id === deleteDialog.parentId ? { ...u, temas: u.temas.filter((t) => t.id !== deleteDialog.id) } : u, ) } setUnidades(next) setDeleteDialog(null) void persistUnidades(next) // toast.success("Eliminado correctamente"); } return (

Contenido Temático

{unidades.length} unidades • {totalHoras} horas estimadas totales

{unidades.map((unidad, index) => ( { if (el) unitContainerRefs.current.set(unidad.id, el) else unitContainerRefs.current.delete(unidad.id) }} > {({ handleRef }) => ( <> {index === 0 && ( insertUnidadAt(index)} /> )} insertUnidadAt(index + 1)} /> toggleUnit(unidad.id)} >
Unidad {unidad.numero} {editingUnit === unidad.id ? ( setUnitDraftNombre(e.target.value) } onBlur={() => { if (cancelNextBlurRef.current) { cancelNextBlurRef.current = false return } commitEditUnit() }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() e.currentTarget.blur() return } if (e.key === 'Escape') { e.preventDefault() cancelNextBlurRef.current = true cancelEditUnit() e.currentTarget.blur() } }} className="h-8 max-w-md bg-white" /> ) : ( beginEditUnit(unidad.id)} > {unidad.nombre} )}
{' '} {unidad.temas.reduce( (sum, t) => sum + (t.horasEstimadas || 0), 0, )} h
{unidad.temas.map((tema, idx) => ( beginEditTema(unidad.id, tema.id) } onDraftNombreChange={setTemaDraftNombre} onDraftHorasChange={setTemaDraftHoras} onEditorBlurCapture={ handleTemaEditorBlurCapture } onEditorKeyDownCapture={ handleTemaEditorKeyDownCapture } onNombreInputRef={(el) => { temaNombreInputElRef.current = el }} onDelete={() => setDeleteDialog({ type: 'tema', id: tema.id, parentId: unidad.id, }) } /> ))}
)}
))}
) } // --- Componentes Auxiliares --- interface TemaRowProps { tema: Tema index: number isEditing: boolean draftNombre: string draftHoras: string onBeginEdit: () => void onDraftNombreChange: (value: string) => void onDraftHorasChange: (value: string) => void onEditorBlurCapture: (e: FocusEvent) => void onEditorKeyDownCapture: (e: KeyboardEvent) => void onNombreInputRef: (el: HTMLInputElement | null) => void onDelete: () => void } function TemaRow({ tema, index, isEditing, draftNombre, draftHoras, onBeginEdit, onDraftNombreChange, onDraftHorasChange, onEditorBlurCapture, onEditorKeyDownCapture, onNombreInputRef, onDelete, }: TemaRowProps) { return (
{index}. {isEditing ? (
onDraftNombreChange(e.target.value)} className="h-8 flex-1 bg-white" placeholder="Nombre" /> onDraftHorasChange(e.target.value)} className="h-8 w-16 bg-white" />
) : ( <>
)}
) } interface DeleteDialogState { type: 'unidad' | 'tema' id: string parentId?: string } interface DeleteConfirmDialogProps { dialog: DeleteDialogState | null setDialog: (value: DeleteDialogState | null) => void onConfirm: () => void } function DeleteConfirmDialog({ dialog, setDialog, onConfirm, }: DeleteConfirmDialogProps) { return ( setDialog(null)}> ¿Confirmar eliminación? Estás a punto de borrar un {dialog?.type}. Esta acción no se puede deshacer. Cancelar Eliminar ) }