8 Commits

Author SHA1 Message Date
b1a233fa8c Feat: generación IA de asignaturas, navegación con confetti y ajustes de API
closes #63:
- Añadido AIGenerateSubjectInput y nueva implementación ai_generate_subject que envía FormData (soporta archivosAdjuntos) al Edge Function.
- Creado hook useGenerateSubjectAI (mutation) y usado en WizardControls de asignaturas para generar la asignatura vía IA.
- WizardControls (asignaturas) construye el payload IA, invoca la mutación y navega al detalle de la asignatura creada pasando state.showConfetti para lanzar confetti.
- Ajustes en subjects.api.ts (nombres de endpoint, tipos y envío de datos) y sincronización de tipos en WizardControls (plan y campos básicos).
- Ruta de detalle de asignatura ($asignaturaId) ahora lee location.state.showConfetti y dispara lateralConfetti al entrar.
- Eliminado el prop onCreate del modal de nueva asignatura (la creación IA se gestiona internamente).
2026-02-05 13:41:10 -06:00
f00fabeac5 Fix #63: mostrar mensaje real de error de Edge Function en UI
- Mejorar invokeEdge para parsear el body JSON de errores HTTP de las Edge Functions y extraer un message humano (soporta { error: { message } }, { error: "..." } y { message: "..." }).
- EdgeFunctionError ahora incluye status y details; se manejan también FunctionsRelayError y FunctionsFetchError con mensajes más descriptivos.
- Ajustes en el front: WizardControls muestra el mensaje real del error (no el genérico "Edge Function returned a non-2xx status code"), y se corrige navegación/logging tras crear plan IA (uso de `plan` en vez de `data` y `navigate` a `/planes/{plan.id}`).
- Actualización de types/API: renombrados campos en AIGeneratePlanInput para alinear nombres (descripcionEnfoqueAcademico, instruccionesAdicionalesIA).
2026-02-05 13:41:09 -06:00
c82fac52f7 Refactor: unifica wizards con WizardLayout/WizardResponsiveHeader y convierte asignaturas en layout con Outlet
- Se introdujo un layout genérico de wizard (WizardLayout) con headerSlot/footerSlot y se migraron los modales de Nuevo Plan y Nueva Asignatura a esta estructura usando defineStepper.
- Se creó y reutilizó WizardResponsiveHeader para un encabezado responsivo consistente (progreso en móvil y navegación en escritorio) en ambos wizards.
- Se homologó WizardControls del wizard de asignaturas para alinearlo al patrón del wizard de planes (props onPrev/onNext, flags de disable, manejo de error/loading y creación).
- Se mejoró la captura de datos en el wizard de asignatura: créditos como flotante con 2 decimales, placeholders/estilos en inputs/selects y uso de catálogo real de estructuras vía useSubjectEstructuras con qk.estructurasAsignatura.
- Se reorganizó la sección de asignaturas del detalle del plan: el contenido del antiguo index se movió a asignaturas.tsx como layout y se agregó <Outlet />; navegación a “nueva asignatura” ajustada al path correcto.
2026-02-05 13:41:09 -06:00
fafe90e5e8 Merge pull request 'En el mapa curricular editar los nombres de las líneas curriculares #57' (#65) from issue/57-en-el-mapa-curricular-editar-los-nombres-de-las-ln into main
Reviewed-on: #65
2026-02-04 20:34:07 +00:00
0e9648d61a En el mapa curricular editar los nombres de las líneas curriculares fix #57 2026-02-04 14:29:46 -06:00
bd8bef142a Merge remote-tracking branch 'origin/issue/45-integrar-el-wizard-de-creacin-de-materia' into issue/42-que-tenga-persistencia-el-plan-de-estudios 2026-02-04 07:29:35 -06:00
261dec7fa9 Se agrega persistencia en tab de datos y mapa curricular
fix #42
fix #54
2026-02-03 16:05:05 -06:00
64d9aa336f Se agrega persistencia a planes en datos, se arregla bug de nombre de claves en asignaturas, se cambia en historial clves por los titulos corresppndientes 2026-01-30 15:51:43 -06:00
9 changed files with 433 additions and 82 deletions

View File

@@ -442,20 +442,13 @@ function DatosGenerales({
? config.examples[0] ? config.examples[0]
: '' : ''
// 2. CONTENIDO REAL (Viene de data.datos -> valoresActuales)
// El problema: Si 'description' en 'datos' es igual a la de la 'estructura',
// el usuario aún no ha redactado nada real.
const valActual = valoresActuales[key] const valActual = valoresActuales[key]
// Lógica para determinar si mostrar el contenido o dejarlo vacío (para que salga el placeholder)
// Si el contenido en 'datos' es idéntico a la instrucción de la 'estructura',
// asumimos que no hay contenido real todavía.
const isContentEmpty = const isContentEmpty =
!valActual?.description || !valActual?.description ||
valActual.description === config.description valActual.description === config.description
const currentContent = valActual?.description ?? '' const currentContent = valActual ?? ''
return ( return (
<InfoCard <InfoCard

View File

@@ -165,7 +165,7 @@ export async function plan_asignaturas_list(
const { data, error } = await supabase const { data, error } = await supabase
.from('asignaturas') .from('asignaturas')
.select( .select(
'id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,horas_independientes,horas_academicas,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en', 'id,plan_estudio_id,horas_academicas, horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en',
) )
.eq('plan_estudio_id', planId) .eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false }) .order('numero_ciclo', { ascending: true, nullsFirst: false })

View File

@@ -36,7 +36,7 @@ export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
.from('asignaturas') .from('asignaturas')
.select( .select(
` `
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,horas_independientes,horas_academicas,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
planes_estudio( planes_estudio(
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)) carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
@@ -277,3 +277,69 @@ export async function subjects_get_structure_catalog(): Promise<
return data return data
} }
export async function asignaturas_update(
asignaturaId: UUID,
patch: Partial<Asignatura>, // O tu tipo específico para el Patch de materias
): Promise<Asignatura> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignaturas')
.update(patch)
.eq('id', asignaturaId)
.select() // Trae la materia actualizada
.single()
throwIfError(error)
return requireData(data, 'No se pudo actualizar la asignatura.')
}
// Insertar una nueva línea
export async function lineas_insert(linea: {
nombre: string
plan_estudio_id: string
orden: number
area?: string
}) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('lineas_plan') // Asegúrate que el nombre de la tabla sea correcto
.insert([linea])
.select()
.single()
if (error) throw error
return data
}
// Actualizar una línea existente
export async function lineas_update(
lineaId: string,
patch: { nombre?: string; orden?: number; area?: string },
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('lineas_plan')
.update(patch)
.eq('id', lineaId)
.select()
.single()
if (error) throw error
return data
}
export async function lineas_delete(lineaId: string) {
const supabase = supabaseBrowser()
// Nota: Si configuraste "ON DELETE SET NULL" en tu base de datos,
// las asignaturas se desvincularán solas. Si no, Supabase podría dar error.
const { error } = await supabase
.from('lineas_plan')
.delete()
.eq('id', lineaId)
if (error) throw error
return lineaId
}

View File

@@ -23,6 +23,7 @@ import {
plans_update_fields, plans_update_fields,
plans_update_map, plans_update_map,
} from '../api/plans.api' } from '../api/plans.api'
import { lineas_delete } from '../api/subjects.api'
import { qk } from '../query/keys' import { qk } from '../query/keys'
import type { import type {
@@ -257,3 +258,15 @@ export function useGeneratePlanDocumento() {
}, },
}) })
} }
export function useDeleteLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: lineas_delete,
onSuccess: (idEliminado) => {
// Invalidamos para que las materias y líneas se refresquen
qc.invalidateQueries({ queryKey: ['plan_lineas'] })
qc.invalidateQueries({ queryKey: ['plan_asignaturas'] })
},
})
}

View File

@@ -2,6 +2,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
ai_generate_subject, ai_generate_subject,
asignaturas_update,
lineas_insert,
lineas_update,
subjects_bibliografia_list, subjects_bibliografia_list,
subjects_clone_from_existing, subjects_clone_from_existing,
subjects_create_manual, subjects_create_manual,
@@ -207,3 +210,53 @@ export function useGenerateSubjectDocumento() {
}, },
}) })
} }
export function useUpdateAsignatura() {
const qc = useQueryClient()
return useMutation({
mutationFn: (vars: {
asignaturaId: UUID
patch: Partial<SubjectsUpdateFieldsPatch>
}) => asignaturas_update(vars.asignaturaId, vars.patch),
onSuccess: (updated) => {
// 1. Actualizamos la materia específica en la caché si tienes un query de "detalle"
qc.setQueryData(['asignatura', updated.id], updated)
// 2. IMPORTANTÍSIMO: Invalidamos la lista de materias del plan
// para que el mapa curricular vea los cambios (créditos, horas, nombre, etc.)
qc.invalidateQueries({
queryKey: ['plan_asignaturas', updated.plan_estudio_id],
})
// 3. Si tienes una lista general de asignaturas, también la invalidamos
qc.invalidateQueries({ queryKey: ['asignaturas', 'list'] })
},
})
}
export function useCreateLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: lineas_insert,
onSuccess: (nuevaLinea) => {
qc.invalidateQueries({
queryKey: ['plan_lineas', nuevaLinea.plan_estudio_id],
})
},
})
}
export function useUpdateLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: (vars: { lineaId: string; patch: any }) =>
lineas_update(vars.lineaId, vars.patch),
onSuccess: (updated) => {
qc.invalidateQueries({
queryKey: ['plan_lineas', updated.plan_estudio_id],
})
},
})
}

View File

@@ -221,7 +221,11 @@ function RouteComponent() {
<Tab to="/planes/$planId/" params={{ planId }}> <Tab to="/planes/$planId/" params={{ planId }}>
Datos Generales Datos Generales
</Tab> </Tab>
<Tab to="/planes/$planId/mapa" params={{ planId }}> <Tab
to="/planes/$planId/mapa"
params={{ planId }}
search={{ ciclo: data?.numero_ciclos }}
>
Mapa Curricular Mapa Curricular
</Tab> </Tab>
<Tab to="/planes/$planId/asignaturas" params={{ planId }}> <Tab to="/planes/$planId/asignaturas" params={{ planId }}>
@@ -236,7 +240,13 @@ function RouteComponent() {
<Tab to="/planes/$planId/documento" params={{ planId }}> <Tab to="/planes/$planId/documento" params={{ planId }}>
Documento Documento
</Tab> </Tab>
<Tab to="/planes/$planId/historial" params={{ planId }}> <Tab
to="/planes/$planId/historial"
params={{ planId }}
search={{
structure: data?.estructuras_plan?.definicion?.properties,
}}
>
Historial Historial
</Tab> </Tab>
</nav> </nav>
@@ -290,16 +300,20 @@ const InfoCard = forwardRef<
function Tab({ function Tab({
to, to,
params, params,
search,
children, children,
}: { }: {
to: string to: string
params?: any params?: any
search?: any
children: React.ReactNode children: React.ReactNode
}) { }) {
console.log(search)
return ( return (
<Link <Link
to={to} to={to}
params={params} params={params}
search={search}
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800" className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800"
activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }} activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }}
activeOptions={{ activeOptions={{

View File

@@ -27,6 +27,9 @@ import { usePlanHistorial } from '@/data/hooks/usePlans'
export const Route = createFileRoute('/planes/$planId/_detalle/historial')({ export const Route = createFileRoute('/planes/$planId/_detalle/historial')({
component: RouteComponent, component: RouteComponent,
validateSearch: (search: { structure?: any }) => ({
structure: search.structure ?? null,
}),
}) })
const getEventConfig = (tipo: string, campo: string) => { const getEventConfig = (tipo: string, campo: string) => {
@@ -58,6 +61,9 @@ const getEventConfig = (tipo: string, campo: string) => {
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const { data: rawData, isLoading } = usePlanHistorial(planId) const { data: rawData, isLoading } = usePlanHistorial(planId)
const { structure } = Route.useSearch()
console.log(structure?.vigencia?.title)
console.log(structure)
// ESTADOS PARA EL MODAL // ESTADOS PARA EL MODAL
const [selectedEvent, setSelectedEvent] = useState<any>(null) const [selectedEvent, setSelectedEvent] = useState<any>(null)
@@ -77,7 +83,9 @@ function RouteComponent() {
description: description:
item.campo === 'datos' item.campo === 'datos'
? `Actualización general de: ${item.valor_nuevo?.nombre || 'información del plan'}` ? `Actualización general de: ${item.valor_nuevo?.nombre || 'información del plan'}`
: `Se modificó el campo ${item.campo}`, : `Se modificó el campo ${
structure?.[item.campo]?.title ?? item.campo
}`,
date: parseISO(item.cambiado_en), date: parseISO(item.cambiado_en),
icon: config.icon, icon: config.icon,
campo: item.campo, campo: item.campo,

View File

@@ -18,7 +18,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { usePlan } from '@/data' import { usePlan, useUpdatePlanFields } from '@/data'
// import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea // import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea
export const Route = createFileRoute('/planes/$planId/_detalle/')({ export const Route = createFileRoute('/planes/$planId/_detalle/')({
@@ -39,7 +39,7 @@ function DatosGeneralesPage() {
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('') const [editValue, setEditValue] = useState('')
const location = useLocation() const location = useLocation()
const updatePlan = useUpdatePlanFields()
// Confetti al llegar desde creación // Confetti al llegar desde creación
useEffect(() => { useEffect(() => {
if (location.state.showConfetti) { if (location.state.showConfetti) {
@@ -122,14 +122,47 @@ function DatosGeneralesPage() {
setEditValue('') setEditValue('')
} }
const handleSave = (id: string) => { const handleSave = (campo: DatosGeneralesField) => {
// Actualizamos el estado local de la lista if (!data?.datos) return
const currentValue = (data.datos as any)[campo.clave]
let newValue: any
if (
typeof currentValue === 'object' &&
currentValue !== null &&
'description' in currentValue
) {
// Caso 1: objeto con description
newValue = {
...currentValue,
description: editValue,
}
} else {
// Caso 2: valor plano (string, number, etc)
newValue = editValue
}
const datosActualizados = {
...data.datos,
[campo.clave]: newValue,
}
updatePlan.mutate({
planId,
patch: {
datos: datosActualizados,
},
})
// UI optimista
setCampos((prev) => setCampos((prev) =>
prev.map((c) => (c.id === id ? { ...c, value: editValue } : c)), prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)),
) )
setEditingId(null) setEditingId(null)
setEditValue('') setEditValue('')
// toast.success('Cambios guardados localmente')
} }
const handleIARequest = (clave: string) => { const handleIARequest = (clave: string) => {
@@ -245,7 +278,7 @@ function DatosGeneralesPage() {
<Button <Button
size="sm" size="sm"
className="bg-teal-600 hover:bg-teal-700" className="bg-teal-600 hover:bg-teal-700"
onClick={() => handleSave(campo.id)} onClick={() => handleSave(campo)}
> >
<Check size={14} className="mr-1" /> Guardar <Check size={14} className="mr-1" /> Guardar
</Button> </Button>

View File

@@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/label-has-associated-control */
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { import {
@@ -33,7 +34,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { usePlanAsignaturas, usePlanLineas } from '@/data' import {
useCreateLinea,
useDeleteLinea,
usePlanAsignaturas,
usePlanLineas,
useUpdateAsignatura,
useUpdateLinea,
} from '@/data'
// --- Mapeadores (Fuera del componente para mayor limpieza) --- // --- Mapeadores (Fuera del componente para mayor limpieza) ---
const mapLineasToLineaCurricular = ( const mapLineasToLineaCurricular = (
@@ -60,8 +68,9 @@ const mapAsignaturasToAsignaturas = (
tipo: asig.tipo === 'OBLIGATORIA' ? 'obligatoria' : 'optativa', tipo: asig.tipo === 'OBLIGATORIA' ? 'obligatoria' : 'optativa',
estado: 'borrador', estado: 'borrador',
orden: asig.orden_celda ?? 0, orden: asig.orden_celda ?? 0,
hd: Math.floor((asig.horas_semana ?? 0) / 2), // Mapeo directo de los nuevos campos de la API
hi: Math.ceil((asig.horas_semana ?? 0) / 2), hd: asig.horas_academicas ?? 0,
hi: asig.horas_independientes ?? 0,
prerrequisitos: [], prerrequisitos: [],
})) }))
} }
@@ -157,11 +166,19 @@ function AsignaturaCardItem({
export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({ export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
component: MapaCurricularPage, component: MapaCurricularPage,
validateSearch: (search: { ciclo?: number }) => ({
ciclo: search.ciclo ?? null,
}),
}) })
function MapaCurricularPage() { function MapaCurricularPage() {
const { planId } = Route.useParams() // Idealmente usa el ID de la ruta const { planId } = Route.useParams() // Idealmente usa el ID de la ruta
const { ciclo } = Route.useSearch()
const [editingLineaId, setEditingLineaId] = useState<string | null>(null)
const [tempNombreLinea, setTempNombreLinea] = useState('')
const { mutate: createLinea } = useCreateLinea()
const { mutate: updateLineaApi } = useUpdateLinea()
const { mutate: deleteLineaApi } = useDeleteLinea()
// 1. Fetch de Datos // 1. Fetch de Datos
const { data: asignaturasApi, isLoading: loadingAsig } = const { data: asignaturasApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId) usePlanAsignaturas(planId)
@@ -178,59 +195,83 @@ function MapaCurricularPage() {
useState<Asignatura | null>(null) useState<Asignatura | null>(null)
const [hasAreaComun, setHasAreaComun] = useState(false) const [hasAreaComun, setHasAreaComun] = useState(false)
const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado
const { mutate: updateAsignatura, isPending } = useUpdateAsignatura()
const manejarAgregarLinea = (nombre: string) => { const manejarAgregarLinea = (nombre: string) => {
const nombreNormalizado = nombre.trim() const nombreNormalizado = nombre.trim()
// 1. Validar que no esté vacío // 1. Validar vacío
if (!nombreNormalizado) return if (!nombreNormalizado) return
// 2. Validar duplicados (Insensible a mayúsculas/minúsculas y acentos) // 2. Validar duplicados en el estado local (Insensible a mayúsculas/acentos)
const nombreParaComparar = nombreNormalizado const nombreBusqueda = nombreNormalizado
.toLowerCase() .toLowerCase()
.normalize('NFD') .normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') .replace(/[\u0300-\u036f]/g, '')
const yaExiste = lineas.some((l) => { const yaExiste = lineas.some((l) => {
const lineaNombreBase = l.nombre const lineaExistente = l.nombre
.toLowerCase() .toLowerCase()
.normalize('NFD') .normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') .replace(/[\u0300-\u036f]/g, '')
return lineaNombreBase === nombreParaComparar return lineaExistente === nombreBusqueda
}) })
if (yaExiste) { if (yaExiste) {
alert(`La línea "${nombreNormalizado}" ya existe.`) alert(`La línea "${nombreNormalizado}" ya existe en este plan.`)
return return // DETIENE la ejecución aquí, no llega a la mutación
} }
// 3. Validar Área Común (usando tu lógica previa) // 3. Si pasa las validaciones, procedemos con la persistencia
const esAreaComun = const maxOrden = lineas.reduce((max, l) => Math.max(max, l.orden || 0), 0)
nombreNormalizado.toLowerCase() === 'área común' ||
nombreNormalizado.toLowerCase() === 'area comun'
if (esAreaComun && hasAreaComun) { createLinea(
alert('El Área Común ya ha sido agregada.') {
return
}
// 4. Agregar la línea si todo está bien
const nueva = {
id: crypto.randomUUID(),
nombre: nombreNormalizado, nombre: nombreNormalizado,
orden: lineas.length + 1, plan_estudio_id: planId,
orden: maxOrden + 1,
area: 'sin asignar',
},
{
onSuccess: (nueva) => {
// Sincronización local que ya teníamos
const mapeada = {
id: nueva.id,
nombre: nueva.nombre,
orden: nueva.orden,
color: '#1976d2', color: '#1976d2',
} }
setLineas((prev) => [...prev, mapeada])
setLineas([...lineas, nueva]) setNombreNuevaLinea('')
},
if (esAreaComun) { },
setHasAreaComun(true) )
}
const guardarEdicionLinea = (id: string) => {
if (!tempNombreLinea.trim()) {
setEditingLineaId(null)
return
} }
setNombreNuevaLinea('') // Limpiar input updateLineaApi(
{
lineaId: id,
patch: { nombre: tempNombreLinea.trim() },
},
{
onSuccess: (lineaActualizada) => {
// ACTUALIZACIÓN MANUAL DEL ESTADO LOCAL
setLineas((prev) =>
prev.map((l) =>
l.id === id ? { ...l, nombre: lineaActualizada.nombre } : l,
),
)
setEditingLineaId(null)
setTempNombreLinea('')
},
},
)
} }
const tieneAreaComun = useMemo(() => { const tieneAreaComun = useMemo(() => {
return lineas.some( return lineas.some(
(l) => (l) =>
@@ -249,7 +290,7 @@ function MapaCurricularPage() {
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi)) if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
}, [lineasApi]) }, [lineasApi])
const ciclosTotales = 9 const ciclosTotales = Number(ciclo)
const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1) const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1)
// Nuevo estado para controlar los datos temporales del modal de edición // Nuevo estado para controlar los datos temporales del modal de edición
@@ -263,13 +304,41 @@ function MapaCurricularPage() {
setAsignaturas((prev) => setAsignaturas((prev) =>
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)), prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
) )
// setIsEditModalOpen(false)
// Preparamos el patch con la estructura de tu tabla
const patch = {
nombre: editingData.nombre,
codigo: editingData.clave,
creditos: editingData.creditos,
horas_academicas: editingData.hd,
horas_independientes: editingData.hi,
numero_ciclo: editingData.ciclo,
linea_plan_id: editingData.lineaCurricularId,
tipo: editingData.tipo.toUpperCase(), // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
// datos: editingData.datos, // Si editaste algo del JSONB
}
updateAsignatura(
{ asignaturaId: editingData.id, patch },
{
onSuccess: () => {
setIsEditModalOpen(false) setIsEditModalOpen(false)
// Opcional: Mostrar un toast de éxito
},
onError: (error) => {
console.error('Error al guardar:', error)
alert('Hubo un error al guardar los cambios.')
},
},
)
} }
// 2. MODIFICACIÓN: Zona de soltado siempre visible // 2. MODIFICACIÓN: Zona de soltado siempre visible
// Cambiamos la condición: Mostramos la sección si hay asignaturas sin asignar // Cambiamos la condición: Mostramos la sección si hay asignaturas sin asignar
// O si simplemente queremos tener el "depósito" disponible. // O si simplemente queremos tener el "depósito" disponible.
const unassignedAsignaturas = asignaturas.filter((m) => m.ciclo === null) const unassignedAsignaturas = asignaturas.filter(
(m) => m.ciclo === null || m.lineaCurricularId === null,
)
// --- Lógica de Gestión --- // --- Lógica de Gestión ---
const agregarLinea = (nombre: string) => { const agregarLinea = (nombre: string) => {
@@ -278,14 +347,37 @@ function MapaCurricularPage() {
} }
const borrarLinea = (id: string) => { const borrarLinea = (id: string) => {
// 1. Opcional: Confirmación de seguridad
if (
!confirm(
'¿Estás seguro de eliminar esta línea? Las materias asignadas volverán a la bandeja de entrada.',
)
) {
return
}
// 2. Llamada a la API
deleteLineaApi(id, {
onSuccess: () => {
// 3. Actualización instantánea del estado local
// Primero: Las materias que estaban en esa línea pasan a ser "huérfanas"
setAsignaturas((prev) => setAsignaturas((prev) =>
prev.map((m) => prev.map((asig) =>
m.lineaCurricularId === id asig.lineaCurricularId === id
? { ...m, ciclo: null, lineaCurricularId: null } ? { ...asig, ciclo: null, lineaCurricularId: null }
: m, : asig,
), ),
) )
// Segundo: Quitamos la línea del estado
setLineas((prev) => prev.filter((l) => l.id !== id)) setLineas((prev) => prev.filter((l) => l.id !== id))
},
onError: (error) => {
console.error(error)
alert('No se pudo eliminar la línea. Verifica si tiene dependencias.')
},
})
} }
// --- Selectores/Cálculos --- // --- Selectores/Cálculos ---
@@ -304,7 +396,7 @@ function MapaCurricularPage() {
const getSubtotalLinea = (lineaId: string) => { const getSubtotalLinea = (lineaId: string) => {
return asignaturas return asignaturas
.filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null) .filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null) // Aseguramos que pertenezca a la línea Y tenga ciclo
.reduce( .reduce(
(acc, m) => ({ (acc, m) => ({
cr: acc.cr + (m.creditos || 0), cr: acc.cr + (m.creditos || 0),
@@ -327,6 +419,7 @@ function MapaCurricularPage() {
) => { ) => {
e.preventDefault() e.preventDefault()
if (draggedAsignatura) { if (draggedAsignatura) {
// 1. Actualización optimista del UI
setAsignaturas((prev) => setAsignaturas((prev) =>
prev.map((m) => prev.map((m) =>
m.id === draggedAsignatura m.id === draggedAsignatura
@@ -334,6 +427,23 @@ function MapaCurricularPage() {
: m, : m,
), ),
) )
// 2. Persistir en la API
const patch = {
numero_ciclo: ciclo,
linea_plan_id: lineaId,
}
updateAsignatura(
{ asignaturaId: draggedAsignatura, patch },
{
onError: (error) => {
console.error('Error al mover:', error)
// Opcional: Revertir el estado local si falla
},
},
)
setDraggedAsignatura(null) setDraggedAsignatura(null)
} }
} }
@@ -368,10 +478,15 @@ function MapaCurricularPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{asignaturas.filter((m) => !m.ciclo).length > 0 && ( {asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId).length >
0 && (
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50"> <Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
<AlertTriangle size={14} className="mr-1" />{' '} <AlertTriangle size={14} className="mr-1" />{' '}
{asignaturas.filter((m) => !m.ciclo).length} sin asignar {
asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId)
.length
}{' '}
sin asignar
</Badge> </Badge>
)} )}
<DropdownMenu> <DropdownMenu>
@@ -465,11 +580,33 @@ function MapaCurricularPage() {
<div <div
className={`flex items-center justify-between rounded-xl border-l-4 p-4 ${lineColors[idx % lineColors.length]}`} className={`flex items-center justify-between rounded-xl border-l-4 p-4 ${lineColors[idx % lineColors.length]}`}
> >
<span className="text-xs font-bold">{linea.nombre}</span> {editingLineaId === linea.id ? (
<Input
autoFocus
className="h-7 bg-white text-xs"
value={tempNombreLinea}
onChange={(e) => setTempNombreLinea(e.target.value)}
onBlur={() => guardarEdicionLinea(linea.id)}
onKeyDown={(e) =>
e.key === 'Enter' && guardarEdicionLinea(linea.id)
}
/>
) : (
<span
className="cursor-pointer text-xs font-bold hover:underline"
onClick={() => {
setEditingLineaId(linea.id)
setTempNombreLinea(linea.nombre)
}}
>
{linea.nombre}
</span>
)}
<Trash2 <Trash2
size={14} size={14}
className="cursor-pointer text-slate-400 hover:text-red-500" className="cursor-pointer text-slate-400 hover:text-red-500"
onClick={() => borrarLinea(linea.id)} onClick={() => borrarLinea(linea.id)} // Aquí también podrías añadir una mutación delete
/> />
</div> </div>
@@ -477,6 +614,8 @@ function MapaCurricularPage() {
<div <div
key={ciclo} key={ciclo}
onDragOver={handleDragOver} onDragOver={handleDragOver}
// ANTES: onDrop={(e) => handleDrop(e, null, null)}
// AHORA: Usamos las variables 'ciclo' y 'linea.id' del map
onDrop={(e) => handleDrop(e, ciclo, linea.id)} onDrop={(e) => handleDrop(e, ciclo, linea.id)}
className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2" className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2"
> >
@@ -588,7 +727,10 @@ function MapaCurricularPage() {
{/* Modal de Edición */} {/* Modal de Edición */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}> <Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="sm:max-w-[550px]"> <DialogContent
className="sm:max-w-[550px]"
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader> <DialogHeader>
<DialogTitle className="font-bold text-slate-700"> <DialogTitle className="font-bold text-slate-700">
Editar Asignatura Editar Asignatura
@@ -734,32 +876,61 @@ function MapaCurricularPage() {
</div> </div>
</div> </div>
{/* Fila 4: Seriación (Igual a tu imagen) */} {/* Fila 4: Seriación (Prerrequisitos) */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase"> <label className="text-xs font-bold text-slate-500 uppercase">
Seriación (Prerrequisitos) Seriación (Prerrequisitos)
</label> </label>
<Select> <Select
onValueChange={(val) => {
// Evitamos duplicados en la lista de prerrequisitos local
if (!editingData.prerrequisitos.includes(val)) {
setEditingData({
...editingData,
prerrequisitos: [...editingData.prerrequisitos, val],
})
}
}}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Seleccionar asignatura..." /> <SelectValue placeholder="Seleccionar asignatura..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{asignaturas.map((m) => ( {/* FILTRO CLAVE:
Solo mostramos materias cuyo ID sea diferente al de la materia que estamos editando
*/}
{asignaturas
.filter((m) => m.id !== editingData.id)
.map((m) => (
<SelectItem key={m.id} value={m.clave}> <SelectItem key={m.id} value={m.clave}>
{m.nombre} {m.nombre} ({m.clave})
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<div className="mt-2 flex gap-2">
{/* Aquí usamos el array vacío que inicializamos en el mapeador */} {/* Visualización de los prerrequisitos seleccionados */}
<div className="mt-2 flex flex-wrap gap-2">
{editingData.prerrequisitos.map((pre) => ( {editingData.prerrequisitos.map((pre) => (
<Badge <Badge
key={pre} key={pre}
variant="secondary" variant="secondary"
className="bg-slate-100 text-slate-600" className="bg-slate-100 text-slate-600"
> >
{pre} <span className="ml-1 cursor-pointer">×</span> {pre}
<button
className="ml-1 hover:text-red-500"
onClick={() => {
setEditingData({
...editingData,
prerrequisitos: editingData.prerrequisitos.filter(
(p) => p !== pre,
),
})
}}
>
×
</button>
</Badge> </Badge>
))} ))}
</div> </div>