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

@@ -0,0 +1,545 @@
// routes/_authenticated/carreras.tsx
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { useEffect, useMemo, useState } from "react"
import { supabase } from "@/auth/supabase"
import * as Icons from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label"
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog"
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from "@/components/ui/select"
import {
Accordion, AccordionItem, AccordionTrigger, AccordionContent,
} from "@/components/ui/accordion"
import { Switch } from "@/components/ui/switch"
/* -------------------- Tipos -------------------- */
type FacultadLite = { id: string; nombre: string; color?: string | null; icon?: string | null }
type CarreraRow = {
id: string
nombre: string
semestres: number
activo: boolean
facultad_id: string | null
facultades?: FacultadLite | null
}
type LoaderData = { carreras: CarreraRow[]; facultades: FacultadLite[] }
/* -------------------- Ruta -------------------- */
export const Route = createFileRoute("/_authenticated/carreras")({
component: RouteComponent,
loader: async (): Promise<LoaderData> => {
const [{ data: carreras, error: e1 }, { data: facultades, error: e2 }] = await Promise.all([
supabase
.from("carreras")
.select(`id, nombre, semestres, activo, facultad_id, facultades:facultades ( id, nombre, color, icon )`)
.order("nombre", { ascending: true }),
supabase.from("facultades").select("id, nombre, color, icon").order("nombre", { ascending: true }),
])
if (e1) throw e1
if (e2) throw e2
return {
carreras: (carreras ?? []) as unknown as CarreraRow[],
facultades: (facultades ?? []) as FacultadLite[],
}
},
})
/* -------------------- Helpers UI -------------------- */
const tint = (hex?: string | null, a = .18) => {
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 StatusPill = ({ active }: { active: boolean }) => (
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${active ? "bg-emerald-50 text-emerald-700 border-emerald-200" : "bg-neutral-100 text-neutral-700 border-neutral-200"}`}>
{active ? "Activa" : "Inactiva"}
</span>
)
/* -------------------- Página -------------------- */
function RouteComponent() {
const router = useRouter()
const { carreras, facultades } = Route.useLoaderData() as LoaderData
const [q, setQ] = useState("")
const [fac, setFac] = useState<string>("todas")
const [state, setState] = useState<"todas" | "activas" | "inactivas">("todas")
const [detail, setDetail] = useState<CarreraRow | null>(null)
const [editCarrera, setEditCarrera] = useState<CarreraRow | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const filtered = useMemo(() => {
const term = q.trim().toLowerCase()
return carreras.filter((c) => {
if (fac !== "todas" && c.facultad_id !== fac) return false
if (state === "activas" && !c.activo) return false
if (state === "inactivas" && c.activo) return false
if (!term) return true
return [c.nombre, c.facultades?.nombre].filter(Boolean)
.some(v => String(v).toLowerCase().includes(term))
})
}, [q, fac, state, carreras])
return (
<div className="p-6 space-y-4">
<Card>
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle>Carreras</CardTitle>
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row md:items-center">
<div className="relative w-full md:w-80">
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por nombre o facultad…"
className="pl-8"
/>
</div>
<Select value={fac} onValueChange={(v) => setFac(v)}>
<SelectTrigger className="md:w-[220px]"><SelectValue placeholder="Facultad" /></SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas las facultades</SelectItem>
{facultades.map(f => <SelectItem key={f.id} value={f.id}>{f.nombre}</SelectItem>)}
</SelectContent>
</Select>
<Select value={state} onValueChange={(v: any) => setState(v)}>
<SelectTrigger className="md:w-[160px]"><SelectValue placeholder="Estado" /></SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas</SelectItem>
<SelectItem value="activas">Activas</SelectItem>
<SelectItem value="inactivas">Inactivas</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
<Icons.RefreshCcw className="h-4 w-4" />
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Icons.Plus className="h-4 w-4 mr-2" /> Nueva carrera
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map(c => {
const fac = c.facultades
const border = tint(fac?.color, .28)
const chip = tint(fac?.color, .10)
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
return (
<article
key={c.id}
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
style={{ borderColor: border, background: `linear-gradient(180deg, ${chip}, transparent)` }}
>
<div className="p-5 h-44 flex flex-col justify-between">
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2 bg-white/70"
style={{ borderColor: border }}>
<IconComp className="w-6 h-6" />
</span>
<div className="min-w-0">
<div className="font-semibold truncate">{c.nombre}</div>
<div className="text-xs text-neutral-600 truncate">
{fac?.nombre ?? "—"} · {c.semestres} semestres
</div>
</div>
</div>
<div className="mt-2 flex items-center justify-between">
<StatusPill active={c.activo} />
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="sm" onClick={() => setDetail(c)}>
<Icons.Eye className="w-4 h-4 mr-1" /> Ver
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditCarrera(c)}>
<Icons.Pencil className="w-4 h-4 mr-1" /> Editar
</Button>
</div>
</div>
</div>
</article>
)
})}
</div>
{!filtered.length && (
<div className="text-center text-sm text-neutral-500 py-10">No hay resultados</div>
)}
</CardContent>
</Card>
{/* Crear / Editar */}
<CarreraFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
facultades={facultades}
mode="create"
onSaved={() => router.invalidate()}
/>
<CarreraFormDialog
open={!!editCarrera}
onOpenChange={(o) => !o && setEditCarrera(null)}
facultades={facultades}
mode="edit"
carrera={editCarrera ?? undefined}
onSaved={() => { setEditCarrera(null); router.invalidate() }}
/>
{/* Detalle + añadir criterio */}
<CarreraDetailDialog carrera={detail} onOpenChange={setDetail} onChanged={() => router.invalidate()} />
</div>
)
}
/* -------------------- Form crear/editar -------------------- */
function CarreraFormDialog({
open, onOpenChange, mode, carrera, facultades, onSaved,
}: {
open: boolean
onOpenChange: (o: boolean) => void
mode: "create" | "edit"
carrera?: CarreraRow
facultades: FacultadLite[]
onSaved?: () => void
}) {
const [saving, setSaving] = useState(false)
const [nombre, setNombre] = useState(carrera?.nombre ?? "")
const [semestres, setSemestres] = useState<number>(carrera?.semestres ?? 9)
const [activo, setActivo] = useState<boolean>(carrera?.activo ?? true)
const [facultadId, setFacultadId] = useState<string | "none">(carrera?.facultad_id ?? "none")
useEffect(() => {
if (mode === "edit" && carrera) {
setNombre(carrera.nombre)
setSemestres(carrera.semestres)
setActivo(carrera.activo)
setFacultadId(carrera.facultad_id ?? "none")
} else if (mode === "create") {
setNombre("")
setSemestres(9)
setActivo(true)
setFacultadId("none")
}
}, [mode, carrera, open])
async function save() {
if (!nombre.trim()) { alert("Escribe un nombre"); return }
setSaving(true)
const payload = {
nombre: nombre.trim(),
semestres: Number(semestres) || 9,
activo,
facultad_id: facultadId === "none" ? null : facultadId,
}
const action = mode === "create"
? supabase.from("carreras").insert([payload]).select("id").single()
: supabase.from("carreras").update(payload).eq("id", carrera!.id).select("id").single()
const { error } = await action
setSaving(false)
if (error) { alert(error.message); return }
onOpenChange(false)
onSaved?.()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{mode === "create" ? "Nueva carrera" : "Editar carrera"}</DialogTitle>
<DialogDescription>
{mode === "create" ? "Crea una nueva carrera en la base de datos." : "Actualiza los datos de la carrera."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div className="space-y-1">
<Label>Nombre</Label>
<Input value={nombre} onChange={(e) => setNombre(e.target.value)} placeholder="Ing. en Software" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Semestres</Label>
<Input
type="number"
min={1}
max={20}
value={semestres}
onChange={(e) => setSemestres(parseInt(e.target.value || "9", 10))}
/>
</div>
<div className="space-y-1">
<Label>Estado</Label>
<div className="flex h-10 items-center gap-2 rounded-md border px-3">
<Switch checked={activo} onCheckedChange={setActivo} />
<span className="text-sm">{activo ? "Activa" : "Inactiva"}</span>
</div>
</div>
</div>
<div className="space-y-1">
<Label>Facultad</Label>
<Select value={facultadId} onValueChange={(v) => setFacultadId(v as any)}>
<SelectTrigger><SelectValue placeholder="Selecciona una facultad (opcional)" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">Sin facultad</SelectItem>
{facultades.map(f => <SelectItem key={f.id} value={f.id}>{f.nombre}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : (mode === "create" ? "Crear" : "Guardar")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* -------------------- Detalle (criterios) -------------------- */
function CarreraDetailDialog({
carrera,
onOpenChange,
onChanged,
}: {
carrera: CarreraRow | null
onOpenChange: (c: CarreraRow | null) => void
onChanged?: () => void
}) {
const [loading, setLoading] = useState(false)
const [criterios, setCriterios] = useState<Array<{
id: number
nombre: string
descripcion: string | null
tipo: string | null
obligatorio: boolean | null
referencia_documento: string | null
fecha_creacion: string | null
}>>([])
const [q, setQ] = useState("")
const [newCritOpen, setNewCritOpen] = useState(false)
async function load() {
if (!carrera) return
setLoading(true)
const { data, error } = await supabase
.from("criterios_carrera")
.select("id, nombre, descripcion, tipo, obligatorio, referencia_documento, fecha_creacion")
.eq("carrera_id", carrera.id)
.order("fecha_creacion", { ascending: true })
setLoading(false)
if (error) { alert(error.message); return }
setCriterios(data ?? [])
}
useEffect(() => { load() /* eslint-disable-next-line */ }, [carrera?.id])
const filtered = useMemo(() => {
const t = q.trim().toLowerCase()
if (!t) return criterios
return criterios.filter(c =>
[c.nombre, c.descripcion, c.tipo, c.referencia_documento]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t)),
)
}, [q, criterios])
return (
<Dialog open={!!carrera} onOpenChange={(o) => !o && onOpenChange(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{carrera?.nombre}</DialogTitle>
<DialogDescription>
{carrera?.facultades?.nombre ?? "—"} · {carrera?.semestres} semestres{" "}
{typeof carrera?.activo === "boolean" && (
<Badge variant="outline" className="ml-2">{carrera?.activo ? "Activa" : "Inactiva"}</Badge>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="relative flex-1">
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Buscar criterio por nombre, tipo o referencia…"
className="pl-8"
/>
</div>
<Button onClick={() => setNewCritOpen(true)}>
<Icons.Plus className="h-4 w-4 mr-2" /> Añadir criterio
</Button>
</div>
{loading ? (
<div className="text-sm text-neutral-500">Cargando criterios</div>
) : (
<>
<div className="text-xs text-neutral-600">
{filtered.length} criterio(s){q ? " (filtrado)" : ""}
</div>
{filtered.length === 0 ? (
<div className="text-sm text-neutral-500 py-6 text-center">No hay criterios</div>
) : (
<Accordion type="multiple" className="mt-1">
{filtered.map((c) => (
<AccordionItem key={c.id} value={`c-${c.id}`} className="border rounded-xl mb-2 overflow-hidden">
<AccordionTrigger className="px-4 py-2 hover:no-underline text-left">
<div className="flex items-center justify-between w-full">
<span className="font-medium">{c.nombre}</span>
<div className="flex items-center gap-2 text-[11px]">
{c.tipo && <Badge variant="outline">{c.tipo}</Badge>}
<Badge variant="outline">{c.obligatorio ? "Obligatorio" : "Opcional"}</Badge>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-5 pb-3">
{c.descripcion && <p className="text-sm text-neutral-800 leading-relaxed mb-2">{c.descripcion}</p>}
<div className="text-xs text-neutral-600 flex flex-wrap gap-3">
{c.referencia_documento && (
<span className="inline-flex items-center gap-1">
<Icons.Link className="h-3 w-3" />
<a className="underline" href={c.referencia_documento} target="_blank" rel="noreferrer">
Referencia
</a>
</span>
)}
{c.fecha_creacion && (
<span className="inline-flex items-center gap-1">
<Icons.CalendarClock className="h-3 w-3" />
{new Date(c.fecha_creacion).toLocaleString()}
</span>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</>
)}
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(null)}>Cerrar</Button>
</DialogFooter>
{/* Crear criterio */}
<CriterioFormDialog
open={newCritOpen}
onOpenChange={setNewCritOpen}
carreraId={carrera?.id ?? ""}
onSaved={() => { setNewCritOpen(false); load(); onChanged?.() }}
/>
</DialogContent>
</Dialog>
)
}
/* -------------------- Form crear criterio -------------------- */
function CriterioFormDialog({
open, onOpenChange, carreraId, onSaved,
}: {
open: boolean
onOpenChange: (o: boolean) => void
carreraId: string
onSaved?: () => void
}) {
const [saving, setSaving] = useState(false)
const [nombre, setNombre] = useState("")
const [tipo, setTipo] = useState<string>("")
const [descripcion, setDescripcion] = useState("")
const [obligatorio, setObligatorio] = useState(true)
const [referencia, setReferencia] = useState("")
useEffect(() => {
if (!open) {
setNombre(""); setTipo(""); setDescripcion(""); setObligatorio(true); setReferencia("")
}
}, [open])
async function save() {
if (!carreraId) return
if (!nombre.trim()) { alert("Escribe un nombre"); return }
setSaving(true)
const { error } = await supabase.from("criterios_carrera").insert([{
nombre: nombre.trim(),
tipo: tipo || null,
descripcion: descripcion || null,
obligatorio,
referencia_documento: referencia || null,
carrera_id: carreraId,
}])
setSaving(false)
if (error) { alert(error.message); return }
onSaved?.()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Nuevo criterio</DialogTitle>
<DialogDescription>Agrega un criterio para esta carrera.</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div className="space-y-1">
<Label>Nombre</Label>
<Input value={nombre} onChange={(e) => setNombre(e.target.value)} placeholder="Infraestructura de laboratorios" />
</div>
<div className="space-y-1">
<Label>Tipo</Label>
<Input value={tipo} onChange={(e) => setTipo(e.target.value)} placeholder="Académico / Operativo / Otro" />
</div>
<div className="space-y-1">
<Label>Descripción</Label>
<Input value={descripcion} onChange={(e) => setDescripcion(e.target.value)} placeholder="Detalle o alcance del criterio" />
</div>
<div className="grid grid-cols-2 gap-3 items-center">
<div className="space-y-1">
<Label>¿Obligatorio?</Label>
<div className="flex h-10 items-center gap-2 rounded-md border px-3">
<Switch checked={obligatorio} onCheckedChange={setObligatorio} />
<span className="text-sm">{obligatorio ? "Sí" : "No"}</span>
</div>
</div>
<div className="space-y-1">
<Label>Referencia (URL)</Label>
<Input value={referencia} onChange={(e) => setReferencia(e.target.value)} placeholder="https://…" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Crear"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}