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:
@@ -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="1–10" />
|
||||
</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="1–10" />
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user