993 lines
38 KiB
TypeScript
993 lines
38 KiB
TypeScript
// 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<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 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 grid grid-cols-1 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)} />
|
||
<BorrarAsignaturaButton asignatura_id={a.id} />
|
||
</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 */}
|
||
|
||
<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>
|
||
|
||
{/* NUEVO: botón de edición */}
|
||
<div className="flex justify-end mb-2">
|
||
<EditContenidosButton
|
||
asignaturaId={a.id}
|
||
value={a.contenidos as any}
|
||
onSaved={(contenidos) => setA(s => ({ ...s, contenidos }))}
|
||
/>
|
||
</div>
|
||
|
||
{/* …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 (
|
||
<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 && Number(u.key) ? Number(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 */}
|
||
<Section id="bibliografia" title="Bibliografía" icon={Icons.LibraryBig}>
|
||
<div className="flex justify-end mb-2">
|
||
<EditBibliografiaButton
|
||
asignaturaId={a.id}
|
||
value={a.bibliografia ?? []}
|
||
onSaved={(bibliografia) => setA(s => ({ ...s, bibliografia }))}
|
||
/>
|
||
</div>
|
||
|
||
{a.bibliografia && a.bibliografia.length > 0 ? (
|
||
<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>
|
||
) : (
|
||
<div className="text-sm text-neutral-500">Sin bibliografía.</div>
|
||
)}
|
||
</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 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<Asignatura>) {
|
||
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 (
|
||
<>
|
||
<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 className="font-mono" >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">
|
||
<Select value={form.tipo ?? ""} onValueChange={v => setForm(s => ({ ...s, tipo: v }))}>
|
||
<SelectTrigger className="w-full">
|
||
<SelectValue placeholder="Selecciona tipo…" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="Obligatoria">
|
||
<span className={typeStyle("Obligatoria").chip}>Obligatoria</span>
|
||
</SelectItem>
|
||
<SelectItem value="Optativa">
|
||
<span className={typeStyle("Optativa").chip}>Optativa</span>
|
||
</SelectItem>
|
||
<SelectItem value="Taller">
|
||
<span className={typeStyle("Taller").chip}>Taller</span>
|
||
</SelectItem>
|
||
<SelectItem value="Laboratorio">
|
||
<span className={typeStyle("Laboratorio").chip}>Laboratorio</span>
|
||
</SelectItem>
|
||
<SelectItem value="Otro">
|
||
<span className={typeStyle("Otro").chip}>Otro</span>
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</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(`${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 (
|
||
<>
|
||
<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 className="font-mono" >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}
|
||
className={
|
||
loading
|
||
? "relative overflow-hidden text-white shadow-md"
|
||
: ""
|
||
}
|
||
>
|
||
{loading ? (
|
||
<span className="relative z-10">Pensando…</span>
|
||
) : (
|
||
"Aplicar ajuste"
|
||
)}
|
||
{loading && (
|
||
<span className="absolute inset-0 animate-aurora" />
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function BorrarAsignaturaButton({ asignatura_id, onDeleted }: { asignatura_id: string; onDeleted?: () => void }) {
|
||
const [confirm, setConfirm] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
const router = useRouter()
|
||
const queryClient = useQueryClient()
|
||
|
||
async function handleDelete() {
|
||
setLoading(true)
|
||
try {
|
||
const { error, status, statusText } = await supabase.from("asignaturas").delete().eq("id", asignatura_id)
|
||
console.log({ status, statusText });
|
||
|
||
|
||
if (error) throw error
|
||
setConfirm(false)
|
||
queryClient.invalidateQueries({ queryKey: ["asignaturas"] })
|
||
if (onDeleted) onDeleted()
|
||
router.navigate({ to: "/asignaturas", search: {
|
||
q: "", // Término de búsqueda vacío
|
||
planId: "", // ID del plan (vacío si no aplica)
|
||
carreraId: "", // ID de la carrera (vacío si no aplica)
|
||
facultadId: "", // ID de la facultad (vacío si no aplica)
|
||
f: "", // Filtro vacío
|
||
}})
|
||
} catch (e: any) {
|
||
alert(e?.message || "Error al eliminar la asignatura")
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return confirm ? (
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
|
||
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
|
||
{loading ? "Eliminando…" : "Confirmar eliminación"}
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<Button variant="outline" onClick={() => setConfirm(true)}>
|
||
Eliminar asignatura
|
||
</Button>
|
||
)
|
||
}
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
|
||
type UnitDraft = { title: string; temas: string[] }
|
||
|
||
export function EditContenidosButton({
|
||
asignaturaId,
|
||
value,
|
||
onSaved,
|
||
}: {
|
||
asignaturaId: string
|
||
value: any
|
||
onSaved: (contenidos: any) => void
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const [saving, setSaving] = useState(false)
|
||
const [units, setUnits] = useState<UnitDraft[]>([])
|
||
const [initialUnits, setInitialUnits] = useState<UnitDraft[]>([])
|
||
const auth = useSupabaseAuth() // 👈 para registrar el usuario que edita
|
||
|
||
// --- Normaliza entrada flexible a estructura estable
|
||
const normalize = useCallback((v: any): UnitDraft[] => {
|
||
try {
|
||
const entries = Object.entries(v ?? {})
|
||
.sort(([a], [b]) => Number(a) - Number(b))
|
||
.map(([key, val]) => {
|
||
const obj = val as any
|
||
const title =
|
||
typeof obj?.titulo === "string" && obj.titulo.trim()
|
||
? obj.titulo.trim()
|
||
: `Unidad ${key}`
|
||
let temas: string[] = []
|
||
if (Array.isArray(obj?.subtemas)) temas = obj.subtemas.map(String)
|
||
else if (obj?.subtemas && typeof obj.subtemas === "object") {
|
||
temas = Object.entries(obj.subtemas)
|
||
.sort(([a], [b]) => Number(a) - Number(b))
|
||
.map(([, t]) => String(t))
|
||
} else if (Array.isArray(obj)) {
|
||
temas = obj.map(String)
|
||
} else if (obj && typeof obj === "object") {
|
||
const nums = Object.entries(obj).filter(
|
||
([k, v]) => /^\d+$/.test(String(k)) && (typeof v === "string" || typeof v === "number"),
|
||
)
|
||
if (nums.length)
|
||
temas = nums.sort(([a], [b]) => Number(a) - Number(b)).map(([, t]) => String(t))
|
||
}
|
||
return { title, temas }
|
||
})
|
||
return entries.length ? entries : [{ title: "", temas: [] }]
|
||
} catch {
|
||
return [{ title: "", temas: [] }]
|
||
}
|
||
}, [])
|
||
|
||
// --- Construye payload consistente
|
||
const buildPayload = useCallback((us: UnitDraft[]) => {
|
||
const out: Record<string, any> = {}
|
||
us.forEach((u, idx) => {
|
||
const k = String(idx + 1)
|
||
const sub: Record<string, string> = {}
|
||
u.temas
|
||
.map((t) => t.trim())
|
||
.filter(Boolean)
|
||
.forEach((t, i) => {
|
||
sub[String(i + 1)] = t
|
||
})
|
||
out[k] = { titulo: (u.title || "").trim(), subtemas: sub }
|
||
})
|
||
return out
|
||
}, [])
|
||
|
||
// --- Limpia UI
|
||
const cleanUnits = useCallback((us: UnitDraft[]) => {
|
||
return us.map((u) => {
|
||
const seen = new Set<string>()
|
||
const temas = u.temas
|
||
.map((t) => t.trim())
|
||
.filter((t) => {
|
||
if (!t) return false
|
||
const key = t.toLowerCase()
|
||
if (seen.has(key)) return false
|
||
seen.add(key)
|
||
return true
|
||
})
|
||
return { title: (u.title || "").trim(), temas }
|
||
})
|
||
}, [])
|
||
|
||
const openEditor = () => {
|
||
const base = normalize(value)
|
||
setUnits(cleanUnits(base))
|
||
setInitialUnits(cleanUnits(base))
|
||
setOpen(true)
|
||
}
|
||
|
||
const hasChanges = useMemo(
|
||
() => JSON.stringify(cleanUnits(units)) !== JSON.stringify(cleanUnits(initialUnits)),
|
||
[units, initialUnits, cleanUnits],
|
||
)
|
||
|
||
// --- Atajos: Ctrl/Cmd + Enter
|
||
useEffect(() => {
|
||
if (!open) return
|
||
const handler = (e: KeyboardEvent) => {
|
||
const ctrlOrCmd = e.ctrlKey || e.metaKey
|
||
if (ctrlOrCmd && e.key.toLowerCase() === "enter") {
|
||
e.preventDefault()
|
||
void save()
|
||
}
|
||
}
|
||
window.addEventListener("keydown", handler)
|
||
return () => window.removeEventListener("keydown", handler)
|
||
}, [open, units, saving])
|
||
|
||
// --- Acciones por unidad
|
||
const removeUnit = (idx: number) => {
|
||
if (!confirm("¿Eliminar esta unidad?")) return
|
||
setUnits((prev) => prev.filter((_, i) => i !== idx))
|
||
}
|
||
|
||
const moveUnit = (idx: number, dir: -1 | 1) => {
|
||
setUnits((prev) => {
|
||
const next = [...prev]
|
||
const j = idx + dir
|
||
if (j < 0 || j >= next.length) return prev
|
||
;[next[idx], next[j]] = [next[j], next[idx]]
|
||
return next
|
||
})
|
||
}
|
||
|
||
const duplicateUnit = (idx: number) => {
|
||
setUnits((prev) => {
|
||
const next = [...prev]
|
||
next.splice(idx + 1, 0, {
|
||
title: `${prev[idx].title} (copia)`,
|
||
temas: [...prev[idx].temas],
|
||
})
|
||
return next
|
||
})
|
||
}
|
||
|
||
// ✅ Función para guardar con respaldo histórico
|
||
async function save() {
|
||
setSaving(true)
|
||
try {
|
||
const cleaned = cleanUnits(units)
|
||
const contenidos = buildPayload(cleaned)
|
||
|
||
// 1️⃣ Generar diff entre valor anterior y nuevo
|
||
const diff = [
|
||
{
|
||
op: "replace",
|
||
path: "/contenidos",
|
||
from: value,
|
||
value: contenidos,
|
||
},
|
||
]
|
||
|
||
// 2️⃣ Guardar respaldo en tabla de histórico (solo si hay cambios)
|
||
if (JSON.stringify(value) !== JSON.stringify(contenidos)) {
|
||
const { error: backupError } = await supabase
|
||
.from("historico_cambios_asignaturas") // 👈 nombre de tu tabla de respaldo
|
||
.insert({
|
||
id_asignatura: asignaturaId,
|
||
json_cambios: diff,
|
||
user_id: auth.user?.id,
|
||
created_at: new Date().toISOString(),
|
||
})
|
||
if (backupError) throw backupError
|
||
}
|
||
|
||
// 3️⃣ Actualizar campo contenidos
|
||
const { data, error } = await supabase
|
||
.from("asignaturas")
|
||
.update({ contenidos })
|
||
.eq("id", asignaturaId)
|
||
.select()
|
||
.maybeSingle()
|
||
|
||
if (error) throw error
|
||
|
||
setInitialUnits(cleaned)
|
||
onSaved((data as any)?.contenidos ?? contenidos)
|
||
setOpen(false)
|
||
} catch (err: any) {
|
||
alert(err.message || "Error al guardar contenidos")
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const cancel = () => {
|
||
if (hasChanges && !confirm("Hay cambios sin guardar. ¿Cerrar de todos modos?")) return
|
||
setOpen(false)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Button size="sm" variant="outline" onClick={openEditor}>
|
||
<Icons.Pencil className="h-4 w-4 mr-2" /> Editar contenidos
|
||
</Button>
|
||
|
||
<Dialog open={open} onOpenChange={(o) => (o ? openEditor() : cancel())}>
|
||
<DialogContent className="max-w-4xl p-0 overflow-hidden">
|
||
{/* Header sticky */}
|
||
<div className="sticky top-0 z-10 border-b bg-background/80 backdrop-blur">
|
||
<DialogHeader className="px-6 pt-5 pb-3">
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Icons.BookOpen className="h-5 w-5" />
|
||
Editar contenidos
|
||
{hasChanges && (
|
||
<Badge variant="secondary" className="ml-1">Cambios sin guardar</Badge>
|
||
)}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
Títulos de unidad y temas (uno por línea). Se guardará con <code>titulo</code> y <code>subtemas</code>.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="px-6 pb-3 flex items-center justify-between text-xs text-muted-foreground">
|
||
<span>{units.length} unidad(es)</span>
|
||
<span className="inline-flex items-center gap-1">
|
||
<Icons.Keyboard className="h-3 w-3" /> Atajo: <kbd className="px-1 border rounded">Ctrl/⌘</kbd>+<kbd className="px-1 border rounded">Enter</kbd>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<ScrollArea className="max-h-[65vh] px-6">
|
||
<div className="space-y-4 py-4">
|
||
{units.map((u, i) => (
|
||
<div key={i} className="rounded-2xl border p-4">
|
||
<div className="flex items-center justify-between mb-3 gap-2">
|
||
<div className="font-medium text-sm">Unidad {i + 1}</div>
|
||
<div className="flex items-center gap-1">
|
||
<Button size="icon" variant="ghost" title="Subir" onClick={() => moveUnit(i, -1)} disabled={i === 0}>
|
||
<Icons.ArrowUp className="w-4 h-4" />
|
||
</Button>
|
||
<Button size="icon" variant="ghost" title="Bajar" onClick={() => moveUnit(i, 1)} disabled={i === units.length - 1}>
|
||
<Icons.ArrowDown className="w-4 h-4" />
|
||
</Button>
|
||
<Button size="icon" variant="ghost" title="Duplicar unidad" onClick={() => duplicateUnit(i)}>
|
||
<Icons.Copy className="w-4 h-4" />
|
||
</Button>
|
||
<Separator orientation="vertical" className="mx-1 h-5" />
|
||
<Button size="icon" variant="ghost" title="Eliminar unidad" onClick={() => removeUnit(i)}>
|
||
<Icons.Trash2 className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid md:grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>Título</Label>
|
||
<Input
|
||
value={u.title}
|
||
onChange={(e) =>
|
||
setUnits((prev) => prev.map((uu, idx) => (idx === i ? { ...uu, title: e.target.value } : uu)))
|
||
}
|
||
placeholder={`Unidad ${i + 1}`}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1 md:col-span-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label>Temas (uno por línea)</Label>
|
||
<Badge variant="outline">{u.temas.filter((t) => t.trim()).length} tema(s)</Badge>
|
||
</div>
|
||
<Textarea
|
||
className="min-h-[140px]"
|
||
value={u.temas.join("\n")}
|
||
onChange={(e) => {
|
||
const lines = e.target.value.split("\n")
|
||
setUnits((prev) => prev.map((uu, idx) => (idx === i ? { ...uu, temas: lines } : uu)))
|
||
}}
|
||
placeholder={`Tema 1\nTema 2\n…`}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<div className="flex justify-end">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() =>
|
||
setUnits((prev) => [...prev, { title: "", temas: [] }])
|
||
}
|
||
>
|
||
<Icons.Plus className="w-4 h-4 mr-2" /> Agregar unidad
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
<DialogFooter className="px-6 pb-5">
|
||
<Button variant="outline" onClick={cancel}>Cancelar</Button>
|
||
<Button onClick={save} disabled={saving || !hasChanges || units.some(u => !u.title.trim())}>
|
||
{saving ? (
|
||
<span className="inline-flex items-center gap-2">
|
||
<Icons.Loader2 className="h-4 w-4 animate-spin" /> Guardando…
|
||
</span>
|
||
) : (
|
||
"Guardar"
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
)
|
||
} |