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 (
+
+
+
+
+
+
+ {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}
+ />
+ )}
)
}