Compare commits
3 Commits
8ae3469e10
...
f5a4b0b9af
| Author | SHA1 | Date | |
|---|---|---|---|
| f5a4b0b9af | |||
| b3e9d63833 | |||
| 7a2f16b160 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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<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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
const { mutate: updateAsignatura } = useUpdateAsignatura()
|
||||
const inputRef = useRef<HTMLInputElement>(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<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) {
|
||||
@@ -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,24 +726,18 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
const stats = useMemo(
|
||||
() =>
|
||||
@@ -1448,6 +1539,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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user