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) })
},
})