Merge branch 'main' into issue/129-renderizar-datos-generales-ligados-a-columnas-de-t

This commit is contained in:
2026-02-24 12:30:42 -06:00
16 changed files with 712 additions and 742 deletions

View File

@@ -1,19 +1,12 @@
import {
createFileRoute,
Link,
useNavigate,
useParams,
useRouterState,
} from '@tanstack/react-router'
import { ArrowLeft, GraduationCap, Pencil, Sparkles } from 'lucide-react'
import { Pencil, Sparkles } from 'lucide-react'
import { useCallback, useState, useEffect } from 'react'
import { BibliographyItem } from './BibliographyItem'
import { ContenidoTematico } from './ContenidoTematico'
import { DocumentoSEPTab } from './DocumentoSEPTab'
import { HistorialTab } from './HistorialTab'
import { IAAsignaturaTab } from './IAAsignaturaTab'
import type { AsignaturaDetail } from '@/data'
import type {
CampoEstructura,
@@ -21,11 +14,8 @@ import type {
IASugerencia,
} 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 { Separator } from '@/components/ui/separator'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
@@ -34,11 +24,6 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
import {
mockAsignatura,
mockEstructura,
mockDocumentoSep,
} from '@/data/mockAsignaturaData'
export interface BibliografiaEntry {
id: string
@@ -294,181 +279,19 @@ export default function AsignaturaDetailPage() {
}, 2000)
}, [])
return (
<div className="w-full">
{/* ================= HEADER ACTUALIZADO ================= */}
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="mx-auto max-w-7xl px-6 py-10">
<Link
to="/planes/$planId/asignaturas"
params={{ planId }}
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
>
<ArrowLeft className="h-4 w-4" /> Volver al plan
</Link>
<div className="flex items-start justify-between gap-6">
<div className="space-y-3">
{/* CÓDIGO EDITABLE */}
<Badge className="border border-blue-700 bg-blue-900/50">
<EditableHeaderField
value={headerData.codigo}
onSave={(val) => handleUpdateHeader('codigo', val)}
/>
</Badge>
{/* NOMBRE EDITABLE */}
<h1 className="text-3xl font-bold">
<EditableHeaderField
value={headerData.nombre}
onSave={(val) => handleUpdateHeader('nombre', val)}
/>
</h1>
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4 shrink-0" />
<span className="text-blue-100">
{(() => {
const datosPlan = asignaturaApi?.planes_estudio?.datos
return isRecord(datosPlan)
? String((datosPlan as any).nombre ?? '')
: ''
})()}
</span>
</span>
<span className="flex items-center gap-1">
<span className="text-blue-100">
{asignaturaApi?.planes_estudio?.carreras?.facultades
?.nombre || ''}
</span>
</span>
</div>
<p className="text-sm text-blue-300">
Pertenece al plan:{' '}
<span className="cursor-pointer underline">
{asignaturaApi?.planes_estudio?.nombre}
</span>
</p>
</div>
<div className="flex flex-col items-end gap-2 text-right">
{/* CRÉDITOS EDITABLES */}
<Badge variant="secondary" className="gap-1">
<span className="inline-flex max-w-fit">
<EditableHeaderField
value={headerData.creditos}
onSave={(val) =>
handleUpdateHeader('creditos', parseInt(val) || 0)
}
/>
</span>
<span>créditos</span>
</Badge>
{/* SEMESTRE EDITABLE */}
<Badge variant="secondary" className="gap-1">
<EditableHeaderField
value={headerData.ciclo}
onSave={(val) =>
handleUpdateHeader('ciclo', parseInt(val) || 0)
}
/>
<span>° ciclo</span>
</Badge>
<Badge variant="secondary">{asignaturaApi?.tipo}</Badge>
</div>
</div>
</div>
</section>
{/* ================= TABS ================= */}
<section className="border-b bg-white">
<div className="mx-auto max-w-7xl px-6">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="h-auto gap-6 bg-transparent p-0">
<TabsTrigger value="datos">Datos generales</TabsTrigger>
<TabsTrigger value="contenido">Contenido temático</TabsTrigger>
<TabsTrigger value="bibliografia">Bibliografía</TabsTrigger>
<TabsTrigger value="ia">IA de la asignatura</TabsTrigger>
<TabsTrigger value="sep">Documento SEP</TabsTrigger>
<TabsTrigger value="historial">Historial</TabsTrigger>
</TabsList>
<Separator className="mt-2" />
{/* ================= TAB: DATOS GENERALES ================= */}
<TabsContent value="datos">
<DatosGenerales
data={asignatura ?? asignaturaApi ?? null}
isLoading={loadingAsig}
asignaturaId={asignaturaId}
onPersistDato={handlePersistDatoGeneral}
/>
</TabsContent>
<TabsContent value="contenido">
<ContenidoTematico
asignaturaId={asignaturaId}
data={asignaturaApi ?? null}
isLoading={loadingAsig}
></ContenidoTematico>
</TabsContent>
<TabsContent value="bibliografia">
<BibliographyItem
bibliografia={bibliografia}
id={asignaturaId}
onSave={handleSaveBibliografia}
isSaving={isSaving}
/>
</TabsContent>
<TabsContent value="ia">
<IAAsignaturaTab
campos={campos}
asignatura={(asignatura ?? asignaturaApi ?? {}) as any}
messages={messages}
onSendMessage={handleSendMessage}
onAcceptSuggestion={handleAcceptSuggestion}
onRejectSuggestion={
(_id) =>
console.log(
'Rechazada',
) /* toast.error("Sugerencia rechazada")*/
}
/>
</TabsContent>
<TabsContent value="sep">
<DocumentoSEPTab
documento={mockDocumentoSep}
estructura={mockEstructura}
asignatura={mockAsignatura}
datosGenerales={(asignatura as any)?.datos ?? {}}
onRegenerate={handleRegenerateDocument}
isRegenerating={isRegenerating}
/>
</TabsContent>
<TabsContent value="historial">
<HistorialTab asignaturaId={asignaturaId} />
</TabsContent>
</Tabs>
</div>
</section>
</div>
)
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
}
/* ================= TAB CONTENT ================= */
interface EstructuraDefinicion {
properties?: Record<
string,
{
title?: string
description?: string
examples?: Array<string>
}
>
}
interface DatosGeneralesProps {
asignaturaId: string
data: AsignaturaDetail | null
@@ -476,27 +299,21 @@ interface DatosGeneralesProps {
onPersistDato: (clave: string, value: string) => void
}
function DatosGenerales({
data,
isLoading,
asignaturaId,
onPersistDato,
}: DatosGeneralesProps) {
// 1. Extraemos la definición de la estructura (los metadatos)
const definicionRaw = data?.estructuras_asignatura?.definicion
const definicion = isRecord(definicionRaw)
? (definicionRaw as Record<string, unknown>)
: null
}: {
onPersistDato: (clave: string, value: string) => void
}) {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const propertiesRaw = definicion ? (definicion as any).properties : undefined
const structureProps = isRecord(propertiesRaw)
? (propertiesRaw as Record<string, any>)
: {}
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
// 2. Extraemos los valores reales (el contenido redactado)
const datosRaw = data?.datos
const valoresActuales = isRecord(datosRaw)
? (datosRaw as Record<string, any>)
: {}
const structureProps =
(data?.estructuras_asignatura?.definicion as EstructuraDefinicion)
.properties || {}
const valoresActuales = data?.datos || {}
if (isLoading) return <p>Cargando información...</p>
return (
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
@@ -516,54 +333,33 @@ function DatosGenerales({
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Columna Principal (Más ancha) */}
<div className="space-y-6 md:col-span-2">
{isLoading && <p>Cargando información...</p>}
{Object.entries(structureProps).map(
([key, config]: [string, any]) => {
const cardTitle = config.title || key
const description = config.description || ''
{!isLoading &&
Object.entries(structureProps).map(
([key, config]: [string, any]) => {
// 1. METADATOS (Vienen de structureProps -> config)
const cardTitle = config.title || key
const description = config.description || ''
const placeholder =
config.examples && config.examples.length > 0
? config.examples[0]
: ''
const xColumn =
typeof config?.['x-column'] === 'string'
? config['x-column']
: undefined
const valActual = (valoresActuales as Record<string, any>)[key]
const currentContent = valActual ?? ''
// Obtenemos el placeholder del arreglo 'examples' de la estructura
const placeholder =
config.examples && config.examples.length > 0
? config.examples[0]
: ''
const valActual = valoresActuales[key]
let currentContent = valActual ?? ''
if (xColumn) {
const rawValue = (data as any)?.[xColumn]
const parser = columnParsers[xColumn]
currentContent = parser
? parser(rawValue)
: String(rawValue ?? '')
}
return (
<InfoCard
asignaturaId={asignaturaId}
key={key}
clave={key}
title={cardTitle}
initialContent={currentContent} // Si es igual a la descripción de la SEP, pasamos vacío
xColumn={xColumn}
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)}
/>
)
},
)}
return (
<InfoCard
asignaturaId={asignaturaId}
key={key}
clave={key}
title={cardTitle}
initialContent={currentContent}
placeholder={placeholder}
description={description}
onPersist={(clave, value) => onPersistDato(clave, value)}
/>
)
},
)}
</div>
{/* Columna Lateral (Información Secundaria) */}
@@ -661,7 +457,7 @@ function InfoCard({
// Añadimos un timestamp a la state para forzar que la navegación
// genere una nueva ubicación incluso si la ruta y los params son iguales.
navigate({
to: '/planes/$planId/asignaturas/$asignaturaId',
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
params: { planId, asignaturaId: asignaturaId! },
state: {
activeTab: 'ia',

View File

@@ -1,3 +1,4 @@
import { useParams } from '@tanstack/react-router'
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
import { useEffect, useState } from 'react'
@@ -44,23 +45,13 @@ export interface BibliografiaEntry {
fuenteBiblioteca?: any
}
interface BibliografiaTabProps {
id: string
bibliografia: Array<BibliografiaEntry>
onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean
}
export function BibliographyItem({
bibliografia,
id,
onSave,
isSaving,
}: BibliografiaTabProps) {
console.log(id)
export function BibliographyItem() {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: bibliografia2, isLoading: loadinasignatura } =
useSubjectBibliografia(id)
useSubjectBibliografia(asignaturaId)
const [entries, setEntries] = useState<Array<BibliografiaEntry>>([])
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
@@ -76,7 +67,7 @@ export function BibliographyItem({
if (bibliografia2 && Array.isArray(bibliografia2)) {
setEntries(bibliografia2)
}
}, [bibliografia2, bibliografia])
}, [bibliografia2])
const basicaEntries = entries.filter((e) => e.tipo === 'BASICA')
const complementariaEntries = entries.filter(

View File

@@ -1,3 +1,4 @@
import { useParams } from '@tanstack/react-router'
import {
Plus,
GripVertical,
@@ -30,7 +31,7 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { useUpdateSubjectContenido } from '@/data/hooks/useSubjects'
import { useSubject, useUpdateSubjectContenido } from '@/data/hooks/useSubjects'
import { cn } from '@/lib/utils'
// import { toast } from 'sonner';
@@ -156,20 +157,14 @@ function serializeUnidadesToApi(
}
// Props del componente
interface ContenidoTematicoProps {
asignaturaId: string
data?: {
contenido_tematico?: unknown
} | null
isLoading: boolean
}
export function ContenidoTematico({
asignaturaId,
data,
isLoading,
}: ContenidoTematicoProps) {
const updateContenido = useUpdateSubjectContenido()
export function ContenidoTematico() {
const updateContenido = useUpdateSubjectContenido()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
const [unidades, setUnidades] = useState<Array<UnidadTematica>>([])
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set())
const [deleteDialog, setDeleteDialog] = useState<{

View File

@@ -1,19 +1,6 @@
import {
FileText,
Download,
RefreshCw,
FileCheck,
AlertTriangle,
Loader2,
} from 'lucide-react'
import { FileCheck, Download, RefreshCw, Loader2 } from 'lucide-react'
import { useState } from 'react'
import type {
DocumentoAsignatura,
Asignatura,
AsignaturaStructure,
} from '@/types/asignatura'
import {
AlertDialog,
AlertDialogAction,
@@ -25,51 +12,34 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} 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 { cn } from '@/lib/utils'
// import { toast } from 'sonner';
// import { format } from 'date-fns';
// import { es } from 'date-fns/locale';
import { Card } from '@/components/ui/card'
interface DocumentoSEPTabProps {
documento: DocumentoAsignatura | null
asignatura: Asignatura
estructura: AsignaturaStructure
datosGenerales: Record<string, any>
pdfUrl: string | null
isLoading: boolean
onDownload: () => void
onRegenerate: () => void
isRegenerating: boolean
}
export function DocumentoSEPTab({
documento,
asignatura,
datosGenerales,
estructura,
pdfUrl,
isLoading,
onDownload,
onRegenerate,
isRegenerating,
}: DocumentoSEPTabProps) {
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
// Check completeness
const camposObligatorios = estructura.campos.filter((c) => c.obligatorio)
const camposCompletos = camposObligatorios.filter((c) =>
datosGenerales[c.id]?.trim(),
)
const completeness = Math.round(
(camposCompletos.length / camposObligatorios.length) * 100,
)
const isComplete = completeness === 100
const handleRegenerate = () => {
setShowConfirmDialog(false)
onRegenerate()
// toast.success('Regenerando documento...');
}
return (
<div className="animate-fade-in space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
@@ -77,30 +47,24 @@ export function DocumentoSEPTab({
Documento SEP
</h2>
<p className="text-muted-foreground mt-1 text-sm">
Previsualización del documento oficial para la SEP
Previsualización del documento oficial generado
</p>
</div>
<div className="flex items-center gap-2">
{documento?.estado === 'listo' && (
<Button
variant="outline"
onClick={
() =>
console.log(
'descargando',
) /* toast.info('Descarga iniciada')*/
}
>
{pdfUrl && !isLoading && (
<Button variant="outline" onClick={onDownload}>
<Download className="mr-2 h-4 w-4" />
Descargar
</Button>
)}
<AlertDialog
open={showConfirmDialog}
onOpenChange={setShowConfirmDialog}
>
<AlertDialogTrigger asChild>
<Button disabled={isRegenerating || !isComplete}>
<Button disabled={isRegenerating}>
{isRegenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
@@ -109,15 +73,16 @@ export function DocumentoSEPTab({
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
<AlertDialogDescription>
Se creará una nueva versión del documento con los datos
actuales de la asignatura. La versión anterior quedará en el
historial.
Se generará una nueva versión del documento con la información
actual.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleRegenerate}>
@@ -129,308 +94,24 @@ export function DocumentoSEPTab({
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Document preview */}
<div className="lg:col-span-2">
<Card className="card-elevated h-[700px] overflow-hidden">
{documento?.estado === 'listo' ? (
<div className="bg-muted/30 flex h-full flex-col">
{/* Simulated document header */}
<div className="bg-card border-b p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
<span className="text-foreground font-medium">
Programa de Estudios - {asignatura.clave}
</span>
</div>
<Badge variant="outline">Versión {documento.version}</Badge>
</div>
</div>
{/* Document content simulation */}
<div className="flex-1 overflow-y-auto p-8">
<div className="bg-card mx-auto max-w-2xl space-y-6 rounded-lg p-8 shadow-lg">
{/* Header */}
<div className="border-b pb-6 text-center">
<p className="text-muted-foreground mb-2 text-xs tracking-wide uppercase">
Secretaría de Educación Pública
</p>
<h1 className="font-display text-primary mb-1 text-2xl font-bold">
{asignatura.nombre}
</h1>
<p className="text-muted-foreground text-sm">
Clave: {asignatura.clave} | Créditos:{' '}
{asignatura.creditos || 'N/A'}
</p>
</div>
{/* Datos de la institución */}
<div className="space-y-1 text-sm">
<p>
<strong>Carrera:</strong> {asignatura.carrera}
</p>
<p>
<strong>Facultad:</strong> {asignatura.facultad}
</p>
<p>
<strong>Plan de estudios:</strong>{' '}
{asignatura.planNombre}
</p>
{asignatura.ciclo && (
<p>
<strong>Ciclo:</strong> {asignatura.ciclo}
</p>
)}
</div>
{/* Campos del documento */}
{estructura.campos.map((campo) => {
const valor = datosGenerales[campo.id]
if (!valor) return null
return (
<div key={campo.id} className="space-y-2">
<h3 className="text-foreground border-b pb-1 font-semibold">
{campo.nombre}
</h3>
<p className="text-foreground text-sm leading-relaxed whitespace-pre-wrap">
{valor}
</p>
</div>
)
})}
{/* Footer */}
<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 })*/}
</p>
<p className="mt-1">Universidad La Salle</p>
</div>
</div>
</div>
</div>
) : documento?.estado === 'generando' ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Loader2 className="text-accent mx-auto mb-4 h-12 w-12 animate-spin" />
<p className="text-muted-foreground">
Generando documento...
</p>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center">
<div className="max-w-sm text-center">
<FileText className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
<p className="text-muted-foreground mb-4">
No hay documento generado aún
</p>
{!isComplete && (
<div className="bg-warning/10 text-warning-foreground rounded-lg p-4 text-sm">
<AlertTriangle className="mr-2 inline h-4 w-4" />
Completa todos los campos obligatorios para generar el
documento
</div>
)}
</div>
</div>
)}
</Card>
</div>
{/* Info sidebar */}
<div className="space-y-4">
{/* Status */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Estado del documento
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{documento && (
<>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Versión
</span>
<Badge variant="outline">{documento.version}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Generado
</span>
<span className="text-sm">
{/* format(documento.fechaGeneracion, "d MMM yyyy, HH:mm", { locale: es })*/}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Estado
</span>
<Badge
className={cn(
documento.estado === 'listo' &&
'bg-success text-success-foreground',
documento.estado === 'generando' &&
'bg-info text-info-foreground',
documento.estado === 'error' &&
'bg-destructive text-destructive-foreground',
)}
>
{documento.estado === 'listo' && 'Listo'}
{documento.estado === 'generando' && 'Generando'}
{documento.estado === 'error' && 'Error'}
</Badge>
</div>
</>
)}
</CardContent>
</Card>
{/* Completeness */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Completitud de datos
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Campos obligatorios
</span>
<span className="font-medium">
{camposCompletos.length}/{camposObligatorios.length}
</span>
</div>
<div className="bg-muted h-2 overflow-hidden rounded-full">
<div
className={cn(
'h-full transition-all duration-500',
completeness === 100 ? 'bg-success' : 'bg-accent',
)}
style={{ width: `${completeness}%` }}
/>
</div>
<p
className={cn(
'text-xs',
completeness === 100
? 'text-success'
: 'text-muted-foreground',
)}
>
{completeness === 100
? 'Todos los campos obligatorios están completos'
: `Faltan ${camposObligatorios.length - camposCompletos.length} campos por completar`}
</p>
</div>
{/* Missing fields */}
{!isComplete && (
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium">
Campos faltantes:
</p>
{camposObligatorios
.filter((c) => !datosGenerales[c.id]?.trim())
.map((campo) => (
<div
key={campo.id}
className="flex items-center gap-2 text-sm"
>
<AlertTriangle className="text-warning h-3 w-3" />
<span className="text-foreground">{campo.nombre}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Requirements */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Requisitos SEP
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
<li className="flex items-start gap-2">
<div
className={cn(
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
datosGenerales['objetivo_general']
? 'bg-success/20'
: 'bg-muted',
)}
>
{datosGenerales['objetivo_general'] && (
<Check className="text-success h-3 w-3" />
)}
</div>
<span className="text-muted-foreground">
Objetivo general definido
</span>
</li>
<li className="flex items-start gap-2">
<div
className={cn(
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
datosGenerales['competencias']
? 'bg-success/20'
: 'bg-muted',
)}
>
{datosGenerales['competencias'] && (
<Check className="text-success h-3 w-3" />
)}
</div>
<span className="text-muted-foreground">
Competencias especificadas
</span>
</li>
<li className="flex items-start gap-2">
<div
className={cn(
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
datosGenerales['evaluacion']
? 'bg-success/20'
: 'bg-muted',
)}
>
{datosGenerales['evaluacion'] && (
<Check className="text-success h-3 w-3" />
)}
</div>
<span className="text-muted-foreground">
Criterios de evaluación
</span>
</li>
</ul>
</CardContent>
</Card>
</div>
</div>
{/* PDF Preview */}
<Card className="h-[800px] overflow-hidden">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin" />
</div>
) : pdfUrl ? (
<iframe
src={`${pdfUrl}#toolbar=0`}
className="h-full w-full border-none"
title="Documento SEP"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center">
No se pudo cargar el documento.
</div>
)}
</Card>
</div>
)
}
function Check({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)
}

View File

@@ -1,3 +1,4 @@
import { useParams } from '@tanstack/react-router'
import { format, parseISO } from 'date-fns'
import { es } from 'date-fns/locale'
import {
@@ -53,7 +54,10 @@ const tipoConfig: Record<string, { label: string; icon: any; color: string }> =
},
}
export function HistorialTab({ asignaturaId }) {
export function HistorialTab() {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId/historial',
})
// 1. Obtenemos los datos directamente dentro del componente
const { data: rawData, isLoading } = useSubjectHistorial(asignaturaId)

View File

@@ -1,4 +1,4 @@
import { useRouterState } from '@tanstack/react-router'
import { useParams, useRouterState } from '@tanstack/react-router'
import {
Sparkles,
Send,
@@ -13,17 +13,14 @@ import {
} from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react'
import type {
IAMessage,
IASugerencia,
CampoEstructura,
} from '@/types/asignatura'
import type { IAMessage, IASugerencia } from '@/types/asignatura'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { useSubject } from '@/data'
import { cn } from '@/lib/utils'
// Tipos importados de tu archivo de asignatura
@@ -62,7 +59,6 @@ interface SelectedField {
}
interface IAAsignaturaTabProps {
campos: Array<CampoEstructura>
asignatura: Record<string, any>
messages: Array<IAMessage>
onSendMessage: (message: string, campoId?: string) => void
@@ -71,15 +67,18 @@ interface IAAsignaturaTabProps {
}
export function IAAsignaturaTab({
campos,
asignatura: datosGenerales,
messages,
onSendMessage,
onAcceptSuggestion,
onRejectSuggestion,
}: IAAsignaturaTabProps) {
const routerState = useRouterState()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: datosGenerales, isLoading: loadingAsig } =
useSubject(asignaturaId)
// ESTADOS PRINCIPALES (Igual que en Planes)
const [input, setInput] = useState('')
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
@@ -89,25 +88,25 @@ export function IAAsignaturaTab({
// 1. Transformar datos de la asignatura para el menú
const availableFields = useMemo(() => {
// Extraemos las claves directamente del objeto datosGenerales
// ["nombre", "descripcion", "perfil_de_egreso", "fines_de_aprendizaje_o_formacion"]
if (!datosGenerales.datos) return []
return Object.keys(datosGenerales.datos).map((key) => {
// Buscamos si existe un nombre amigable en la estructura de campos
const estructuraCampo = campos.find((c) => c.id === key)
if (!datosGenerales?.datos) return []
const estructuraProps =
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
return Object.keys(datosGenerales.datos).map((key) => {
const estructuraCampo = estructuraProps[key]
// Si existe en 'campos', usamos su nombre; si no, formateamos la clave (ej: perfil_de_egreso -> Perfil De Egreso)
const labelAmigable =
estructuraCampo?.nombre ||
estructuraCampo?.title ||
key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
return {
key: key,
key,
label: labelAmigable,
value: String(datosGenerales[key] || ''),
value: String(datosGenerales.datos[key] || ''),
}
})
}, [campos, datosGenerales])
}, [datosGenerales])
// 2. Manejar el estado inicial si viene de "Datos de Asignatura" (Prefill)
@@ -218,7 +217,7 @@ export function IAAsignaturaTab({
<div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
{messages.map((msg) => (
{messages?.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}