// 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> | 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 (
{label}
{value}
) } function Section({ id, title, icon: Icon, children }: { id: string; title: string; icon: any; children: React.ReactNode }) { return (

{title}

{children}
) } /* ================== Página ================== */ function Page() { const router = useRouter() const { a: aFromLoader, plan } = Route.useLoaderData() as { a: Asignatura; plan: PlanMini | null } const [a, setA] = useState(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(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 (
{/* ===== Migas ===== */} {/* ===== Hero ===== */}
Asignatura {plan && <> · {plan.nombre} }

{a.nombre}

{a.clave && Clave: {a.clave}} {a.tipo && {a.tipo}} {a.creditos != null && {a.creditos} créditos} H T/P: {horasT}/{horasP} Semestre {a.semestre ?? "—"}
{/* Acciones rápidas */}
setA(nuevo)} />
{/* Stats rápidos */}
{/* ===== Layout principal ===== */}
{/* ===== Columna principal ===== */}
{/* Objetivo */} {a.objetivos && (

{a.objetivos}

)} {/* Syllabus */} {unidades.length > 0 && (
setQuery(e.target.value)} placeholder="Buscar tema dentro del programa (⌘/Ctrl K)…" className="pl-8" />
{query && ( )}
{(() => { // --- 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 ( {visible.map((u: any, i: number) => (
{/^\s*\d+/.test(String(u.key)) ? `Unidad ${u.key ? u.key : 1}${u.__title ? `: ${u.__title}` : ""}` : u.__title} {u.__temas.length} tema(s)
    {u.__temas.map((t: string, idx: number) => (
  • {t}
  • ))}
))} {!visible.length && (
No hay temas que coincidan.
)}
) })()}
)} {/* Bibliografía */} {a.bibliografia && a.bibliografia.length > 0 && (
    {a.bibliografia.map((ref, i) => (
  • {ref}
  • ))}
)} {/* Evaluación */} {a.criterios_evaluacion && (

{a.criterios_evaluacion}

)}
{/* ===== Sidebar ===== */}
{/* ===== Volver ===== */}
) } /* ===== Bits Sidebar ===== */ function MiniKV({ label, value }: { label: string; value: string | number }) { return (
{label}
{value}
) } function Anchor({ href, label }: { href: string; label: string }) { return ( {label} ) } /* ======= 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>({}) 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 ( <> Editar asignatura Actualiza campos básicos.
setForm(s => ({ ...s, nombre: e.target.value }))} /> setForm(s => ({ ...s, clave: e.target.value }))} /> setForm(s => ({ ...s, tipo: e.target.value }))} /> setForm(s => ({ ...s, semestre: Number(e.target.value) || null }))} /> setForm(s => ({ ...s, creditos: Number(e.target.value) || null }))} /> setForm(s => ({ ...s, horas_teoricas: Number(e.target.value) || null }))} /> setForm(s => ({ ...s, horas_practicas: Number(e.target.value) || null }))} />
) } 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 ( <> Mejorar asignatura con IA Describe el ajuste que deseas (p. ej. “refuerza contenidos prácticos y añade bibliografía reciente”).