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:
@@ -12,6 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
||||
import { postAPI } from "@/lib/api"
|
||||
|
||||
|
||||
export type PlanDeEstudios = {
|
||||
@@ -79,6 +80,112 @@ function InfoChip({
|
||||
)
|
||||
}
|
||||
|
||||
function CreatePlanDialog({
|
||||
open, onOpenChange
|
||||
}: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||
const router = useRouter()
|
||||
const auth = useSupabaseAuth()
|
||||
const role = auth.claims?.role
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const [facultadId, setFacultadId] = useState(auth.claims?.facultad_id ?? "")
|
||||
const [carreraId, setCarreraId] = useState(auth.claims?.carrera_id ?? "")
|
||||
const [nivel, setNivel] = useState("")
|
||||
const [prompt, setPrompt] = useState(
|
||||
"Genera un plan de estudios claro y realista: "
|
||||
)
|
||||
|
||||
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
|
||||
const lockCarrera = role === "jefe_carrera"
|
||||
|
||||
async function crearConIA() {
|
||||
setErr(null)
|
||||
if (!carreraId) { setErr("Selecciona una carrera."); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
// 1) Generar (e insertar) plan vía tu API
|
||||
const res = await postAPI("/api/generar/plan", {
|
||||
carreraId,
|
||||
prompt,
|
||||
insert: true, // 👈 hace que tu backend inserte en supabase
|
||||
})
|
||||
|
||||
// 2) Si el backend devuelve el ID, vamos directo; si no, recargamos listado
|
||||
const newId = res?.id || res?.plan?.id || res?.data?.id
|
||||
if (newId) {
|
||||
onOpenChange(false)
|
||||
router.invalidate()
|
||||
router.navigate({ to: "/plan/$planId", params: { planId: newId } })
|
||||
} else {
|
||||
onOpenChange(false)
|
||||
router.invalidate()
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErr(typeof e?.message === "string" ? e.message : "Error al generar el plan.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(92vw,760px)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nuevo plan de estudios (IA)</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<Label>Prompt</Label>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[120px]"
|
||||
placeholder="Describe cómo debe ser el plan…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Nivel (opcional)</Label>
|
||||
<Input value={nivel} onChange={(e) => setNivel(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={facultadId}
|
||||
onChange={(id) => { setFacultadId(id); setCarreraId("") }}
|
||||
disabled={lockFacultad}
|
||||
placeholder="Elige una facultad…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<Label>Carrera *</Label>
|
||||
<CarreraCombobox
|
||||
facultadId={facultadId}
|
||||
value={carreraId}
|
||||
onChange={setCarreraId}
|
||||
disabled={!facultadId || lockCarrera}
|
||||
placeholder={facultadId ? "Elige una carrera…" : "Selecciona una facultad primero"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="text-sm text-red-600">{err}</div>}
|
||||
|
||||
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||
<Button className="w-full sm:w-auto" onClick={crearConIA} disabled={saving}>
|
||||
{saving ? "Generando…" : "Generar y crear"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const auth = useSupabaseAuth()
|
||||
@@ -196,189 +303,8 @@ function RouteComponent() {
|
||||
<CreatePlanDialog
|
||||
open={openCreate}
|
||||
onOpenChange={setOpenCreate}
|
||||
onCreated={(id) => {
|
||||
setOpenCreate(false)
|
||||
router.invalidate()
|
||||
router.navigate({ to: "/plan/$planId", params: { planId: id } })
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function CreatePlanDialog({
|
||||
open, onOpenChange, onCreated,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
onCreated: (newId: string) => void
|
||||
}) {
|
||||
const auth = useSupabaseAuth()
|
||||
const role = auth.claims?.role
|
||||
const defaultFac = auth.claims?.facultad_id ?? ""
|
||||
const defaultCar = auth.claims?.carrera_id ?? ""
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<{
|
||||
nombre: string
|
||||
nivel: string
|
||||
duracion: string
|
||||
total_creditos: number | null
|
||||
facultad_id: string
|
||||
carrera_id: string
|
||||
objetivo_general?: string
|
||||
}>({
|
||||
nombre: "",
|
||||
nivel: "",
|
||||
duracion: "",
|
||||
total_creditos: null,
|
||||
facultad_id: defaultFac,
|
||||
carrera_id: defaultCar,
|
||||
objetivo_general: "",
|
||||
})
|
||||
|
||||
// Reglas por rol:
|
||||
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
|
||||
const lockCarrera = role === "jefe_carrera"
|
||||
const needsFacultad = role === "secretario_academico" || role === "jefe_carrera" || role === "vicerrectoria" || role === "lci"
|
||||
const needsCarrera = role !== "planeacion" // en general todos crean sobre una carrera
|
||||
|
||||
async function createPlan() {
|
||||
setError(null)
|
||||
if (!form.nombre.trim()) return setError("El nombre es obligatorio.")
|
||||
if (needsCarrera && !form.carrera_id) return setError("Selecciona una carrera.")
|
||||
|
||||
setSaving(true)
|
||||
const { data, error } = await supabase
|
||||
.from("plan_estudios")
|
||||
.insert({
|
||||
nombre: form.nombre.trim(),
|
||||
nivel: form.nivel || null,
|
||||
duracion: form.duracion || null,
|
||||
total_creditos: form.total_creditos ?? null,
|
||||
objetivo_general: form.objetivo_general || null,
|
||||
carrera_id: form.carrera_id,
|
||||
estado: "activo",
|
||||
})
|
||||
.select("id")
|
||||
.single()
|
||||
|
||||
setSaving(false)
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
return
|
||||
}
|
||||
onCreated(data!.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(92vw,720px)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nuevo plan de estudios</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Nombre *</Label>
|
||||
<Input
|
||||
value={form.nombre}
|
||||
onChange={(e) => setForm(s => ({ ...s, nombre: e.target.value }))}
|
||||
placeholder="Ej. Licenciatura en Ciberseguridad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Nivel</Label>
|
||||
<Input
|
||||
value={form.nivel}
|
||||
onChange={(e) => setForm(s => ({ ...s, nivel: e.target.value }))}
|
||||
placeholder="Licenciatura / Maestría…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Duración</Label>
|
||||
<Input
|
||||
value={form.duracion}
|
||||
onChange={(e) => setForm(s => ({ ...s, duracion: e.target.value }))}
|
||||
placeholder="9 semestres / 3 años…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Créditos totales</Label>
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
value={form.total_creditos ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim()
|
||||
setForm(s => ({ ...s, total_creditos: v === "" ? null : Number(v) || 0 }))
|
||||
}}
|
||||
placeholder="270"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Objetivo general</Label>
|
||||
<Textarea
|
||||
value={form.objetivo_general ?? ""}
|
||||
onChange={(e) => setForm(s => ({ ...s, objetivo_general: e.target.value }))}
|
||||
placeholder="Describe el objetivo general del plan…"
|
||||
className="min-h-[90px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Procedencia */}
|
||||
{needsFacultad && (
|
||||
<div className="space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={form.facultad_id}
|
||||
onChange={(id) =>
|
||||
setForm(s => ({
|
||||
...s,
|
||||
facultad_id: id,
|
||||
carrera_id: lockCarrera ? s.carrera_id : "", // limpia carrera si no está bloqueada
|
||||
}))
|
||||
}
|
||||
disabled={lockFacultad}
|
||||
/>
|
||||
{lockFacultad && (
|
||||
<p className="text-[11px] text-neutral-500">Fijado por tu rol.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsCarrera && (
|
||||
<div className="space-y-1">
|
||||
<Label>Carrera *</Label>
|
||||
<CarreraCombobox
|
||||
facultadId={form.facultad_id}
|
||||
value={form.carrera_id}
|
||||
onChange={(id) => setForm(s => ({ ...s, carrera_id: id }))}
|
||||
disabled={lockCarrera || !form.facultad_id}
|
||||
placeholder={form.facultad_id ? "Selecciona carrera…" : "Selecciona una facultad primero"}
|
||||
/>
|
||||
{lockCarrera && (
|
||||
<p className="text-[11px] text-neutral-500">Fijada por tu rol.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||
|
||||
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||
<Button className="w-full sm:w-auto" onClick={createPlan} disabled={saving}>
|
||||
{saving ? "Creando…" : "Crear plan"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user