diff --git a/src/components/asignaturas/detalle/mapa/AlertaConflicto.tsx b/src/components/asignaturas/detalle/mapa/AlertaConflicto.tsx new file mode 100644 index 0000000..b529637 --- /dev/null +++ b/src/components/asignaturas/detalle/mapa/AlertaConflicto.tsx @@ -0,0 +1,87 @@ +import * as AlertDialog from '@radix-ui/react-alert-dialog' +import { AlertTriangle } from 'lucide-react' + +import { Button } from '@/components/ui/button' + +interface Props { + isOpen: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void + titulo?: string + descripcion?: string +} +export const AlertaConflicto = ({ + isOpen, + onOpenChange, + onConfirm, + titulo, + descripcion, +}: Props) => { + // Intentamos parsear el mensaje si viene como JSON para la lista de materias + let contenido + try { + const data = JSON.parse(descripcion as any) + contenido = ( +
+

{data.main}

+
+ {data.materias.map((m: string, i: number) => ( + + + {m} + + ))} +
+

+ ¿Deseas ignorar la regla y moverla de todos modos? +

+
+ ) + } catch { + contenido =

{descripcion}

+ } + + return ( + + + + +
+
+ +
+ + {titulo} + +
+ + {contenido} + +
+ + + + + + + +
+
+
+
+ ) +} diff --git a/src/data/api/subjects.api.ts b/src/data/api/subjects.api.ts index e745812..303b047 100644 --- a/src/data/api/subjects.api.ts +++ b/src/data/api/subjects.api.ts @@ -591,3 +591,49 @@ export async function bibliografia_delete(id: string) { if (error) throw error return id } + +export async function checkPrerrequisitoConflicts( + asignaturaId: string, + nuevoCiclo: number, +): Promise> { + const supabase = supabaseBrowser() + + // CORRECCIÓN 1: Agregamos 'id' al select + // CORRECCIÓN 2: Quitamos espacios en el .or() + const { data, error } = await supabase + .from('asignaturas') + .select('id, nombre, numero_ciclo, prerrequisito_asignatura_id') + .or(`prerrequisito_asignatura_id.eq.${asignaturaId},id.eq.${asignaturaId}`) + + if (error) throw error + + // CORRECCIÓN 3: 'data' ahora está disponible porque lo desestructuramos arriba + if (!data) return [] + + const conflictos: Array = [] + + data.forEach((asig) => { + // Caso 1: Materias que tienen a esta como prerrequisito (Hijas) + // "Si yo me muevo al ciclo 5, y mi hija está en el 4, se rompe la regla" + if (asig.prerrequisito_asignatura_id === asignaturaId) { + if (asig.numero_ciclo !== null && asig.numero_ciclo <= nuevoCiclo) { + conflictos.push(asig.nombre) + } + } + + // Caso 2: El prerrequisito de la materia que estoy moviendo (Padre) + // "Si yo me muevo al ciclo 2, y mi padre está en el 3, se rompe la regla" + if (asig.id === asignaturaId && asig.prerrequisito_asignatura_id) { + const padre = data.find((p) => p.id === asig.prerrequisito_asignatura_id) + if ( + padre && + padre.numero_ciclo !== null && + padre.numero_ciclo >= nuevoCiclo + ) { + conflictos.push(padre.nombre) + } + } + }) + + return conflictos +} diff --git a/src/data/hooks/useSubjects.ts b/src/data/hooks/useSubjects.ts index 29dba1e..d3ae9ef 100644 --- a/src/data/hooks/useSubjects.ts +++ b/src/data/hooks/useSubjects.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' import { ai_generate_subject, @@ -6,6 +7,7 @@ import { bibliografia_delete, bibliografia_insert, bibliografia_update, + checkPrerrequisitoConflicts, lineas_insert, lineas_update, subjects_bibliografia_list, @@ -317,3 +319,34 @@ export function useDeleteBibliografia(asignaturaId: string) { }, }) } + +export function useAsignaturaConflictos() { + const [isValidating, setIsValidating] = useState(false) + + const validarCambioCiclo = async ( + asignaturaId: string, + nuevoCiclo: number, + ) => { + setIsValidating(true) + try { + const nombresConflictivos = await checkPrerrequisitoConflicts( + asignaturaId, + nuevoCiclo, + ) + + if (nombresConflictivos.length > 0) { + const mensaje = `Si mueves esta materia al ciclo ${nuevoCiclo}, se perderá la seriación con:\n\n• ${nombresConflictivos.join('\n• ')}\n\n¿Deseas continuar?` + return confirm(mensaje) // Puedes usar un Modal de Shadcn aquí en lugar de confirm + } + + return true // Sin conflictos + } catch (error) { + console.error(error) + return false + } finally { + setIsValidating(false) + } + } + + return { validarCambioCiclo, isValidating } +} diff --git a/src/routes/planes/$planId/_detalle/mapa.tsx b/src/routes/planes/$planId/_detalle/mapa.tsx index e3622b7..eef383c 100644 --- a/src/routes/planes/$planId/_detalle/mapa.tsx +++ b/src/routes/planes/$planId/_detalle/mapa.tsx @@ -8,6 +8,7 @@ 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 { @@ -34,6 +35,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import { + useAsignaturaConflictos, useCreateLinea, useDeleteLinea, usePlan, @@ -338,6 +340,51 @@ function MapaCurricularPage() { 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) { @@ -351,6 +398,39 @@ function MapaCurricularPage() { } }, [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 @@ -614,14 +694,31 @@ function MapaCurricularPage() { e.dataTransfer.effectAllowed = 'move' } const handleDragOver = (e: React.DragEvent) => e.preventDefault() - const handleDrop = ( + const handleDrop = async ( e: React.DragEvent, cicloDestino: number | null, lineaId: string | null, ) => { e.preventDefault() - if (draggedAsignatura) { - // 1. Actualización optimista del UI + + 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 @@ -629,23 +726,17 @@ function MapaCurricularPage() { : m, ), ) - const patch = { - numero_ciclo: cicloDestino, - linea_plan_id: lineaId, - } updateAsignatura( - { asignaturaId: draggedAsignatura, patch }, { - onError: (error) => { - console.error('Error al mover:', error) - // Opcional: Revertir el estado local si falla - }, + asignaturaId: draggedAsignatura, + patch: { numero_ciclo: cicloDestino, linea_plan_id: lineaId }, }, + { onError: (err) => console.error('Error al mover:', err) }, ) - - setDraggedAsignatura(null) } + + setDraggedAsignatura(null) } const stats = useMemo( @@ -1448,6 +1539,23 @@ function MapaCurricularPage() { )} + + {confirmState && ( + { + if (!open) { + confirmState.resolve(false) + setConfirmState(null) + } + }} + onConfirm={() => { + confirmState.resolve(true) + }} + titulo="Conflicto de Seriación" + descripcion={confirmState.mensaje} + /> + )} ) }