From 02c415a91de387ba9c760861cec1121a209824b0 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Tue, 17 Feb 2026 14:17:09 -0600 Subject: [PATCH] =?UTF-8?q?Fix=20#114:=20Refactor=20ContenidoTem=C3=A1tico?= =?UTF-8?q?:=20persistencia=20inmediata=20y=20normalizaci=C3=B3n=20de=20da?= =?UTF-8?q?tos?= 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) }) }, })