// 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" /* ================== 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, plan } = Route.useLoaderData() as { a: Asignatura; plan: PlanMini | null } 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” 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(([a],[b]) => Number(a) - Number(b)) })) }, [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 y compartir 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) // feedback visual mínimo alert("Enlace copiado") } } catch { /* noop */ } } 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 */}
{/* 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 && ( )}
{filteredUnidades.map((u, i) => (
{/^\s*\d+/.test(u.key) ? `Unidad ${u.key}` : u.title} {u.temas.length} tema(s)
    {u.temas.map(([k, t]) =>
  • {t}
  • )}
))} {filteredUnidades.length === 0 && (
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} ) }