/* eslint-disable jsx-a11y/label-has-associated-control */ import { createFileRoute } from '@tanstack/react-router' import { Plus, AlertTriangle, Trash2, Download } from 'lucide-react' import * as Icons from 'lucide-react' import { useMemo, useState, useEffect, Fragment, useRef } from 'react' import type { TipoAsignatura } from '@/data' import type { Asignatura } from '@/types/plan' import type { TablesUpdate } from '@/types/supabase' import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' import { useAsignaturaConflictos, useCreateLinea, useDeleteLinea, usePlan, usePlanAsignaturas, usePlanLineas, useUpdateAsignatura, useUpdateLinea, } from '@/data' import { cn } from '@/lib/utils' import { generarColorContrastante } from '@/utils/colors' // --- Mapeadores (Fuera del componente para mayor limpieza) --- const palette = [ '#4F46E5', // índigo '#7C3AED', // violeta '#EA580C', // naranja '#059669', // esmeralda '#DC2626', // rojo '#0891B2', // cyan '#CA8A04', // ámbar '#C026D3', // fucsia ] type LineaCurricularUI = { id: string nombre: string orden: number color: string } const mapLineasToLineaCurricular = ( lineasApi: Array = [], ): Array => { return lineasApi.map((linea, index) => ({ id: linea.id, nombre: linea.nombre, orden: linea.orden ?? 0, color: linea.color ?? palette[index % palette.length], })) } const mapAsignaturasToAsignaturas = ( asigApi: Array = [], ): Array => { return asigApi.map((asig) => { return { 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, 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, prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null, } }) } // --- Subcomponentes --- function StatItem({ label, value, total, }: { label: string value: number total?: number }) { return (
{label} {value} {total ? ( /{total} ) : ( '' )}
) } const estadoConfig: Record< Asignatura['estado'], { label: string dot: string soft: string icon: React.ComponentType<{ className?: string }> } > = { borrador: { label: 'Borrador', dot: 'bg-slate-500', soft: 'bg-slate-100 text-slate-700', icon: Icons.FileText, }, revisada: { label: 'Revisada', dot: 'bg-amber-500', soft: 'bg-amber-100 text-amber-700', icon: Icons.ScanSearch, }, aprobada: { label: 'Aprobada', dot: 'bg-emerald-500', soft: 'bg-emerald-100 text-emerald-700', icon: Icons.BadgeCheck, }, generando: { label: 'Generando', dot: 'bg-sky-500', soft: 'bg-sky-100 text-sky-700', icon: Icons.LoaderCircle, }, } function hexToRgba(hex: string, alpha: number) { const clean = hex.replace('#', '') const bigint = parseInt(clean, 16) const r = (bigint >> 16) & 255 const g = (bigint >> 8) & 255 const b = bigint & 255 return `rgba(${r}, ${g}, ${b}, ${alpha})` } function AsignaturaCardItem({ asignatura, lineaColor, lineaNombre, onDragStart, isDragging, onClick, }: { asignatura: Asignatura lineaColor: string lineaNombre?: string onDragStart: (e: React.DragEvent, id: string) => void isDragging: boolean onClick: () => void }) { const estado = estadoConfig[asignatura.estado] const EstadoIcon = estado.icon return (
{/* ciclo */} {asignatura.ciclo ? ( C{asignatura.ciclo} · ) : null} {lineaNombre ? ( {lineaNombre} · ) : null} {asignatura.nombre}
) } export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({ component: MapaCurricularPage, }) function MapaCurricularPage() { const { planId } = Route.useParams() // Idealmente usa el ID de la ruta const { data } = usePlan(planId) const [totalCiclos, setTotalCiclos] = useState(0) const [editingLineaId, setEditingLineaId] = useState(null) const { mutate: createLinea, isPending: isCreatingLinea } = useCreateLinea() const { mutate: updateLineaApi } = useUpdateLinea() const { mutate: deleteLineaApi } = useDeleteLinea() const { data: asignaturaApi, isLoading: loadingAsig } = usePlanAsignaturas(planId) const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId) const [asignaturas, setAsignaturas] = useState>([]) const [lineas, setLineas] = useState>([]) const [draggedAsignatura, setDraggedAsignatura] = useState( null, ) const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [isAddLineaDialogOpen, setIsAddLineaDialogOpen] = useState(false) const [selectedLineaOption, setSelectedLineaOption] = useState< 'matematicas' | 'area_comun' | 'custom' | '' >('') const [customLineaNombre, setCustomLineaNombre] = useState('') const [ultimoHue, setUltimoHue] = useState(null) const { mutate: updateAsignatura } = useUpdateAsignatura() const inputRef = useRef(null) const { validarCambioCiclo } = useAsignaturaConflictos() const [confirmState, setConfirmState] = useState<{ isOpen: boolean resolve: (value: boolean) => void mensaje: string } | null>(null) const validarConInterrupcion = async ( asignaturaId: string, nuevoCiclo: number, ): Promise => { const asignatura = asignaturas.find((a) => a.id === asignaturaId) if (!asignatura) return true // Buscamos las materias que causan el conflicto const materiasConflicto = asignaturas.filter((a) => { const esPrerrequisitoConflictivo = asignatura.prerrequisito_asignatura_id === a.id && (a.ciclo ?? 0) >= nuevoCiclo const esDependienteConflictiva = a.prerrequisito_asignatura_id === asignatura.id && (a.ciclo ?? 0) <= nuevoCiclo && a.ciclo !== null return esPrerrequisitoConflictivo || esDependienteConflictiva }) if (materiasConflicto.length === 0) return true // Extraemos solo los nombres para la lista visual const listaNombres = materiasConflicto.map((m) => m.nombre) return new Promise((resolve) => { setConfirmState({ isOpen: true, resolve, // Guardamos la lista de nombres en el mensaje de forma que podamos procesarla mensaje: JSON.stringify({ main: `Mover "${asignatura.nombre}" al ciclo ${nuevoCiclo} genera conflictos con:`, materias: listaNombres, }), }) }) } useEffect(() => { if (data?.numero_ciclos) { setTotalCiclos(data.numero_ciclos) } }, [data]) useEffect(() => { if (selectedLineaOption === 'custom' && inputRef.current) { inputRef.current.focus() } }, [selectedLineaOption]) const handleCambioCicloSeguro = async ( asignatura: Asignatura, nuevoCiclo: number, ) => { const acepto = await validarConInterrupcion(asignatura.id, nuevoCiclo) if (!acepto) { setConfirmState(null) return } const patch = { numero_ciclo: nuevoCiclo } updateAsignatura( { asignaturaId: asignatura.id, patch: patch as any }, { onSuccess: () => { setAsignaturas((prev) => prev.map((m) => m.id === asignatura.id ? { ...m, ciclo: nuevoCiclo } : m, ), ) setConfirmState(null) // Cerramos el modal de Radix al tener éxito }, onError: (err) => { // En lugar de alert nativo, podrías usar un toast aquí console.error('Error al actualizar:', err) setConfirmState(null) }, }, ) } const manejarAgregarLinea = (nombre: string, color: string, hue: number) => { const nombreNormalizado = nombre.trim() if (!nombreNormalizado) return 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 } 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', color, }, { onSuccess: (nueva) => { const mapeada = { id: nueva.id, nombre: nueva.nombre, orden: nueva.orden, color: nueva.color ?? color, } setLineas((prev) => [...prev, mapeada]) setUltimoHue(hue) setIsAddLineaDialogOpen(false) setSelectedLineaOption('') setCustomLineaNombre('') }, onError: (err) => { console.error('Error al crear linea:', err) }, }, ) } const canAddLinea = selectedLineaOption === 'matematicas' || selectedLineaOption === 'area_comun' || (selectedLineaOption === 'custom' && customLineaNombre.trim().length > 0) const handleAgregarLinea = () => { if (!canAddLinea || isCreatingLinea) return const nombreSeleccionado = selectedLineaOption === 'matematicas' ? 'Matemáticas' : selectedLineaOption === 'area_comun' ? 'Área Común' : customLineaNombre.trim() if (!nombreSeleccionado) return const { hex, hue } = generarColorContrastante(ultimoHue) manejarAgregarLinea(nombreSeleccionado, hex, hue) } const cambiarColorLinea = (lineaId: string, nuevoColor: string) => { setLineas((prev) => prev.map((l) => (l.id === lineaId ? { ...l, color: nuevoColor } : l)), ) updateLineaApi( { lineaId, patch: { color: nuevoColor }, }, { onError: (err) => { console.error('Error al actualizar color de linea:', err) }, }, ) } const guardarEdicionLinea = (id: string, nuevoNombre: string) => { const nombreAFijar = nuevoNombre.trim() if (!nombreAFijar) { setEditingLineaId(null) return } updateLineaApi( { lineaId: id, patch: { nombre: nombreAFijar }, }, { onSuccess: (lineaActualizada) => { setLineas((prev) => prev.map((l) => l.id === id ? { ...l, nombre: lineaActualizada.nombre } : l, ), ) setEditingLineaId(null) }, onError: (err) => { console.error('Error al actualizar linea:', err) // Opcional: revertir cambios o avisar al usuario }, }, ) } useEffect(() => { if (asignaturaApi) setAsignaturas(mapAsignaturasToAsignaturas(asignaturaApi)) }, [asignaturaApi]) useEffect(() => { if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi)) }, [lineasApi]) const ciclosTotales = Number(totalCiclos) const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1) const [editingData, setEditingData] = useState(null) const handleIntegerChange = (value: string) => { if (value === '') return value // Solo números, máximo 3 cifras const regex = /^\d{1,3}$/ if (!regex.test(value)) return null return value } const handleDecimalChange = (value: string, max?: number): string | null => { if (value === '') return '' const val = value.replace(',', '.') const regex = /^\d*\.?\d{0,2}$/ if (!regex.test(val)) return null if (max !== undefined) { const num = Number(val) if (!isNaN(num) && num > max) { return max.toFixed(2) } } return val } const handleSaveChanges = () => { if (!editingData) return setAsignaturas((prev) => prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)), ) type AsignaturaPatch = { codigo?: TablesUpdate<'asignaturas'>['codigo'] nombre?: TablesUpdate<'asignaturas'>['nombre'] tipo?: TablesUpdate<'asignaturas'>['tipo'] creditos?: TablesUpdate<'asignaturas'>['creditos'] horas_academicas?: TablesUpdate<'asignaturas'>['horas_academicas'] horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes'] numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo'] linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id'] prerrequisito_asignatura_id?: string | null } const patch: Partial = { 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, prerrequisito_asignatura_id: editingData.prerrequisito_asignatura_id, tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA) } updateAsignatura( { asignaturaId: editingData.id, patch: patch as any }, { 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.') }, }, ) } const unassignedAsignaturas = asignaturas.filter( (m) => m.ciclo === null || m.lineaCurricularId === null, ) const unassignedCount = unassignedAsignaturas.length const borrarLinea = (id: string) => { if ( !confirm( '¿Estás seguro de eliminar esta línea? Las materias asignadas volverán a la bandeja de entrada.', ) ) { return } deleteLineaApi(id, { onSuccess: () => { // 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, ), ) 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 = (cicloNumero: number) => { return asignaturas .filter((m) => m.ciclo === cicloNumero) .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 = async ( e: React.DragEvent, cicloDestino: number | null, lineaId: string | null, ) => { e.preventDefault() if (!draggedAsignatura) return // Buscamos la asignatura completa para tener sus datos const asignatura = asignaturas.find((a) => a.id === draggedAsignatura) if (!asignatura) return // SI EL CAMBIO ES DE CICLO (y no solo de línea o a la bandeja) // Ejecutamos la validación segura if (cicloDestino !== null && cicloDestino !== asignatura.ciclo) { // Llamamos a la función que agregaste // IMPORTANTE: Asegúrate que handleCambioCicloSeguro esté definida ANTES o sea accesible await handleCambioCicloSeguro(asignatura, cicloDestino) // Si la validación interna de handleCambioCicloSeguro falla o el usuario cancela, // la ejecución se detiene dentro de esa función. } else { // CASO B: Es un movimiento a la bandeja (null) o cambio de línea en el mismo ciclo // Mantenemos la lógica simple de actualización setAsignaturas((prev) => prev.map((m) => m.id === draggedAsignatura ? { ...m, ciclo: cicloDestino, lineaCurricularId: lineaId } : m, ), ) updateAsignatura( { asignaturaId: draggedAsignatura, patch: { numero_ciclo: cicloDestino, linea_plan_id: lineaId }, }, { onError: (err) => console.error('Error al mover:', err) }, ) } 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], ) const confirmarEdicionLinea = (id: string, nuevoNombreRaw: string) => { const nuevoNombre = nuevoNombreRaw.trim() const lineaOriginal = lineas.find((l) => l.id === id) if (!nuevoNombre) { setEditingLineaId(null) return } if (nuevoNombre !== lineaOriginal?.nombre) { guardarEdicionLinea(id, nuevoNombre) return } setEditingLineaId(null) } if (loadingAsig || loadingLineas) return
Cargando mapa curricular...
return (
{/* Header */}

Mapa Curricular

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

{unassignedCount > 0 && ( {unassignedCount} sin asignar )}
{/* Barra Totales */}
LÍNEA CURRICULAR
{ciclosArray.map((n) => (
Ciclo {n}
))}
SUBTOTAL
{lineas.map((linea) => { const sub = getSubtotalLinea(linea.id) return (
setEditingLineaId(linea.id)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() e.currentTarget.blur() } if (e.key === 'Escape') { e.preventDefault() e.currentTarget.textContent = linea.nombre e.currentTarget.blur() } }} onBlur={(e) => confirmarEdicionLinea( linea.id, e.currentTarget.textContent, ) } className="text-foreground hover:text-foreground/85 block w-full cursor-text text-sm leading-snug wrap-break-word transition-colors outline-none" > {linea.nombre} {linea.nombre}
cambiarColorLinea(linea.id, e.target.value) } className="absolute inset-0 h-full w-full cursor-pointer opacity-0" />
{ciclosArray.map((cicloNumero) => (
handleDrop(e, cicloNumero, linea.id)} className={`min-h-35 space-y-2 rounded-xl border border-dashed p-1.5 transition-colors ${ draggedAsignatura ? 'border-primary/35 bg-primary/6' : 'border-border/70 bg-muted/15' }`} > {asignaturas .filter( (m) => m.ciclo === cicloNumero && m.lineaCurricularId === linea.id, ) .map((m) => ( { setEditingData(m) setIsEditModalOpen(true) }} /> ))}
))}
{sub.cr === 0 && sub.hd === 0 && sub.hi === 0 ? (
) : ( <>
Cr: {sub.cr}
HD: {sub.hd} • HI: {sub.hi}
)}
) })} {/* Agregar línea (sticky dentro del overflow-x) Nota: Se envuelve en un row `col-span-full` para evitar bugs de sticky en mobile/iOS cuando el sticky es un grid-item. */}
Totales por Ciclo
{ciclosArray.map((cicloNumero) => { const t = getTotalesCiclo(cicloNumero) const isEmpty = t.cr === 0 && t.hd === 0 && t.hi === 0 return (
{isEmpty ? (
) : ( <>
Cr: {t.cr}
HD: {t.hd} • HI: {t.hi}
)}
) })}
{stats.cr} Cr
{stats.hd + stats.hi} Hrs
{/* Asignaturas Sin Asignar */}

Bandeja de entrada

{unassignedAsignaturas.length}

Asignaturas sin ciclo o línea curricular

Arrastra aquí para desasignar
handleDrop(e, null, null)} className={[ 'rounded-3xl border-2 border-dashed p-4 transition-all duration-300', 'min-h-55', draggedAsignatura ? 'border-primary/35 bg-primary/6 shadow-inner' : 'border-border bg-muted/20', ].join(' ')} > {unassignedAsignaturas.length > 0 ? (
{unassignedAsignaturas.map((m) => (
{ setEditingData(m) setIsEditModalOpen(true) }} />
))}
) : (

No hay asignaturas pendientes

Todo está colocado en el mapa. Arrastra una asignatura aquí para quitarle ciclo y línea curricular.

)}
{/* Modal de Edición */} { setIsAddLineaDialogOpen(open) if (!open) { setSelectedLineaOption(undefined) setCustomLineaNombre('') } }} > Agregar línea curricular setSelectedLineaOption(val as typeof selectedLineaOption) } className="grid grid-cols-[1fr_auto_1fr] gap-8 py-4" > {/* Columna Izquierda: Predefinidas */}
Catálogo Institucional
{/* Tarjeta: Matemáticas */}

Línea base para ciencias exactas.

{/* Tarjeta: Área Común */}

Materias compartidas entre programas.

{/* Separador */}
{/* Columna Derecha: Personalizada */}
Línea personalizada
{/* Tarjeta: Custom */}
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() // Evita que la página haga scroll con el espacio setSelectedLineaOption('custom') inputRef.current?.focus() } }} onClick={() => { setSelectedLineaOption('custom') inputRef.current?.focus() }} className={`focus-visible:ring-primary relative flex w-full cursor-pointer items-start gap-3 rounded-md border p-4 shadow-sm transition-all outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${ selectedLineaOption === 'custom' ? 'border-primary/50 bg-primary/5' : 'border-input hover:bg-muted/50' }`} > {/* Omitimos after:absolute para no tapar el input */}
setCustomLineaNombre(e.target.value.slice(0, 200)) } placeholder="Escribe el nombre aquí" maxLength={200} disabled={selectedLineaOption !== 'custom'} className="bg-background h-9 w-full" />
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 */}
{ const val = handleDecimalChange(e.target.value, 10) if (val !== null) { setEditingData({ ...editingData, creditos: val === '' ? 0 : Number(val), }) } }} />
{ const val = handleIntegerChange(e.target.value) if (val !== null) { setEditingData({ ...editingData, hd: Number(e.target.value), }) } }} />
{ const val = handleIntegerChange(e.target.value) if (val !== null) { setEditingData({ ...editingData, hi: Number(e.target.value), }) } }} />
{/* Fila 3: Ciclo y Línea */}
{/* Fila 4: Seriación (Prerrequisitos) */}
{/* Visualización del Prerrequisito con el Nombre */}
{/* Fila 5: Tipo */}
) : (
No hay datos seleccionados
)}
{confirmState && ( { if (!open) { confirmState.resolve(false) setConfirmState(null) } }} onConfirm={() => { confirmState.resolve(true) }} titulo="Conflicto de Seriación" descripcion={confirmState.mensaje} /> )}
) }