Se agrega persistencia en tab de datos y mapa curricular

fix #42
fix #54
This commit is contained in:
2026-02-03 16:05:05 -06:00
parent 64d9aa336f
commit 261dec7fa9
4 changed files with 171 additions and 37 deletions

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,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,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

@@ -1,6 +1,9 @@
import { supabaseBrowser } from '../supabase/client' import { supabaseBrowser } from '../supabase/client'
import { invokeEdge } from '../supabase/invokeEdge' import { invokeEdge } from '../supabase/invokeEdge'
import { throwIfError, requireData } from './_helpers' import { throwIfError, requireData } from './_helpers'
import type { DocumentoResult } from './plans.api'
import type { import type {
Asignatura, Asignatura,
BibliografiaAsignatura, BibliografiaAsignatura,
@@ -8,7 +11,6 @@ import type {
TipoAsignatura, TipoAsignatura,
UUID, UUID,
} from '../types/domain' } from '../types/domain'
import type { DocumentoResult } from './plans.api'
const EDGE = { const EDGE = {
subjects_create_manual: 'subjects_create_manual', subjects_create_manual: 'subjects_create_manual',
@@ -32,7 +34,7 @@ export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
.from('asignaturas') .from('asignaturas')
.select( .select(
` `
id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,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))
@@ -49,7 +51,7 @@ export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
export async function subjects_history( export async function subjects_history(
subjectId: UUID, subjectId: UUID,
): Promise<CambioAsignatura[]> { ): Promise<Array<CambioAsignatura>> {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from('cambios_asignatura') .from('cambios_asignatura')
@@ -65,7 +67,7 @@ export async function subjects_history(
export async function subjects_bibliografia_list( export async function subjects_bibliografia_list(
subjectId: UUID, subjectId: UUID,
): Promise<BibliografiaAsignatura[]> { ): Promise<Array<BibliografiaAsignatura>> {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from('bibliografia_asignatura') .from('bibliografia_asignatura')
@@ -112,9 +114,9 @@ export async function ai_generate_subject(payload: {
iaConfig: { iaConfig: {
descripcionEnfoque: string descripcionEnfoque: string
notasAdicionales?: string notasAdicionales?: string
archivosExistentesIds?: UUID[] archivosExistentesIds?: Array<UUID>
repositoriosIds?: UUID[] repositoriosIds?: Array<UUID>
archivosAdhocIds?: UUID[] archivosAdhocIds?: Array<UUID>
usarMCP?: boolean usarMCP?: boolean
} }
}): Promise<any> { }): Promise<any> {
@@ -145,7 +147,7 @@ export async function subjects_clone_from_existing(payload: {
export async function subjects_import_from_file(payload: { export async function subjects_import_from_file(payload: {
planId: UUID planId: UUID
archivoWordAsignaturaId: UUID archivoWordAsignaturaId: UUID
archivosAdicionalesIds?: UUID[] archivosAdicionalesIds?: Array<UUID>
}): Promise<Asignatura> { }): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_import_from_file, payload) return invokeEdge<Asignatura>(EDGE.subjects_import_from_file, payload)
} }
@@ -175,7 +177,7 @@ export async function subjects_update_fields(
export async function subjects_update_contenido( export async function subjects_update_contenido(
subjectId: UUID, subjectId: UUID,
unidades: any[], unidades: Array<any>,
): Promise<Asignatura> { ): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_update_contenido, { return invokeEdge<Asignatura>(EDGE.subjects_update_contenido, {
subjectId, subjectId,
@@ -224,3 +226,20 @@ export async function subjects_get_document(
subjectId, subjectId,
}) })
} }
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.')
}

View File

@@ -1,13 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { qk } from '../query/keys'
import type { UUID } from '../types/domain'
import type {
BibliografiaUpsertInput,
SubjectsCreateManualInput,
SubjectsUpdateFieldsPatch,
} from '../api/subjects.api'
import { import {
ai_generate_subject, ai_generate_subject,
asignaturas_update,
subjects_bibliografia_list, subjects_bibliografia_list,
subjects_clone_from_existing, subjects_clone_from_existing,
subjects_create_manual, subjects_create_manual,
@@ -21,6 +16,14 @@ import {
subjects_update_contenido, subjects_update_contenido,
subjects_update_fields, subjects_update_fields,
} from '../api/subjects.api' } from '../api/subjects.api'
import { qk } from '../query/keys'
import type {
BibliografiaUpsertInput,
SubjectsCreateManualInput,
SubjectsUpdateFieldsPatch,
} from '../api/subjects.api'
import type { UUID } from '../types/domain'
export function useSubject(subjectId: UUID | null | undefined) { export function useSubject(subjectId: UUID | null | undefined) {
return useQuery({ return useQuery({
@@ -159,7 +162,7 @@ export function useUpdateSubjectContenido() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (vars: { subjectId: UUID; unidades: any[] }) => mutationFn: (vars: { subjectId: UUID; unidades: Array<any> }) =>
subjects_update_contenido(vars.subjectId, vars.unidades), subjects_update_contenido(vars.subjectId, vars.unidades),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(qk.asignatura(updated.id), updated) qc.setQueryData(qk.asignatura(updated.id), updated)
@@ -194,3 +197,28 @@ 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'] })
},
})
}

View File

@@ -33,7 +33,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { usePlanAsignaturas, usePlanLineas } from '@/data' import { usePlanAsignaturas, usePlanLineas, useUpdateAsignatura } from '@/data'
// --- Mapeadores (Fuera del componente para mayor limpieza) --- // --- Mapeadores (Fuera del componente para mayor limpieza) ---
const mapLineasToLineaCurricular = ( const mapLineasToLineaCurricular = (
@@ -60,8 +60,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: [],
})) }))
} }
@@ -183,6 +184,7 @@ 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()
@@ -268,13 +270,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) => {
@@ -309,7 +339,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),
@@ -332,6 +362,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
@@ -339,6 +370,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)
} }
} }
@@ -373,10 +421,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>
@@ -482,6 +535,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"
> >
@@ -593,7 +648,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
@@ -739,32 +797,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>