This repository has been archived on 2026-01-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Acad-IA/src/routes/_authenticated/plan/$planId.tsx
Alejandro Rosales 012a5a58b0 feat: add AI-generated study plan creation dialog and API integration
- Implemented CreatePlanDialog component for generating study plans using AI.
- Integrated postAPI function for handling API requests.
- Updated planes.tsx to include AI plan generation logic.
- Modified usuarios.tsx to enable email confirmation for new users.
- Added Switch component for UI consistency.
- Created api.ts for centralized API handling.
- Developed carreras.tsx for managing career data with filtering and CRUD operations.
- Added CarreraFormDialog and CarreraDetailDialog for creating and editing career details.
- Implemented CriterioFormDialog for adding criteria to careers.
2025-08-25 09:29:22 -06:00

632 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { supabase, useSupabaseAuth } from '@/auth/supabase'
import * as Icons from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { AcademicSections } from '@/components/planes/academic-sections'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs'
gsap.registerPlugin(ScrollTrigger)
type PlanFull = {
id: string; nombre: string; nivel: string | null;
objetivo_general: string | null; perfil_ingreso: string | null; perfil_egreso: string | null;
duracion: string | null; total_creditos: number | null;
competencias_genericas: string | null; competencias_especificas: string | null;
sistema_evaluacion: string | null; indicadores_desempeno: string | null;
pertinencia: string | null; prompt: string | null;
estado: string | null; fecha_creacion: string | null;
carreras: { id: string; nombre: string; facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null } | null
}
type AsignaturaLite = { id: string; nombre: string; semestre: number | null; creditos: number | null }
type LoaderData = { plan: PlanFull; asignaturasCount: number; asignaturasPreview: AsignaturaLite[] }
/* ============== ROUTE ============== */
export const Route = createFileRoute('/_authenticated/plan/$planId')({
component: RouteComponent,
pendingComponent: PageSkeleton,
loader: async ({ params }): Promise<LoaderData> => {
const { data: plan, error } = await supabase
.from('plan_estudios')
.select(`
id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos,
competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno,
pertinencia, prompt, estado, fecha_creacion,
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
`)
.eq('id', params.planId)
.single()
if (error || !plan) throw error ?? new Error('Plan no encontrado')
const { count } = await supabase
.from('asignaturas')
.select('*', { count: 'exact', head: true })
.eq('plan_id', params.planId)
const { data: asignaturasPreview } = await supabase
.from('asignaturas')
.select('id, nombre, semestre, creditos')
.eq('plan_id', params.planId)
.order('semestre', { ascending: true })
.order('nombre', { ascending: true })
.limit(8)
return {
plan: plan as unknown as PlanFull,
asignaturasCount: count ?? 0,
asignaturasPreview: (asignaturasPreview ?? []) as AsignaturaLite[],
}
},
})
/* ============== COLOR / MESH HELPERS ============== */
function hexToRgb(hex?: string | null): [number, number, number] {
if (!hex) return [37, 99, 235]
const h = hex.replace('#', '')
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
const n = parseInt(v, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
function softAccentStyle(color?: string | null) {
const [r, g, b] = hexToRgb(color)
return {
borderColor: `rgba(${r},${g},${b},.28)`,
background: `linear-gradient(180deg, rgba(${r},${g},${b},.06), rgba(${r},${g},${b},.02))`,
} as React.CSSProperties
}
function lighten([r, g, b]: [number, number, number], amt = 30) { return [r + amt, g + amt, b + amt].map(v => Math.max(0, Math.min(255, v))) as [number, number, number] }
function toRGBA([r, g, b]: [number, number, number], a: number) { return `rgba(${r},${g},${b},${a})` }
/* ============== GRADIENT MESH LAYER ============== */
function GradientMesh({ color }: { color?: string | null }) {
const meshRef = useRef<HTMLDivElement>(null)
const base = hexToRgb(color)
const soft = lighten(base, 20)
const pop = lighten(base, -20)
useEffect(() => {
if (!meshRef.current) return
const blobs = meshRef.current.querySelectorAll('.blob')
blobs.forEach((el, i) => {
gsap.to(el, {
x: gsap.utils.random(-30, 30),
y: gsap.utils.random(-20, 20),
rotate: gsap.utils.random(-6, 6),
duration: gsap.utils.random(6, 10),
ease: 'sine.inOut',
yoyo: true,
repeat: -1,
delay: i * 0.2,
})
})
return () => gsap.killTweensOf(blobs)
}, [color])
return (
<div ref={meshRef} className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<div className="blob absolute -top-24 -left-24 w-[38rem] h-[38rem] rounded-full blur-3xl"
style={{ background: `radial-gradient(circle, ${toRGBA(soft, .35)}, transparent 60%)` }} />
<div className="blob absolute -bottom-28 -right-20 w-[34rem] h-[34rem] rounded-full blur-[60px]"
style={{ background: `radial-gradient(circle, ${toRGBA(base, .28)}, transparent 60%)` }} />
<div className="blob absolute top-1/3 left-1/2 -translate-x-1/2 w-[22rem] h-[22rem] rounded-full blur-[50px]"
style={{ background: `radial-gradient(circle, ${toRGBA(pop, .22)}, transparent 60%)` }} />
<div className="absolute inset-0 opacity-40"
style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }} />
</div>
)
}
/* ============== PAGE ============== */
function RouteComponent() {
const router = useRouter()
const { plan, asignaturasCount, asignaturasPreview } = Route.useLoaderData() as LoaderData
const auth = useSupabaseAuth()
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
const showCarrera = auth.claims?.role === 'secretario_academico'
const fac = plan.carreras?.facultades
const accent = useMemo(() => softAccentStyle(fac?.color), [fac?.color])
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
// Refs para animaciones
const headerRef = useRef<HTMLDivElement>(null)
const statsRef = useRef<HTMLDivElement>(null)
const fieldsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// Header intro
if (headerRef.current) {
const ctx = gsap.context(() => {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
tl.from('.hdr-icon', { y: 12, opacity: 0, duration: .5 })
.from('.hdr-title', { y: 8, opacity: 0, duration: .4 }, '-=.25')
.from('.hdr-chips > *', { y: 6, opacity: 0, stagger: .06, duration: .35 }, '-=.25')
}, headerRef)
return () => ctx.revert()
}
}, [])
useEffect(() => {
// Stats y campos con ScrollTrigger
if (statsRef.current) {
const ctx = gsap.context(() => {
gsap.from('.academics', {
y: 14, opacity: 0, stagger: .08, duration: .4,
scrollTrigger: { trigger: statsRef.current, start: 'top 85%' }
})
}, statsRef)
return () => ctx.revert()
}
}, [])
useEffect(() => {
if (fieldsRef.current) {
const ctx = gsap.context(() => {
gsap.utils.toArray<HTMLElement>('.long-field').forEach((el, i) => {
gsap.from(el, {
y: 22, opacity: 0, duration: .45, delay: i * 0.03,
scrollTrigger: { trigger: el, start: 'top 90%' }
})
})
}, fieldsRef)
return () => ctx.revert()
}
}, [])
const facColor = plan.carreras?.facultades?.color ?? null
return (
<div className="relative p-6 space-y-6">
{/* Mesh global */}
<GradientMesh color={fac?.color} />
<nav className="relative text-sm text-neutral-500">
<Link to="/planes" className="hover:underline">Planes de estudio</Link>
<span className="mx-1">/</span>
<span className="text-primary">{plan.nombre}</span>
</nav>
{/* Header con acciones y brillo */}
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
{/* velo de color muy suave */}
<div className="absolute inset-0 -z-0" style={accent} />
<CardHeader className="relative z-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 min-w-0">
<span className="hdr-icon inline-flex items-center justify-center rounded-2xl border px-3 py-2 bg-white/70"
style={{ borderColor: accent.borderColor as string }}>
<IconComp className="w-6 h-6" />
</span>
<div className="min-w-0">
<CardTitle className="hdr-title truncate">{plan.nombre}</CardTitle>
<div className="hdr-chips text-xs text-neutral-600 truncate">
{showCarrera && plan.carreras?.nombre ? `Carrera: ${plan.carreras.nombre}` : null}
{showFacultad && fac?.nombre ? `${showCarrera ? ' · ' : ''}Facultad: ${fac.nombre}` : null}
</div>
</div>
</div>
<div className="hdr-chips flex flex-wrap items-center gap-2">
{plan.estado && (
<Badge variant="outline" className="bg-white/60" style={{ borderColor: accent.borderColor }}>
{plan.estado}
</Badge>
)}
<div className='flex gap-2'>
<EditPlanButton plan={plan} />
<AdjustAIButton plan={plan} />
</div>
</div>
</CardHeader>
{/* stats */}
<CardContent
ref={statsRef}
>
<div className="academics relative z-10 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
<StatCard label="Nivel" value={plan.nivel ?? "—"} Icon={Icons.GraduationCap} accent={facColor} />
<StatCard label="Duración" value={plan.duracion ?? "—"} Icon={Icons.Clock} accent={facColor} />
<StatCard label="Créditos" value={fmt(plan.total_creditos)} Icon={Icons.Coins} accent={facColor} />
<StatCard
label="Creado"
value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"}
Icon={Icons.CalendarDays}
accent={facColor}
/>
</div>
</CardContent>
</Card>
<div className="academics">
<AcademicSections planId={plan.id} plan={plan} color={fac?.color} />
</div>
<Card className="border shadow-sm">
<CardHeader className="flex items-center justify-between gap-2">
<CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle>
<div className="flex items-center gap-2">
<AddAsignaturaButton planId={plan.id} onAdded={() => router.invalidate()} />
<Link
to="/asignaturas/$planId"
search={{ q: "", planId: plan.id, carreraId: '', f: '', facultadId: '' }}
params={{ planId: plan.id }}
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
>
<Icons.BookOpen className="w-4 h-4" /> Ver todas
</Link>
</div>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{asignaturasPreview.length === 0 && (
<div className="text-sm text-neutral-500">Sin asignaturas</div>
)}
{asignaturasPreview.map(a => (
<Link
to="/asignatura/$asignaturaId"
params={{ asignaturaId: a.id }}
className="rounded-full border px-3 py-1 text-xs bg-white/70 hover:bg-white transition"
title={a.nombre}
>
{a.semestre ? `S${a.semestre} · ` : ''}{a.nombre}
</Link>
))}
</CardContent>
</Card>
</div>
)
}
function hexToRgbA(hex?: string | null, a = .25) {
if (!hex) return `rgba(37,99,235,${a})`
const h = hex.replace("#", "")
const v = h.length === 3 ? h.split("").map(c => c + c).join("") : h
const n = parseInt(v, 16)
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255
return `rgba(${r},${g},${b},${a})`
}
const fmt = (n?: number | null) => (n !== null && n !== undefined) ? Intl.NumberFormat().format(n) : "—"
/* ===== UI bits ===== */
type StatProps = {
label: string
value?: React.ReactNode
Icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>
accent?: string | null // color de facultad (hex) opcional
className?: string
title?: string
}
function StatCard({ label, value = "—", Icon = Icons.Info, accent, className = "", title }: StatProps) {
const border = hexToRgbA(accent, .28)
const chipBg = hexToRgbA(accent, .08)
const glow = hexToRgbA(accent, .14)
return (
<div
className={`group relative overflow-hidden rounded-2xl border p-4 sm:p-5
bg-white/70 dark:bg-neutral-900/60 backdrop-blur
shadow-sm hover:shadow-md transition-all ${className}`}
style={{ borderColor: border }}
title={title ?? (typeof value === "string" ? value : undefined)}
aria-label={`${label}: ${typeof value === "string" ? value : ""}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-neutral-500">{label}</div>
<span
className="inline-flex items-center justify-center rounded-xl px-2.5 py-2 border"
style={{ borderColor: border, background: chipBg }}
>
<Icon className="h-4 w-4 opacity-80" />
</span>
</div>
<div className="mt-1 text-2xl font-semibold tabular-nums tracking-tight truncate">
{value}
</div>
{/* glow sutil en hover */}
<div
className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: `radial-gradient(600px 120px at 20% -10%, ${glow}, transparent 60%)` }}
/>
</div>
)
}
/* ===== Editar ===== */
function EditPlanButton({ plan }: { plan: PlanFull }) {
const [open, setOpen] = useState(false)
const [form, setForm] = useState<Partial<PlanFull>>({})
const [saving, setSaving] = useState(false)
async function save() {
setSaving(true)
const { error } = await supabase.from('plan_estudios').update({
nombre: form.nombre ?? plan.nombre,
nivel: form.nivel ?? plan.nivel,
duracion: form.duracion ?? plan.duracion,
total_creditos: form.total_creditos ?? plan.total_creditos,
}).eq('id', plan.id)
setSaving(false)
if (!error) setOpen(false)
}
return (
<>
<Button variant="secondary" onClick={() => { setForm(plan); setOpen(true) }}>
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Editar plan</DialogTitle>
<DialogDescription>Actualiza datos básicos.</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<Field label="Nombre"><Input value={form.nombre ?? ''} onChange={(e) => setForm({ ...form, nombre: e.target.value })} /></Field>
<Field label="Nivel"><Input value={form.nivel ?? ''} onChange={(e) => setForm({ ...form, nivel: e.target.value })} /></Field>
<Field label="Duración"><Input value={form.duracion ?? ''} onChange={(e) => setForm({ ...form, duracion: e.target.value })} /></Field>
<Field label="Créditos totales"><Input value={String(form.total_creditos ?? '')} onChange={(e) => setForm({ ...form, total_creditos: Number(e.target.value) || null })} /></Field>
</div>
<DialogFooter>
<Button onClick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<Label className="text-xs text-neutral-600">{label}</Label>
{children}
</div>
)
}
/* ===== Ajustar IA ===== */
function AdjustAIButton({ plan }: { plan: PlanFull }) {
const [open, setOpen] = useState(false)
const [prompt, setPrompt] = useState('')
const [loading, setLoading] = useState(false)
async function apply() {
setLoading(true)
await fetch('https://genesis-engine.apps.lci.ulsa.mx/ajustar/plan', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, plan }),
}).catch(() => { })
setLoading(false)
setOpen(false)
}
return (
<>
<Button onClick={() => setOpen(true)}>
<Icons.Sparkles className="w-4 h-4 mr-2" /> Ajustar con IA
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Ajustar con IA</DialogTitle>
<DialogDescription>Describe cómo quieres modificar el plan actual.</DialogDescription>
</DialogHeader>
<Textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Ej.: Enfatiza ciberseguridad y proyectos prácticos…" className="min-h-[120px]" />
<DialogFooter>
<Button onClick={apply} disabled={!prompt.trim() || loading}>
{loading ? 'Aplicando…' : 'Aplicar ajuste'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/* ===== Skeleton ===== */
function Pulse({ className = '' }: { className?: string }) {
return <div className={`animate-pulse bg-neutral-200 rounded-xl ${className}`} />
}
function PageSkeleton() {
return (
<div className="p-6 space-y-6">
<div className="border rounded-2xl p-6">
<div className="flex items-center gap-3">
<Pulse className="w-10 h-10" />
<div className="flex-1 space-y-2">
<Pulse className="h-5 w-64" />
<Pulse className="h-3 w-48" />
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 mt-4">
{Array.from({ length: 5 }).map((_, i) => <Pulse key={i} className="h-14" />)}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => <Pulse key={i} className="h-40" />)}
</div>
</div>
)
}
function AddAsignaturaButton({
planId, onAdded,
}: { planId: string; onAdded?: () => void }) {
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [mode, setMode] = useState<"manual" | "ia">("manual")
// --- Manual ---
const [f, setF] = useState({
nombre: "", clave: "", tipo: "", semestre: "", creditos: "",
horas_teoricas: "", horas_practicas: "", objetivos: "",
})
// --- IA ---
const [iaPrompt, setIaPrompt] = useState("")
const [iaSemestre, setIaSemestre] = useState("")
const toNull = (s: string) => s.trim() ? s : null
const toNum = (s: string) => s.trim() ? Number(s) || null : null
const canManual = f.nombre.trim().length > 0
const canIA = iaPrompt.trim().length > 0
const canSubmit = mode === "manual" ? canManual : canIA
async function createManual() {
if (!canManual) return
setSaving(true)
const payload = {
plan_id: planId,
nombre: f.nombre.trim(),
clave: toNull(f.clave),
tipo: toNull(f.tipo),
semestre: toNum(f.semestre),
creditos: toNum(f.creditos),
horas_teoricas: toNum(f.horas_teoricas),
horas_practicas: toNum(f.horas_practicas),
objetivos: toNull(f.objetivos),
contenidos: {}, bibliografia: [], criterios_evaluacion: null,
}
const { error } = await supabase.from("asignaturas").insert([payload])
setSaving(false)
if (error) { alert(error.message); return }
setOpen(false); onAdded?.()
}
async function createWithAI() {
if (!canIA) return
setSaving(true)
try {
const res = await fetch("http://localhost:3001/api/generar/asignatura", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
planEstudiosId: planId,
prompt: iaPrompt,
semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined,
insert: true, // que la API inserte en DB
}),
})
if (!res.ok) throw new Error(await res.text())
setOpen(false); onAdded?.()
} catch (e:any) {
alert(e?.message ?? "Error al generar la asignatura")
} finally {
setSaving(false)
}
}
const submit = () => (mode === "manual" ? createManual() : createWithAI())
return (
<>
<Button onClick={() => setOpen(true)}>
<Icons.Plus className="w-4 h-4 mr-2" /> Nueva asignatura
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[min(92vw,760px)]">
<DialogHeader>
<DialogTitle>Nueva asignatura</DialogTitle>
<DialogDescription>Elige cómo crearla: manual o generada por IA.</DialogDescription>
</DialogHeader>
{/* Conmutador elegante */}
<Tabs value={mode} onValueChange={v => setMode(v as "manual" | "ia")} className="w-full">
<TabsList
className="grid w-full grid-cols-2 rounded-xl border bg-neutral-50 p-1"
aria-label="Modo de creación"
>
<TabsTrigger
value="manual"
className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm"
>
<Icons.PencilLine className="h-4 w-4 mr-2" /> Manual
</TabsTrigger>
<TabsTrigger
value="ia"
className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm"
>
<Icons.Sparkles className="h-4 w-4 mr-2" /> Generado por IA
</TabsTrigger>
</TabsList>
{/* --- Pestaña: Manual --- */}
<TabsContent value="manual" className="mt-4">
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Nombre">
<Input value={f.nombre} onChange={e=>setF(s=>({...s, nombre:e.target.value}))} />
</Field>
<Field label="Clave">
<Input value={f.clave} onChange={e=>setF(s=>({...s, clave:e.target.value}))} />
</Field>
<Field label="Tipo">
<Input value={f.tipo} onChange={e=>setF(s=>({...s, tipo:e.target.value}))} placeholder="Obligatoria / Optativa / Taller…" />
</Field>
<Field label="Semestre">
<Input value={f.semestre} onChange={e=>setF(s=>({...s, semestre:e.target.value}))} placeholder="110" />
</Field>
<Field label="Créditos">
<Input value={f.creditos} onChange={e=>setF(s=>({...s, creditos:e.target.value}))} />
</Field>
<Field label="Horas teóricas">
<Input value={f.horas_teoricas} onChange={e=>setF(s=>({...s, horas_teoricas:e.target.value}))} />
</Field>
<Field label="Horas prácticas">
<Input value={f.horas_practicas} onChange={e=>setF(s=>({...s, horas_practicas:e.target.value}))} />
</Field>
<div className="sm:col-span-2">
<Field label="Objetivo (opcional)">
<Textarea value={f.objetivos} onChange={e=>setF(s=>({...s, objetivos:e.target.value}))} className="min-h-[90px]" />
</Field>
</div>
</div>
</TabsContent>
{/* --- Pestaña: IA --- */}
<TabsContent value="ia" className="mt-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<Field label="Indica el enfoque / requisitos">
<Textarea
value={iaPrompt}
onChange={e=>setIaPrompt(e.target.value)}
className="min-h-[120px]"
placeholder="Ej.: Diseña una materia de Programación Web con proyectos, evaluación por rúbricas y bibliografía actual…"
/>
</Field>
</div>
<Field label="Periodo (opcional)">
<Input value={iaSemestre} onChange={e=>setIaSemestre(e.target.value)} placeholder="110" />
</Field>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
<Button onClick={submit} disabled={saving || !canSubmit}>
{saving
? (mode === "manual" ? "Guardando…" : "Generando…")
: (mode === "manual" ? "Crear" : "Generar e insertar")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}