This repository has been archived on 2026-01-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Acad-IA/src/routes/_authenticated/asignatura/$asignaturaId.tsx

993 lines
38 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 : 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>
</>
)
}