// routes/_authenticated/asignaturas/$planId.tsx import { createFileRoute, Link, useRouter } from "@tanstack/react-router" import { supabase } from "@/auth/supabase" import * as Icons from "lucide-react" import { useEffect, useMemo, useRef, useState } from "react" import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue, } from "@/components/ui/select" import { Badge } from "@/components/ui/badge" /* ================== 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 } type ModalData = { planId: string planNombre: string asignaturas: Asignatura[] } /* ================== Ruta (modal) ================== */ export const Route = createFileRoute("/_authenticated/asignaturas/$planId")({ component: Page, loader: async ({ params }): Promise => { const planId = params.planId const { data: plan, error: planErr } = await supabase .from("plan_estudios") .select("id, nombre") .eq("id", planId) .single() if (planErr || !plan) throw planErr ?? new Error("Plan no encontrado") const { data: asignaturas, error: aErr } = await supabase .from("asignaturas") .select(` id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas, objetivos, contenidos, bibliografia, criterios_evaluacion `) .eq("plan_id", planId) .order("semestre", { ascending: true }) .order("nombre", { ascending: true }) if (aErr) throw aErr return { planId, planNombre: plan.nombre, asignaturas: (asignaturas ?? []) as Asignatura[] } }, }) /* ================== Página ================== */ function Page() { const { planId, planNombre, asignaturas } = Route.useLoaderData() as ModalData const router = useRouter() // ---- estado UI const [query, setQuery] = useState("") const [sem, setSem] = useState("todos") const [tipo, setTipo] = useState("todos") const [orden, setOrden] = useState<"nombre" | "semestre" | "creditos">("semestre") const [vista, setVista] = useState<"cards" | "tabla">("cards") // ---- atajos const searchRef = useRef(null) useEffect(() => { const onKey = (e: KeyboardEvent) => { const meta = e.ctrlKey || e.metaKey if (meta && e.key.toLowerCase() === "k") { e.preventDefault() searchRef.current?.focus() } if (e.key === "Escape") { router.navigate({ to: "/plan/$planId", params: { planId }, replace: true }) } } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) }, [router, planId]) // ---- semestres y tipos disponibles const semestres = useMemo(() => { const s = new Set() asignaturas.forEach(a => s.add(String(a.semestre ?? "—"))) return Array.from(s).sort((a, b) => (a === "—" ? 1 : 0) - (b === "—" ? 1 : 0) || Number(a) - Number(b)) }, [asignaturas]) const tipos = useMemo(() => { const s = new Set() asignaturas.forEach(a => s.add(a.tipo ?? "—")) return Array.from(s).sort() }, [asignaturas]) // ---- KPIs const kpis = useMemo(() => { const total = asignaturas.length const creditos = asignaturas.reduce((acc, a) => acc + (a.creditos ?? 0), 0) const ht = asignaturas.reduce((acc, a) => acc + (a.horas_teoricas ?? 0), 0) const hp = asignaturas.reduce((acc, a) => acc + (a.horas_practicas ?? 0), 0) const porTipo: Record = {} asignaturas.forEach(a => { const key = (a.tipo ?? "—").toLowerCase() porTipo[key] = (porTipo[key] ?? 0) + 1 }) return { total, creditos, ht, hp, porTipo } }, [asignaturas]) // ---- filtro + orden const filtradas = useMemo(() => { const t = query.trim().toLowerCase() const list = asignaturas.filter(a => { const matchTexto = !t || [a.nombre, a.clave, a.tipo, a.objetivos] .filter(Boolean) .some(v => String(v).toLowerCase().includes(t)) const semOK = sem === "todos" || String(a.semestre ?? "—") === sem const tipoOK = tipo === "todos" || (a.tipo ?? "—") === tipo return matchTexto && semOK && tipoOK }) const sortList = [...list].sort((A, B) => { if (orden === "nombre") return A.nombre.localeCompare(B.nombre) if (orden === "creditos") return (B.creditos ?? 0) - (A.creditos ?? 0) // semestre const a = A.semestre ?? 999 const b = B.semestre ?? 999 if (a === b) return A.nombre.localeCompare(B.nombre) return a - b }) return sortList }, [asignaturas, query, sem, tipo, orden]) // ---- agrupación por semestre (para la vista de cards) const grupos = useMemo(() => { const m = new Map() for (const a of filtradas) { const k = a.semestre ?? "—" if (!m.has(k)) m.set(k, []) m.get(k)!.push(a) } return Array.from(m.entries()).sort(([a], [b]) => { if (a === "—") return 1 if (b === "—") return -1 return Number(a) - Number(b) }) }, [filtradas]) // ---- helpers const limpiar = () => { setQuery(""); setSem("todos"); setTipo("todos"); setOrden("semestre") } return ( router.navigate({ to: "/plan/$planId", params: { planId }, replace: true })} > {/* HERO ===================================================== */}
Plan
{planNombre}
{Object.entries(kpis.porTipo).slice(0, 3).map(([t, n]) => ( {t} · {n} ))}
{/* TOOLBAR sticky ========================================= */}
setQuery(e.target.value)} placeholder="Buscar (⌘/Ctrl K)…" className="pl-8" />
{(query || sem !== "todos" || tipo !== "todos" || orden !== "semestre") && ( )}
{/* CONTENIDO scrolleable ==================================== */}
{filtradas.length === 0 ? ( ) : vista === "tabla" ? ( ) : (
{grupos.map(([sem, items]) => (

Semestre {sem}

    {items.map(a => )}
))}
)}
) } /* ================== UI bits ================== */ function KpiChip({ icon: Icon, label, value }:{ icon: any; label: string; value: number | string }) { return ( {label}: {value} ) } function ViewToggle({ value, onChange }:{ value:"cards"|"tabla"; onChange:(v:"cards"|"tabla")=>void }) { return (
) } function EmptyState() { return (

Sin resultados

Ajusta los filtros o la búsqueda.

) } /* ================== Card ================== */ function tipoMeta(tipo?: string | null) { const t = (tipo ?? "").toLowerCase() if (t.includes("oblig")) return { label: "Obligatoria", color: "emerald" } if (t.includes("opt")) return { label: "Optativa", color: "amber" } if (t.includes("taller")) return { label: "Taller", color: "indigo" } if (t.includes("lab")) return { label: "Laboratorio", color: "sky" } return { label: tipo ?? "Genérica", color: "neutral" } } function AsignaturaCard({ a }: { a: Asignatura }) { const horasT = a.horas_teoricas ?? 0 const horasP = a.horas_practicas ?? 0 const meta = tipoMeta(a.tipo) return (
  • {/* franja lateral por tipo */}

    {a.nombre}

    {a.clave && {a.clave}} {meta.label} {a.creditos != null && {a.creditos} créditos} {(horasT + horasP) > 0 && H T/P: {horasT}/{horasP}} Semestre {a.semestre ?? "—"}
    Ver detalles Editar Ajustar con IA
    {/* objetivo (clamp) */} {a.objetivos && (

    {a.objetivos}

    )} {/* CTA */}
    Ver ficha
  • ) } function Chip({ children, className = "" }:{ children: React.ReactNode; className?: string }) { return ( {children} ) } /* ================== Tabla compacta ================== */ function Tabla({ asignaturas }: { asignaturas: Asignatura[] }) { return (
    {asignaturas.map(a => ( ))}
    Nombre Clave Tipo Sem. Créditos H T/P
    {a.nombre}
    {a.objetivos &&
    {a.objetivos}
    }
    {a.clave ?? "—"} {a.tipo ?? "—"} {a.semestre ?? "—"} {a.creditos ?? "—"} {(a.horas_teoricas ?? 0)}/{(a.horas_practicas ?? 0)}
    Ver detalles Editar Ajustar con IA
    ) }