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:
@@ -14,6 +14,7 @@ import { DocumentoSEPTab } from './DocumentoSEPTab'
|
||||
import { HistorialTab } from './HistorialTab'
|
||||
import { IAAsignaturaTab } from './IAAsignaturaTab'
|
||||
|
||||
import type { AsignaturaDetail } from '@/data'
|
||||
import type {
|
||||
CampoEstructura,
|
||||
IAMessage,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useSubject } from '@/data/hooks/useSubjects'
|
||||
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
||||
import {
|
||||
mockAsignatura,
|
||||
mockEstructura,
|
||||
@@ -117,13 +118,14 @@ export default function AsignaturaDetailPage() {
|
||||
const { planId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
const { data: asignaturasApi, isLoading: loadingAsig } =
|
||||
const { data: asignaturaApi, isLoading: loadingAsig } =
|
||||
useSubject(asignaturaId)
|
||||
// 1. Asegúrate de tener estos estados en tu componente principal
|
||||
const [messages, setMessages] = useState<Array<IAMessage>>([])
|
||||
const [datosGenerales, setDatosGenerales] = useState({})
|
||||
const [asignatura, setAsignatura] = useState({})
|
||||
const [campos, setCampos] = useState<Array<CampoEstructura>>([])
|
||||
const [activeTab, setActiveTab] = useState('datos')
|
||||
const updateAsignatura = useUpdateAsignatura()
|
||||
|
||||
// Dentro de AsignaturaDetailPage
|
||||
const [headerData, setHeaderData] = useState({
|
||||
@@ -142,27 +144,59 @@ export default function AsignaturaDetailPage() {
|
||||
|
||||
// Sincronizar cuando llegue la API
|
||||
useEffect(() => {
|
||||
if (asignaturasApi) {
|
||||
if (asignaturaApi) {
|
||||
setHeaderData({
|
||||
codigo: asignaturasApi.codigo ?? '',
|
||||
nombre: asignaturasApi.nombre,
|
||||
creditos: asignaturasApi.creditos,
|
||||
ciclo: asignaturasApi.numero_ciclo ?? 0,
|
||||
codigo: asignaturaApi.codigo ?? '',
|
||||
nombre: asignaturaApi.nombre,
|
||||
creditos: asignaturaApi.creditos,
|
||||
ciclo: asignaturaApi.numero_ciclo ?? 0,
|
||||
})
|
||||
}
|
||||
}, [asignaturasApi])
|
||||
}, [asignaturaApi])
|
||||
|
||||
const handleUpdateHeader = (key: string, value: string | number) => {
|
||||
const newData = { ...headerData, [key]: value }
|
||||
setHeaderData(newData)
|
||||
console.log('💾 Guardando en estado y base de datos:', key, value)
|
||||
|
||||
const patch: Record<string, any> =
|
||||
key === 'ciclo'
|
||||
? { numero_ciclo: value }
|
||||
: {
|
||||
[key]: value,
|
||||
}
|
||||
|
||||
updateAsignatura.mutate({
|
||||
asignaturaId,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
|
||||
const handlePersistDatoGeneral = (clave: string, value: string) => {
|
||||
const baseDatos =
|
||||
(asignatura as any)?.datos ?? (asignaturaApi as any)?.datos ?? {}
|
||||
const mergedDatos = { ...baseDatos, [clave]: value }
|
||||
|
||||
// Mantener estado local coherente para merges posteriores.
|
||||
setAsignatura((prev: any) => ({
|
||||
...(prev && Object.keys(prev).length
|
||||
? prev
|
||||
: ((asignaturaApi as any) ?? {})),
|
||||
datos: mergedDatos,
|
||||
}))
|
||||
|
||||
updateAsignatura.mutate({
|
||||
asignaturaId,
|
||||
patch: {
|
||||
datos: mergedDatos,
|
||||
},
|
||||
})
|
||||
}
|
||||
/* ---------- sincronizar API ---------- */
|
||||
useEffect(() => {
|
||||
if (asignaturasApi?.datos) {
|
||||
setDatosGenerales(asignaturasApi)
|
||||
if (asignaturaApi?.datos) {
|
||||
setAsignatura(asignaturaApi)
|
||||
}
|
||||
}, [asignaturasApi])
|
||||
}, [asignaturaApi])
|
||||
|
||||
// 2. Funciones de manejo para la IA
|
||||
const handleSendMessage = (text: string, campoId?: string) => {
|
||||
@@ -180,7 +214,7 @@ export default function AsignaturaDetailPage() {
|
||||
}
|
||||
|
||||
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
|
||||
// Lógica para actualizar el valor del campo en tu estado de datosGenerales
|
||||
// Lógica para actualizar el valor del campo en tu estado de asignatura
|
||||
// toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
|
||||
}
|
||||
|
||||
@@ -250,13 +284,13 @@ export default function AsignaturaDetailPage() {
|
||||
<span className="flex items-center gap-1">
|
||||
<GraduationCap className="h-4 w-4 shrink-0" />
|
||||
<span className="text-blue-100">
|
||||
{asignaturasApi?.planes_estudio?.datos?.nombre || ''}
|
||||
{asignaturaApi?.planes_estudio?.datos?.nombre || ''}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-blue-100">
|
||||
{asignaturasApi?.planes_estudio?.carreras?.facultades
|
||||
{asignaturaApi?.planes_estudio?.carreras?.facultades
|
||||
?.nombre || ''}
|
||||
</span>
|
||||
</span>
|
||||
@@ -265,7 +299,7 @@ export default function AsignaturaDetailPage() {
|
||||
<p className="text-sm text-blue-300">
|
||||
Pertenece al plan:{' '}
|
||||
<span className="cursor-pointer underline">
|
||||
{asignaturasApi?.planes_estudio?.nombre}
|
||||
{asignaturaApi?.planes_estudio?.nombre}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -295,7 +329,7 @@ export default function AsignaturaDetailPage() {
|
||||
<span>° ciclo</span>
|
||||
</Badge>
|
||||
|
||||
<Badge variant="secondary">{asignaturasApi?.tipo}</Badge>
|
||||
<Badge variant="secondary">{asignaturaApi?.tipo}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,15 +357,17 @@ export default function AsignaturaDetailPage() {
|
||||
{/* ================= TAB: DATOS GENERALES ================= */}
|
||||
<TabsContent value="datos">
|
||||
<DatosGenerales
|
||||
data={datosGenerales}
|
||||
data={asignatura}
|
||||
isLoading={loadingAsig}
|
||||
asignaturaId={asignaturaId}
|
||||
onPersistDato={handlePersistDatoGeneral}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="contenido">
|
||||
<ContenidoTematico
|
||||
data={asignaturasApi}
|
||||
asignaturaId={asignaturaId}
|
||||
data={asignaturaApi ?? null}
|
||||
isLoading={loadingAsig}
|
||||
></ContenidoTematico>
|
||||
</TabsContent>
|
||||
@@ -348,7 +384,7 @@ export default function AsignaturaDetailPage() {
|
||||
<TabsContent value="ia">
|
||||
<IAAsignaturaTab
|
||||
campos={campos}
|
||||
datosGenerales={datosGenerales}
|
||||
asignatura={asignatura}
|
||||
messages={messages}
|
||||
onSendMessage={handleSendMessage}
|
||||
onAcceptSuggestion={handleAcceptSuggestion}
|
||||
@@ -364,9 +400,9 @@ export default function AsignaturaDetailPage() {
|
||||
<TabsContent value="sep">
|
||||
<DocumentoSEPTab
|
||||
documento={mockDocumentoSep}
|
||||
asignatura={mockAsignatura}
|
||||
estructura={mockEstructura}
|
||||
datosGenerales={datosGenerales}
|
||||
asignatura={mockAsignatura}
|
||||
datosGenerales={(asignatura as any)?.datos ?? {}}
|
||||
onRegenerate={handleRegenerateDocument}
|
||||
isRegenerating={isRegenerating}
|
||||
/>
|
||||
@@ -385,23 +421,25 @@ export default function AsignaturaDetailPage() {
|
||||
/* ================= TAB CONTENT ================= */
|
||||
interface DatosGeneralesProps {
|
||||
asignaturaId: string
|
||||
data: AsignaturaDatos
|
||||
data: AsignaturaDetail
|
||||
isLoading: boolean
|
||||
onPersistDato: (clave: string, value: string) => void
|
||||
}
|
||||
function DatosGenerales({
|
||||
data,
|
||||
isLoading,
|
||||
asignaturaId,
|
||||
onPersistDato,
|
||||
}: DatosGeneralesProps) {
|
||||
const formatTitle = (key: string): string =>
|
||||
key.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase())
|
||||
|
||||
// 1. Extraemos la definición de la estructura (los metadatos)
|
||||
const structureProps =
|
||||
data?.estructuras_asignatura?.definicion?.properties || {}
|
||||
data.estructuras_asignatura?.definicion?.properties || {}
|
||||
|
||||
// 2. Extraemos los valores reales (el contenido redactado)
|
||||
const valoresActuales = data?.datos || {}
|
||||
const valoresActuales = data.datos || {}
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
|
||||
@@ -454,6 +492,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 +548,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 +560,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 +576,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 +591,7 @@ function InfoCard({
|
||||
|
||||
navigate({
|
||||
to: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
params: { asignaturaId: asignaturaId! },
|
||||
params: { planId, asignaturaId: asignaturaId! },
|
||||
state: {
|
||||
activeTab: 'ia',
|
||||
prefillCampo: campoClave,
|
||||
@@ -586,7 +635,7 @@ function InfoCard({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
||||
onClick={() => handleIARequest(clave)}
|
||||
onClick={() => clave && handleIARequest(clave)}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -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<Tema>
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
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<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
|
||||
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<UnidadTematica[]>([])
|
||||
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(
|
||||
new Set(['u1']),
|
||||
)
|
||||
export function ContenidoTematico({
|
||||
asignaturaId,
|
||||
data,
|
||||
isLoading,
|
||||
}: ContenidoTematicoProps) {
|
||||
const updateContenido = useUpdateSubjectContenido()
|
||||
|
||||
const [unidades, setUnidades] = useState<Array<UnidadTematica>>([])
|
||||
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(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<UnidadTematica>) => {
|
||||
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<UnidadTematica> = 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) {
|
||||
<Button variant="outline" onClick={addUnidad} className="gap-2">
|
||||
<Plus className="h-4 w-4" /> Nueva unidad
|
||||
</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>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
) : (
|
||||
<CardTitle
|
||||
@@ -318,13 +415,17 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
|
||||
tema={tema}
|
||||
index={idx + 1}
|
||||
isEditing={
|
||||
editingTema?.unitId === unidad.id &&
|
||||
editingTema?.temaId === tema.id
|
||||
!!editingTema &&
|
||||
editingTema.unitId === unidad.id &&
|
||||
editingTema.temaId === tema.id
|
||||
}
|
||||
onEdit={() =>
|
||||
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
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -417,9 +517,13 @@ function TemaRow({
|
||||
</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>
|
||||
</div>
|
||||
</button>
|
||||
<Badge variant="secondary" className="text-[10px] opacity-60">
|
||||
{tema.horasEstimadas}h
|
||||
</Badge>
|
||||
|
||||
@@ -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')*/
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<p>
|
||||
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 className="mt-1">Universidad La Salle</p>
|
||||
</div>
|
||||
@@ -261,7 +263,7 @@ export function DocumentoSEPTab({
|
||||
Generado
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -63,7 +63,7 @@ interface SelectedField {
|
||||
|
||||
interface IAAsignaturaTabProps {
|
||||
campos: Array<CampoEstructura>
|
||||
datosGenerales: Record<string, any>
|
||||
asignatura: Record<string, any>
|
||||
messages: Array<IAMessage>
|
||||
onSendMessage: (message: string, campoId?: string) => void
|
||||
onAcceptSuggestion: (sugerencia: IASugerencia) => void
|
||||
@@ -72,7 +72,7 @@ interface IAAsignaturaTabProps {
|
||||
|
||||
export function IAAsignaturaTab({
|
||||
campos,
|
||||
datosGenerales,
|
||||
asignatura: datosGenerales,
|
||||
messages,
|
||||
onSendMessage,
|
||||
onAcceptSuggestion,
|
||||
|
||||
@@ -7,7 +7,11 @@ import type { DocumentoResult } from './plans.api'
|
||||
import type {
|
||||
Asignatura,
|
||||
BibliografiaAsignatura,
|
||||
CarreraRow,
|
||||
CambioAsignatura,
|
||||
EstructuraAsignatura,
|
||||
FacultadRow,
|
||||
PlanEstudioRow,
|
||||
TipoAsignatura,
|
||||
UUID,
|
||||
} from '../types/domain'
|
||||
@@ -27,14 +31,82 @@ 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 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 { data, error } = await supabase
|
||||
@@ -53,7 +125,10 @@ export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
|
||||
.single()
|
||||
|
||||
throwIfError(error)
|
||||
return requireData(data, 'Asignatura no encontrada.')
|
||||
return requireData(
|
||||
data,
|
||||
'Asignatura no encontrada.',
|
||||
) as unknown as AsignaturaDetail
|
||||
}
|
||||
|
||||
export async function subjects_history(
|
||||
@@ -271,12 +346,24 @@ export async function subjects_update_fields(
|
||||
|
||||
export async function subjects_update_contenido(
|
||||
subjectId: UUID,
|
||||
unidades: Array<any>,
|
||||
unidades: Array<ContenidoApi>,
|
||||
): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(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<{
|
||||
|
||||
@@ -23,6 +23,7 @@ import { qk } from '../query/keys'
|
||||
|
||||
import type {
|
||||
BibliografiaUpsertInput,
|
||||
ContenidoApi,
|
||||
SubjectsUpdateFieldsPatch,
|
||||
} from '../api/subjects.api'
|
||||
import type { UUID } from '../types/domain'
|
||||
@@ -97,7 +98,6 @@ export function useCreateSubjectManual() {
|
||||
}
|
||||
|
||||
export function useGenerateSubjectAI() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ai_generate_subject,
|
||||
})
|
||||
@@ -162,7 +162,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),
|
||||
})
|
||||
@@ -175,10 +177,19 @@ export function useUpdateSubjectContenido() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { subjectId: UUID; unidades: Array<any> }) =>
|
||||
mutationFn: (vars: { subjectId: UUID; unidades: Array<ContenidoApi> }) =>
|
||||
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.planAsignaturas(updated.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planHistorial(updated.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
|
||||
},
|
||||
})
|
||||
@@ -221,17 +232,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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ function MapaCurricularPage() {
|
||||
const { mutate: createLinea } = useCreateLinea()
|
||||
const { mutate: updateLineaApi } = useUpdateLinea()
|
||||
const { mutate: deleteLineaApi } = useDeleteLinea()
|
||||
const { data: asignaturasApi, isLoading: loadingAsig } =
|
||||
const { data: asignaturaApi, isLoading: loadingAsig } =
|
||||
usePlanAsignaturas(planId)
|
||||
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
|
||||
const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([])
|
||||
@@ -286,9 +286,9 @@ function MapaCurricularPage() {
|
||||
}, [lineas])
|
||||
|
||||
useEffect(() => {
|
||||
if (asignaturasApi)
|
||||
setAsignaturas(mapAsignaturasToAsignaturas(asignaturasApi))
|
||||
}, [asignaturasApi])
|
||||
if (asignaturaApi)
|
||||
setAsignaturas(mapAsignaturasToAsignaturas(asignaturaApi))
|
||||
}, [asignaturaApi])
|
||||
|
||||
useEffect(() => {
|
||||
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
|
||||
|
||||
Reference in New Issue
Block a user