Compare commits
8 Commits
268ac064b1
...
b1a233fa8c
| Author | SHA1 | Date | |
|---|---|---|---|
| b1a233fa8c | |||
| f00fabeac5 | |||
| c82fac52f7 | |||
| fafe90e5e8 | |||
| 0e9648d61a | |||
| bd8bef142a | |||
| 261dec7fa9 | |||
| 64d9aa336f |
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
nombre: nombreNormalizado,
|
||||||
}
|
plan_estudio_id: planId,
|
||||||
|
orden: maxOrden + 1,
|
||||||
// 4. Agregar la línea si todo está bien
|
area: 'sin asignar',
|
||||||
const nueva = {
|
},
|
||||||
id: crypto.randomUUID(),
|
{
|
||||||
nombre: nombreNormalizado,
|
onSuccess: (nueva) => {
|
||||||
orden: lineas.length + 1,
|
// Sincronización local que ya teníamos
|
||||||
color: '#1976d2',
|
const mapeada = {
|
||||||
}
|
id: nueva.id,
|
||||||
|
nombre: nueva.nombre,
|
||||||
setLineas([...lineas, nueva])
|
orden: nueva.orden,
|
||||||
|
color: '#1976d2',
|
||||||
if (esAreaComun) {
|
}
|
||||||
setHasAreaComun(true)
|
setLineas((prev) => [...prev, mapeada])
|
||||||
}
|
setNombreNuevaLinea('')
|
||||||
|
},
|
||||||
setNombreNuevaLinea('') // Limpiar input
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
const guardarEdicionLinea = (id: string) => {
|
||||||
|
if (!tempNombreLinea.trim()) {
|
||||||
|
setEditingLineaId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
// 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)
|
||||||
|
// 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) => {
|
||||||
setAsignaturas((prev) =>
|
// 1. Opcional: Confirmación de seguridad
|
||||||
prev.map((m) =>
|
if (
|
||||||
m.lineaCurricularId === id
|
!confirm(
|
||||||
? { ...m, ciclo: null, lineaCurricularId: null }
|
'¿Estás seguro de eliminar esta línea? Las materias asignadas volverán a la bandeja de entrada.',
|
||||||
: m,
|
)
|
||||||
),
|
) {
|
||||||
)
|
return
|
||||||
setLineas((prev) => prev.filter((l) => l.id !== id))
|
}
|
||||||
|
|
||||||
|
// 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) =>
|
||||||
|
prev.map((asig) =>
|
||||||
|
asig.lineaCurricularId === id
|
||||||
|
? { ...asig, ciclo: null, lineaCurricularId: null }
|
||||||
|
: asig,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Segundo: Quitamos la línea del estado
|
||||||
|
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:
|
||||||
<SelectItem key={m.id} value={m.clave}>
|
Solo mostramos materias cuyo ID sea diferente al de la materia que estamos editando
|
||||||
{m.nombre}
|
*/}
|
||||||
</SelectItem>
|
{asignaturas
|
||||||
))}
|
.filter((m) => m.id !== editingData.id)
|
||||||
|
.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.clave}>
|
||||||
|
{m.nombre} ({m.clave})
|
||||||
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user