import { useParams } from '@tanstack/react-router' import { Plus, GripVertical, ChevronDown, ChevronRight, Edit3, Trash2, Clock, } from 'lucide-react' import { useEffect, useState } from 'react' import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api' 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 [deleteDialog, setDeleteDialog] = useState<{ type: 'unidad' | 'tema' id: string parentId?: string } | null>(null) const [editingUnit, setEditingUnit] = useState(null) const [editingTema, setEditingTema] = useState<{ unitId: string temaId: string } | null>(null) const persistUnidades = async (nextUnidades: Array) => { const payload = serializeUnidadesToApi(nextUnidades) await updateContenido.mutateAsync({ subjectId: asignaturaId, unidades: payload, }) } useEffect(() => { const contenido = mapContenidoTematicoFromDb( data ? data.contenido_tematico : undefined, ) const transformed = contenido.map((u, idx) => ({ id: `u-${idx}`, 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-${idx}-${tidx}`, nombre: typeof t === 'string' ? t : t?.nombre || 'Tema', horasEstimadas: t?.horasEstimadas || 0, })) : [], })) setUnidades(transformed) // Expandir la primera unidad automáticamente if (transformed.length > 0) { setExpandedUnits(new Set([transformed[0].id])) } else { setExpandedUnits(new Set()) } }, [data]) 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 newId = `u-${Date.now()}` const newUnidad: UnidadTematica = { id: newId, nombre: 'Nueva Unidad', numero: unidades.length + 1, temas: [], } const next = [...unidades, newUnidad] setUnidades(next) setExpandedUnits(new Set([...expandedUnits, newId])) setEditingUnit(newId) } const updateUnidadNombre = (id: string, nombre: string) => { setUnidades(unidades.map((u) => (u.id === id ? { ...u, nombre } : u))) } // --- Lógica de Temas --- const addTema = (unidadId: string) => { setUnidades( unidades.map((u) => { if (u.id === unidadId) { const newTemaId = `t-${Date.now()}` const newTema: Tema = { id: newTemaId, nombre: 'Nuevo tema', horasEstimadas: 2, } setEditingTema({ unitId: unidadId, temaId: newTemaId }) return { ...u, temas: [...u.temas, newTema] } } return u }), ) } const updateTema = ( unidadId: string, temaId: string, updates: Partial, ) => { setUnidades( unidades.map((u) => { if (u.id === unidadId) { return { ...u, temas: u.temas.map((t) => t.id === temaId ? { ...t, ...updates } : t, ), } } return u }), ) } 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) => ( toggleUnit(unidad.id)} >
Unidad {unidad.numero} {editingUnit === unidad.id ? ( updateUnidadNombre(unidad.id, e.target.value) } onBlur={() => { setEditingUnit(null) void persistUnidades(unidades) }} onKeyDown={(e) => { if (e.key === 'Enter') { setEditingUnit(null) void persistUnidades(unidades) } }} className="h-8 max-w-md bg-white" /> ) : ( setEditingUnit(unidad.id)} > {unidad.nombre} )}
{' '} {unidad.temas.reduce( (sum, t) => sum + (t.horasEstimadas || 0), 0, )} h
{unidad.temas.map((tema, idx) => ( setEditingTema({ unitId: unidad.id, temaId: tema.id }) } onStopEditing={() => { setEditingTema(null) void persistUnidades(unidades) }} onUpdate={(updates) => updateTema(unidad.id, tema.id, updates) } onDelete={() => setDeleteDialog({ type: 'tema', id: tema.id, parentId: unidad.id, }) } /> ))}
))}
) } // --- Componentes Auxiliares --- interface TemaRowProps { tema: Tema index: number isEditing: boolean onEdit: () => void onStopEditing: () => void onUpdate: (updates: Partial) => void onDelete: () => void } function TemaRow({ tema, index, isEditing, onEdit, onStopEditing, onUpdate, onDelete, }: TemaRowProps) { return (
{index}. {isEditing ? (
onUpdate({ nombre: e.target.value })} className="h-8 flex-1 bg-white" placeholder="Nombre" /> onUpdate({ horasEstimadas: parseInt(e.target.value) || 0 }) } className="h-8 w-16 bg-white" />
) : ( <> {tema.horasEstimadas}h
)}
) } 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 ) }