Merge pull request 'Ahora hay persistencia en la asignatura' (#118) from issue/114-persistencia-de-asignaturas into main

Reviewed-on: #118
This commit was merged in pull request #118.
This commit is contained in:
2026-02-17 20:39:10 +00:00
8 changed files with 430 additions and 172 deletions

View File

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

View File

@@ -1,4 +1,3 @@
import { useEffect, useState } from 'react'
import { import {
Plus, Plus,
GripVertical, GripVertical,
@@ -7,17 +6,11 @@ import {
Edit3, Edit3,
Trash2, Trash2,
Clock, Clock,
Save,
} from 'lucide-react' } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
import { Badge } from '@/components/ui/badge'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -28,8 +21,18 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } 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 { cn } from '@/lib/utils'
//import { toast } from 'sonner'; // import { toast } from 'sonner';
export interface Tema { export interface Tema {
id: string id: string
@@ -42,41 +45,133 @@ export interface UnidadTematica {
id: string id: string
nombre: string nombre: string
numero: number numero: number
temas: Tema[] temas: Array<Tema>
} }
const initialData: UnidadTematica[] = [ function isRecord(value: unknown): value is Record<string, unknown> {
{ return typeof value === 'object' && value !== null && !Array.isArray(value)
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 },
],
},
]
// Estructura que viene de tu JSON/API function coerceNumber(value: unknown): number | undefined {
interface ContenidoApi { if (typeof value === 'number' && Number.isFinite(value)) return value
unidad: number if (typeof value === 'string') {
titulo: string const trimmed = value.trim()
temas: string[] | any[] // Acepta strings o objetos if (!trimmed) return undefined
[key: string]: any // Esta línea permite que haya más claves desconocidas 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<ContenidoTemaApi> = []
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<ContenidoApi> {
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<UnidadTematica>,
): Array<ContenidoApi> {
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 // Props del componente
interface ContenidoTematicoProps { interface ContenidoTematicoProps {
data: { asignaturaId: string
contenido_tematico: ContenidoApi[] data?: {
} contenido_tematico?: unknown
} | null
isLoading: boolean isLoading: boolean
} }
export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) { export function ContenidoTematico({
const [unidades, setUnidades] = useState<UnidadTematica[]>([]) asignaturaId,
const [expandedUnits, setExpandedUnits] = useState<Set<string>>( data,
new Set(['u1']), isLoading,
) }: ContenidoTematicoProps) {
const updateContenido = useUpdateSubjectContenido()
const [unidades, setUnidades] = useState<Array<UnidadTematica>>([])
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set())
const [deleteDialog, setDeleteDialog] = useState<{ const [deleteDialog, setDeleteDialog] = useState<{
type: 'unidad' | 'tema' type: 'unidad' | 'tema'
id: string id: string
@@ -87,30 +182,40 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
unitId: string unitId: string
temaId: string temaId: string
} | null>(null) } | null>(null)
const [isSaving, setIsSaving] = useState(false)
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
const payload = serializeUnidadesToApi(nextUnidades)
await updateContenido.mutateAsync({
subjectId: asignaturaId,
unidades: payload,
})
}
useEffect(() => { useEffect(() => {
if (data?.contenido_tematico) { const contenido = mapContenidoTematicoFromDb(
const transformed = data.contenido_tematico.map( data ? data.contenido_tematico : undefined,
(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)
// Expandir la primera unidad automáticamente const transformed = contenido.map((u, idx) => ({
if (transformed.length > 0) { id: `u-${idx}`,
setExpandedUnits(new Set([transformed[0].id])) 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]) }, [data])
@@ -139,7 +244,8 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
numero: unidades.length + 1, numero: unidades.length + 1,
temas: [], temas: [],
} }
setUnidades([...unidades, newUnidad]) const next = [...unidades, newUnidad]
setUnidades(next)
setExpandedUnits(new Set([...expandedUnits, newId])) setExpandedUnits(new Set([...expandedUnits, newId]))
setEditingUnit(newId) setEditingUnit(newId)
} }
@@ -189,23 +295,22 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
const handleDelete = () => { const handleDelete = () => {
if (!deleteDialog) return if (!deleteDialog) return
let next: Array<UnidadTematica> = unidades
if (deleteDialog.type === 'unidad') { if (deleteDialog.type === 'unidad') {
setUnidades( next = unidades
unidades .filter((u) => u.id !== deleteDialog.id)
.filter((u) => u.id !== deleteDialog.id) .map((u, i) => ({ ...u, numero: i + 1 }))
.map((u, i) => ({ ...u, numero: i + 1 })),
)
} else if (deleteDialog.parentId) { } else if (deleteDialog.parentId) {
setUnidades( next = unidades.map((u) =>
unidades.map((u) => u.id === deleteDialog.parentId
u.id === deleteDialog.parentId ? { ...u, temas: u.temas.filter((t) => t.id !== deleteDialog.id) }
? { ...u, temas: u.temas.filter((t) => t.id !== deleteDialog.id) } : u,
: u,
),
) )
} }
setUnidades(next)
setDeleteDialog(null) setDeleteDialog(null)
//toast.success("Eliminado correctamente"); void persistUnidades(next)
// toast.success("Eliminado correctamente");
} }
return ( return (
@@ -223,19 +328,6 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
<Button variant="outline" onClick={addUnidad} className="gap-2"> <Button variant="outline" onClick={addUnidad} className="gap-2">
<Plus className="h-4 w-4" /> Nueva unidad <Plus className="h-4 w-4" /> Nueva unidad
</Button> </Button>
<Button
onClick={() => {
setIsSaving(true)
setTimeout(() => {
setIsSaving(false) /*toast.success("Guardado")*/
}, 1000)
}}
disabled={isSaving}
className="bg-blue-600 hover:bg-blue-700"
>
<Save className="mr-2 h-4 w-4" />{' '}
{isSaving ? 'Guardando...' : 'Guardar'}
</Button>
</div> </div>
</div> </div>
@@ -271,12 +363,17 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
onChange={(e) => onChange={(e) =>
updateUnidadNombre(unidad.id, e.target.value) updateUnidadNombre(unidad.id, e.target.value)
} }
onBlur={() => setEditingUnit(null)} onBlur={() => {
onKeyDown={(e) => setEditingUnit(null)
e.key === 'Enter' && setEditingUnit(null) void persistUnidades(unidades)
} }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setEditingUnit(null)
void persistUnidades(unidades)
}
}}
className="h-8 max-w-md bg-white" className="h-8 max-w-md bg-white"
autoFocus
/> />
) : ( ) : (
<CardTitle <CardTitle
@@ -318,13 +415,17 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
tema={tema} tema={tema}
index={idx + 1} index={idx + 1}
isEditing={ isEditing={
editingTema?.unitId === unidad.id && !!editingTema &&
editingTema?.temaId === tema.id editingTema.unitId === unidad.id &&
editingTema.temaId === tema.id
} }
onEdit={() => onEdit={() =>
setEditingTema({ unitId: unidad.id, temaId: tema.id }) setEditingTema({ unitId: unidad.id, temaId: tema.id })
} }
onStopEditing={() => setEditingTema(null)} onStopEditing={() => {
setEditingTema(null)
void persistUnidades(unidades)
}}
onUpdate={(updates) => onUpdate={(updates) =>
updateTema(unidad.id, tema.id, updates) updateTema(unidad.id, tema.id, updates)
} }
@@ -397,7 +498,6 @@ function TemaRow({
onChange={(e) => onUpdate({ nombre: e.target.value })} onChange={(e) => onUpdate({ nombre: e.target.value })}
className="h-8 flex-1 bg-white" className="h-8 flex-1 bg-white"
placeholder="Nombre" placeholder="Nombre"
autoFocus
/> />
<Input <Input
type="number" type="number"
@@ -417,9 +517,13 @@ function TemaRow({
</div> </div>
) : ( ) : (
<> <>
<div className="flex-1 cursor-pointer" onClick={onEdit}> <button
type="button"
className="flex-1 cursor-pointer text-left"
onClick={onEdit}
>
<p className="text-sm font-medium text-slate-700">{tema.nombre}</p> <p className="text-sm font-medium text-slate-700">{tema.nombre}</p>
</div> </button>
<Badge variant="secondary" className="text-[10px] opacity-60"> <Badge variant="secondary" className="text-[10px] opacity-60">
{tema.horasEstimadas}h {tema.horasEstimadas}h
</Badge> </Badge>

View File

@@ -1,16 +1,19 @@
import { useState } from 'react'
import { import {
FileText, FileText,
Download, Download,
RefreshCw, RefreshCw,
Calendar,
FileCheck, FileCheck,
AlertTriangle, AlertTriangle,
Loader2, Loader2,
} from 'lucide-react' } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import type {
DocumentoAsignatura,
Asignatura,
AsignaturaStructure,
} from '@/types/asignatura'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -22,16 +25,13 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import type { import { Badge } from '@/components/ui/badge'
DocumentoAsignatura, import { Button } from '@/components/ui/button'
Asignatura, import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
AsignaturaStructure,
} from '@/types/asignatura'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSubjectBibliografia } from '@/data/hooks/useSubjects' // import { toast } from 'sonner';
//import { toast } from 'sonner'; // import { format } from 'date-fns';
//import { format } from 'date-fns'; // import { es } from 'date-fns/locale';
//import { es } from 'date-fns/locale';
interface DocumentoSEPTabProps { interface DocumentoSEPTabProps {
documento: DocumentoAsignatura | null documento: DocumentoAsignatura | null
@@ -45,8 +45,8 @@ interface DocumentoSEPTabProps {
export function DocumentoSEPTab({ export function DocumentoSEPTab({
documento, documento,
asignatura, asignatura,
estructura,
datosGenerales, datosGenerales,
estructura,
onRegenerate, onRegenerate,
isRegenerating, isRegenerating,
}: DocumentoSEPTabProps) { }: DocumentoSEPTabProps) {
@@ -65,7 +65,7 @@ export function DocumentoSEPTab({
const handleRegenerate = () => { const handleRegenerate = () => {
setShowConfirmDialog(false) setShowConfirmDialog(false)
onRegenerate() onRegenerate()
//toast.success('Regenerando documento...'); // toast.success('Regenerando documento...');
} }
return ( return (
@@ -86,7 +86,9 @@ export function DocumentoSEPTab({
variant="outline" variant="outline"
onClick={ onClick={
() => () =>
console.log('descargando') /*toast.info('Descarga iniciada')*/ console.log(
'descargando',
) /* toast.info('Descarga iniciada')*/
} }
> >
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
@@ -202,7 +204,7 @@ export function DocumentoSEPTab({
<div className="text-muted-foreground mt-8 border-t pt-6 text-center text-xs"> <div className="text-muted-foreground mt-8 border-t pt-6 text-center text-xs">
<p> <p>
Documento generado el{' '} Documento generado el{' '}
{/*format(documento.fechaGeneracion, "d 'de' MMMM 'de' yyyy", { locale: es })*/} {/* format(documento.fechaGeneracion, "d 'de' MMMM 'de' yyyy", { locale: es })*/}
</p> </p>
<p className="mt-1">Universidad La Salle</p> <p className="mt-1">Universidad La Salle</p>
</div> </div>
@@ -261,7 +263,7 @@ export function DocumentoSEPTab({
Generado Generado
</span> </span>
<span className="text-sm"> <span className="text-sm">
{/*format(documento.fechaGeneracion, "d MMM yyyy, HH:mm", { locale: es })*/} {/* format(documento.fechaGeneracion, "d MMM yyyy, HH:mm", { locale: es })*/}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

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

View File

@@ -7,7 +7,11 @@ import type { DocumentoResult } from './plans.api'
import type { import type {
Asignatura, Asignatura,
BibliografiaAsignatura, BibliografiaAsignatura,
CarreraRow,
CambioAsignatura, CambioAsignatura,
EstructuraAsignatura,
FacultadRow,
PlanEstudioRow,
TipoAsignatura, TipoAsignatura,
UUID, UUID,
} from '../types/domain' } from '../types/domain'
@@ -27,14 +31,82 @@ const EDGE = {
subjects_import_from_file: 'subjects_import_from_file', subjects_import_from_file: 'subjects_import_from_file',
subjects_update_fields: 'subjects_update_fields', subjects_update_fields: 'subjects_update_fields',
subjects_update_contenido: 'subjects_update_contenido',
subjects_update_bibliografia: 'subjects_update_bibliografia', subjects_update_bibliografia: 'subjects_update_bibliografia',
subjects_generate_document: 'subjects_generate_document', subjects_generate_document: 'subjects_generate_document',
subjects_get_document: 'subjects_get_document', subjects_get_document: 'subjects_get_document',
} as const } as const
export async function subjects_get(subjectId: UUID): Promise<Asignatura> { 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<ContenidoTemaApi>
[key: string]: unknown
}
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 = Omit<Asignatura, 'contenido_tematico'> & {
contenido_tematico: Array<ContenidoApi> | null
planes_estudio: PlanEstudioInSubject | null
estructuras_asignatura: EstructuraAsignaturaInSubject | null
}
export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
@@ -53,7 +125,10 @@ export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
.single() .single()
throwIfError(error) throwIfError(error)
return requireData(data, 'Asignatura no encontrada.') return requireData(
data,
'Asignatura no encontrada.',
) as unknown as AsignaturaDetail
} }
export async function subjects_history( export async function subjects_history(
@@ -271,12 +346,24 @@ export async function subjects_update_fields(
export async function subjects_update_contenido( export async function subjects_update_contenido(
subjectId: UUID, subjectId: UUID,
unidades: Array<any>, unidades: Array<ContenidoApi>,
): Promise<Asignatura> { ): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_update_contenido, { const supabase = supabaseBrowser()
subjectId,
unidades, 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<{ export type BibliografiaUpsertInput = Array<{

View File

@@ -23,6 +23,7 @@ import { qk } from '../query/keys'
import type { import type {
BibliografiaUpsertInput, BibliografiaUpsertInput,
ContenidoApi,
SubjectsUpdateFieldsPatch, SubjectsUpdateFieldsPatch,
} from '../api/subjects.api' } from '../api/subjects.api'
import type { UUID } from '../types/domain' import type { UUID } from '../types/domain'
@@ -97,7 +98,6 @@ export function useCreateSubjectManual() {
} }
export function useGenerateSubjectAI() { export function useGenerateSubjectAI() {
const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ai_generate_subject, mutationFn: ai_generate_subject,
}) })
@@ -162,7 +162,9 @@ export function useUpdateSubjectFields() {
mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) => mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) =>
subjects_update_fields(vars.subjectId, vars.patch), subjects_update_fields(vars.subjectId, vars.patch),
onSuccess: (updated) => { 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({ qc.invalidateQueries({
queryKey: qk.planAsignaturas(updated.plan_estudio_id), queryKey: qk.planAsignaturas(updated.plan_estudio_id),
}) })
@@ -175,10 +177,19 @@ export function useUpdateSubjectContenido() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (vars: { subjectId: UUID; unidades: Array<any> }) => mutationFn: (vars: { subjectId: UUID; unidades: Array<ContenidoApi> }) =>
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), (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) }) qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
}, },
}) })
@@ -221,17 +232,22 @@ export function useUpdateAsignatura() {
}) => asignaturas_update(vars.asignaturaId, vars.patch), }) => asignaturas_update(vars.asignaturaId, vars.patch),
onSuccess: (updated) => { onSuccess: (updated) => {
// 1. Actualizamos la materia específica en la caché si tienes un query de "detalle" // ✅ Mantener consistencia con las query keys centralizadas (qk)
qc.setQueryData(['asignatura', updated.id], updated) // 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 // 2) Refresca vistas derivadas del plan
// para que el mapa curricular vea los cambios (créditos, horas, nombre, etc.)
qc.invalidateQueries({ 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 // 3) Refresca historial de la asignatura si existe
qc.invalidateQueries({ queryKey: ['asignaturas', 'list'] }) qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
}, },
}) })
} }

View File

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

View File

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