From 7d45eb4dfa155c82ac34bbf7bf9ab81ee3a0cf25 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Tue, 17 Feb 2026 13:20:49 -0600 Subject: [PATCH 1/2] =?UTF-8?q?fix=20#114:=20refactorizaci=C3=B3n=20de=20A?= =?UTF-8?q?signaturaDetailPage=20y=20hooks=20relacionados:=20persistencia,?= =?UTF-8?q?=20cach=C3=A9=20y=20tipado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../detalle/AsignaturaDetailPage.tsx | 106 +++++++++++++----- .../asignaturas/detalle/DocumentoSEPTab.tsx | 40 +++---- .../asignaturas/detalle/IAAsignaturaTab.tsx | 4 +- src/data/api/subjects.api.ts | 54 ++++++++- src/data/hooks/useSubjects.ts | 28 +++-- .../planes/$planId/_detalle/asignaturas.tsx | 6 +- src/routes/planes/$planId/_detalle/mapa.tsx | 8 +- 7 files changed, 178 insertions(+), 68 deletions(-) diff --git a/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx b/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx index fc949c2..c7493c6 100644 --- a/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx +++ b/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx @@ -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>([]) - const [datosGenerales, setDatosGenerales] = useState({}) + const [asignatura, setAsignatura] = useState({}) const [campos, setCampos] = useState>([]) 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 = + 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() { - {asignaturasApi?.planes_estudio?.datos?.nombre || ''} + {asignaturaApi?.planes_estudio?.datos?.nombre || ''} - {asignaturasApi?.planes_estudio?.carreras?.facultades + {asignaturaApi?.planes_estudio?.carreras?.facultades ?.nombre || ''} @@ -265,7 +299,7 @@ export default function AsignaturaDetailPage() {

Pertenece al plan:{' '} - {asignaturasApi?.planes_estudio?.nombre} + {asignaturaApi?.planes_estudio?.nombre}

@@ -295,7 +329,7 @@ export default function AsignaturaDetailPage() { ° ciclo - {asignaturasApi?.tipo} + {asignaturaApi?.tipo} @@ -323,15 +357,16 @@ export default function AsignaturaDetailPage() { {/* ================= TAB: DATOS GENERALES ================= */} @@ -348,7 +383,7 @@ export default function AsignaturaDetailPage() { @@ -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 (
@@ -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)} > diff --git a/src/components/asignaturas/detalle/DocumentoSEPTab.tsx b/src/components/asignaturas/detalle/DocumentoSEPTab.tsx index 0baad29..771f41c 100644 --- a/src/components/asignaturas/detalle/DocumentoSEPTab.tsx +++ b/src/components/asignaturas/detalle/DocumentoSEPTab.tsx @@ -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,16 +25,13 @@ 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'; +// import { toast } from 'sonner'; +// import { format } from 'date-fns'; +// import { es } from 'date-fns/locale'; interface DocumentoSEPTabProps { documento: DocumentoAsignatura | null @@ -45,8 +45,8 @@ interface DocumentoSEPTabProps { export function DocumentoSEPTab({ documento, asignatura, - estructura, datosGenerales, + estructura, onRegenerate, isRegenerating, }: DocumentoSEPTabProps) { @@ -65,7 +65,7 @@ export function DocumentoSEPTab({ const handleRegenerate = () => { setShowConfirmDialog(false) onRegenerate() - //toast.success('Regenerando documento...'); + // toast.success('Regenerando documento...'); } return ( @@ -86,7 +86,9 @@ export function DocumentoSEPTab({ variant="outline" onClick={ () => - console.log('descargando') /*toast.info('Descarga iniciada')*/ + console.log( + 'descargando', + ) /* toast.info('Descarga iniciada')*/ } > @@ -202,7 +204,7 @@ export function DocumentoSEPTab({

Documento generado el{' '} - {/*format(documento.fechaGeneracion, "d 'de' MMMM 'de' yyyy", { locale: es })*/} + {/* format(documento.fechaGeneracion, "d 'de' MMMM 'de' yyyy", { locale: es })*/}

Universidad La Salle

@@ -261,7 +263,7 @@ export function DocumentoSEPTab({ Generado - {/*format(documento.fechaGeneracion, "d MMM yyyy, HH:mm", { locale: es })*/} + {/* format(documento.fechaGeneracion, "d MMM yyyy, HH:mm", { locale: es })*/}
diff --git a/src/components/asignaturas/detalle/IAAsignaturaTab.tsx b/src/components/asignaturas/detalle/IAAsignaturaTab.tsx index fc97699..05a49dc 100644 --- a/src/components/asignaturas/detalle/IAAsignaturaTab.tsx +++ b/src/components/asignaturas/detalle/IAAsignaturaTab.tsx @@ -63,7 +63,7 @@ interface SelectedField { interface IAAsignaturaTabProps { campos: Array - datosGenerales: Record + asignatura: Record messages: Array 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, diff --git a/src/data/api/subjects.api.ts b/src/data/api/subjects.api.ts index ee59ebe..b880985 100644 --- a/src/data/api/subjects.api.ts +++ b/src/data/api/subjects.api.ts @@ -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 { +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 { const supabase = supabaseBrowser() const { data, error } = await supabase diff --git a/src/data/hooks/useSubjects.ts b/src/data/hooks/useSubjects.ts index 9914b15..8b76c8d 100644 --- a/src/data/hooks/useSubjects.ts +++ b/src/data/hooks/useSubjects.ts @@ -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 }) => 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) }) }, }) } diff --git a/src/routes/planes/$planId/_detalle/asignaturas.tsx b/src/routes/planes/$planId/_detalle/asignaturas.tsx index d51f6b9..2a11c5d 100644 --- a/src/routes/planes/$planId/_detalle/asignaturas.tsx +++ b/src/routes/planes/$planId/_detalle/asignaturas.tsx @@ -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]) diff --git a/src/routes/planes/$planId/_detalle/mapa.tsx b/src/routes/planes/$planId/_detalle/mapa.tsx index 4a4e545..ef9e665 100644 --- a/src/routes/planes/$planId/_detalle/mapa.tsx +++ b/src/routes/planes/$planId/_detalle/mapa.tsx @@ -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>([]) @@ -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)) From 02c415a91de387ba9c760861cec1121a209824b0 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Tue, 17 Feb 2026 14:17:09 -0600 Subject: [PATCH 2/2] =?UTF-8?q?Fix=20#114:=20Refactor=20ContenidoTem=C3=A1?= =?UTF-8?q?tico:=20persistencia=20inmediata=20y=20normalizaci=C3=B3n=20de?= =?UTF-8?q?=20datos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Elimina botón "Guardar": persistencia automática al pulsar "Listo", al confirmar eliminación y al terminar de editar nombre de unidad. - Añade mapper (mapContenidoTematicoFromDb) y serializador (serializeUnidadesToApi) para normalizar contenido_tematico <-> Array. - Conecta persistencia a useUpdateSubjectContenido: hace update directo de asignaturas.contenido_tematico en la BDD. - Manejo de caché: setQueryData con merge y invalidación de keys centralizadas (qk.planAsignaturas, qk.planHistorial, qk.asignaturaHistorial) para evitar caché desactualizada o pérdida de relaciones. - UX/estabilidad: identificadores consistentes, expansión inicial, y persistencia inmediata en puntos clave (añadir, editar, eliminar). --- .../detalle/AsignaturaDetailPage.tsx | 3 +- .../asignaturas/detalle/ContenidoTematico.tsx | 296 ++++++++++++------ src/data/api/subjects.api.ts | 51 ++- src/data/hooks/useSubjects.ts | 10 +- 4 files changed, 254 insertions(+), 106 deletions(-) diff --git a/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx b/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx index c7493c6..d7f46ac 100644 --- a/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx +++ b/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx @@ -366,7 +366,8 @@ export default function AsignaturaDetailPage() { diff --git a/src/components/asignaturas/detalle/ContenidoTematico.tsx b/src/components/asignaturas/detalle/ContenidoTematico.tsx index e49cc98..1bafc4d 100644 --- a/src/components/asignaturas/detalle/ContenidoTematico.tsx +++ b/src/components/asignaturas/detalle/ContenidoTematico.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react' import { Plus, GripVertical, @@ -7,17 +6,11 @@ import { Edit3, Trash2, Clock, - Save, } from 'lucide-react' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Badge } from '@/components/ui/badge' -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible' +import { useEffect, useState } from 'react' + +import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api' + import { AlertDialog, AlertDialogAction, @@ -28,8 +21,18 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible' +import { Input } from '@/components/ui/input' +import { useUpdateSubjectContenido } from '@/data/hooks/useSubjects' import { cn } from '@/lib/utils' -//import { toast } from 'sonner'; +// import { toast } from 'sonner'; export interface Tema { id: string @@ -42,41 +45,133 @@ export interface UnidadTematica { id: string nombre: string numero: number - temas: Tema[] + temas: Array } -const initialData: UnidadTematica[] = [ - { - id: 'u1', - numero: 1, - nombre: 'Fundamentos de Inteligencia Artificial', - temas: [ - { id: 't1', nombre: 'Tipos de IA y aplicaciones', horasEstimadas: 6 }, - { id: 't2', nombre: 'Ética en IA', horasEstimadas: 3 }, - ], - }, -] +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} -// Estructura que viene de tu JSON/API -interface ContenidoApi { - unidad: number - titulo: string - temas: string[] | any[] // Acepta strings o objetos - [key: string]: any // Esta línea permite que haya más claves desconocidas +function coerceNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return undefined + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : undefined + } + return undefined +} + +function coerceString(value: unknown): string | undefined { + if (typeof value === 'string') return value + return undefined +} + +function mapTemaValue(value: unknown): ContenidoTemaApi | null { + if (typeof value === 'string') { + const trimmed = value.trim() + return trimmed ? trimmed : null + } + if (isRecord(value)) { + const nombre = coerceString(value.nombre) + if (!nombre) return null + const horasEstimadas = coerceNumber(value.horasEstimadas) + const descripcion = coerceString(value.descripcion) + return { + ...value, + nombre, + horasEstimadas, + descripcion, + } + } + return null +} + +function mapContenidoItem(value: unknown, index: number): ContenidoApi | null { + if (!isRecord(value)) return null + + const unidad = coerceNumber(value.unidad) ?? index + 1 + const titulo = coerceString(value.titulo) ?? 'Sin título' + + let temas: Array = [] + if (Array.isArray(value.temas)) { + temas = value.temas + .map(mapTemaValue) + .filter((t): t is ContenidoTemaApi => t !== null) + } else if (typeof value.temas === 'string' && value.temas.trim()) { + temas = value.temas + .split(/\r?\n|,/) + .map((t) => t.trim()) + .filter(Boolean) + } + + return { unidad, titulo, temas } +} + +function mapContenidoTematicoFromDb(value: unknown): Array { + if (value == null) return [] + + if (typeof value === 'string') { + try { + return mapContenidoTematicoFromDb(JSON.parse(value)) + } catch { + return [] + } + } + + if (Array.isArray(value)) { + return value + .map((item, idx) => mapContenidoItem(item, idx)) + .filter((x): x is ContenidoApi => x !== null) + } + + if (isRecord(value)) { + if (Array.isArray(value.contenido_tematico)) { + return mapContenidoTematicoFromDb(value.contenido_tematico) + } + if (Array.isArray(value.unidades)) { + return mapContenidoTematicoFromDb(value.unidades) + } + } + + return [] +} + +function serializeUnidadesToApi( + unidades: Array, +): Array { + return unidades + .slice() + .sort((a, b) => a.numero - b.numero) + .map((u, idx) => ({ + unidad: u.numero || idx + 1, + titulo: u.nombre || 'Sin título', + temas: u.temas.map((t) => ({ + nombre: t.nombre || 'Tema', + horasEstimadas: t.horasEstimadas ?? 0, + descripcion: t.descripcion, + })), + })) } // Props del componente interface ContenidoTematicoProps { - data: { - contenido_tematico: ContenidoApi[] - } + asignaturaId: string + data?: { + contenido_tematico?: unknown + } | null isLoading: boolean } -export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) { - const [unidades, setUnidades] = useState([]) - const [expandedUnits, setExpandedUnits] = useState>( - new Set(['u1']), - ) +export function ContenidoTematico({ + asignaturaId, + data, + isLoading, +}: ContenidoTematicoProps) { + const updateContenido = useUpdateSubjectContenido() + + const [unidades, setUnidades] = useState>([]) + const [expandedUnits, setExpandedUnits] = useState>(new Set()) const [deleteDialog, setDeleteDialog] = useState<{ type: 'unidad' | 'tema' id: string @@ -87,30 +182,40 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) { unitId: string temaId: string } | null>(null) - const [isSaving, setIsSaving] = useState(false) + + const persistUnidades = async (nextUnidades: Array) => { + const payload = serializeUnidadesToApi(nextUnidades) + await updateContenido.mutateAsync({ + subjectId: asignaturaId, + unidades: payload, + }) + } useEffect(() => { - if (data?.contenido_tematico) { - const transformed = data.contenido_tematico.map( - (u: any, idx: number) => ({ - id: `u-${idx}`, - numero: u.unidad || idx + 1, - nombre: u.titulo || 'Sin título', - temas: Array.isArray(u.temas) - ? u.temas.map((t: any, tidx: number) => ({ - id: `t-${idx}-${tidx}`, - nombre: typeof t === 'string' ? t : t.nombre || 'Tema', - horasEstimadas: t.horasEstimadas || 0, - })) - : [], - }), - ) - setUnidades(transformed) + const contenido = mapContenidoTematicoFromDb( + data ? data.contenido_tematico : undefined, + ) - // Expandir la primera unidad automáticamente - if (transformed.length > 0) { - setExpandedUnits(new Set([transformed[0].id])) - } + const transformed = contenido.map((u, idx) => ({ + id: `u-${idx}`, + numero: u.unidad || idx + 1, + nombre: u.titulo || 'Sin título', + temas: Array.isArray(u.temas) + ? u.temas.map((t: any, tidx: number) => ({ + id: `t-${idx}-${tidx}`, + nombre: typeof t === 'string' ? t : t?.nombre || 'Tema', + horasEstimadas: t?.horasEstimadas || 0, + })) + : [], + })) + + setUnidades(transformed) + + // Expandir la primera unidad automáticamente + if (transformed.length > 0) { + setExpandedUnits(new Set([transformed[0].id])) + } else { + setExpandedUnits(new Set()) } }, [data]) @@ -139,7 +244,8 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) { numero: unidades.length + 1, temas: [], } - setUnidades([...unidades, newUnidad]) + const next = [...unidades, newUnidad] + setUnidades(next) setExpandedUnits(new Set([...expandedUnits, newId])) setEditingUnit(newId) } @@ -189,23 +295,22 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) { const handleDelete = () => { if (!deleteDialog) return + let next: Array = unidades if (deleteDialog.type === 'unidad') { - setUnidades( - unidades - .filter((u) => u.id !== deleteDialog.id) - .map((u, i) => ({ ...u, numero: i + 1 })), - ) + next = unidades + .filter((u) => u.id !== deleteDialog.id) + .map((u, i) => ({ ...u, numero: i + 1 })) } else if (deleteDialog.parentId) { - setUnidades( - unidades.map((u) => - u.id === deleteDialog.parentId - ? { ...u, temas: u.temas.filter((t) => t.id !== deleteDialog.id) } - : u, - ), + next = unidades.map((u) => + u.id === deleteDialog.parentId + ? { ...u, temas: u.temas.filter((t) => t.id !== deleteDialog.id) } + : u, ) } + setUnidades(next) setDeleteDialog(null) - //toast.success("Eliminado correctamente"); + void persistUnidades(next) + // toast.success("Eliminado correctamente"); } return ( @@ -223,19 +328,6 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) { -
@@ -271,12 +363,17 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) { onChange={(e) => updateUnidadNombre(unidad.id, e.target.value) } - onBlur={() => setEditingUnit(null)} - onKeyDown={(e) => - e.key === 'Enter' && setEditingUnit(null) - } + onBlur={() => { + setEditingUnit(null) + void persistUnidades(unidades) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setEditingUnit(null) + void persistUnidades(unidades) + } + }} className="h-8 max-w-md bg-white" - autoFocus /> ) : ( setEditingTema({ unitId: unidad.id, temaId: tema.id }) } - onStopEditing={() => setEditingTema(null)} + onStopEditing={() => { + setEditingTema(null) + void persistUnidades(unidades) + }} onUpdate={(updates) => updateTema(unidad.id, tema.id, updates) } @@ -397,7 +498,6 @@ function TemaRow({ onChange={(e) => onUpdate({ nombre: e.target.value })} className="h-8 flex-1 bg-white" placeholder="Nombre" - autoFocus /> ) : ( <> -
+
+ {tema.horasEstimadas}h diff --git a/src/data/api/subjects.api.ts b/src/data/api/subjects.api.ts index b880985..56a8e11 100644 --- a/src/data/api/subjects.api.ts +++ b/src/data/api/subjects.api.ts @@ -31,13 +31,32 @@ const EDGE = { subjects_import_from_file: 'subjects_import_from_file', subjects_update_fields: 'subjects_update_fields', - subjects_update_contenido: 'subjects_update_contenido', subjects_update_bibliografia: 'subjects_update_bibliografia', subjects_generate_document: 'subjects_generate_document', subjects_get_document: 'subjects_get_document', } as const +export type ContenidoTemaApi = + | string + | { + nombre: string + horasEstimadas?: number + descripcion?: string + [key: string]: unknown + } + +/** + * Estructura persistida en `asignaturas.contenido_tematico`. + * La BDD guarda un arreglo de unidades, cada una con temas (strings u objetos). + */ +export type ContenidoApi = { + unidad: number + titulo: string + temas: Array + [key: string]: unknown +} + export type FacultadInSubject = Pick< FacultadRow, 'id' | 'nombre' | 'nombre_corto' | 'color' | 'icono' @@ -81,7 +100,8 @@ export type EstructuraAsignaturaInSubject = Pick< * Tipo real que devuelve `subjects_get` (asignatura + relaciones seleccionadas). * Nota: `asignaturas_update` (update directo) NO devuelve estas relaciones. */ -export type AsignaturaDetail = Asignatura & { +export type AsignaturaDetail = Omit & { + contenido_tematico: Array | null planes_estudio: PlanEstudioInSubject | null estructuras_asignatura: EstructuraAsignaturaInSubject | null } @@ -105,7 +125,10 @@ export async function subjects_get(subjectId: UUID): Promise { .single() throwIfError(error) - return requireData(data, 'Asignatura no encontrada.') + return requireData( + data, + 'Asignatura no encontrada.', + ) as unknown as AsignaturaDetail } export async function subjects_history( @@ -323,12 +346,24 @@ export async function subjects_update_fields( export async function subjects_update_contenido( subjectId: UUID, - unidades: Array, + unidades: Array, ): Promise { - return invokeEdge(EDGE.subjects_update_contenido, { - subjectId, - unidades, - }) + const supabase = supabaseBrowser() + + type AsignaturaUpdate = Database['public']['Tables']['asignaturas']['Update'] + + const { data, error } = await supabase + .from('asignaturas') + .update({ + contenido_tematico: + unidades as unknown as AsignaturaUpdate['contenido_tematico'], + }) + .eq('id', subjectId) + .select() + .single() + + throwIfError(error) + return requireData(data, 'No se pudo actualizar la asignatura.') } export type BibliografiaUpsertInput = Array<{ diff --git a/src/data/hooks/useSubjects.ts b/src/data/hooks/useSubjects.ts index 8b76c8d..9975763 100644 --- a/src/data/hooks/useSubjects.ts +++ b/src/data/hooks/useSubjects.ts @@ -23,6 +23,7 @@ import { qk } from '../query/keys' import type { BibliografiaUpsertInput, + ContenidoApi, SubjectsUpdateFieldsPatch, } from '../api/subjects.api' import type { UUID } from '../types/domain' @@ -176,12 +177,19 @@ export function useUpdateSubjectContenido() { const qc = useQueryClient() return useMutation({ - mutationFn: (vars: { subjectId: UUID; unidades: Array }) => + mutationFn: (vars: { subjectId: UUID; unidades: Array }) => subjects_update_contenido(vars.subjectId, vars.unidades), onSuccess: (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), + }) + qc.invalidateQueries({ + queryKey: qk.planHistorial(updated.plan_estudio_id), + }) qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) }) }, })