fix #114: refactorización de AsignaturaDetailPage y hooks relacionados: persistencia, caché y tipado

- Persistencia de cambios de "Datos generales" usando updateAsignatura.mutate.
- Corregido el manejo de caché: uso de qk centralizada y merge en setQueryData para no perder relaciones.
- Corregidos los tipos devueltos por subjects_get.
- Evitado estado inválido tras guardar (merge local + actualización de cache).

Verificar: editar → guardar → volver al plan → reingresar muestra datos actualizados sin parpadeos.
This commit is contained in:
2026-02-17 13:20:49 -06:00
parent 54b22b7adf
commit 7d45eb4dfa
7 changed files with 178 additions and 68 deletions

View File

@@ -14,6 +14,7 @@ import { DocumentoSEPTab } from './DocumentoSEPTab'
import { HistorialTab } from './HistorialTab'
import { IAAsignaturaTab } from './IAAsignaturaTab'
import type { AsignaturaDetail } from '@/data'
import type {
CampoEstructura,
IAMessage,
@@ -32,7 +33,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useSubject } from '@/data/hooks/useSubjects'
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
import {
mockAsignatura,
mockEstructura,
@@ -117,13 +118,14 @@ export default function AsignaturaDetailPage() {
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturasApi, isLoading: loadingAsig } =
const { data: asignaturaApi, isLoading: loadingAsig } =
useSubject(asignaturaId)
// 1. Asegúrate de tener estos estados en tu componente principal
const [messages, setMessages] = useState<Array<IAMessage>>([])
const [datosGenerales, setDatosGenerales] = useState({})
const [asignatura, setAsignatura] = useState({})
const [campos, setCampos] = useState<Array<CampoEstructura>>([])
const [activeTab, setActiveTab] = useState('datos')
const updateAsignatura = useUpdateAsignatura()
// Dentro de AsignaturaDetailPage
const [headerData, setHeaderData] = useState({
@@ -142,27 +144,59 @@ export default function AsignaturaDetailPage() {
// Sincronizar cuando llegue la API
useEffect(() => {
if (asignaturasApi) {
if (asignaturaApi) {
setHeaderData({
codigo: asignaturasApi.codigo ?? '',
nombre: asignaturasApi.nombre,
creditos: asignaturasApi.creditos,
ciclo: asignaturasApi.numero_ciclo ?? 0,
codigo: asignaturaApi.codigo ?? '',
nombre: asignaturaApi.nombre,
creditos: asignaturaApi.creditos,
ciclo: asignaturaApi.numero_ciclo ?? 0,
})
}
}, [asignaturasApi])
}, [asignaturaApi])
const handleUpdateHeader = (key: string, value: string | number) => {
const newData = { ...headerData, [key]: value }
setHeaderData(newData)
console.log('💾 Guardando en estado y base de datos:', key, value)
const patch: Record<string, any> =
key === 'ciclo'
? { numero_ciclo: value }
: {
[key]: value,
}
updateAsignatura.mutate({
asignaturaId,
patch,
})
}
const handlePersistDatoGeneral = (clave: string, value: string) => {
const baseDatos =
(asignatura as any)?.datos ?? (asignaturaApi as any)?.datos ?? {}
const mergedDatos = { ...baseDatos, [clave]: value }
// Mantener estado local coherente para merges posteriores.
setAsignatura((prev: any) => ({
...(prev && Object.keys(prev).length
? prev
: ((asignaturaApi as any) ?? {})),
datos: mergedDatos,
}))
updateAsignatura.mutate({
asignaturaId,
patch: {
datos: mergedDatos,
},
})
}
/* ---------- sincronizar API ---------- */
useEffect(() => {
if (asignaturasApi?.datos) {
setDatosGenerales(asignaturasApi)
if (asignaturaApi?.datos) {
setAsignatura(asignaturaApi)
}
}, [asignaturasApi])
}, [asignaturaApi])
// 2. Funciones de manejo para la IA
const handleSendMessage = (text: string, campoId?: string) => {
@@ -180,7 +214,7 @@ export default function AsignaturaDetailPage() {
}
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
// Lógica para actualizar el valor del campo en tu estado de datosGenerales
// Lógica para actualizar el valor del campo en tu estado de asignatura
// toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
}
@@ -250,13 +284,13 @@ export default function AsignaturaDetailPage() {
<span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4 shrink-0" />
<span className="text-blue-100">
{asignaturasApi?.planes_estudio?.datos?.nombre || ''}
{asignaturaApi?.planes_estudio?.datos?.nombre || ''}
</span>
</span>
<span className="flex items-center gap-1">
<span className="text-blue-100">
{asignaturasApi?.planes_estudio?.carreras?.facultades
{asignaturaApi?.planes_estudio?.carreras?.facultades
?.nombre || ''}
</span>
</span>
@@ -265,7 +299,7 @@ export default function AsignaturaDetailPage() {
<p className="text-sm text-blue-300">
Pertenece al plan:{' '}
<span className="cursor-pointer underline">
{asignaturasApi?.planes_estudio?.nombre}
{asignaturaApi?.planes_estudio?.nombre}
</span>
</p>
</div>
@@ -295,7 +329,7 @@ export default function AsignaturaDetailPage() {
<span>° ciclo</span>
</Badge>
<Badge variant="secondary">{asignaturasApi?.tipo}</Badge>
<Badge variant="secondary">{asignaturaApi?.tipo}</Badge>
</div>
</div>
</div>
@@ -323,15 +357,16 @@ export default function AsignaturaDetailPage() {
{/* ================= TAB: DATOS GENERALES ================= */}
<TabsContent value="datos">
<DatosGenerales
data={datosGenerales}
data={asignatura}
isLoading={loadingAsig}
asignaturaId={asignaturaId}
onPersistDato={handlePersistDatoGeneral}
/>
</TabsContent>
<TabsContent value="contenido">
<ContenidoTematico
data={asignaturasApi}
data={asignaturaApi}
isLoading={loadingAsig}
></ContenidoTematico>
</TabsContent>
@@ -348,7 +383,7 @@ export default function AsignaturaDetailPage() {
<TabsContent value="ia">
<IAAsignaturaTab
campos={campos}
datosGenerales={datosGenerales}
asignatura={asignatura}
messages={messages}
onSendMessage={handleSendMessage}
onAcceptSuggestion={handleAcceptSuggestion}
@@ -364,9 +399,9 @@ export default function AsignaturaDetailPage() {
<TabsContent value="sep">
<DocumentoSEPTab
documento={mockDocumentoSep}
asignatura={mockAsignatura}
estructura={mockEstructura}
datosGenerales={datosGenerales}
asignatura={mockAsignatura}
datosGenerales={(asignatura as any)?.datos ?? {}}
onRegenerate={handleRegenerateDocument}
isRegenerating={isRegenerating}
/>
@@ -385,23 +420,25 @@ export default function AsignaturaDetailPage() {
/* ================= TAB CONTENT ================= */
interface DatosGeneralesProps {
asignaturaId: string
data: AsignaturaDatos
data: AsignaturaDetail
isLoading: boolean
onPersistDato: (clave: string, value: string) => void
}
function DatosGenerales({
data,
isLoading,
asignaturaId,
onPersistDato,
}: DatosGeneralesProps) {
const formatTitle = (key: string): string =>
key.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase())
// 1. Extraemos la definición de la estructura (los metadatos)
const structureProps =
data?.estructuras_asignatura?.definicion?.properties || {}
data.estructuras_asignatura?.definicion?.properties || {}
// 2. Extraemos los valores reales (el contenido redactado)
const valoresActuales = data?.datos || {}
const valoresActuales = data.datos || {}
return (
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
@@ -454,6 +491,7 @@ function DatosGenerales({
placeholder={placeholder} // Aquí irá "Primer semestre", "MAT-101", etc.
description={description} // El texto largo de "Indicar el ciclo..."
onEnhanceAI={(contenido) => console.log(contenido)}
onPersist={(clave, value) => onPersistDato(clave, value)}
/>
)
},
@@ -509,6 +547,7 @@ interface InfoCardProps {
required?: boolean // Nueva prop para el asterisco
type?: 'text' | 'requirements' | 'evaluation'
onEnhanceAI?: (content: any) => void
onPersist?: (clave: string, value: string) => void
}
function InfoCard({
@@ -520,11 +559,15 @@ function InfoCard({
description,
required,
type = 'text',
onPersist,
}: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false)
const [data, setData] = useState(initialContent)
const [tempText, setTempText] = useState(initialContent)
const navigate = useNavigate()
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
useEffect(() => {
setData(initialContent)
@@ -532,9 +575,14 @@ function InfoCard({
}, [initialContent])
const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? ''))
setData(tempText)
setIsEditing(false)
// Aquí iría tu lógica de guardado a la DB
if (type === 'text' && clave && onPersist) {
onPersist(clave, String(tempText ?? ''))
}
}
const handleIARequest = (campoClave: string) => {
@@ -542,7 +590,7 @@ function InfoCard({
navigate({
to: '/planes/$planId/asignaturas/$asignaturaId',
params: { asignaturaId: asignaturaId! },
params: { planId, asignaturaId: asignaturaId! },
state: {
activeTab: 'ia',
prefillCampo: campoClave,
@@ -586,7 +634,7 @@ function InfoCard({
variant="ghost"
size="icon"
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
onClick={() => handleIARequest(clave)}
onClick={() => clave && handleIARequest(clave)}
>
<Sparkles className="h-4 w-4" />
</Button>

View File

@@ -1,16 +1,19 @@
import { useState } from 'react'
import {
FileText,
Download,
RefreshCw,
Calendar,
FileCheck,
AlertTriangle,
Loader2,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useState } from 'react'
import type {
DocumentoAsignatura,
Asignatura,
AsignaturaStructure,
} from '@/types/asignatura'
import {
AlertDialog,
AlertDialogAction,
@@ -22,13 +25,10 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import type {
DocumentoAsignatura,
Asignatura,
AsignaturaStructure,
} from '@/types/asignatura'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { useSubjectBibliografia } from '@/data/hooks/useSubjects'
// import { toast } from 'sonner';
// import { format } from 'date-fns';
// import { es } from 'date-fns/locale';
@@ -45,8 +45,8 @@ interface DocumentoSEPTabProps {
export function DocumentoSEPTab({
documento,
asignatura,
estructura,
datosGenerales,
estructura,
onRegenerate,
isRegenerating,
}: DocumentoSEPTabProps) {
@@ -86,7 +86,9 @@ export function DocumentoSEPTab({
variant="outline"
onClick={
() =>
console.log('descargando') /*toast.info('Descarga iniciada')*/
console.log(
'descargando',
) /* toast.info('Descarga iniciada')*/
}
>
<Download className="mr-2 h-4 w-4" />

View File

@@ -63,7 +63,7 @@ interface SelectedField {
interface IAAsignaturaTabProps {
campos: Array<CampoEstructura>
datosGenerales: Record<string, any>
asignatura: Record<string, any>
messages: Array<IAMessage>
onSendMessage: (message: string, campoId?: string) => void
onAcceptSuggestion: (sugerencia: IASugerencia) => void
@@ -72,7 +72,7 @@ interface IAAsignaturaTabProps {
export function IAAsignaturaTab({
campos,
datosGenerales,
asignatura: datosGenerales,
messages,
onSendMessage,
onAcceptSuggestion,

View File

@@ -7,7 +7,11 @@ import type { DocumentoResult } from './plans.api'
import type {
Asignatura,
BibliografiaAsignatura,
CarreraRow,
CambioAsignatura,
EstructuraAsignatura,
FacultadRow,
PlanEstudioRow,
TipoAsignatura,
UUID,
} from '../types/domain'
@@ -34,7 +38,55 @@ const EDGE = {
subjects_get_document: 'subjects_get_document',
} as const
export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
export type FacultadInSubject = Pick<
FacultadRow,
'id' | 'nombre' | 'nombre_corto' | 'color' | 'icono'
>
export type CarreraInSubject = Pick<
CarreraRow,
'id' | 'facultad_id' | 'nombre' | 'nombre_corto' | 'clave_sep' | 'activa'
> & {
facultades: FacultadInSubject | null
}
export type PlanEstudioInSubject = Pick<
PlanEstudioRow,
| '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: CarreraInSubject | null
}
export type EstructuraAsignaturaInSubject = Pick<
EstructuraAsignatura,
'id' | 'nombre' | 'version' | 'definicion'
>
/**
* Tipo real que devuelve `subjects_get` (asignatura + relaciones seleccionadas).
* Nota: `asignaturas_update` (update directo) NO devuelve estas relaciones.
*/
export type AsignaturaDetail = Asignatura & {
planes_estudio: PlanEstudioInSubject | null
estructuras_asignatura: EstructuraAsignaturaInSubject | null
}
export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
const supabase = supabaseBrowser()
const { data, error } = await supabase

View File

@@ -97,7 +97,6 @@ export function useCreateSubjectManual() {
}
export function useGenerateSubjectAI() {
const qc = useQueryClient()
return useMutation({
mutationFn: ai_generate_subject,
})
@@ -162,7 +161,9 @@ export function useUpdateSubjectFields() {
mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) =>
subjects_update_fields(vars.subjectId, vars.patch),
onSuccess: (updated) => {
qc.setQueryData(qk.asignatura(updated.id), updated)
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
prev ? { ...(prev as any), ...(updated as any) } : updated,
)
qc.invalidateQueries({
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
})
@@ -178,7 +179,9 @@ export function useUpdateSubjectContenido() {
mutationFn: (vars: { subjectId: UUID; unidades: Array<any> }) =>
subjects_update_contenido(vars.subjectId, vars.unidades),
onSuccess: (updated) => {
qc.setQueryData(qk.asignatura(updated.id), updated)
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
prev ? { ...(prev as any), ...(updated as any) } : updated,
)
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
},
})
@@ -221,17 +224,22 @@ export function useUpdateAsignatura() {
}) => 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)
// ✅ Mantener consistencia con las query keys centralizadas (qk)
// 1) Actualiza el detalle (esto evita volver a entrar con caché vieja)
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
prev ? { ...(prev as any), ...(updated as any) } : updated,
)
// 2. IMPORTANTÍSIMO: Invalidamos la lista de materias del plan
// para que el mapa curricular vea los cambios (créditos, horas, nombre, etc.)
// 2) Refresca vistas derivadas del plan
qc.invalidateQueries({
queryKey: ['plan_asignaturas', updated.plan_estudio_id],
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(updated.plan_estudio_id),
})
// 3. Si tienes una lista general de asignaturas, también la invalidamos
qc.invalidateQueries({ queryKey: ['asignaturas', 'list'] })
// 3) Refresca historial de la asignatura si existe
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
},
})
}

View File

@@ -87,7 +87,7 @@ function AsignaturasPage() {
const navigate = useNavigate()
// 1. Fetch de datos reales
const { data: asignaturasApi, isLoading: loadingAsig } =
const { data: asignaturaApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
@@ -99,8 +99,8 @@ function AsignaturasPage() {
// 3. Procesamiento de datos
const asignaturas = useMemo(
() => mapAsignaturas(asignaturasApi),
[asignaturasApi],
() => mapAsignaturas(asignaturaApi),
[asignaturaApi],
)
const lineas = useMemo(() => lineasApi || [], [lineasApi])

View File

@@ -183,7 +183,7 @@ function MapaCurricularPage() {
const { mutate: createLinea } = useCreateLinea()
const { mutate: updateLineaApi } = useUpdateLinea()
const { mutate: deleteLineaApi } = useDeleteLinea()
const { data: asignaturasApi, isLoading: loadingAsig } =
const { data: asignaturaApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([])
@@ -286,9 +286,9 @@ function MapaCurricularPage() {
}, [lineas])
useEffect(() => {
if (asignaturasApi)
setAsignaturas(mapAsignaturasToAsignaturas(asignaturasApi))
}, [asignaturasApi])
if (asignaturaApi)
setAsignaturas(mapAsignaturasToAsignaturas(asignaturaApi))
}, [asignaturaApi])
useEffect(() => {
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))