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
|
if (error) throw error
|
||||||
return id
|
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ai_generate_subject,
|
ai_generate_subject,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
bibliografia_delete,
|
bibliografia_delete,
|
||||||
bibliografia_insert,
|
bibliografia_insert,
|
||||||
bibliografia_update,
|
bibliografia_update,
|
||||||
|
checkPrerrequisitoConflicts,
|
||||||
lineas_insert,
|
lineas_insert,
|
||||||
lineas_update,
|
lineas_update,
|
||||||
subjects_bibliografia_list,
|
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 { Asignatura } from '@/types/plan'
|
||||||
import type { TablesUpdate } from '@/types/supabase'
|
import type { TablesUpdate } from '@/types/supabase'
|
||||||
|
|
||||||
|
import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import {
|
import {
|
||||||
|
useAsignaturaConflictos,
|
||||||
useCreateLinea,
|
useCreateLinea,
|
||||||
useDeleteLinea,
|
useDeleteLinea,
|
||||||
usePlan,
|
usePlan,
|
||||||
@@ -338,6 +340,51 @@ function MapaCurricularPage() {
|
|||||||
const [ultimoHue, setUltimoHue] = useState<number | null>(null)
|
const [ultimoHue, setUltimoHue] = useState<number | null>(null)
|
||||||
const { mutate: updateAsignatura } = useUpdateAsignatura()
|
const { mutate: updateAsignatura } = useUpdateAsignatura()
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (data?.numero_ciclos) {
|
if (data?.numero_ciclos) {
|
||||||
@@ -351,6 +398,39 @@ function MapaCurricularPage() {
|
|||||||
}
|
}
|
||||||
}, [selectedLineaOption])
|
}, [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 manejarAgregarLinea = (nombre: string, color: string, hue: number) => {
|
||||||
const nombreNormalizado = nombre.trim()
|
const nombreNormalizado = nombre.trim()
|
||||||
if (!nombreNormalizado) return
|
if (!nombreNormalizado) return
|
||||||
@@ -614,14 +694,31 @@ function MapaCurricularPage() {
|
|||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
}
|
}
|
||||||
const handleDragOver = (e: React.DragEvent) => e.preventDefault()
|
const handleDragOver = (e: React.DragEvent) => e.preventDefault()
|
||||||
const handleDrop = (
|
const handleDrop = async (
|
||||||
e: React.DragEvent,
|
e: React.DragEvent,
|
||||||
cicloDestino: number | null,
|
cicloDestino: number | null,
|
||||||
lineaId: string | null,
|
lineaId: string | null,
|
||||||
) => {
|
) => {
|
||||||
e.preventDefault()
|
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) =>
|
setAsignaturas((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === draggedAsignatura
|
m.id === draggedAsignatura
|
||||||
@@ -629,23 +726,17 @@ function MapaCurricularPage() {
|
|||||||
: m,
|
: m,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const patch = {
|
|
||||||
numero_ciclo: cicloDestino,
|
|
||||||
linea_plan_id: lineaId,
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAsignatura(
|
updateAsignatura(
|
||||||
{ asignaturaId: draggedAsignatura, patch },
|
|
||||||
{
|
{
|
||||||
onError: (error) => {
|
asignaturaId: draggedAsignatura,
|
||||||
console.error('Error al mover:', error)
|
patch: { numero_ciclo: cicloDestino, linea_plan_id: lineaId },
|
||||||
// Opcional: Revertir el estado local si falla
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
{ onError: (err) => console.error('Error al mover:', err) },
|
||||||
)
|
)
|
||||||
|
|
||||||
setDraggedAsignatura(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDraggedAsignatura(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = useMemo(
|
const stats = useMemo(
|
||||||
@@ -1448,6 +1539,23 @@ function MapaCurricularPage() {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user