/* 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 { Materia, 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 { usePlanAsignaturas, usePlanLineas } 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 mapAsignaturasToMaterias = (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, hd: Math.floor((asig.horas_semana ?? 0) / 2), hi: Math.ceil((asig.horas_semana ?? 0) / 2), 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 MateriaCardItem({ materia, onDragStart, isDragging, onClick, }: { materia: Materia onDragStart: (e: React.DragEvent, id: string) => void isDragging: boolean onClick: () => void }) { return ( ) } export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({ component: MapaCurricularPage, }) function MapaCurricularPage() { const { planId } = Route.useParams() // Idealmente usa el ID de la ruta // 1. Fetch de Datos const { data: asignaturasApi, isLoading: loadingAsig } = usePlanAsignaturas(planId) const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId) // 2. Estado Local (Para interactividad) const [materias, setMaterias] = useState>([]) const [lineas, setLineas] = useState>([]) const [draggedMateria, setDraggedMateria] = useState(null) const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [selectedMateria, setSelectedMateria] = useState(null) const [hasAreaComun, setHasAreaComun] = useState(false) const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado const manejarAgregarLinea = (nombre: string) => { const nombreNormalizado = nombre.trim() // 1. Validar que no esté vacío if (!nombreNormalizado) return // 2. Validar duplicados (Insensible a mayúsculas/minúsculas y acentos) const nombreParaComparar = nombreNormalizado .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') const yaExiste = lineas.some((l) => { const lineaNombreBase = l.nombre .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') return lineaNombreBase === nombreParaComparar }) if (yaExiste) { alert(`La línea "${nombreNormalizado}" ya existe.`) return } // 3. Validar Área Común (usando tu lógica previa) const esAreaComun = nombreNormalizado.toLowerCase() === 'área común' || nombreNormalizado.toLowerCase() === 'area comun' if (esAreaComun && hasAreaComun) { alert('El Área Común ya ha sido agregada.') return } // 4. Agregar la línea si todo está bien const nueva = { id: crypto.randomUUID(), nombre: nombreNormalizado, orden: lineas.length + 1, color: '#1976d2', } setLineas([...lineas, nueva]) if (esAreaComun) { setHasAreaComun(true) } setNombreNuevaLinea('') // Limpiar input } 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) setMaterias(mapAsignaturasToMaterias(asignaturasApi)) }, [asignaturasApi]) useEffect(() => { if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi)) }, [lineasApi]) const ciclosTotales = 9 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(materias) setMaterias((prev) => prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)), ) setIsEditModalOpen(false) } // 2. MODIFICACIÓN: Zona de soltado siempre visible // Cambiamos la condición: Mostramos la sección si hay materias sin asignar // O si simplemente queremos tener el "depósito" disponible. const unassignedMaterias = materias.filter((m) => m.ciclo === 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) => { setMaterias((prev) => prev.map((m) => m.lineaCurricularId === id ? { ...m, ciclo: null, lineaCurricularId: null } : m, ), ) setLineas((prev) => prev.filter((l) => l.id !== id)) } // --- Selectores/Cálculos --- const getTotalesCiclo = (ciclo: number) => { return materias .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 materias .filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null) .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) => { setDraggedMateria(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 (draggedMateria) { setMaterias((prev) => prev.map((m) => m.id === draggedMateria ? { ...m, ciclo, lineaCurricularId: lineaId } : m, ), ) setDraggedMateria(null) } } const stats = useMemo( () => materias.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 }, ), [materias], ) if (loadingAsig || loadingLineas) return
Cargando mapa curricular...
return (
{/* Header */}

Mapa Curricular

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

{materias.filter((m) => !m.ciclo).length > 0 && ( {' '} {materias.filter((m) => !m.ciclo).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 (
{linea.nombre} borrarLinea(linea.id)} />
{ciclosArray.map((ciclo) => (
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" > {materias .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
{/* Materias Sin Asignar */} {/* SECCIÓN DE MATERIAS SIN ASIGNAR (Mejorada para estar siempre disponible) */}

Bandeja de Entrada / Materias sin asignar

{unassignedMaterias.length}

Arrastra una materia aquí para quitarla del mapa

handleDrop(e, null, null)} // Limpia ciclo y línea > {unassignedMaterias.map((m) => (
{ setEditingData(m) // Cargamos los datos en el estado de edición setIsEditModalOpen(true) }} />
))} {unassignedMaterias.length === 0 && (
No hay materias pendientes. Arrastra una materia aquí para desasignarla.
)}
{/* Modal de Edición */} Editar Materia {/* 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 (Igual a tu imagen) */}
{/* Aquí usamos el array vacío que inicializamos en el mapeador */} {editingData.prerrequisitos.map((pre) => ( {pre} × ))}
{/* Fila 5: Tipo */}
) : (
No hay datos seleccionados
)}
) }