Refactor user management in usuarios.tsx: integrate react-query for data fetching and mutations, streamline role handling, and enhance user ban/unban functionality.

This commit is contained in:
2025-08-27 16:15:42 -06:00
parent 234c41d0b6
commit 3bc4498e4f
11 changed files with 1279 additions and 1313 deletions

View File

@@ -1,24 +1,35 @@
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { supabase, useSupabaseAuth } from '@/auth/supabase'
import * as Icons from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { AcademicSections } from '@/components/planes/academic-sections'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs'
import confetti from 'canvas-confetti'
import { AuroraButton } from '@/components/effect/aurora-button'
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
import { useEffect, useMemo, useRef, useState } from "react"
import * as Icons from "lucide-react"
import { useQueryClient, useSuspenseQuery, useMutation } from "@tanstack/react-query"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs"
import { AcademicSections } from "@/components/planes/academic-sections"
import { AuroraButton } from "@/components/effect/aurora-button"
import confetti from "canvas-confetti"
import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
gsap.registerPlugin(ScrollTrigger)
type PlanFull = {
/* ================= Query Keys & Options ================= */
const planKeys = {
byId: (id: string) => ["plan", id] as const,
}
const asignaturaKeys = {
count: (planId: string) => ["asignaturas", "count", planId] as const,
preview: (planId: string) => ["asignaturas", "preview", planId] as const,
extra: (asigId: string) => ["asignatura", "extra", asigId] as const,
}
export type PlanFull = {
id: string; nombre: string; nivel: string | null;
objetivo_general: string | null; perfil_ingreso: string | null; perfil_egreso: string | null;
duracion: string | null; total_creditos: number | null;
@@ -28,44 +39,95 @@ type PlanFull = {
estado: string | null; fecha_creacion: string | null;
carreras: { id: string; nombre: string; facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null } | null
}
type AsignaturaLite = { id: string; nombre: string; semestre: number | null; creditos: number | null }
type LoaderData = { plan: PlanFull; asignaturasCount: number; asignaturasPreview: AsignaturaLite[] }
export type AsignaturaLite = { id: string; nombre: string; semestre: number | null; creditos: number | null }
type LoaderData = { planId: string }
/* ---------- Query option builders ---------- */
function planByIdOptions(planId: string) {
return {
queryKey: planKeys.byId(planId),
queryFn: async (): Promise<PlanFull> => {
const { data, error } = await supabase
.from("plan_estudios")
.select(`
id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos,
competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno,
pertinencia, prompt, estado, fecha_creacion,
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
`)
.eq("id", planId)
.maybeSingle()
if (error || !data) throw error ?? new Error("Plan no encontrado")
return data as unknown as PlanFull
},
staleTime: 60_000,
} as const
}
function asignaturasCountOptions(planId: string) {
return {
queryKey: asignaturaKeys.count(planId),
queryFn: async (): Promise<number> => {
const { count, error } = await supabase
.from("asignaturas")
.select("*", { count: "exact", head: true })
.eq("plan_id", planId)
if (error) throw error
return count ?? 0
},
staleTime: 30_000,
} as const
}
function asignaturasPreviewOptions(planId: string) {
return {
queryKey: asignaturaKeys.preview(planId),
queryFn: async (): Promise<AsignaturaLite[]> => {
const { data, error } = await supabase
.from("asignaturas")
.select("id, nombre, semestre, creditos")
.eq("plan_id", planId)
.order("semestre", { ascending: true })
.order("nombre", { ascending: true })
.limit(8)
if (error) throw error
return (data ?? []) as unknown as AsignaturaLite[]
},
staleTime: 30_000,
} as const
}
function asignaturaExtraOptions(asigId: string) {
return {
queryKey: asignaturaKeys.extra(asigId),
queryFn: async (): Promise<{
tipo: string | null
horas_teoricas: number | null
horas_practicas: number | null
contenidos: Record<string, Record<string, string>> | null
} | null> => {
const { data, error } = await supabase
.from("asignaturas")
.select("tipo, horas_teoricas, horas_practicas, contenidos")
.eq("id", asigId)
.maybeSingle()
if (error) throw error
return (data as any) ?? null
},
} as const
}
/* ============== ROUTE ============== */
export const Route = createFileRoute('/_authenticated/plan/$planId')({
export const Route = createFileRoute("/_authenticated/plan/$planId")({
component: RouteComponent,
pendingComponent: PageSkeleton,
loader: async ({ params }): Promise<LoaderData> => {
const { data: plan, error } = await supabase
.from('plan_estudios')
.select(`
id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos,
competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno,
pertinencia, prompt, estado, fecha_creacion,
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
`)
.eq('id', params.planId)
.single()
if (error || !plan) throw error ?? new Error('Plan no encontrado')
const { count } = await supabase
.from('asignaturas')
.select('*', { count: 'exact', head: true })
.eq('plan_id', params.planId)
const { data: asignaturasPreview } = await supabase
.from('asignaturas')
.select('id, nombre, semestre, creditos')
.eq('plan_id', params.planId)
.order('semestre', { ascending: true })
.order('nombre', { ascending: true })
.limit(8)
return {
plan: plan as unknown as PlanFull,
asignaturasCount: count ?? 0,
asignaturasPreview: (asignaturasPreview ?? []) as AsignaturaLite[],
}
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
const { planId } = params
// Prefetch/ensure all queries needed for the page
await Promise.all([
queryClient.ensureQueryData(planByIdOptions(planId)),
queryClient.ensureQueryData(asignaturasCountOptions(planId)),
queryClient.ensureQueryData(asignaturasPreviewOptions(planId)),
])
return { planId }
},
})
@@ -129,12 +191,18 @@ function GradientMesh({ color }: { color?: string | null }) {
/* ============== PAGE ============== */
function RouteComponent() {
const router = useRouter()
const { plan, asignaturasCount, asignaturasPreview } = Route.useLoaderData() as LoaderData
const qc = useQueryClient()
const { planId } = Route.useLoaderData() as LoaderData
const auth = useSupabaseAuth()
// Fetch via React Query (suspense)
const { data: plan } = useSuspenseQuery(planByIdOptions(planId))
const { data: asignaturasCount } = useSuspenseQuery(asignaturasCountOptions(planId))
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(planId))
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
const showCarrera = auth.claims?.role === 'secretario_academico'
const fac = plan.carreras?.facultades
const accent = useMemo(() => softAccentStyle(fac?.color), [fac?.color])
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
@@ -145,7 +213,6 @@ function RouteComponent() {
const fieldsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// Header intro
if (headerRef.current) {
const ctx = gsap.context(() => {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
@@ -156,9 +223,7 @@ function RouteComponent() {
return () => ctx.revert()
}
}, [])
useEffect(() => {
// Stats y campos con ScrollTrigger
if (statsRef.current) {
const ctx = gsap.context(() => {
gsap.from('.academics', {
@@ -182,11 +247,11 @@ function RouteComponent() {
return () => ctx.revert()
}
}, [])
const facColor = plan.carreras?.facultades?.color ?? null
return (
<div className="relative p-6 space-y-6">
{/* Mesh global */}
<GradientMesh color={fac?.color} />
<nav className="relative text-sm text-neutral-500">
@@ -195,9 +260,8 @@ function RouteComponent() {
<span className="text-primary">{plan.nombre}</span>
</nav>
{/* Header con acciones y brillo */}
{/* Header */}
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
{/* velo de color muy suave */}
<div className="absolute inset-0 -z-0" style={accent} />
<CardHeader className="relative z-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 min-w-0">
@@ -228,37 +292,30 @@ function RouteComponent() {
</div>
</CardHeader>
{/* stats */}
<CardContent
ref={statsRef}
>
<CardContent ref={statsRef}>
<div className="academics relative z-10 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
<StatCard label="Nivel" value={plan.nivel ?? "—"} Icon={Icons.GraduationCap} accent={facColor} />
<StatCard label="Duración" value={plan.duracion ?? "—"} Icon={Icons.Clock} accent={facColor} />
<StatCard label="Créditos" value={fmt(plan.total_creditos)} Icon={Icons.Coins} accent={facColor} />
<StatCard
label="Creado"
value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"}
Icon={Icons.CalendarDays}
accent={facColor}
/>
<StatCard label="Creado" value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"} Icon={Icons.CalendarDays} accent={facColor} />
</div>
</CardContent>
</Card>
<div className="academics">
<AcademicSections planId={plan.id} plan={plan} color={fac?.color} />
<AcademicSections planId={plan.id} color={fac?.color} />
</div>
{/* ===== Asignaturas (preview cards) ===== */}
<Card className="border shadow-sm">
<CardHeader className="flex items-center justify-between gap-2">
<CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle>
<div className="flex items-center gap-2">
<AddAsignaturaButton planId={plan.id} onAdded={() => router.invalidate()} />
<AddAsignaturaButton planId={plan.id} onAdded={() => {
qc.invalidateQueries({ queryKey: asignaturaKeys.count(plan.id) })
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(plan.id) })
}} />
<Link
to="/asignaturas/$planId"
search={{ q: "", planId: plan.id, carreraId: "", f: "", facultadId: "" }}
@@ -283,7 +340,6 @@ function RouteComponent() {
)}
</CardContent>
</Card>
</div>
)
}
@@ -298,48 +354,35 @@ function hexToRgbA(hex?: string | null, a = .25) {
}
const fmt = (n?: number | null) => (n !== null && n !== undefined) ? Intl.NumberFormat().format(n) : "—"
/* ===== UI bits ===== */
type StatProps = {
function StatCard({ label, value = "—", Icon = Icons.Info, accent, className = "", title }: {
label: string
value?: React.ReactNode
Icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>
accent?: string | null // color de facultad (hex) opcional
accent?: string | null
className?: string
title?: string
}
function StatCard({ label, value = "—", Icon = Icons.Info, accent, className = "", title }: StatProps) {
}) {
const border = hexToRgbA(accent, .28)
const chipBg = hexToRgbA(accent, .08)
const glow = hexToRgbA(accent, .14)
return (
<div
className={`group relative overflow-hidden rounded-2xl border p-4 sm:p-5
bg-white/70 dark:bg-neutral-900/60 backdrop-blur
shadow-sm hover:shadow-md transition-all ${className}`}
className={`group relative overflow-hidden rounded-2xl border p-4 sm:p-5 bg-white/70 dark:bg-neutral-900/60 backdrop-blur shadow-sm hover:shadow-md transition-all ${className}`}
style={{ borderColor: border }}
title={title ?? (typeof value === "string" ? value : undefined)}
aria-label={`${label}: ${typeof value === "string" ? value : ""}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-neutral-500">{label}</div>
<span
className="inline-flex items-center justify-center rounded-xl px-2.5 py-2 border"
style={{ borderColor: border, background: chipBg }}
>
<span className="inline-flex items-center justify-center rounded-xl px-2.5 py-2 border" style={{ borderColor: border, background: chipBg }}>
<Icon className="h-4 w-4 opacity-80" />
</span>
</div>
<div className="mt-1 text-2xl font-semibold tabular-nums tracking-tight truncate">
{value}
</div>
{/* glow sutil en hover */}
<div
className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: `radial-gradient(600px 120px at 20% -10%, ${glow}, transparent 60%)` }}
/>
<div className="mt-1 text-2xl font-semibold tabular-nums tracking-tight truncate">{value}</div>
<div className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ background: `radial-gradient(600px 120px at 20% -10%, ${glow}, transparent 60%)` }} />
</div>
)
}
@@ -349,17 +392,38 @@ function EditPlanButton({ plan }: { plan: PlanFull }) {
const [open, setOpen] = useState(false)
const [form, setForm] = useState<Partial<PlanFull>>({})
const [saving, setSaving] = useState(false)
const qc = useQueryClient()
const mutation = useMutation({
mutationFn: async (payload: Partial<PlanFull>) => {
const { error } = await supabase.from('plan_estudios').update({
nombre: payload.nombre ?? plan.nombre,
nivel: payload.nivel ?? plan.nivel,
duracion: payload.duracion ?? plan.duracion,
total_creditos: payload.total_creditos ?? plan.total_creditos,
}).eq('id', plan.id)
if (error) throw error
},
onMutate: async (payload) => {
await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) })
const prev = qc.getQueryData<PlanFull>(planKeys.byId(plan.id))
qc.setQueryData<PlanFull>(planKeys.byId(plan.id), (old) => old ? { ...old, ...payload } as PlanFull : old as any)
return { prev }
},
onError: (_e, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev)
},
onSettled: async () => {
await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) })
}
})
async function save() {
setSaving(true)
const { error } = await supabase.from('plan_estudios').update({
nombre: form.nombre ?? plan.nombre,
nivel: form.nivel ?? plan.nivel,
duracion: form.duracion ?? plan.duracion,
total_creditos: form.total_creditos ?? plan.total_creditos,
}).eq('id', plan.id)
setSaving(false)
if (!error) setOpen(false)
try {
await mutation.mutateAsync(form)
setOpen(false)
} finally { setSaving(false) }
}
return (
@@ -380,7 +444,7 @@ function EditPlanButton({ plan }: { plan: PlanFull }) {
<Field label="Créditos totales"><Input value={String(form.total_creditos ?? '')} onChange={(e) => setForm({ ...form, total_creditos: Number(e.target.value) || null })} /></Field>
</div>
<DialogFooter>
<Button onClick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
<Button onClick={save} disabled={saving || mutation.isPending}>{(saving || mutation.isPending) ? 'Guardando…' : 'Guardar'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -410,7 +474,6 @@ function AdjustAIButton({ plan }: { plan: PlanFull }) {
}).catch(() => { })
setLoading(false)
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
setOpen(false)
}
@@ -463,20 +526,101 @@ function PageSkeleton() {
)
}
function AddAsignaturaButton({
planId, onAdded,
}: { planId: string; onAdded?: () => void }) {
/* ===== Asignaturas ===== */
function AsignaturaPreviewCard({ asignatura }: { asignatura: AsignaturaLite }) {
const { data: extra } = useSuspenseQuery(asignaturaExtraOptions(asignatura.id))
const horasT = extra?.horas_teoricas ?? null
const horasP = extra?.horas_practicas ?? null
const horasTot = (horasT ?? 0) + (horasP ?? 0)
const resumenContenidos = useMemo(() => {
const c = extra?.contenidos
if (!c) return { unidades: 0, temas: 0 }
const unidades = Object.keys(c).length
const temas = Object.values(c).reduce((acc, temasObj) => acc + Object.keys(temasObj || {}).length, 0)
return { unidades, temas }
}, [extra?.contenidos])
const tipo = (extra?.tipo ?? "").toLowerCase()
const tipoChip =
tipo.includes("oblig") ? "bg-emerald-50 text-emerald-700 border-emerald-200" :
tipo.includes("opt") ? "bg-amber-50 text-amber-800 border-amber-200" :
tipo.includes("taller") ? "bg-indigo-50 text-indigo-700 border-indigo-200" :
tipo.includes("lab") ? "bg-sky-50 text-sky-700 border-sky-200" :
"bg-neutral-100 text-neutral-700 border-neutral-200"
return (
<article className="group relative overflow-hidden rounded-2xl border bg-white/70 dark:bg-neutral-900/60 backdrop-blur p-4 shadow-sm hover:shadow-md transition-all hover:-translate-y-0.5" role="region" aria-label={asignatura.nombre}>
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
<Icons.BookOpen className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="font-medium truncate" title={asignatura.nombre}>{asignatura.nombre}</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
{asignatura.semestre != null && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
<Icons.Calendar className="h-3 w-3" /> S{asignatura.semestre}
</span>
)}
{asignatura.creditos != null && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
<Icons.Coins className="h-3 w-3" /> {asignatura.creditos} cr
</span>
)}
{extra?.tipo && (
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 ${tipoChip}`}>
<Icons.Tag className="h-3 w-3" /> {extra.tipo}
</span>
)}
</div>
</div>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px]">
<SmallStat icon={Icons.Clock} label="Horas" value={horasTot || "—"} />
<SmallStat icon={Icons.BookMarked} label="Unidades" value={resumenContenidos.unidades || "—"} />
<SmallStat icon={Icons.ListTree} label="Temas" value={resumenContenidos.temas || "—"} />
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-[11px] text-neutral-500">
{horasT != null || horasP != null ? (
<>H T/P: {horasT ?? "—"}/{horasP ?? "—"}</>
) : (
<span className="opacity-70">Resumen listo</span>
)}
</div>
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: asignatura.id }} className="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs hover:bg-neutral-50" title="Ver detalle">
Ver detalle <Icons.ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ background: "radial-gradient(600px 120px at 20% -10%, rgba(0,0,0,.06), transparent 60%)" }} />
</article>
)
}
function SmallStat({ icon: Icon, label, value }: { icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; label: string; value: string | number }) {
return (
<div className="rounded-lg border bg-white/60 dark:bg-neutral-900/50 px-2.5 py-2">
<div className="flex items-center gap-1 text-[10px] text-neutral-500">
<Icon className="h-3.5 w-3.5" /> {label}
</div>
<div className="mt-0.5 font-medium tabular-nums">{value}</div>
</div>
)
}
/* ===== Crear Asignatura ===== */
function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [mode, setMode] = useState<"manual" | "ia">("manual")
// --- Manual ---
const [f, setF] = useState({
nombre: "", clave: "", tipo: "", semestre: "", creditos: "",
horas_teoricas: "", horas_practicas: "", objetivos: "",
})
// --- IA ---
const [f, setF] = useState({ nombre: "", clave: "", tipo: "", semestre: "", creditos: "", horas_teoricas: "", horas_practicas: "", objetivos: "" })
const [iaPrompt, setIaPrompt] = useState("")
const [iaSemestre, setIaSemestre] = useState("")
@@ -505,7 +649,11 @@ function AddAsignaturaButton({
const { error } = await supabase.from("asignaturas").insert([payload])
setSaving(false)
if (error) { alert(error.message); return }
setOpen(false); onAdded?.()
setOpen(false)
onAdded?.()
// Warm up cache quickly
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
}
async function createWithAI() {
@@ -515,22 +663,17 @@ function AddAsignaturaButton({
const res = await fetch("https://genesis-engine.apps.lci.ulsa.mx/api/generar/asignatura", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
planEstudiosId: planId,
prompt: iaPrompt,
semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined,
insert: true, // que la API inserte en DB
}),
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true }),
})
if (!res.ok) throw new Error(await res.text())
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
setOpen(false); onAdded?.()
setOpen(false)
onAdded?.()
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
} catch (e: any) {
alert(e?.message ?? "Error al generar la asignatura")
} finally {
setSaving(false)
}
} finally { setSaving(false) }
}
const submit = () => (mode === "manual" ? createManual() : createWithAI())
@@ -548,86 +691,41 @@ function AddAsignaturaButton({
<DialogDescription>Elige cómo crearla: manual o generada por IA.</DialogDescription>
</DialogHeader>
{/* Conmutador elegante */}
<Tabs value={mode} onValueChange={v => setMode(v as "manual" | "ia")} className="w-full">
<TabsList
className="grid w-full grid-cols-2 rounded-xl border bg-neutral-50 p-1"
aria-label="Modo de creación"
>
<TabsTrigger
value="manual"
className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm"
>
<TabsList className="grid w-full grid-cols-2 rounded-xl border bg-neutral-50 p-1" aria-label="Modo de creación">
<TabsTrigger value="manual" className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm">
<Icons.PencilLine className="h-4 w-4 mr-2" /> Manual
</TabsTrigger>
<TabsTrigger
value="ia"
className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm"
>
<TabsTrigger value="ia" className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm">
<Icons.Sparkles className="h-4 w-4 mr-2" /> Generado por IA
</TabsTrigger>
</TabsList>
{/* --- Pestaña: Manual --- */}
<TabsContent value="manual" className="mt-4">
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Nombre">
<Input value={f.nombre} onChange={e => setF(s => ({ ...s, nombre: e.target.value }))} />
</Field>
<Field label="Clave">
<Input value={f.clave} onChange={e => setF(s => ({ ...s, clave: e.target.value }))} />
</Field>
<Field label="Tipo">
<Input value={f.tipo} onChange={e => setF(s => ({ ...s, tipo: e.target.value }))} placeholder="Obligatoria / Optativa / Taller…" />
</Field>
<Field label="Semestre">
<Input value={f.semestre} onChange={e => setF(s => ({ ...s, semestre: e.target.value }))} placeholder="110" />
</Field>
<Field label="Créditos">
<Input value={f.creditos} onChange={e => setF(s => ({ ...s, creditos: e.target.value }))} />
</Field>
<Field label="Horas teóricas">
<Input value={f.horas_teoricas} onChange={e => setF(s => ({ ...s, horas_teoricas: e.target.value }))} />
</Field>
<Field label="Horas prácticas">
<Input value={f.horas_practicas} onChange={e => setF(s => ({ ...s, horas_practicas: e.target.value }))} />
</Field>
<div className="sm:col-span-2">
<Field label="Objetivo (opcional)">
<Textarea value={f.objetivos} onChange={e => setF(s => ({ ...s, objetivos: e.target.value }))} className="min-h-[90px]" />
</Field>
</div>
<Field label="Nombre"><Input value={f.nombre} onChange={e => setF(s => ({ ...s, nombre: e.target.value }))} /></Field>
<Field label="Clave"><Input value={f.clave} onChange={e => setF(s => ({ ...s, clave: e.target.value }))} /></Field>
<Field label="Tipo"><Input value={f.tipo} onChange={e => setF(s => ({ ...s, tipo: e.target.value }))} placeholder="Obligatoria / Optativa / Taller…" /></Field>
<Field label="Semestre"><Input value={f.semestre} onChange={e => setF(s => ({ ...s, semestre: e.target.value }))} placeholder="110" /></Field>
<Field label="Créditos"><Input value={f.creditos} onChange={e => setF(s => ({ ...s, creditos: e.target.value }))} /></Field>
<Field label="Horas teóricas"><Input value={f.horas_teoricas} onChange={e => setF(s => ({ ...s, horas_teoricas: e.target.value }))} /></Field>
<Field label="Horas prácticas"><Input value={f.horas_practicas} onChange={e => setF(s => ({ ...s, horas_practicas: e.target.value }))} /></Field>
<div className="sm:col-span-2"><Field label="Objetivo (opcional)"><Textarea value={f.objetivos} onChange={e => setF(s => ({ ...s, objetivos: e.target.value }))} className="min-h-[90px]" /></Field></div>
</div>
</TabsContent>
{/* --- Pestaña: IA --- */}
<TabsContent value="ia" className="mt-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<Field label="Indica el enfoque / requisitos">
<Textarea
value={iaPrompt}
onChange={e => setIaPrompt(e.target.value)}
className="min-h-[120px]"
placeholder="Ej.: Diseña una materia de Programación Web con proyectos, evaluación por rúbricas y bibliografía actual…"
/>
</Field>
</div>
<Field label="Periodo (opcional)">
<Input value={iaSemestre} onChange={e => setIaSemestre(e.target.value)} placeholder="110" />
</Field>
<div className="sm:col-span-2"><Field label="Indica el enfoque / requisitos"><Textarea value={iaPrompt} onChange={e => setIaPrompt(e.target.value)} className="min-h-[120px]" placeholder="Ej.: Diseña una materia de Programación Web con proyectos, evaluación por rúbricas y bibliografía actual…" /></Field></div>
<Field label="Periodo (opcional)"><Input value={iaSemestre} onChange={e => setIaSemestre(e.target.value)} placeholder="110" /></Field>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
<Button >
</Button>
<AuroraButton onClick={submit} disabled={saving || !canSubmit}>
{saving
? (mode === "manual" ? "Guardando…" : "Generando…")
: (mode === "manual" ? "Crear" : "Generar e insertar")}
{saving ? (mode === "manual" ? "Guardando…" : "Generando…") : (mode === "manual" ? "Crear" : "Generar e insertar")}
</AuroraButton>
</DialogFooter>
</DialogContent>
@@ -635,146 +733,3 @@ function AddAsignaturaButton({
</>
)
}
function AsignaturaPreviewCard({
asignatura,
}: {
asignatura: { id: string; nombre: string; semestre: number | null; creditos: number | null }
}) {
const [extra, setExtra] = useState<{
tipo: string | null
horas_teoricas: number | null
horas_practicas: number | null
contenidos: Record<string, Record<string, string>> | null
} | null>(null)
// Carga perezosa de info extra para enriquecer la tarjeta (8 items máx: ok)
useEffect(() => {
let ignore = false
; (async () => {
const { data } = await supabase
.from("asignaturas")
.select("tipo, horas_teoricas, horas_practicas, contenidos")
.eq("id", asignatura.id)
.maybeSingle()
if (!ignore) setExtra((data as any) ?? null)
})()
return () => {
ignore = true
}
}, [asignatura.id])
const horasT = extra?.horas_teoricas ?? null
const horasP = extra?.horas_practicas ?? null
const horasTot = (horasT ?? 0) + (horasP ?? 0)
// Conteo rápido de unidades/temas si existen
const resumenContenidos = useMemo(() => {
const c = extra?.contenidos
if (!c) return { unidades: 0, temas: 0 }
const unidades = Object.keys(c).length
const temas = Object.values(c).reduce((acc, temasObj) => acc + Object.keys(temasObj || {}).length, 0)
return { unidades, temas }
}, [extra?.contenidos])
// estilo por tipo
const tipo = (extra?.tipo ?? "").toLowerCase()
const tipoChip =
tipo.includes("oblig")
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: tipo.includes("opt")
? "bg-amber-50 text-amber-800 border-amber-200"
: tipo.includes("taller")
? "bg-indigo-50 text-indigo-700 border-indigo-200"
: tipo.includes("lab")
? "bg-sky-50 text-sky-700 border-sky-200"
: "bg-neutral-100 text-neutral-700 border-neutral-200"
return (
<article
className="group relative overflow-hidden rounded-2xl border bg-white/70 dark:bg-neutral-900/60 backdrop-blur p-4 shadow-sm hover:shadow-md transition-all hover:-translate-y-0.5"
role="region"
aria-label={asignatura.nombre}
>
{/* header */}
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
<Icons.BookOpen className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="font-medium truncate" title={asignatura.nombre}>
{asignatura.nombre}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
{asignatura.semestre != null && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
<Icons.Calendar className="h-3 w-3" /> S{asignatura.semestre}
</span>
)}
{asignatura.creditos != null && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
<Icons.Coins className="h-3 w-3" /> {asignatura.creditos} cr
</span>
)}
{extra?.tipo && (
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 ${tipoChip}`}>
<Icons.Tag className="h-3 w-3" /> {extra.tipo}
</span>
)}
</div>
</div>
</div>
{/* cuerpo */}
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px]">
<SmallStat icon={Icons.Clock} label="Horas" value={horasTot || "—"} />
<SmallStat icon={Icons.BookMarked} label="Unidades" value={resumenContenidos.unidades || "—"} />
<SmallStat icon={Icons.ListTree} label="Temas" value={resumenContenidos.temas || "—"} />
</div>
{/* footer */}
<div className="mt-3 flex items-center justify-between">
<div className="text-[11px] text-neutral-500">
{horasT != null || horasP != null ? (
<>H T/P: {horasT ?? "—"}/{horasP ?? "—"}</>
) : (
<span className="opacity-70">Resumen listo</span>
)}
</div>
<Link
to="/asignatura/$asignaturaId"
params={{ asignaturaId: asignatura.id }}
className="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs hover:bg-neutral-50"
title="Ver detalle"
>
Ver detalle <Icons.ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
{/* glow sutil en hover */}
<div className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: "radial-gradient(600px 120px at 20% -10%, rgba(0,0,0,.06), transparent 60%)" }} />
</article>
)
}
function SmallStat({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
label: string
value: string | number
}) {
return (
<div className="rounded-lg border bg-white/60 dark:bg-neutral-900/50 px-2.5 py-2">
<div className="flex items-center gap-1 text-[10px] text-neutral-500">
<Icon className="h-3.5 w-3.5" /> {label}
</div>
<div className="mt-0.5 font-medium tabular-nums">{value}</div>
</div>
)
}