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 } 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 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((t): t is ContenidoTemaApi => t !== null) } else if (typeof value.temas === 'string' && value.temas.trim()) { temas = value.temas .split(/\r?\n|,/) .map((t) => t.trim()) .filter(Boolean) } return { unidad, titulo, temas } } function mapContenidoTematicoFromDb(value: unknown): Array { if (value == null) return [] 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 persistUnidades = async (nextUnidades: Array) => { 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 commitEditTema = () => { if (!editingTema) return const parsedHoras = Number.parseInt(temaDraftHoras, 10) const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0 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, ) const transformed = contenido.map((u, idx) => ({ id: `u-${u.unidad || idx + 1}`, numero: u.unidad || idx + 1, nombre: u.titulo || 'Sin título', temas: Array.isArray(u.temas) ? u.temas.map((t: any, tidx: number) => ({ id: `t-${u.unidad || idx + 1}-${tidx + 1}`, nombre: typeof t === 'string' ? t : t?.nombre || 'Tema', horasEstimadas: t?.horasEstimadas || 0, })) : [], })) setUnidades(transformed) // Mantener las unidades ya expandidas si existen; si no, expandir la primera. setExpandedUnits((prev) => { const validIds = new Set(transformed.map((u) => u.id)) const filtered = new Set( Array.from(prev).filter((id) => validIds.has(id)), ) if (filtered.size > 0) return filtered return transformed.length > 0 ? new Set([transformed[0].id]) : new Set() }) }, [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 addUnidad = () => { const newNumero = unidades.length + 1 const newId = `u-${newNumero}` const newUnidad: UnidadTematica = { id: newId, nombre: 'Nueva Unidad', numero: newNumero, temas: [], } const next = [...unidades, newUnidad] setUnidades(next) setExpandedUnits((prev) => { const n = new Set(prev) n.add(newId) return n }) setPendingScrollUnitId(newId) // Abrir edición del título inmediatamente setEditingUnit(newId) setUnitDraftNombre(newUnidad.nombre) setUnitOriginalNombre(newUnidad.nombre) } // --- 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) => (
{ if (el) unitContainerRefs.current.set(unidad.id, el) else unitContainerRefs.current.delete(unidad.id) }} > 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 ) }