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:
@@ -9,6 +9,11 @@ import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
|
||||
} from "@/components/ui/accordion"
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
/* ================== Tipos ================== */
|
||||
type Asignatura = {
|
||||
@@ -44,15 +49,13 @@ export const Route = createFileRoute("/_authenticated/asignatura/$asignaturaId")
|
||||
function typeStyle(tipo?: string | null) {
|
||||
const t = (tipo ?? "").toLowerCase()
|
||||
if (t.includes("oblig")) return { chip: "bg-emerald-50 text-emerald-700 border-emerald-200", halo: "from-emerald-100/60" }
|
||||
if (t.includes("opt")) return { chip: "bg-amber-50 text-amber-800 border-amber-200", halo: "from-amber-100/60" }
|
||||
if (t.includes("opt")) return { chip: "bg-amber-50 text-amber-800 border-amber-200", halo: "from-amber-100/60" }
|
||||
if (t.includes("taller")) return { chip: "bg-indigo-50 text-indigo-700 border-indigo-200", halo: "from-indigo-100/60" }
|
||||
if (t.includes("lab")) return { chip: "bg-sky-50 text-sky-700 border-sky-200", halo: "from-sky-100/60" }
|
||||
if (t.includes("lab")) return { chip: "bg-sky-50 text-sky-700 border-sky-200", halo: "from-sky-100/60" }
|
||||
return { chip: "bg-neutral-100 text-neutral-700 border-neutral-200", halo: "from-primary/10" }
|
||||
}
|
||||
|
||||
function Stat({ icon: Icon, label, value }:{
|
||||
icon: any; label: string; value: string | number
|
||||
}) {
|
||||
function Stat({ icon: Icon, label, value }: { icon: any; label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
|
||||
@@ -66,9 +69,7 @@ function Stat({ icon: Icon, label, value }:{
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ id, title, icon: Icon, children }:{
|
||||
id: string; title: string; icon: any; children: React.ReactNode
|
||||
}) {
|
||||
function Section({ id, title, icon: Icon, children }: { id: string; title: string; icon: any; children: React.ReactNode }) {
|
||||
return (
|
||||
<section id={id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 scroll-mt-24">
|
||||
<header className="flex items-center gap-2 mb-2">
|
||||
@@ -83,13 +84,15 @@ function Section({ id, title, icon: Icon, children }:{
|
||||
/* ================== Página ================== */
|
||||
function Page() {
|
||||
const router = useRouter()
|
||||
const { a, plan } = Route.useLoaderData() as { a: Asignatura; plan: PlanMini | null }
|
||||
const { a: aFromLoader, plan } = Route.useLoaderData() as { a: Asignatura; plan: PlanMini | null }
|
||||
const [a, setA] = useState<Asignatura>(aFromLoader)
|
||||
|
||||
const horasT = a.horas_teoricas ?? 0
|
||||
const horasP = a.horas_practicas ?? 0
|
||||
const horas = horasT + horasP
|
||||
const style = typeStyle(a.tipo)
|
||||
|
||||
// ordenar unidades de forma “natural”
|
||||
// ordenar unidades
|
||||
const unidades = useMemo(() => {
|
||||
const entries = Object.entries(a.contenidos ?? {})
|
||||
const norm = (s: string) => {
|
||||
@@ -99,9 +102,8 @@ function Page() {
|
||||
return entries
|
||||
.map(([k, v]) => ({ key: k, order: norm(k)[0], title: norm(k)[1], temas: Object.entries(v) }))
|
||||
.sort((A, B) => (A.order === B.order ? A.title.localeCompare(B.title) : A.order - B.order))
|
||||
.map(u => ({ ...u, temas: u.temas.sort(([a],[b]) => Number(a) - Number(b)) }))
|
||||
.map(u => ({ ...u, temas: u.temas.sort(([x], [y]) => Number(x) - Number(y)) }))
|
||||
}, [a.contenidos])
|
||||
|
||||
const temasCount = useMemo(() => unidades.reduce((acc, u) => acc + u.temas.length, 0), [unidades])
|
||||
|
||||
// buscar dentro del syllabus
|
||||
@@ -115,7 +117,7 @@ function Page() {
|
||||
})).filter(u => u.temas.length > 0)
|
||||
}, [query, unidades])
|
||||
|
||||
// atajos y compartir
|
||||
// atajos
|
||||
const searchRef = useRef<HTMLInputElement | null>(null)
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -130,12 +132,8 @@ function Page() {
|
||||
const url = window.location.href
|
||||
try {
|
||||
if (navigator.share) await navigator.share({ title: a.nombre, url })
|
||||
else {
|
||||
await navigator.clipboard.writeText(url)
|
||||
// feedback visual mínimo
|
||||
alert("Enlace copiado")
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
else { await navigator.clipboard.writeText(url); alert("Enlace copiado") }
|
||||
} catch { }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -170,7 +168,7 @@ function Page() {
|
||||
<h1 className="mt-1 text-2xl md:text-3xl font-bold truncate">{a.nombre}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px]">
|
||||
{a.clave && <Badge variant="outline">Clave: {a.clave}</Badge>}
|
||||
{a.tipo && <Badge className={style.chip} variant="secondary">{a.tipo}</Badge>}
|
||||
{a.tipo && <Badge className={style.chip} variant="secondary">{a.tipo}</Badge>}
|
||||
{a.creditos != null && <Badge variant="outline">{a.creditos} créditos</Badge>}
|
||||
<Badge variant="outline">H T/P: {horasT}/{horasP}</Badge>
|
||||
<Badge variant="outline">Semestre {a.semestre ?? "—"}</Badge>
|
||||
@@ -185,6 +183,8 @@ function Page() {
|
||||
<Button variant="outline" size="sm" onClick={share}>
|
||||
<Icons.Share2 className="h-4 w-4 mr-2" /> Compartir
|
||||
</Button>
|
||||
<EditAsignaturaButton asignatura={a} onUpdate={setA} />
|
||||
<MejorarAIButton asignaturaId={a.id} onApply={(nuevo) => setA(nuevo)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -225,37 +225,94 @@ function Page() {
|
||||
/>
|
||||
</div>
|
||||
{query && (
|
||||
<Button variant="ghost" onClick={() => setQuery("")}>
|
||||
Limpiar
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setQuery("")}>Limpiar</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" className="mt-2">
|
||||
{filteredUnidades.map((u, i) => (
|
||||
<AccordionItem key={u.key} value={`u-${i}`} 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">
|
||||
{/^\s*\d+/.test(u.key) ? `Unidad ${u.key}` : u.title}
|
||||
</span>
|
||||
<span className="text-[11px] text-neutral-500">{u.temas.length} tema(s)</span>
|
||||
{(() => {
|
||||
// --- helpers de normalización ---
|
||||
const titleOf = (u: any): string => {
|
||||
// Si viene como arreglo [{titulo, seccion, subtemas}], u.temas tendrá pares [ 'titulo' , '…' ]
|
||||
const t = (u.temas || []).find(([k]: any[]) => String(k).toLowerCase() === "titulo")?.[1]
|
||||
if (typeof t === "string" && t.trim()) return t
|
||||
// Fallback: si la clave de la unidad es numérica, usa "Unidad N" o el título ya calculado
|
||||
return /^\s*\d+/.test(String(u.key))
|
||||
? (u.title && u.title !== u.key ? u.title : `Unidad ${u.key ? u.key : 1}`)
|
||||
: (u.title || String(u.key))
|
||||
}
|
||||
|
||||
const temasOf = (u: any): string[] => {
|
||||
const pairs: any[] = Array.isArray(u.temas) ? u.temas : []
|
||||
// 1) Estructura con subtemas
|
||||
const sub = pairs.find(([k]) => String(k).toLowerCase() === "subtemas")?.[1]
|
||||
if (Array.isArray(sub)) {
|
||||
// subtemas: ["t1", "t2", ...]
|
||||
return sub.map(String)
|
||||
}
|
||||
if (sub && typeof sub === "object") {
|
||||
// subtemas: { "1": "t1", "2": "t2", ... }
|
||||
return Object.entries(sub)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([, v]) => String(v))
|
||||
}
|
||||
// 2) Estructura plana numerada { "1": "t1", "2": "t2", ... }
|
||||
const numerados = pairs
|
||||
.filter(([k, v]) => /^\d+$/.test(String(k)) && (typeof v === "string" || typeof v === "number"))
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([, v]) => String(v))
|
||||
if (numerados.length) return numerados
|
||||
// 3) Fallback: toma valores string excepto metadatos
|
||||
return pairs
|
||||
.filter(([k, v]) => !["titulo", "seccion"].includes(String(k).toLowerCase()) && typeof v === "string")
|
||||
.map(([, v]) => String(v))
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
const visible = (filteredUnidades.length ? filteredUnidades : unidades)
|
||||
.map((u: any) => {
|
||||
const list = temasOf(u)
|
||||
const title = titleOf(u)
|
||||
const match = !q || list.some(t => t.toLowerCase().includes(q)) || title.toLowerCase().includes(q)
|
||||
return { ...u, __title: title, __temas: list, __match: match }
|
||||
})
|
||||
.filter((u: any) => u.__match)
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" className="mt-2">
|
||||
{visible.map((u: any, i: number) => (
|
||||
<AccordionItem key={`${u.key}-${i}`} value={`u-${i}`} 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">
|
||||
{/^\s*\d+/.test(String(u.key))
|
||||
? `Unidad ${u.key ? u.key : 1}${u.__title ? `: ${u.__title}` : ""}`
|
||||
: u.__title}
|
||||
</span>
|
||||
<span className="text-[11px] text-neutral-500">{u.__temas.length} tema(s)</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-5 pb-3">
|
||||
<ul className="list-disc ml-5 text-[13px] text-neutral-700 space-y-1">
|
||||
{u.__temas.map((t: string, idx: number) => (
|
||||
<li key={idx} className="break-words">{t}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
|
||||
{!visible.length && (
|
||||
<div className="text-sm text-neutral-500 py-6 text-center">
|
||||
No hay temas que coincidan.
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-5 pb-3">
|
||||
<ul className="list-disc ml-5 text-[13px] text-neutral-700 space-y-1">
|
||||
{u.temas.map(([k, t]) => <li key={k} className="break-words">{t}</li>)}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
{filteredUnidades.length === 0 && (
|
||||
<div className="text-sm text-neutral-500 py-6 text-center">No hay temas que coincidan.</div>
|
||||
)}
|
||||
</Accordion>
|
||||
)}
|
||||
</Accordion>
|
||||
)
|
||||
})()}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
|
||||
{/* Bibliografía */}
|
||||
{a.bibliografia && a.bibliografia.length > 0 && (
|
||||
<Section id="bibliografia" title="Bibliografía" icon={Icons.LibraryBig}>
|
||||
@@ -335,7 +392,7 @@ function Page() {
|
||||
}
|
||||
|
||||
/* ===== Bits Sidebar ===== */
|
||||
function MiniKV({ label, value }:{ label: string; value: string | number }) {
|
||||
function MiniKV({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-white/60 p-2">
|
||||
<div className="text-[11px] text-neutral-500">{label}</div>
|
||||
@@ -343,10 +400,160 @@ function MiniKV({ label, value }:{ label: string; value: string | number }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function Anchor({ href, label }:{ href: string; label: string }) {
|
||||
function Anchor({ href, label }: { href: string; label: string }) {
|
||||
return (
|
||||
<a href={href} className="flex items-center gap-2 text-neutral-700 hover:underline">
|
||||
<Icons.Dot className="h-5 w-5 -ml-1" /> {label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/* ======= Modales ======= */
|
||||
function EditAsignaturaButton({ asignatura, onUpdate }: {
|
||||
asignatura: Asignatura; onUpdate: (a: Asignatura) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<Partial<Asignatura>>({})
|
||||
|
||||
const openAndFill = () => { setForm(asignatura); setOpen(true) }
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
const payload = {
|
||||
nombre: form.nombre ?? asignatura.nombre,
|
||||
clave: form.clave ?? asignatura.clave,
|
||||
tipo: form.tipo ?? asignatura.tipo,
|
||||
semestre: form.semestre ?? asignatura.semestre,
|
||||
creditos: form.creditos ?? asignatura.creditos,
|
||||
horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas,
|
||||
horas_practicas: form.horas_practicas ?? asignatura.horas_practicas,
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.update(payload)
|
||||
.eq("id", asignatura.id)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
setSaving(false)
|
||||
if (!error && data) { onUpdate(data as Asignatura); setOpen(false) }
|
||||
else alert(error?.message ?? "Error al guardar")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={openAndFill}>
|
||||
<Icons.Pencil className="h-4 w-4 mr-2" /> Editar
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar asignatura</DialogTitle>
|
||||
<DialogDescription>Actualiza campos básicos.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Nombre">
|
||||
<Input value={form.nombre ?? ""} onChange={e => setForm(s => ({ ...s, nombre: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Clave">
|
||||
<Input value={form.clave ?? ""} onChange={e => setForm(s => ({ ...s, clave: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Tipo">
|
||||
<Input value={form.tipo ?? ""} onChange={e => setForm(s => ({ ...s, tipo: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Semestre">
|
||||
<Input value={String(form.semestre ?? "")} onChange={e => setForm(s => ({ ...s, semestre: Number(e.target.value) || null }))} />
|
||||
</Field>
|
||||
<Field label="Créditos">
|
||||
<Input value={String(form.creditos ?? "")} onChange={e => setForm(s => ({ ...s, creditos: Number(e.target.value) || null }))} />
|
||||
</Field>
|
||||
<Field label="Horas teóricas">
|
||||
<Input value={String(form.horas_teoricas ?? "")} onChange={e => setForm(s => ({ ...s, horas_teoricas: Number(e.target.value) || null }))} />
|
||||
</Field>
|
||||
<Field label="Horas prácticas">
|
||||
<Input value={String(form.horas_practicas ?? "")} onChange={e => setForm(s => ({ ...s, horas_practicas: Number(e.target.value) || null }))} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MejorarAIButton({ asignaturaId, onApply }: {
|
||||
asignaturaId: string; onApply: (a: Asignatura) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [insert, setInsert] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function apply() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch("http://localhost:3001/api/mejorar/asignatura", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ asignatura_id: asignaturaId, prompt, insert }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text()
|
||||
throw new Error(txt || "Error IA")
|
||||
}
|
||||
const nuevo = await res.json()
|
||||
onApply(nuevo as Asignatura)
|
||||
setOpen(false)
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Error al mejorar la asignatura")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
<Icons.Sparkles className="h-4 w-4 mr-2" /> Mejorar con IA
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Mejorar asignatura con IA</DialogTitle>
|
||||
<DialogDescription>
|
||||
Describe el ajuste que deseas (p. ej. “refuerza contenidos prácticos y añade bibliografía reciente”).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[140px]"
|
||||
placeholder="Escribe tu prompt…"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={insert} onChange={(e) => setInsert(e.target.checked)} />
|
||||
Guardar cambios
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={apply} disabled={!prompt.trim() || loading}>{loading ? "Aplicando…" : "Aplicar ajuste"}</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user