Merge pull request 'dialog warning al cambiar de ciclo una asignatura que rompería la seriación #210' (#228) from issue/210-dialog-warning-al-cambiar-de-ciclo-una-asignatura- into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m26s

Reviewed-on: #228
This commit was merged in pull request #228.
This commit is contained in:
2026-03-26 22:07:02 +00:00
4 changed files with 288 additions and 14 deletions
@@ -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 = (
<div className="space-y-3">
<p className="text-sm text-slate-600">{data.main}</p>
<div className="flex flex-wrap gap-2 py-2">
{data.materias.map((m: string, i: number) => (
<span
key={i}
className="animate-in fade-in zoom-in-95 inline-flex items-center rounded-md border border-red-100 bg-red-50 px-2.5 py-1 text-xs font-medium text-red-700 duration-300"
>
<AlertTriangle className="mr-1.5 h-3 w-3 shrink-0" />
{m}
</span>
))}
</div>
<p className="mt-2 text-xs font-semibold text-slate-500">
¿Deseas ignorar la regla y moverla de todos modos?
</p>
</div>
)
} catch {
contenido = <p className="text-sm text-slate-600">{descripcion}</p>
}
return (
<AlertDialog.Root open={isOpen} onOpenChange={onOpenChange}>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-[200] bg-slate-950/40 backdrop-blur-[2px]" />
<AlertDialog.Content className="fixed top-1/2 left-1/2 z-[201] w-[95vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-slate-100 bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-red-600">
<AlertTriangle className="h-6 w-6" />
</div>
<AlertDialog.Title className="text-xl font-bold tracking-tight text-slate-900">
{titulo}
</AlertDialog.Title>
</div>
<AlertDialog.Description asChild>{contenido}</AlertDialog.Description>
<div className="mt-8 flex flex-col-reverse justify-end gap-3 sm:flex-row">
<AlertDialog.Cancel asChild>
<Button
variant="ghost"
onClick={() => onConfirm()} // Si tu componente espera resolve
className="font-semibold text-slate-500 hover:text-slate-700"
>
Cancelar
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<Button
onClick={onConfirm}
className="bg-red-600 font-bold text-white shadow-lg shadow-red-200 hover:bg-red-700"
>
Mover de todos modos
</Button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
}
+46
View File
@@ -585,3 +585,49 @@ export async function bibliografia_delete(id: string) {
if (error) throw error
return id
}
export async function checkPrerrequisitoConflicts(
asignaturaId: string,
nuevoCiclo: number,
): Promise<Array<string>> {
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<string> = []
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
}
+33
View File
@@ -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 }
}
+122 -14
View File
@@ -8,6 +8,7 @@ import type { TipoAsignatura } from '@/data'
import type { Asignatura, LineaCurricular } 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 {
@@ -31,6 +32,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
useAsignaturaConflictos,
useCreateLinea,
useDeleteLinea,
usePlan,
@@ -346,6 +348,51 @@ function MapaCurricularPage() {
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado
const { mutate: updateAsignatura } = useUpdateAsignatura()
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<boolean> => {
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) {
@@ -353,6 +400,39 @@ function MapaCurricularPage() {
}
}, [data])
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) => {
const nombreNormalizado = nombre.trim()
if (!nombreNormalizado) return
@@ -576,14 +656,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
@@ -591,23 +688,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(
@@ -1247,6 +1338,23 @@ function MapaCurricularPage() {
)}
</DialogContent>
</Dialog>
{confirmState && (
<AlertaConflicto
isOpen={confirmState.isOpen}
onOpenChange={(open) => {
if (!open) {
confirmState.resolve(false)
setConfirmState(null)
}
}}
onConfirm={() => {
confirmState.resolve(true)
}}
titulo="Conflicto de Seriación"
descripcion={confirmState.mensaje}
/>
)}
</div>
)
}