This repository has been archived on 2026-01-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Acad-IA/src/routes/_authenticated/asignatura/$asignaturaId.tsx
Alejandro Rosales 012a5a58b0 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.
2025-08-25 09:29:22 -06:00

560 lines
24 KiB
TypeScript

// routes/_authenticated/asignatura/$asignaturaId.tsx
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
import * as Icons from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { supabase } from "@/auth/supabase"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
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 = {
id: string; nombre: string; clave: string | null; tipo: string | null; semestre: number | null;
creditos: number | null; horas_teoricas: number | null; horas_practicas: number | null;
objetivos: string | null; contenidos: Record<string, Record<string, string>> | null;
bibliografia: string[] | null; criterios_evaluacion: string | null; plan_id: string | null;
}
type PlanMini = { id: string; nombre: string }
/* ================== Ruta ================== */
export const Route = createFileRoute("/_authenticated/asignatura/$asignaturaId")({
component: Page,
loader: async ({ params }) => {
const { data: a, error } = await supabase
.from("asignaturas")
.select("id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas, objetivos, contenidos, bibliografia, criterios_evaluacion, plan_id")
.eq("id", params.asignaturaId)
.single()
if (error || !a) throw error ?? new Error("Asignatura no encontrada")
let plan: PlanMini | null = null
if (a.plan_id) {
const { data: p } = await supabase
.from("plan_estudios").select("id, nombre").eq("id", a.plan_id).single()
plan = p as PlanMini | null
}
return { a: a as Asignatura, plan }
},
})
/* ================== Helpers UI ================== */
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("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" }
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 }) {
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">
<Icon className="h-4 w-4" />
</div>
<div>
<div className="text-xs text-neutral-500">{label}</div>
<div className="text-lg font-semibold tabular-nums">{value}</div>
</div>
</div>
)
}
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">
<div className="h-8 w-8 rounded-lg grid place-items-center border bg-white/80"><Icon className="h-4 w-4" /></div>
<h3 className="text-sm font-semibold">{title}</h3>
</header>
{children}
</section>
)
}
/* ================== Página ================== */
function Page() {
const router = useRouter()
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
const unidades = useMemo(() => {
const entries = Object.entries(a.contenidos ?? {})
const norm = (s: string) => {
const m = String(s).match(/^\s*(\d+)/)
return m ? [parseInt(m[1], 10), s] as const : [Number.POSITIVE_INFINITY, s] as const
}
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(([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
const [query, setQuery] = useState("")
const filteredUnidades = useMemo(() => {
const t = query.trim().toLowerCase()
if (!t) return unidades
return unidades.map(u => ({
...u,
temas: u.temas.filter(([, tema]) => String(tema).toLowerCase().includes(t)),
})).filter(u => u.temas.length > 0)
}, [query, unidades])
// atajos
const searchRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); searchRef.current?.focus() }
if (e.key === "Escape") router.history.back()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [router])
async function share() {
const url = window.location.href
try {
if (navigator.share) await navigator.share({ title: a.nombre, url })
else { await navigator.clipboard.writeText(url); alert("Enlace copiado") }
} catch { }
}
return (
<div className="relative p-6 space-y-6">
{/* ===== Migas ===== */}
<nav className="text-sm text-neutral-500">
<Link
to={plan ? "/plan/$planId" : "/planes"}
params={plan ? { planId: plan.id } : undefined}
className="hover:underline"
>
{plan ? plan.nombre : "Planes"}
</Link>
<span className="mx-1">/</span>
<span className="text-neutral-900">{a.nombre}</span>
</nav>
{/* ===== Hero ===== */}
<div className="relative overflow-hidden rounded-3xl border shadow-sm">
<div className={`absolute inset-0 bg-gradient-to-br ${style.halo} via-white to-transparent`} />
<div className="relative p-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="inline-flex items-center gap-2 text-xs text-neutral-600">
<Icons.BookOpen className="h-4 w-4" /> Asignatura
{plan && <>
<span>·</span>
<Link to="/plan/$planId" params={{ planId: plan.id }} className="hover:underline">
{plan.nombre}
</Link>
</>}
</div>
<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.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>
</div>
</div>
{/* Acciones rápidas */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => window.print()}>
<Icons.Printer className="h-4 w-4 mr-2" /> Imprimir
</Button>
<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>
{/* Stats rápidos */}
<div className="relative px-6 pb-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat icon={Icons.Coins} label="Créditos" value={a.creditos ?? "—"} />
<Stat icon={Icons.Clock} label="Horas totales" value={horas} />
<Stat icon={Icons.ListTree} label="Unidades" value={unidades.length} />
<Stat icon={Icons.BookMarked} label="Temas" value={temasCount} />
</div>
</div>
</div>
{/* ===== Layout principal ===== */}
<div className="grid gap-6 lg:grid-cols-[1fr,320px]">
{/* ===== Columna principal ===== */}
<div className="space-y-6">
{/* Objetivo */}
{a.objetivos && (
<Section id="objetivo" title="Objetivo de la asignatura" icon={Icons.Target}>
<p className="text-sm leading-relaxed text-neutral-800">{a.objetivos}</p>
</Section>
)}
{/* Syllabus */}
{unidades.length > 0 && (
<Section id="syllabus" title="Programa / Contenidos" icon={Icons.ListTree}>
<div className="flex items-center gap-2 mb-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
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar tema dentro del programa (⌘/Ctrl K)…"
className="pl-8"
/>
</div>
{query && (
<Button variant="ghost" onClick={() => setQuery("")}>Limpiar</Button>
)}
</div>
{(() => {
// --- 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>
)}
</Accordion>
)
})()}
</Section>
)}
{/* Bibliografía */}
{a.bibliografia && a.bibliografia.length > 0 && (
<Section id="bibliografia" title="Bibliografía" icon={Icons.LibraryBig}>
<ul className="space-y-2 text-sm text-neutral-800">
{a.bibliografia.map((ref, i) => (
<li key={i} className="flex items-start gap-2 leading-relaxed">
<span className="mt-1 text-neutral-400"></span>
<span className="break-words">{ref}</span>
</li>
))}
</ul>
</Section>
)}
{/* Evaluación */}
{a.criterios_evaluacion && (
<Section id="evaluacion" title="Criterios de evaluación" icon={Icons.ClipboardCheck}>
<p className="text-sm text-neutral-800 leading-relaxed">{a.criterios_evaluacion}</p>
</Section>
)}
</div>
{/* ===== Sidebar ===== */}
<aside className="space-y-4 lg:sticky lg:top-6 self-start">
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4">
<h4 className="font-semibold text-sm mb-2">Resumen</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
<MiniKV label="Créditos" value={a.creditos ?? "—"} />
<MiniKV label="Semestre" value={a.semestre ?? "—"} />
<MiniKV label="Horas teóricas" value={horasT} />
<MiniKV label="Horas prácticas" value={horasP} />
<MiniKV label="Unidades" value={unidades.length} />
<MiniKV label="Temas" value={temasCount} />
</div>
</div>
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4">
<h4 className="font-semibold text-sm mb-2">Navegación</h4>
<nav className="text-sm space-y-1">
{a.objetivos && <Anchor href="#objetivo" label="Objetivo" />}
{unidades.length > 0 && <Anchor href="#syllabus" label="Programa / Contenidos" />}
{a.bibliografia && a.bibliografia.length > 0 && <Anchor href="#bibliografia" label="Bibliografía" />}
{a.criterios_evaluacion && <Anchor href="#evaluacion" label="Evaluación" />}
</nav>
</div>
{plan && (
<Link
to="/plan/$planId"
params={{ planId: plan.id }}
className="block rounded-2xl border p-4 hover:bg-neutral-50 transition"
>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
<Icons.ScrollText className="h-4 w-4" />
</div>
<div>
<div className="text-xs text-neutral-500">Plan de estudios</div>
<div className="font-medium truncate">{plan.nombre}</div>
</div>
</div>
</Link>
)}
</aside>
</div>
{/* ===== Volver ===== */}
<div className="pt-2">
<Button variant="outline" asChild>
<Link to={plan ? "/plan/$planId" : "/planes"} params={plan ? { planId: plan.id } : undefined}>
<Icons.ArrowLeft className="h-4 w-4 mr-2" /> Volver
</Link>
</Button>
</div>
</div>
)
}
/* ===== Bits Sidebar ===== */
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>
<div className="font-medium tabular-nums">{value}</div>
</div>
)
}
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>
)
}