Merge pull request 'dialog warning al cambiar de ciclo una asignatura que rompería la seriación #210' (#230) 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 1m12s

Reviewed-on: #230
This commit was merged in pull request #230.
This commit is contained in:
2026-03-27 21:57:13 +00:00
4 changed files with 150 additions and 118 deletions
@@ -128,8 +128,6 @@ export default function AsignaturaDetailPage() {
} }
/* ---------- sincronizar API ---------- */ /* ---------- sincronizar API ---------- */
useEffect(() => { useEffect(() => {
console.log(requisitosFormateados)
if (asignaturaApi) setAsignatura(asignaturaApi) if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi, requisitosFormateados]) }, [asignaturaApi, requisitosFormateados])
@@ -182,7 +180,6 @@ function DatosGenerales({
const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => { const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => {
const raw = (data as any)?.criterios_de_evaluacion const raw = (data as any)?.criterios_de_evaluacion
console.log(raw)
if (!Array.isArray(raw)) return [] if (!Array.isArray(raw)) return []
@@ -232,7 +229,7 @@ function DatosGenerales({
if (isLoading) return <p>Cargando información...</p> if (isLoading) return <p>Cargando información...</p>
return ( return (
<div className="animate-in fade-in space-y-8 pb-8 duration-500"> <div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
{/* Encabezado de la Sección */} {/* Encabezado de la Sección */}
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center"> <div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
<div> <div>
@@ -414,8 +411,7 @@ function InfoCard({
useEffect(() => { useEffect(() => {
setData(initialContent) setData(initialContent)
setTempText(initialContent) setTempText(initialContent)
console.log(data)
console.log(initialContent)
if (type === 'evaluation') { if (type === 'evaluation') {
const raw = Array.isArray(initialContent) ? initialContent : [] const raw = Array.isArray(initialContent) ? initialContent : []
const rows: Array<CriterioEvaluacionRowDraft> = raw const rows: Array<CriterioEvaluacionRowDraft> = raw
@@ -457,10 +453,6 @@ function InfoCard({
}, [highlightToken]) }, [highlightToken])
const handleSave = () => { const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? ''))
console.log(clave)
console.log(tempText)
if (type === 'evaluation') { if (type === 'evaluation') {
const cleaned: Array<CriterioEvaluacionRow> = [] const cleaned: Array<CriterioEvaluacionRow> = []
for (const r of evalRows) { for (const r of evalRows) {
@@ -491,8 +483,6 @@ function InfoCard({
return return
} }
if (type === 'requirements') { if (type === 'requirements') {
console.log('entre aqui ')
// Si tempText es un array y tiene elementos, tomamos el ID del primero // Si tempText es un array y tiene elementos, tomamos el ID del primero
// Si es "none" o está vacío, mandamos null (para limpiar la seriación) // Si es "none" o está vacío, mandamos null (para limpiar la seriación)
const prerequisiteId = const prerequisiteId =
@@ -519,8 +509,6 @@ function InfoCard({
} }
const handleIARequest = (campoClave: string) => { const handleIARequest = (campoClave: string) => {
console.log(campoClave)
let targetClave = campoClave let targetClave = campoClave
if (type === 'evaluation' && !targetClave) { if (type === 'evaluation' && !targetClave) {
targetClave = 'criterios_de_evaluacion' targetClave = 'criterios_de_evaluacion'
@@ -529,8 +517,6 @@ function InfoCard({
if (targetClave === 'contenido') { if (targetClave === 'contenido') {
targetClave = 'contenido_tematico' targetClave = 'contenido_tematico'
} }
console.log(targetClave)
console.log(asignaturaId)
navigate({ navigate({
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura', to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
@@ -652,18 +638,12 @@ function InfoCard({
: 'none' : 'none'
} }
onValueChange={(val) => { onValueChange={(val) => {
console.log(availableSubjects)
const selected = availableSubjects?.find( const selected = availableSubjects?.find(
(s) => s.id === val, (s) => s.id === val,
) )
if (val === 'none' || !selected) { if (val === 'none' || !selected) {
console.log('guardando')
setTempText([]) setTempText([])
} else { } else {
console.log('hola')
setTempText([ setTempText([
{ {
id: selected.id, id: selected.id,
@@ -677,20 +657,29 @@ function InfoCard({
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<div className="flex-1 truncate text-left"> <div className="flex-1 truncate text-left">
<SelectValue placeholder="Selecciona una materia" /> <SelectValue placeholder="Selecciona una materia">
{Array.isArray(tempText) && tempText.length > 0
? `${tempText[0].code} - ${tempText[0].name}`
: undefined}
</SelectValue>
</div> </div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none"> <SelectItem value="none">
Ninguna (Sin seriación) Ninguna (Sin seriación)
</SelectItem> </SelectItem>
{availableSubjects?.map((asig) => ( {availableSubjects?.map((asig) => (
<SelectItem <SelectItem
key={asig.id} key={asig.id}
value={asig.id} value={asig.id}
className="max-w-[300px] sm:max-w-[500px]" className="max-w-[300px] sm:max-w-[500px]"
> >
<span className="block truncate"> <span className="text-primary font-bold">
[C{asig.numero_ciclo}]
</span>{' '}
<span className="inline-block truncate">
{asig.codigo} - {asig.nombre} {asig.codigo} - {asig.nombre}
</span> </span>
</SelectItem> </SelectItem>
@@ -36,7 +36,8 @@ export const AlertaConflicto = ({
))} ))}
</div> </div>
<p className="mt-2 text-xs font-semibold text-slate-500"> <p className="mt-2 text-xs font-semibold text-slate-500">
¿Deseas ignorar la regla y moverla de todos modos? ¿Deseas ignorar la regla y moverla de todos modos (Esto eliminará la
seriación)?
</p> </p>
</div> </div>
) )
@@ -62,13 +63,8 @@ export const AlertaConflicto = ({
<div className="mt-8 flex flex-col-reverse justify-end gap-3 sm:flex-row"> <div className="mt-8 flex flex-col-reverse justify-end gap-3 sm:flex-row">
<AlertDialog.Cancel asChild> <AlertDialog.Cancel asChild>
<Button <Button variant="ghost">Cancelar</Button>
variant="ghost" {/* Radix automáticamente llamará a onOpenChange(false) al hacer clic aquí */}
onClick={() => onConfirm()} // Si tu componente espera resolve
className="font-semibold text-slate-500 hover:text-slate-700"
>
Cancelar
</Button>
</AlertDialog.Cancel> </AlertDialog.Cancel>
<AlertDialog.Action asChild> <AlertDialog.Action asChild>
+58 -73
View File
@@ -6,7 +6,6 @@ import { useMemo, useState, useEffect, Fragment, useRef } from 'react'
import type { TipoAsignatura } from '@/data' import type { TipoAsignatura } from '@/data'
import type { Asignatura } from '@/types/plan' import type { Asignatura } from '@/types/plan'
import type { TablesUpdate } from '@/types/supabase'
import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto' import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -402,15 +401,19 @@ function MapaCurricularPage() {
asignatura: Asignatura, asignatura: Asignatura,
nuevoCiclo: number, nuevoCiclo: number,
) => { ) => {
// 1. Esperamos la interacción del usuario
const acepto = await validarConInterrupcion(asignatura.id, nuevoCiclo) const acepto = await validarConInterrupcion(asignatura.id, nuevoCiclo)
if (!acepto) { // 2. Limpiamos el estado del modal ANTES de seguir
setConfirmState(null) setConfirmState(null)
return
// 3. Verificamos la respuesta
if (acepto === false) {
return // Detenemos la ejecución aquí
} }
// 4. Solo si acepto es true, ejecutamos la API
const patch = { numero_ciclo: nuevoCiclo } const patch = { numero_ciclo: nuevoCiclo }
updateAsignatura( updateAsignatura(
{ asignaturaId: asignatura.id, patch: patch as any }, { asignaturaId: asignatura.id, patch: patch as any },
{ {
@@ -420,12 +423,9 @@ function MapaCurricularPage() {
m.id === asignatura.id ? { ...m, ciclo: nuevoCiclo } : m, m.id === asignatura.id ? { ...m, ciclo: nuevoCiclo } : m,
), ),
) )
setConfirmState(null) // Cerramos el modal de Radix al tener éxito
}, },
onError: (err) => { onError: (err) => {
// En lugar de alert nativo, podrías usar un toast aquí
console.error('Error al actualizar:', err) console.error('Error al actualizar:', err)
setConfirmState(null)
}, },
}, },
) )
@@ -587,48 +587,61 @@ function MapaCurricularPage() {
return val return val
} }
const handleSaveChanges = () => {
if (!editingData) return const procesarCambioAsignatura = async (
setAsignaturas((prev) => asignaturaId: string,
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)), nuevosDatos: Partial<Asignatura>,
) ) => {
type AsignaturaPatch = { const asignaturaOriginal = asignaturas.find((a) => a.id === asignaturaId)
codigo?: TablesUpdate<'asignaturas'>['codigo'] if (!asignaturaOriginal) return
nombre?: TablesUpdate<'asignaturas'>['nombre']
tipo?: TablesUpdate<'asignaturas'>['tipo'] // ¿Cambió el ciclo? Si es así, validamos seriación
creditos?: TablesUpdate<'asignaturas'>['creditos'] if (
horas_academicas?: TablesUpdate<'asignaturas'>['horas_academicas'] nuevosDatos.ciclo !== undefined &&
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes'] nuevosDatos.ciclo !== asignaturaOriginal.ciclo
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo'] ) {
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id'] const acepto = await validarConInterrupcion(
prerrequisito_asignatura_id?: string | null asignaturaId,
nuevosDatos.ciclo,
)
setConfirmState(null)
if (!acepto) return // El usuario canceló, no guardamos nada
} }
const patch: Partial<AsignaturaPatch> = {
nombre: editingData.nombre, // Si llegamos aquí, o no cambió el ciclo o el usuario aceptó el conflicto
codigo: editingData.clave, const patch = {
creditos: editingData.creditos, nombre: nuevosDatos.nombre ?? asignaturaOriginal.nombre,
horas_academicas: editingData.hd, codigo: nuevosDatos.clave ?? asignaturaOriginal.clave,
horas_independientes: editingData.hi, numero_ciclo: nuevosDatos.ciclo,
numero_ciclo: editingData.ciclo, linea_plan_id: nuevosDatos.lineaCurricularId,
linea_plan_id: editingData.lineaCurricularId, creditos: nuevosDatos.creditos,
prerrequisito_asignatura_id: editingData.prerrequisito_asignatura_id, horas_academicas: nuevosDatos.hd,
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA) horas_independientes: nuevosDatos.hi,
prerrequisito_asignatura_id: nuevosDatos.prerrequisito_asignatura_id,
tipo: nuevosDatos.tipo?.toUpperCase() as TipoAsignatura,
} }
updateAsignatura( updateAsignatura(
{ asignaturaId: editingData.id, patch: patch as any }, { asignaturaId, patch: patch as any },
{ {
onSuccess: () => { onSuccess: () => {
setIsEditModalOpen(false) setAsignaturas((prev) =>
// Opcional: Mostrar un toast de éxito prev.map((m) =>
}, m.id === asignaturaId ? { ...m, ...nuevosDatos } : m,
onError: (error) => { ),
console.error('Error al guardar:', error) )
alert('Hubo un error al guardar los cambios.') setIsEditModalOpen(false) // Cerramos el modal si estaba abierto
}, },
onError: (err) => console.error('Error al guardar:', err),
}, },
) )
} }
const handleSaveChanges = () => {
if (!editingData) return
// Llamamos a la lógica centralizada que incluye la alerta
procesarCambioAsignatura(editingData.id, editingData)
}
const unassignedAsignaturas = asignaturas.filter( const unassignedAsignaturas = asignaturas.filter(
(m) => m.ciclo === null || m.lineaCurricularId === null, (m) => m.ciclo === null || m.lineaCurricularId === null,
) )
@@ -700,41 +713,13 @@ function MapaCurricularPage() {
lineaId: string | null, lineaId: string | null,
) => { ) => {
e.preventDefault() e.preventDefault()
if (!draggedAsignatura) return if (!draggedAsignatura) return
// Buscamos la asignatura completa para tener sus datos // Solo disparamos la lógica si realmente hay un cambio de posición
const asignatura = asignaturas.find((a) => a.id === draggedAsignatura) procesarCambioAsignatura(draggedAsignatura, {
if (!asignatura) return ciclo: cicloDestino,
lineaCurricularId: lineaId,
// 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
? { ...m, ciclo: cicloDestino, lineaCurricularId: lineaId }
: m,
),
)
updateAsignatura(
{
asignaturaId: draggedAsignatura,
patch: { numero_ciclo: cicloDestino, linea_plan_id: lineaId },
},
{ onError: (err) => console.error('Error al mover:', err) },
)
}
setDraggedAsignatura(null) setDraggedAsignatura(null)
} }
@@ -9,9 +9,10 @@ import {
import { ArrowLeft, GraduationCap } from 'lucide-react' import { ArrowLeft, GraduationCap } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { lateralConfetti } from '@/components/ui/lateral-confetti' import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { useSubject, useUpdateAsignatura } from '@/data' import { usePlanAsignaturas, useSubject, useUpdateAsignatura } from '@/data'
export const Route = createFileRoute( export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId', '/planes/$planId/asignaturas/$asignaturaId',
@@ -73,7 +74,44 @@ function AsignaturaLayout() {
}) })
const { data: asignaturaApi, isLoading: loadingAsig } = const { data: asignaturaApi, isLoading: loadingAsig } =
useSubject(asignaturaId) useSubject(asignaturaId)
// 1. Asegúrate de tener estos estados en tu componente principal const { data: todasLasAsignaturas } = usePlanAsignaturas(planId)
const [confirmState, setConfirmState] = useState<{
isOpen: boolean
resolve: (value: boolean) => void
mensaje: string
} | null>(null)
const validarConInterrupcion = async (
nuevoCiclo: number,
): Promise<boolean> => {
if (!todasLasAsignaturas || !asignaturaApi) return true
const materiasConflicto = todasLasAsignaturas.filter((a) => {
const esPrerrequisitoConflictivo =
asignaturaApi.prerrequisito_asignatura_id === a.id &&
(a.numero_ciclo ?? 0) >= nuevoCiclo
const esDependienteConflictiva =
a.prerrequisito_asignatura_id === asignaturaApi.id &&
(a.numero_ciclo ?? 0) <= nuevoCiclo
return esPrerrequisitoConflictivo || esDependienteConflictiva
})
if (materiasConflicto.length === 0) return true
const listaNombres = materiasConflicto.map((m) => m.nombre)
return new Promise((resolve) => {
setConfirmState({
isOpen: true,
resolve,
mensaje: JSON.stringify({
main: `Mover "${asignaturaApi.nombre}" al ciclo ${nuevoCiclo} genera conflictos con:`,
materias: listaNombres,
}),
})
})
}
const updateAsignatura = useUpdateAsignatura() const updateAsignatura = useUpdateAsignatura()
@@ -97,16 +135,30 @@ function AsignaturaLayout() {
} }
}, [asignaturaApi]) }, [asignaturaApi])
const handleUpdateHeader = (key: string, value: string | number) => { const handleUpdateHeader = async (key: string, value: string | number) => {
// 1. Si es ciclo, validamos antes de hacer nada
if (key === 'ciclo') {
const nuevoCiclo = Number(value)
const acepto = await validarConInterrupcion(nuevoCiclo)
setConfirmState(null) // Cerramos el modal tras la respuesta
if (!acepto) {
// Revertimos el estado local al valor de la API si cancela
setHeaderData((prev) => ({
...prev,
ciclo: asignaturaApi?.numero_ciclo ?? 0,
}))
return
}
}
// 2. Si no es ciclo o si aceptó el conflicto, procedemos
const newData = { ...headerData, [key]: value } const newData = { ...headerData, [key]: value }
setHeaderData(newData) setHeaderData(newData)
const patch: Record<string, any> = const patch: Record<string, any> =
key === 'ciclo' key === 'ciclo' ? { numero_ciclo: value } : { [key]: value }
? { numero_ciclo: value }
: {
[key]: value,
}
updateAsignatura.mutate({ updateAsignatura.mutate({
asignaturaId, asignaturaId,
@@ -166,11 +218,6 @@ function AsignaturaLayout() {
onSave={(val) => handleUpdateHeader('nombre', val)} onSave={(val) => handleUpdateHeader('nombre', val)}
/> />
</h1> </h1>
{
// console.log(headerData),
console.log(asignaturaApi.planes_estudio?.nombre)
}
<div className="flex flex-wrap gap-4 text-sm text-blue-200"> <div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4 shrink-0" /> <GraduationCap className="h-4 w-4 shrink-0" />
@@ -213,6 +260,21 @@ function AsignaturaLayout() {
</div> </div>
</section> </section>
{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}
/>
)}
{/* TABS */} {/* TABS */}
<nav className="sticky top-0 z-20 border-b bg-white"> <nav className="sticky top-0 z-20 border-b bg-white">