/* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/label-has-associated-control */ import { createFileRoute } from '@tanstack/react-router' import { Plus, ChevronDown, AlertTriangle, GripVertical, Trash2, } from 'lucide-react' import { useMemo, useState, useEffect } from 'react' import type { Asignatura, LineaCurricular } from '@/types/plan' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { useCreateLinea, useDeleteLinea, usePlanAsignaturas, usePlanLineas, useUpdateAsignatura, useUpdateLinea, } from '@/data' // --- Mapeadores (Fuera del componente para mayor limpieza) --- const mapLineasToLineaCurricular = ( lineasApi: Array = [], ): Array => { return lineasApi.map((linea) => ({ id: linea.id, nombre: linea.nombre, orden: linea.orden ?? 0, color: '#1976d2', })) } const mapAsignaturasToAsignaturas = ( asigApi: Array = [], ): Array => { return asigApi.map((asig) => ({ id: asig.id, clave: asig.codigo, nombre: asig.nombre, creditos: asig.creditos ?? 0, ciclo: asig.numero_ciclo ?? null, lineaCurricularId: asig.linea_plan_id ?? null, tipo: asig.tipo === 'OBLIGATORIA' ? 'obligatoria' : 'optativa', estado: 'borrador', orden: asig.orden_celda ?? 0, // Mapeo directo de los nuevos campos de la API hd: asig.horas_academicas ?? 0, hi: asig.horas_independientes ?? 0, prerrequisitos: [], })) } const lineColors = [ 'bg-blue-50 border-blue-200 text-blue-700', 'bg-purple-50 border-purple-200 text-purple-700', 'bg-orange-50 border-orange-200 text-orange-700', 'bg-emerald-50 border-emerald-200 text-emerald-700', ] const statusBadge: Record = { borrador: 'bg-slate-100 text-slate-600', revisada: 'bg-amber-100 text-amber-700', aprobada: 'bg-emerald-100 text-emerald-700', } // --- Subcomponentes --- function StatItem({ label, value, total, }: { label: string value: number total?: number }) { return (
{label}: {value} {total ? ( /{total} ) : ( '' )}
) } function AsignaturaCardItem({ asignatura, onDragStart, isDragging, onClick, }: { asignatura: Asignatura onDragStart: (e: React.DragEvent, id: string) => void isDragging: boolean onClick: () => void }) { return ( ) } export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({ component: MapaCurricularPage, validateSearch: (search: { ciclo?: number }) => ({ ciclo: search.ciclo ?? null, }), }) function MapaCurricularPage() { const { planId } = Route.useParams() // Idealmente usa el ID de la ruta const { ciclo } = Route.useSearch() const [editingLineaId, setEditingLineaId] = useState(null) const [tempNombreLinea, setTempNombreLinea] = useState('') const { mutate: createLinea } = useCreateLinea() const { mutate: updateLineaApi } = useUpdateLinea() const { mutate: deleteLineaApi } = useDeleteLinea() // 1. Fetch de Datos const { data: asignaturasApi, isLoading: loadingAsig } = usePlanAsignaturas(planId) const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId) // 2. Estado Local (Para interactividad) const [asignaturas, setAsignaturas] = useState>([]) const [lineas, setLineas] = useState>([]) const [draggedAsignatura, setDraggedAsignatura] = useState( null, ) const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [selectedAsignatura, setSelectedAsignatura] = useState(null) const [hasAreaComun, setHasAreaComun] = useState(false) const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado const { mutate: updateAsignatura, isPending } = useUpdateAsignatura() const manejarAgregarLinea = (nombre: string) => { const nombreNormalizado = nombre.trim() // 1. Validar vacío if (!nombreNormalizado) return // 2. Validar duplicados en el estado local (Insensible a mayúsculas/acentos) const nombreBusqueda = nombreNormalizado .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') const yaExiste = lineas.some((l) => { const lineaExistente = l.nombre .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') return lineaExistente === nombreBusqueda }) if (yaExiste) { alert(`La línea "${nombreNormalizado}" ya existe en este plan.`) return // DETIENE la ejecución aquí, no llega a la mutación } // 3. Si pasa las validaciones, procedemos con la persistencia const maxOrden = lineas.reduce((max, l) => Math.max(max, l.orden || 0), 0) createLinea( { nombre: nombreNormalizado, plan_estudio_id: planId, orden: maxOrden + 1, area: 'sin asignar', }, { onSuccess: (nueva) => { // Sincronización local que ya teníamos const mapeada = { id: nueva.id, nombre: nueva.nombre, orden: nueva.orden, color: '#1976d2', } setLineas((prev) => [...prev, mapeada]) setNombreNuevaLinea('') }, }, ) } const guardarEdicionLinea = (id: string) => { if (!tempNombreLinea.trim()) { setEditingLineaId(null) return } updateLineaApi( { lineaId: id, patch: { nombre: tempNombreLinea.trim() }, }, { onSuccess: (lineaActualizada) => { // ACTUALIZACIÓN MANUAL DEL ESTADO LOCAL setLineas((prev) => prev.map((l) => l.id === id ? { ...l, nombre: lineaActualizada.nombre } : l, ), ) setEditingLineaId(null) setTempNombreLinea('') }, }, ) } const tieneAreaComun = useMemo(() => { return lineas.some( (l) => l.nombre.toLowerCase() === 'área común' || l.nombre.toLowerCase() === 'area comun', ) }, [lineas]) // 3. Sincronizar API -> Estado Local useEffect(() => { if (asignaturasApi) setAsignaturas(mapAsignaturasToAsignaturas(asignaturasApi)) }, [asignaturasApi]) useEffect(() => { if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi)) }, [lineasApi]) const ciclosTotales = Number(ciclo) const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1) // Nuevo estado para controlar los datos temporales del modal de edición const [editingData, setEditingData] = useState(null) // 1. FUNCION DE GUARDAR MODAL const handleSaveChanges = () => { if (!editingData) return console.log(asignaturas) setAsignaturas((prev) => prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)), ) // setIsEditModalOpen(false) // Preparamos el patch con la estructura de tu tabla const patch = { nombre: editingData.nombre, codigo: editingData.clave, creditos: editingData.creditos, horas_academicas: editingData.hd, horas_independientes: editingData.hi, numero_ciclo: editingData.ciclo, linea_plan_id: editingData.lineaCurricularId, tipo: editingData.tipo.toUpperCase(), // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA) // datos: editingData.datos, // Si editaste algo del JSONB } updateAsignatura( { asignaturaId: editingData.id, patch }, { onSuccess: () => { setIsEditModalOpen(false) // Opcional: Mostrar un toast de éxito }, onError: (error) => { console.error('Error al guardar:', error) alert('Hubo un error al guardar los cambios.') }, }, ) } // 2. MODIFICACIÓN: Zona de soltado siempre visible // Cambiamos la condición: Mostramos la sección si hay asignaturas sin asignar // O si simplemente queremos tener el "depósito" disponible. const unassignedAsignaturas = asignaturas.filter( (m) => m.ciclo === null || m.lineaCurricularId === null, ) // --- Lógica de Gestión --- const agregarLinea = (nombre: string) => { const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 } setLineas([...lineas, nueva]) } const borrarLinea = (id: string) => { // 1. Opcional: Confirmación de seguridad if ( !confirm( '¿Estás seguro de eliminar esta línea? Las materias asignadas volverán a la bandeja de entrada.', ) ) { return } // 2. Llamada a la API deleteLineaApi(id, { onSuccess: () => { // 3. Actualización instantánea del estado local // Primero: Las materias que estaban en esa línea pasan a ser "huérfanas" setAsignaturas((prev) => prev.map((asig) => asig.lineaCurricularId === id ? { ...asig, ciclo: null, lineaCurricularId: null } : asig, ), ) // Segundo: Quitamos la línea del estado setLineas((prev) => prev.filter((l) => l.id !== id)) }, onError: (error) => { console.error(error) alert('No se pudo eliminar la línea. Verifica si tiene dependencias.') }, }) } // --- Selectores/Cálculos --- const getTotalesCiclo = (ciclo: number) => { return asignaturas .filter((m) => m.ciclo === ciclo) .reduce( (acc, m) => ({ cr: acc.cr + (m.creditos || 0), hd: acc.hd + (m.hd || 0), hi: acc.hi + (m.hi || 0), }), { cr: 0, hd: 0, hi: 0 }, ) } const getSubtotalLinea = (lineaId: string) => { return asignaturas .filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null) // Aseguramos que pertenezca a la línea Y tenga ciclo .reduce( (acc, m) => ({ cr: acc.cr + (m.creditos || 0), hd: acc.hd + (m.hd || 0), hi: acc.hi + (m.hi || 0), }), { cr: 0, hd: 0, hi: 0 }, ) } const handleDragStart = (e: React.DragEvent, id: string) => { setDraggedAsignatura(id) e.dataTransfer.effectAllowed = 'move' } const handleDragOver = (e: React.DragEvent) => e.preventDefault() const handleDrop = ( e: React.DragEvent, ciclo: number | null, lineaId: string | null, ) => { e.preventDefault() if (draggedAsignatura) { // 1. Actualización optimista del UI setAsignaturas((prev) => prev.map((m) => m.id === draggedAsignatura ? { ...m, ciclo, lineaCurricularId: lineaId } : m, ), ) // 2. Persistir en la API const patch = { numero_ciclo: ciclo, linea_plan_id: lineaId, } updateAsignatura( { asignaturaId: draggedAsignatura, patch }, { onError: (error) => { console.error('Error al mover:', error) // Opcional: Revertir el estado local si falla }, }, ) setDraggedAsignatura(null) } } const stats = useMemo( () => asignaturas.reduce( (acc, m) => { if (m.ciclo !== null) { acc.cr += m.creditos || 0 acc.hd += m.hd || 0 acc.hi += m.hi || 0 } return acc }, { cr: 0, hd: 0, hi: 0 }, ), [asignaturas], ) if (loadingAsig || loadingLineas) return
Cargando mapa curricular...
return (
{/* Header */}

Mapa Curricular

Organiza las asignaturas de la petición por línea y ciclo

{asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId).length > 0 && ( {' '} { asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId) .length }{' '} sin asignar )} {!tieneAreaComun && ( <> manejarAgregarLinea('Área Común')} className="font-bold text-teal-700" > + Agregar Área Común
)} {/* Input para nombre personalizado */}
setNombreNuevaLinea(e.target.value)} placeholder="Ej: Optativas" className="h-8 text-xs" />
{/* Barra Totales */}
LÍNEA CURRICULAR
{ciclosArray.map((n) => (
Ciclo {n}
))}
SUBTOTAL
{lineas.map((linea, idx) => { const sub = getSubtotalLinea(linea.id) return (
{editingLineaId === linea.id ? ( setTempNombreLinea(e.target.value)} onBlur={() => guardarEdicionLinea(linea.id)} onKeyDown={(e) => e.key === 'Enter' && guardarEdicionLinea(linea.id) } /> ) : ( { setEditingLineaId(linea.id) setTempNombreLinea(linea.nombre) }} > {linea.nombre} )} borrarLinea(linea.id)} // Aquí también podrías añadir una mutación delete />
{ciclosArray.map((ciclo) => (
handleDrop(e, null, null)} // AHORA: Usamos las variables 'ciclo' y 'linea.id' del map onDrop={(e) => handleDrop(e, ciclo, linea.id)} className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2" > {asignaturas .filter( (m) => m.ciclo === ciclo && m.lineaCurricularId === linea.id, ) .map((m) => ( { setEditingData(m) setIsEditModalOpen(true) }} /> ))}
))}
Cr: {sub.cr}
HD: {sub.hd}
HI: {sub.hi}
) })}
Totales por Ciclo
{ciclosArray.map((ciclo) => { const t = getTotalesCiclo(ciclo) return (
Cr: {t.cr}
HD: {t.hd} • HI: {t.hi}
) })}
{stats.cr} Cr
{stats.hd + stats.hi} Hrs
{/* Asignaturas Sin Asignar */} {/* SECCIÓN DE MATERIAS SIN ASIGNAR (Mejorada para estar siempre disponible) */}

Bandeja de Entrada / Asignaturas sin asignar

{unassignedAsignaturas.length}

Arrastra una asignatura aquí para quitarla del mapa

handleDrop(e, null, null)} // Limpia ciclo y línea > {unassignedAsignaturas.map((m) => (
{ setEditingData(m) // Cargamos los datos en el estado de edición setIsEditModalOpen(true) }} />
))} {unassignedAsignaturas.length === 0 && (
No hay asignaturas pendientes. Arrastra una asignatura aquí para desasignarla.
)}
{/* Modal de Edición */} e.preventDefault()} > Editar Asignatura {/* Verificación de seguridad: solo renderiza si hay datos */} {editingData ? (
{/* Fila 1: Clave y Nombre */}
setEditingData({ ...editingData, clave: e.target.value }) } />
setEditingData({ ...editingData, nombre: e.target.value }) } />
{/* Fila 2: Créditos y Horas */}
setEditingData({ ...editingData, creditos: Number(e.target.value), }) } />
setEditingData({ ...editingData, hd: Number(e.target.value), }) } />
setEditingData({ ...editingData, hi: Number(e.target.value), }) } />
{/* Fila 3: Ciclo y Línea */}
{/* Fila 4: Seriación (Prerrequisitos) */}
{/* Visualización de los prerrequisitos seleccionados */}
{editingData.prerrequisitos.map((pre) => ( {pre} ))}
{/* Fila 5: Tipo */}
) : (
No hay datos seleccionados
)}
) }