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.
This commit is contained in:
2025-08-25 09:29:22 -06:00
parent ca3fed69b2
commit 012a5a58b0
12 changed files with 1590 additions and 246 deletions

View File

@@ -1,4 +1,4 @@
import { createFileRoute, Link } from '@tanstack/react-router'
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'
@@ -12,6 +12,7 @@ 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)
@@ -125,6 +126,7 @@ function GradientMesh({ color }: { color?: string | null }) {
/* ============== 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'
@@ -252,25 +254,28 @@ function RouteComponent() {
<CardHeader className="flex items-center justify-between gap-2">
<CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle>
{/* Abre el modal enmascarado */}
<Link
to="/asignaturas/$planId"
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 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
key={a.id}
to="/asignaturas/$planId"
params={{ planId: plan.id }}
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}
>
@@ -455,3 +460,172 @@ function PageSkeleton() {
</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>
</>
)
}