// routes/_authenticated/asignatura/$asignaturaId.tsx import { useQueryClient } from "@tanstack/react-query"; import { createFileRoute, Link, useRouter } from "@tanstack/react-router" import * as Icons from "lucide-react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { supabase, useSupabaseAuth } from "@/auth/supabase" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Input } from "@/components/ui/input" import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select" import confetti from "canvas-confetti" 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" import { Separator } from "@/components/ui/separator" import { ScrollArea } from "@/components/ui/scroll-area" import { typeStyle } from "@/components/asignaturas/typeStyle" import { Stat } from "@/components/asignaturas/Stat" import { Section } from "@/components/asignaturas/Section" import { EditBibliografiaButton } from "@/components/asignaturas/EditBibliografíaButton" /* ================== 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 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 */}
setQuery(e.target.value)} placeholder="Buscar tema dentro del programa (⌘/Ctrl K)…" className="pl-8" />
{query && ( )}
{/* NUEVO: botón de edición */}
setA(s => ({ ...s, contenidos }))} />
{/* …tu render flexible existente… */} {(() => { // helpers de normalización (como ya los tienes) const titleOf = (u: any): string => { const t = (u.temas || []).find(([k]: any[]) => String(k).toLowerCase() === "titulo")?.[1] if (typeof t === "string" && t.trim()) return t return /^\s*\d+/.test(String(u.key)) ? (u.title && u.title !== u.key ? u.title : `Unidad ${u.key && Number(u.key) ? Number(u.key) : 1}`) : (u.title || String(u.key)) } const temasOf = (u: any): string[] => { const pairs: any[] = Array.isArray(u.temas) ? u.temas : [] const sub = pairs.find(([k]) => String(k).toLowerCase() === "subtemas")?.[1] if (Array.isArray(sub)) return sub.map(String) if (sub && typeof sub === "object") { return Object.entries(sub).sort(([a], [b]) => Number(a) - Number(b)).map(([, v]) => String(v)) } 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 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 && Number(u.key) ? Number(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 */}
setA(s => ({ ...s, bibliografia }))} />
{a.bibliografia && a.bibliografia.length > 0 ? (
    {a.bibliografia.map((ref, i) => (
  • {ref}
  • ))}
) : (
Sin bibliografía.
)}
{/* 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 auth = useSupabaseAuth() const openAndFill = () => { setForm(asignatura); setOpen(true) } // ✅ Función que genera las diferencias entre los datos anteriores y los nuevos function generateDiff(oldData: Asignatura, newData: Partial) { const changes: any[] = [] for (const key of Object.keys(newData)) { const oldValue = (oldData as any)[key] const newValue = (newData as any)[key] if (newValue !== undefined && newValue !== oldValue) { changes.push({ op: "replace", path: `/${key}`, from: oldValue, value: newValue }) } } return changes } async function save() { setSaving(true) try { // 1️⃣ Preparar el payload final 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, } // 2️⃣ Detectar cambios const diff = generateDiff(asignatura, payload) // 3️⃣ Guardar respaldo si hubo cambios if (diff.length > 0) { const { error: backupError } = await supabase .from("historico_cambios_asignaturas") // 👈 usa el nombre real de tu tabla .insert({ id_asignatura: asignatura.id, json_cambios: diff, // jsonb user_id: auth.user?.id, created_at: new Date().toISOString() }) if (backupError) throw backupError } // 4️⃣ Actualizar el registro principal const { data, error } = await supabase .from("asignaturas") .update(payload) .eq("id", asignatura.id) .select() .maybeSingle() if (error) throw error // 5️⃣ Actualizar vista local if (data) { onUpdate(data as Asignatura) setOpen(false) } } catch (err: any) { alert(err.message ?? "Error al guardar") } finally { setSaving(false) } } return ( <> Editar asignatura Actualiza campos básicos.
setForm(s => ({ ...s, nombre: e.target.value }))} /> setForm(s => ({ ...s, clave: 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(`${import.meta.env.VITE_BACK_ORIGIN}/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) confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 }, }) 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”).