Hacer que se navegue por rutas en los tabs de la asignatura

fix #110
This commit is contained in:
2026-02-24 09:42:53 -06:00
parent 5912a7c1fb
commit 1acc37403d
16 changed files with 712 additions and 720 deletions

View File

@@ -1,19 +1,12 @@
import { import {
createFileRoute, createFileRoute,
Link,
useNavigate, useNavigate,
useParams, useParams,
useRouterState, useRouterState,
} from '@tanstack/react-router' } 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 { 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 { AsignaturaDetail } from '@/data'
import type { import type {
CampoEstructura, CampoEstructura,
@@ -21,11 +14,8 @@ import type {
IASugerencia, IASugerencia,
} from '@/types/asignatura' } from '@/types/asignatura'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' 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 { Textarea } from '@/components/ui/textarea'
import { import {
Tooltip, Tooltip,
@@ -34,11 +24,6 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects' import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
import {
mockAsignatura,
mockEstructura,
mockDocumentoSep,
} from '@/data/mockAsignaturaData'
export interface BibliografiaEntry { export interface BibliografiaEntry {
id: string id: string
@@ -249,176 +234,19 @@ export default function AsignaturaDetailPage() {
}, 2000) }, 2000)
}, []) }, [])
return ( return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
<div className="w-full">
{/* ================= HEADER ACTUALIZADO ================= */}
<section className="bg-gradient-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">
{asignaturaApi?.planes_estudio?.datos?.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}
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}
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>
)
} }
/* ================= TAB CONTENT ================= */ interface EstructuraDefinicion {
properties?: Record<
string,
{
title?: string
description?: string
examples?: Array<string>
}
>
}
interface DatosGeneralesProps { interface DatosGeneralesProps {
asignaturaId: string asignaturaId: string
data: AsignaturaDetail data: AsignaturaDetail
@@ -426,20 +254,21 @@ interface DatosGeneralesProps {
onPersistDato: (clave: string, value: string) => void onPersistDato: (clave: string, value: string) => void
} }
function DatosGenerales({ function DatosGenerales({
data,
isLoading,
asignaturaId,
onPersistDato, onPersistDato,
}: DatosGeneralesProps) { }: {
const formatTitle = (key: string): string => onPersistDato: (clave: string, value: string) => void
key.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase()) }) {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
// 1. Extraemos la definición de la estructura (los metadatos)
const structureProps = const structureProps =
data.estructuras_asignatura?.definicion?.properties || {} (data?.estructuras_asignatura?.definicion as EstructuraDefinicion)
.properties || {}
// 2. Extraemos los valores reales (el contenido redactado) const valoresActuales = data?.datos || {}
const valoresActuales = data.datos || {} if (isLoading) return <p>Cargando información...</p>
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">
@@ -459,44 +288,33 @@ function DatosGenerales({
<div className="grid grid-cols-1 gap-6 md:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Columna Principal (Más ancha) */} {/* Columna Principal (Más ancha) */}
<div className="space-y-6 md:col-span-2"> <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 && const placeholder =
Object.entries(structureProps).map( config.examples && config.examples.length > 0
([key, config]: [string, any]) => { ? config.examples[0]
// 1. METADATOS (Vienen de structureProps -> config) : ''
const cardTitle = config.title || key
const description = config.description || ''
// Obtenemos el placeholder del arreglo 'examples' de la estructura const valActual = (valoresActuales as Record<string, any>)[key]
const placeholder = const currentContent = valActual ?? ''
config.examples && config.examples.length > 0
? config.examples[0]
: ''
const valActual = valoresActuales[key] return (
<InfoCard
const isContentEmpty = asignaturaId={asignaturaId}
!valActual?.description || key={key}
valActual.description === config.description clave={key}
title={cardTitle}
const currentContent = valActual ?? '' initialContent={currentContent}
placeholder={placeholder}
return ( description={description}
<InfoCard onPersist={(clave, value) => onPersistDato(clave, value)}
asignaturaId={asignaturaId} />
key={key} )
clave={key} },
title={cardTitle} )}
initialContent={currentContent} // Si es igual a la descripción de la SEP, pasamos vacío
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)}
/>
)
},
)}
</div> </div>
{/* Columna Lateral (Información Secundaria) */} {/* Columna Lateral (Información Secundaria) */}
@@ -590,7 +408,7 @@ function InfoCard({
console.log(placeholder) console.log(placeholder)
navigate({ navigate({
to: '/planes/$planId/asignaturas/$asignaturaId', to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
params: { planId, asignaturaId: asignaturaId! }, params: { planId, asignaturaId: asignaturaId! },
state: { state: {
activeTab: 'ia', 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 { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -44,23 +45,13 @@ export interface BibliografiaEntry {
fuenteBiblioteca?: any fuenteBiblioteca?: any
} }
interface BibliografiaTabProps { export function BibliographyItem() {
id: string const { asignaturaId } = useParams({
bibliografia: Array<BibliografiaEntry> from: '/planes/$planId/asignaturas/$asignaturaId',
onSave: (bibliografia: Array<BibliografiaEntry>) => void })
isSaving: boolean
}
export function BibliographyItem({
bibliografia,
id,
onSave,
isSaving,
}: BibliografiaTabProps) {
console.log(id)
const { data: bibliografia2, isLoading: loadinasignatura } = const { data: bibliografia2, isLoading: loadinasignatura } =
useSubjectBibliografia(id) useSubjectBibliografia(asignaturaId)
const [entries, setEntries] = useState<Array<BibliografiaEntry>>([]) const [entries, setEntries] = useState<Array<BibliografiaEntry>>([])
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false) const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
@@ -76,7 +67,7 @@ export function BibliographyItem({
if (bibliografia2 && Array.isArray(bibliografia2)) { if (bibliografia2 && Array.isArray(bibliografia2)) {
setEntries(bibliografia2) setEntries(bibliografia2)
} }
}, [bibliografia2, bibliografia]) }, [bibliografia2])
const basicaEntries = entries.filter((e) => e.tipo === 'BASICA') const basicaEntries = entries.filter((e) => e.tipo === 'BASICA')
const complementariaEntries = entries.filter( const complementariaEntries = entries.filter(

View File

@@ -1,3 +1,4 @@
import { useParams } from '@tanstack/react-router'
import { import {
Plus, Plus,
GripVertical, GripVertical,
@@ -30,7 +31,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input' 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 { cn } from '@/lib/utils'
// import { toast } from 'sonner'; // import { toast } from 'sonner';
@@ -156,20 +157,14 @@ function serializeUnidadesToApi(
} }
// Props del componente // 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 [unidades, setUnidades] = useState<Array<UnidadTematica>>([])
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set()) const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set())
const [deleteDialog, setDeleteDialog] = useState<{ const [deleteDialog, setDeleteDialog] = useState<{

View File

@@ -1,19 +1,6 @@
import { import { FileCheck, Download, RefreshCw, Loader2 } from 'lucide-react'
FileText,
Download,
RefreshCw,
FileCheck,
AlertTriangle,
Loader2,
} from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import type {
DocumentoAsignatura,
Asignatura,
AsignaturaStructure,
} from '@/types/asignatura'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -25,51 +12,34 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { cn } from '@/lib/utils'
// import { toast } from 'sonner';
// import { format } from 'date-fns';
// import { es } from 'date-fns/locale';
interface DocumentoSEPTabProps { interface DocumentoSEPTabProps {
documento: DocumentoAsignatura | null pdfUrl: string | null
asignatura: Asignatura isLoading: boolean
estructura: AsignaturaStructure onDownload: () => void
datosGenerales: Record<string, any>
onRegenerate: () => void onRegenerate: () => void
isRegenerating: boolean isRegenerating: boolean
} }
export function DocumentoSEPTab({ export function DocumentoSEPTab({
documento, pdfUrl,
asignatura, isLoading,
datosGenerales, onDownload,
estructura,
onRegenerate, onRegenerate,
isRegenerating, isRegenerating,
}: DocumentoSEPTabProps) { }: DocumentoSEPTabProps) {
const [showConfirmDialog, setShowConfirmDialog] = useState(false) 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 = () => { const handleRegenerate = () => {
setShowConfirmDialog(false) setShowConfirmDialog(false)
onRegenerate() onRegenerate()
// toast.success('Regenerando documento...');
} }
return ( return (
<div className="animate-fade-in space-y-6"> <div className="animate-fade-in space-y-6">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold"> <h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
@@ -77,30 +47,24 @@ export function DocumentoSEPTab({
Documento SEP Documento SEP
</h2> </h2>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
Previsualización del documento oficial para la SEP Previsualización del documento oficial generado
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{documento?.estado === 'listo' && ( {pdfUrl && !isLoading && (
<Button <Button variant="outline" onClick={onDownload}>
variant="outline"
onClick={
() =>
console.log(
'descargando',
) /* toast.info('Descarga iniciada')*/
}
>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
Descargar Descargar
</Button> </Button>
)} )}
<AlertDialog <AlertDialog
open={showConfirmDialog} open={showConfirmDialog}
onOpenChange={setShowConfirmDialog} onOpenChange={setShowConfirmDialog}
> >
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button disabled={isRegenerating || !isComplete}> <Button disabled={isRegenerating}>
{isRegenerating ? ( {isRegenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
@@ -109,15 +73,16 @@ export function DocumentoSEPTab({
{isRegenerating ? 'Generando...' : 'Regenerar documento'} {isRegenerating ? 'Generando...' : 'Regenerar documento'}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle> <AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Se creará una nueva versión del documento con los datos Se generará una nueva versión del documento con la información
actuales de la asignatura. La versión anterior quedará en el actual.
historial.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel> <AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleRegenerate}> <AlertDialogAction onClick={handleRegenerate}>
@@ -129,308 +94,24 @@ export function DocumentoSEPTab({
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> {/* PDF Preview */}
{/* Document preview */} <Card className="h-[800px] overflow-hidden">
<div className="lg:col-span-2"> {isLoading ? (
<Card className="card-elevated h-[700px] overflow-hidden"> <div className="flex h-full items-center justify-center">
{documento?.estado === 'listo' ? ( <Loader2 className="h-10 w-10 animate-spin" />
<div className="bg-muted/30 flex h-full flex-col"> </div>
{/* Simulated document header */} ) : pdfUrl ? (
<div className="bg-card border-b p-4"> <iframe
<div className="flex items-center justify-between"> src={`${pdfUrl}#toolbar=0`}
<div className="flex items-center gap-2"> className="h-full w-full border-none"
<FileText className="text-primary h-5 w-5" /> title="Documento SEP"
<span className="text-foreground font-medium"> />
Programa de Estudios - {asignatura.clave} ) : (
</span> <div className="text-muted-foreground flex h-full items-center justify-center">
</div> No se pudo cargar el documento.
<Badge variant="outline">Versión {documento.version}</Badge> </div>
</div> )}
</div> </Card>
{/* 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>
</div> </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 { format, parseISO } from 'date-fns'
import { es } from 'date-fns/locale' import { es } from 'date-fns/locale'
import { 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 // 1. Obtenemos los datos directamente dentro del componente
const { data: rawData, isLoading } = useSubjectHistorial(asignaturaId) 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 { import {
Sparkles, Sparkles,
Send, Send,
@@ -13,17 +13,14 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import type { import type { IAMessage, IASugerencia } from '@/types/asignatura'
IAMessage,
IASugerencia,
CampoEstructura,
} from '@/types/asignatura'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { useSubject } from '@/data'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
// Tipos importados de tu archivo de asignatura // Tipos importados de tu archivo de asignatura
@@ -62,7 +59,6 @@ interface SelectedField {
} }
interface IAAsignaturaTabProps { interface IAAsignaturaTabProps {
campos: Array<CampoEstructura>
asignatura: 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
@@ -71,15 +67,18 @@ interface IAAsignaturaTabProps {
} }
export function IAAsignaturaTab({ export function IAAsignaturaTab({
campos,
asignatura: datosGenerales,
messages, messages,
onSendMessage, onSendMessage,
onAcceptSuggestion, onAcceptSuggestion,
onRejectSuggestion, onRejectSuggestion,
}: IAAsignaturaTabProps) { }: IAAsignaturaTabProps) {
const routerState = useRouterState() const routerState = useRouterState()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: datosGenerales, isLoading: loadingAsig } =
useSubject(asignaturaId)
// ESTADOS PRINCIPALES (Igual que en Planes) // ESTADOS PRINCIPALES (Igual que en Planes)
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([]) const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
@@ -89,25 +88,25 @@ export function IAAsignaturaTab({
// 1. Transformar datos de la asignatura para el menú // 1. Transformar datos de la asignatura para el menú
const availableFields = useMemo(() => { const availableFields = useMemo(() => {
// Extraemos las claves directamente del objeto datosGenerales if (!datosGenerales?.datos) return []
// ["nombre", "descripcion", "perfil_de_egreso", "fines_de_aprendizaje_o_formacion"]
if (!datosGenerales.datos) return [] const estructuraProps =
return Object.keys(datosGenerales.datos).map((key) => { datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
// Buscamos si existe un nombre amigable en la estructura de campos
const estructuraCampo = campos.find((c) => c.id === key) 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 = const labelAmigable =
estructuraCampo?.nombre || estructuraCampo?.title ||
key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
return { return {
key: key, key,
label: labelAmigable, 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) // 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"> <div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full"> <ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
{messages.map((msg) => ( {messages?.map((msg) => (
<div <div
key={msg.id} key={msg.id}
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`} className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}

View File

@@ -5,7 +5,6 @@ import type {
BibliografiaEntry, BibliografiaEntry,
CambioAsignatura, CambioAsignatura,
DocumentoAsignatura, DocumentoAsignatura,
LibraryResource,
} from '@/types/asignatura' } from '@/types/asignatura'
export const mockAsignatura: Asignatura = { export const mockAsignatura: Asignatura = {
@@ -310,5 +309,3 @@ export const mockDocumentoSep: DocumentoAsignatura = {
fechaGeneracion: new Date('2024-12-06T11:30:00'), fechaGeneracion: new Date('2024-12-06T11:30:00'),
estado: 'listo', estado: 'listo',
} }
export const mockLibraryResources: Array<LibraryResource> = []

View File

@@ -17,13 +17,19 @@ import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-qu
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo' import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
import { Route as PlanesPlanIdDetalleRouteImport } from './routes/planes/$planId/_detalle' import { Route as PlanesPlanIdDetalleRouteImport } from './routes/planes/$planId/_detalle'
import { Route as PlanesPlanIdDetalleIndexRouteImport } from './routes/planes/$planId/_detalle/index' import { Route as PlanesPlanIdDetalleIndexRouteImport } from './routes/planes/$planId/_detalle/index'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId'
import { Route as PlanesPlanIdDetalleMapaRouteImport } from './routes/planes/$planId/_detalle/mapa' import { Route as PlanesPlanIdDetalleMapaRouteImport } from './routes/planes/$planId/_detalle/mapa'
import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$planId/_detalle/iaplan' import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$planId/_detalle/iaplan'
import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial' import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial'
import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo' import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo'
import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento' import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento'
import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas' import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/route'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/index'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/historial'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/documento'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/contenido'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/bibliografia'
import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva' import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva'
const LoginRoute = LoginRouteImport.update({ const LoginRoute = LoginRouteImport.update({
@@ -67,12 +73,6 @@ const PlanesPlanIdDetalleIndexRoute =
path: '/', path: '/',
getParentRoute: () => PlanesPlanIdDetalleRoute, getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any) } as any)
const PlanesPlanIdAsignaturasAsignaturaIdRoute =
PlanesPlanIdAsignaturasAsignaturaIdRouteImport.update({
id: '/planes/$planId/asignaturas/$asignaturaId',
path: '/planes/$planId/asignaturas/$asignaturaId',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({ const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({
id: '/mapa', id: '/mapa',
path: '/mapa', path: '/mapa',
@@ -108,6 +108,48 @@ const PlanesPlanIdDetalleAsignaturasRoute =
path: '/asignaturas', path: '/asignaturas',
getParentRoute: () => PlanesPlanIdDetalleRoute, getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any) } as any)
const PlanesPlanIdAsignaturasAsignaturaIdRouteRoute =
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport.update({
id: '/planes/$planId/asignaturas/$asignaturaId',
path: '/planes/$planId/asignaturas/$asignaturaId',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdIndexRoute =
PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute =
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport.update({
id: '/iaasignatura',
path: '/iaasignatura',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute =
PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport.update({
id: '/historial',
path: '/historial',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute =
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport.update({
id: '/documento',
path: '/documento',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute =
PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport.update({
id: '/contenido',
path: '/contenido',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute =
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport.update({
id: '/bibliografia',
path: '/bibliografia',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdDetalleAsignaturasNuevaRoute = const PlanesPlanIdDetalleAsignaturasNuevaRoute =
PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({ PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({
id: '/nueva', id: '/nueva',
@@ -123,15 +165,21 @@ export interface FileRoutesByFullPath {
'/planes': typeof PlanesListaRouteWithChildren '/planes': typeof PlanesListaRouteWithChildren
'/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren '/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute '/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren '/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute '/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute '/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute '/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute '/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -146,9 +194,14 @@ export interface FileRoutesByTo {
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute '/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute '/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -159,15 +212,21 @@ export interface FileRoutesById {
'/planes/_lista': typeof PlanesListaRouteWithChildren '/planes/_lista': typeof PlanesListaRouteWithChildren
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren '/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute '/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
'/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren '/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute '/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute '/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute '/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/_detalle/iaplan': typeof PlanesPlanIdDetalleIaplanRoute '/planes/$planId/_detalle/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -179,15 +238,21 @@ export interface FileRouteTypes {
| '/planes' | '/planes'
| '/planes/$planId' | '/planes/$planId'
| '/planes/nuevo' | '/planes/nuevo'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/asignaturas' | '/planes/$planId/asignaturas'
| '/planes/$planId/documento' | '/planes/$planId/documento'
| '/planes/$planId/flujo' | '/planes/$planId/flujo'
| '/planes/$planId/historial' | '/planes/$planId/historial'
| '/planes/$planId/iaplan' | '/planes/$planId/iaplan'
| '/planes/$planId/mapa' | '/planes/$planId/mapa'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/' | '/planes/$planId/'
| '/planes/$planId/asignaturas/nueva' | '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@@ -202,9 +267,14 @@ export interface FileRouteTypes {
| '/planes/$planId/historial' | '/planes/$planId/historial'
| '/planes/$planId/iaplan' | '/planes/$planId/iaplan'
| '/planes/$planId/mapa' | '/planes/$planId/mapa'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId' | '/planes/$planId'
| '/planes/$planId/asignaturas/nueva' | '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -214,15 +284,21 @@ export interface FileRouteTypes {
| '/planes/_lista' | '/planes/_lista'
| '/planes/$planId/_detalle' | '/planes/$planId/_detalle'
| '/planes/_lista/nuevo' | '/planes/_lista/nuevo'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/_detalle/asignaturas' | '/planes/$planId/_detalle/asignaturas'
| '/planes/$planId/_detalle/documento' | '/planes/$planId/_detalle/documento'
| '/planes/$planId/_detalle/flujo' | '/planes/$planId/_detalle/flujo'
| '/planes/$planId/_detalle/historial' | '/planes/$planId/_detalle/historial'
| '/planes/$planId/_detalle/iaplan' | '/planes/$planId/_detalle/iaplan'
| '/planes/$planId/_detalle/mapa' | '/planes/$planId/_detalle/mapa'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/_detalle/' | '/planes/$planId/_detalle/'
| '/planes/$planId/_detalle/asignaturas/nueva' | '/planes/$planId/_detalle/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -232,7 +308,7 @@ export interface RootRouteChildren {
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
PlanesListaRoute: typeof PlanesListaRouteWithChildren PlanesListaRoute: typeof PlanesListaRouteWithChildren
PlanesPlanIdDetalleRoute: typeof PlanesPlanIdDetalleRouteWithChildren PlanesPlanIdDetalleRoute: typeof PlanesPlanIdDetalleRouteWithChildren
PlanesPlanIdAsignaturasAsignaturaIdRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRoute PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -293,13 +369,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlanesPlanIdDetalleIndexRouteImport preLoaderRoute: typeof PlanesPlanIdDetalleIndexRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute parentRoute: typeof PlanesPlanIdDetalleRoute
} }
'/planes/$planId/asignaturas/$asignaturaId': {
id: '/planes/$planId/asignaturas/$asignaturaId'
path: '/planes/$planId/asignaturas/$asignaturaId'
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/$planId/_detalle/mapa': { '/planes/$planId/_detalle/mapa': {
id: '/planes/$planId/_detalle/mapa' id: '/planes/$planId/_detalle/mapa'
path: '/mapa' path: '/mapa'
@@ -342,6 +411,55 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute parentRoute: typeof PlanesPlanIdDetalleRoute
} }
'/planes/$planId/asignaturas/$asignaturaId': {
id: '/planes/$planId/asignaturas/$asignaturaId'
path: '/planes/$planId/asignaturas/$asignaturaId'
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/$planId/asignaturas/$asignaturaId/': {
id: '/planes/$planId/asignaturas/$asignaturaId/'
path: '/'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': {
id: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
path: '/iaasignatura'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/historial': {
id: '/planes/$planId/asignaturas/$asignaturaId/historial'
path: '/historial'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/historial'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/documento': {
id: '/planes/$planId/asignaturas/$asignaturaId/documento'
path: '/documento'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/documento'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/contenido': {
id: '/planes/$planId/asignaturas/$asignaturaId/contenido'
path: '/contenido'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/contenido'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': {
id: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
path: '/bibliografia'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/_detalle/asignaturas/nueva': { '/planes/$planId/_detalle/asignaturas/nueva': {
id: '/planes/$planId/_detalle/asignaturas/nueva' id: '/planes/$planId/_detalle/asignaturas/nueva'
path: '/nueva' path: '/nueva'
@@ -403,6 +521,36 @@ const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
const PlanesPlanIdDetalleRouteWithChildren = const PlanesPlanIdDetalleRouteWithChildren =
PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren) PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren)
interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren {
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
}
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren =
{
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute:
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute,
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute:
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute,
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute:
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute,
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute:
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute,
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute:
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute,
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute:
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute,
}
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren =
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute._addFileChildren(
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
DashboardRoute: DashboardRoute, DashboardRoute: DashboardRoute,
@@ -410,8 +558,8 @@ const rootRouteChildren: RootRouteChildren = {
DemoTanstackQueryRoute: DemoTanstackQueryRoute, DemoTanstackQueryRoute: DemoTanstackQueryRoute,
PlanesListaRoute: PlanesListaRouteWithChildren, PlanesListaRoute: PlanesListaRouteWithChildren,
PlanesPlanIdDetalleRoute: PlanesPlanIdDetalleRouteWithChildren, PlanesPlanIdDetalleRoute: PlanesPlanIdDetalleRouteWithChildren,
PlanesPlanIdAsignaturasAsignaturaIdRoute: PlanesPlanIdAsignaturasAsignaturaIdRouteRoute:
PlanesPlanIdAsignaturasAsignaturaIdRoute, PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -1,55 +0,0 @@
import { createFileRoute, notFound, useLocation } from '@tanstack/react-router'
import { useEffect } from 'react'
import AsignaturaDetailPage from '@/components/asignaturas/detalle/AsignaturaDetailPage'
import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { subjects_get } from '@/data/api/subjects.api'
import { qk } from '@/data/query/keys'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId',
)({
loader: async ({ context: { queryClient }, params: { asignaturaId } }) => {
try {
await queryClient.ensureQueryData({
queryKey: qk.asignatura(asignaturaId),
queryFn: () => subjects_get(asignaturaId),
})
} catch (e: any) {
// PGRST116: The result contains 0 rows (Supabase Single response error)
if (e?.code === 'PGRST116') {
throw notFound()
}
throw e
}
},
notFoundComponent: () => {
return (
<NotFoundPage
title="Materia no encontrada"
message="La asignatura que buscas no existe o fue eliminada."
/>
)
},
component: RouteComponent,
})
function RouteComponent() {
// const { planId, asignaturaId } = Route.useParams()
const location = useLocation()
// Confetti al llegar desde creación
useEffect(() => {
if ((location.state as any)?.showConfetti) {
lateralConfetti()
window.history.replaceState({}, document.title) // Limpiar el estado para que no se repita
}
}, [location.state])
return (
<div>
<AsignaturaDetailPage></AsignaturaDetailPage>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { BibliographyItem } from '@/components/asignaturas/detalle/BibliographyItem'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/bibliografia',
)({
component: RouteComponent,
})
function RouteComponent() {
return <BibliographyItem />
}

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { ContenidoTematico } from '@/components/asignaturas/detalle/ContenidoTematico'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/contenido',
)({
component: RouteComponent,
})
function RouteComponent() {
return <ContenidoTematico />
}

View File

@@ -0,0 +1,85 @@
import { createFileRoute, useParams } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react'
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
import { fetchPlanPdf } from '@/data/api/document.api'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/documento',
)({
component: RouteComponent,
})
function RouteComponent() {
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId/documento',
})
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isRegenerating, setIsRegenerating] = useState(false)
const loadPdfPreview = useCallback(async () => {
try {
setIsLoading(true)
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(pdfBlob)
setPdfUrl((prev) => {
if (prev) window.URL.revokeObjectURL(prev)
return url
})
} catch (error) {
console.error('Error cargando PDF:', error)
} finally {
setIsLoading(false)
}
}, [planId])
useEffect(() => {
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
}
}, [loadPdfPreview])
const handleDownload = async () => {
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
link.href = url
link.download = 'documento_sep.pdf'
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
const handleRegenerate = async () => {
try {
setIsRegenerating(true)
await loadPdfPreview()
} finally {
setIsRegenerating(false)
}
}
return (
<DocumentoSEPTab
pdfUrl={pdfUrl}
isLoading={isLoading}
onDownload={handleDownload}
onRegenerate={handleRegenerate}
isRegenerating={isRegenerating}
/>
)
}

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { HistorialTab } from '@/components/asignaturas/detalle/HistorialTab'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/historial',
)({
component: RouteComponent,
})
function RouteComponent() {
return <HistorialTab />
}

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { IAAsignaturaTab } from '@/components/asignaturas/detalle/IAAsignaturaTab'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
)({
component: RouteComponent,
})
function RouteComponent() {
return <IAAsignaturaTab />
}

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import AsignaturaDetailPage from '@/components/asignaturas/detalle/AsignaturaDetailPage'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/',
)({
component: DatosGeneralesPage,
})
function DatosGeneralesPage() {
return <AsignaturaDetailPage />
}

View File

@@ -0,0 +1,264 @@
import {
createFileRoute,
Outlet,
Link,
useParams,
useRouterState,
} from '@tanstack/react-router'
import { ArrowLeft, GraduationCap } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { useSubject, useUpdateAsignatura } from '@/data'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId',
)({
component: AsignaturaLayout,
})
function EditableHeaderField({
value,
onSave,
className,
}: {
value: string | number
onSave: (val: string) => void
className?: string
}) {
const textValue = String(value)
// Manejador para cuando el usuario termina de editar (pierde el foco)
const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
const newValue = e.currentTarget.innerText
if (newValue !== textValue) {
onSave(newValue)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur() // Forzamos el guardado al presionar Enter
}
}
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<span
contentEditable
suppressContentEditableWarning={true} // Evita el warning de React por tener hijos y contentEditable
spellCheck={false}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={`inline-block cursor-text rounded-sm px-1 transition-all hover:bg-white/10 focus:bg-white/20 focus:ring-2 focus:ring-blue-400/50 focus:outline-none ${className ?? ''} `}
>
{textValue}
</span>
)
}
interface DatosPlan {
nombre?: string
}
function AsignaturaLayout() {
const routerState = useRouterState()
const state = routerState.location.state as any
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturaApi, isLoading: loadingAsig } =
useSubject(asignaturaId)
// 1. Asegúrate de tener estos estados en tu componente principal
const updateAsignatura = useUpdateAsignatura()
// Dentro de AsignaturaDetailPage
const [headerData, setHeaderData] = useState({
codigo: '',
nombre: '',
creditos: 0,
ciclo: 0,
})
// Sincronizar cuando llegue la API
useEffect(() => {
if (asignaturaApi) {
setHeaderData({
codigo: asignaturaApi.codigo ?? '',
nombre: asignaturaApi.nombre,
creditos: asignaturaApi.creditos,
ciclo: asignaturaApi.numero_ciclo ?? 0,
})
}
}, [asignaturaApi])
const handleUpdateHeader = (key: string, value: string | number) => {
const newData = { ...headerData, [key]: value }
setHeaderData(newData)
const patch: Record<string, any> =
key === 'ciclo'
? { numero_ciclo: value }
: {
[key]: value,
}
updateAsignatura.mutate({
asignaturaId,
patch,
})
}
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
if (loadingAsig) {
return (
<div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white">
Cargando asignatura...
</div>
)
}
// Si no hay datos y no está cargando, algo falló
if (!asignaturaApi) return null
return (
<div>
<section className="bg-gradient-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">
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
.nombre || ''}
</span>
</span>
<span className="flex items-center gap-1">
<span className="text-blue-100">
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
.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 */}
<nav className="border-b bg-white">
<div className="mx-auto max-w-7xl px-6">
<div className="flex justify-center gap-8">
{[
{ label: 'Datos', to: '' },
{ label: 'Contenido', to: 'contenido' },
{ label: 'Bibliografía', to: 'bibliografia' },
{ label: 'IA', to: 'asignaturaIa' },
{ label: 'Documento SEP', to: 'documento' },
{ label: 'Historial', to: 'historial' },
].map((tab) => {
const isActive =
tab.to === ''
? pathname === `/planes/${planId}/asignaturas/${asignaturaId}`
: pathname.includes(tab.to)
return (
<Link
key={tab.label}
to={
(tab.to === ''
? '/planes/$planId/asignaturas/$asignaturaId'
: `/planes/$planId/asignaturas/$asignaturaId/${tab.to}`) as any
}
from="/planes/$planId/asignaturas/$asignaturaId"
params={{ planId, asignaturaId }}
className={`border-b-2 py-3 text-sm font-medium ${
isActive
? 'border-blue-600 text-blue-600'
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
}`}
>
{tab.label}
</Link>
)
})}
</div>
</div>
</nav>
<div className="mx-auto max-w-7xl px-6 py-8">
<Outlet />
</div>
</div>
)
}