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

538 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 } from "@tanstack/react-router"
import { useEffect, useMemo, useRef, useState } from "react"
import * as Icons from "lucide-react"
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { AcademicSections, planKeys } from "@/components/planes/academic-sections"
import { GradientMesh } from "../../../components/planes/GradientMesh"
import { asignaturaExtraOptions, asignaturaKeys, asignaturasCountOptions, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries"
import { softAccentStyle } from "@/components/planes/planHelpers"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import { DialogFooter, DialogHeader } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import confetti from "canvas-confetti"
import { Textarea } from "@/components/ui/textarea"
import { AuroraButton } from "@/components/effect/aurora-button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { DeletePlanButton } from "@/components/planes/DeletePlan"
type LoaderData = { planId: string }
export const Route = createFileRoute("/_authenticated/plan/$planId")({
component: RouteComponent,
pendingComponent: PageSkeleton,
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
const { planId } = params
await Promise.all([
queryClient.ensureQueryData(planByIdOptions(planId)),
queryClient.ensureQueryData(asignaturasCountOptions(planId)),
queryClient.ensureQueryData(asignaturasPreviewOptions(planId)),
])
return { planId }
},
})
// ...existing code...
function RouteComponent() {
const qc = useQueryClient()
const { planId } = Route.useLoaderData() as LoaderData
const auth = useSupabaseAuth()
const { data: plan } = useSuspenseQuery(planByIdOptions(planId))
const { data: asignaturasCount } = useSuspenseQuery(asignaturasCountOptions(planId))
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(planId))
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
const facColor = plan.carreras?.facultades?.color ?? null
// Animaciones y refs pueden modularizarse si se desea
const headerRef = useRef<HTMLDivElement>(null)
const statsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// ...animaciones header y stats...
}, [])
return (
<div className="relative p-6 space-y-6">
<GradientMesh color={fac?.color} />
<nav className="relative text-sm text-neutral-500">
<Link to="/planes" search={{ plan: '' }} className="hover:underline">Planes de estudio</Link>
<span className="mx-1">/</span>
<span className="text-primary">{plan.nombre}</span>
</nav>
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
<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 font-mono">{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} />
<DeletePlanButton planId={plan.id} />
</div>
</div>
</CardHeader>
<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} color={fac?.color} />
</div>
<Card className="border shadow-sm">
<CardHeader className="flex items-center justify-between gap-2">
<CardTitle className="text-base font-mono">Asignaturas ({asignaturasCount})</CardTitle>
<div className="flex items-center gap-2">
<AddAsignaturaButton planId={plan.id} onAdded={() => {
qc.invalidateQueries({ queryKey: asignaturaKeys.count(plan.id) })
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(plan.id) })
}} />
<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"
title="Ver todas las asignaturas"
>
<Icons.BookOpen className="w-4 h-4" /> Ver en página de Asignaturas
</Link>
</div>
</CardHeader>
<CardContent>
{asignaturasPreview.length === 0 ? (
<div className="text-sm text-neutral-500">Sin asignaturas</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{asignaturasPreview.map((a) => (
<AsignaturaPreviewCard key={a.id} asignatura={a} />
))}
</div>
)}
</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 ===== */
function StatCard({ label, value = "—", Icon = Icons.Info, accent, className = "", title }: {
label: string
value?: React.ReactNode
Icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>
accent?: string | null
className?: string
title?: string
}) {
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>
<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)
const qc = useQueryClient()
const mutation = useMutation({
mutationFn: async (payload: Partial<PlanFull>) => {
const { error } = await supabase.from('plan_estudios').update({
nombre: payload.nombre ?? plan.nombre,
nivel: payload.nivel ?? plan.nivel,
duracion: payload.duracion ?? plan.duracion,
total_creditos: payload.total_creditos ?? plan.total_creditos,
}).eq('id', plan.id)
if (error) throw error
},
onMutate: async (payload) => {
await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) })
const prev = qc.getQueryData<PlanFull>(planKeys.byId(plan.id))
qc.setQueryData<PlanFull>(planKeys.byId(plan.id), (old) => old ? { ...old, ...payload } as PlanFull : old as any)
return { prev }
},
onError: (_e, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev)
},
onSettled: async () => {
await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) })
}
})
async function save() {
setSaving(true)
try {
await mutation.mutateAsync(form)
setOpen(false)
} finally { setSaving(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 className="font-mono" >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 || mutation.isPending}>{(saving || mutation.isPending) ? '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(`${import.meta.env.VITE_BACK_ORIGIN}/api/mejorar/plan`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, plan_id: plan.id }),
}).catch(() => { })
setLoading(false)
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
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 className="font-mono" >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>
<AuroraButton onClick={apply} disabled={!prompt.trim() || loading}>
{loading ? 'Aplicando…' : 'Aplicar ajuste'}
</AuroraButton>
</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>
)
}
/* ===== Asignaturas ===== */
function AsignaturaPreviewCard({ asignatura }: { asignatura: AsignaturaLite }) {
const { data: extra } = useSuspenseQuery(asignaturaExtraOptions(asignatura.id))
const horasT = extra?.horas_teoricas ?? null
const horasP = extra?.horas_practicas ?? null
const horasTot = (horasT ?? 0) + (horasP ?? 0)
const resumenContenidos = useMemo(() => {
const c = extra?.contenidos
if (!c) return { unidades: 0, temas: 0 }
const unidades = Object.keys(c).length
const temas = Object.values(c).reduce((acc, temasObj) => acc + Object.keys(temasObj || {}).length, 0)
return { unidades, temas }
}, [extra?.contenidos])
const tipo = (extra?.tipo ?? "").toLowerCase()
const tipoChip =
tipo.includes("oblig") ? "bg-emerald-50 text-emerald-700 border-emerald-200" :
tipo.includes("opt") ? "bg-amber-50 text-amber-800 border-amber-200" :
tipo.includes("taller") ? "bg-indigo-50 text-indigo-700 border-indigo-200" :
tipo.includes("lab") ? "bg-sky-50 text-sky-700 border-sky-200" :
"bg-neutral-100 text-neutral-700 border-neutral-200"
return (
<article className="group relative overflow-hidden rounded-2xl border bg-white/70 dark:bg-neutral-900/60 backdrop-blur p-4 shadow-sm hover:shadow-md transition-all hover:-translate-y-0.5" role="region" aria-label={asignatura.nombre}>
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
<Icons.BookOpen className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="font-medium truncate" title={asignatura.nombre}>{asignatura.nombre}</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
{asignatura.semestre != null && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
<Icons.Calendar className="h-3 w-3" /> S{asignatura.semestre}
</span>
)}
{asignatura.creditos != null && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
<Icons.Coins className="h-3 w-3" /> {asignatura.creditos} cr
</span>
)}
{extra?.tipo && (
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 ${tipoChip}`}>
<Icons.Tag className="h-3 w-3" /> {extra.tipo}
</span>
)}
</div>
</div>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px]">
<SmallStat icon={Icons.Clock} label="Horas" value={horasTot || "—"} />
<SmallStat icon={Icons.BookMarked} label="Unidades" value={resumenContenidos.unidades || "—"} />
<SmallStat icon={Icons.ListTree} label="Temas" value={resumenContenidos.temas || "—"} />
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-[11px] text-neutral-500">
{horasT != null || horasP != null ? (
<>H T/P: {horasT ?? "—"}/{horasP ?? "—"}</>
) : (
<span className="opacity-70">Resumen listo</span>
)}
</div>
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: asignatura.id }} className="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs hover:bg-neutral-50" title="Ver detalle">
Ver detalle <Icons.ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<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%, rgba(0,0,0,.06), transparent 60%)" }} />
</article>
)
}
function SmallStat({ icon: Icon, label, value }: { icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; label: string; value: string | number }) {
return (
<div className="rounded-lg border bg-white/60 dark:bg-neutral-900/50 px-2.5 py-2">
<div className="flex items-center gap-1 text-[10px] text-neutral-500">
<Icon className="h-3.5 w-3.5" /> {label}
</div>
<div className="mt-0.5 font-medium tabular-nums">{value}</div>
</div>
)
}
/* ===== Crear Asignatura ===== */
function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [mode, setMode] = useState<"manual" | "ia">("manual")
const [f, setF] = useState({ nombre: "", clave: "", tipo: "", semestre: "", creditos: "", horas_teoricas: "", horas_practicas: "", objetivos: "" })
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?.()
// Warm up cache quickly
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
}
async function createWithAI() {
if (!canIA) return
setSaving(true)
try {
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/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 }),
})
if (!res.ok) throw new Error(await res.text())
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
setOpen(false)
onAdded?.()
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
} 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 className="font-mono" >Nueva asignatura</DialogTitle>
<DialogDescription>Elige cómo crearla: manual o generada por IA.</DialogDescription>
</DialogHeader>
<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>
<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>
<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>
<AuroraButton onClick={submit} disabled={saving || !canSubmit}>
{saving ? (mode === "manual" ? "Guardando…" : "Generando…") : (mode === "manual" ? "Crear" : "Generar e insertar")}
</AuroraButton>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}