feat: add Usuarios route and user management functionality

- Introduced a new route for user management under /usuarios.
- Implemented user listing with search and edit capabilities.
- Added role management with visual indicators for user roles.
- Created a modal for editing user details, including role and permissions.
- Integrated Supabase for user data retrieval and updates.
- Enhanced UI components for better user experience.
- Removed unused planes route and related components.
- Added a new plan detail modal for displaying plan information.
- Updated navigation to include new Usuarios link.
This commit is contained in:
2025-08-21 15:30:50 -06:00
parent fe471bcfc2
commit 02ad043ed6
16 changed files with 1542 additions and 97 deletions

View File

@@ -0,0 +1,359 @@
import { createFileRoute, Link, useParams } 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'
gsap.registerPlugin(ScrollTrigger)
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;
competencias_genericas: string | null; competencias_especificas: string | null;
sistema_evaluacion: string | null; indicadores_desempeno: string | null;
pertinencia: string | null; prompt: string | null;
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 LoaderData = { plan: PlanFull; asignaturasCount: number }
/* ============== ROUTE ============== */
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)
return { plan: plan as unknown as PlanFull, asignaturasCount: count ?? 0 }
},
})
/* ============== COLOR / MESH HELPERS ============== */
function hexToRgb(hex?: string | null): [number, number, number] {
if (!hex) return [37, 99, 235]
const h = hex.replace('#', '')
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
const n = parseInt(v, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
function softAccentStyle(color?: string | null) {
const [r, g, b] = hexToRgb(color)
return {
borderColor: `rgba(${r},${g},${b},.28)`,
background: `linear-gradient(180deg, rgba(${r},${g},${b},.06), rgba(${r},${g},${b},.02))`,
} as React.CSSProperties
}
function lighten([r, g, b]: [number, number, number], amt = 30) { return [r + amt, g + amt, b + amt].map(v => Math.max(0, Math.min(255, v))) as [number, number, number] }
function toRGBA([r, g, b]: [number, number, number], a: number) { return `rgba(${r},${g},${b},${a})` }
/* ============== GRADIENT MESH LAYER ============== */
function GradientMesh({ color }: { color?: string | null }) {
const meshRef = useRef<HTMLDivElement>(null)
const base = hexToRgb(color)
const soft = lighten(base, 20)
const pop = lighten(base, -20)
useEffect(() => {
if (!meshRef.current) return
const blobs = meshRef.current.querySelectorAll('.blob')
blobs.forEach((el, i) => {
gsap.to(el, {
x: gsap.utils.random(-30, 30),
y: gsap.utils.random(-20, 20),
rotate: gsap.utils.random(-6, 6),
duration: gsap.utils.random(6, 10),
ease: 'sine.inOut',
yoyo: true,
repeat: -1,
delay: i * 0.2,
})
})
return () => gsap.killTweensOf(blobs)
}, [color])
return (
<div ref={meshRef} className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<div className="blob absolute -top-24 -left-24 w-[38rem] h-[38rem] rounded-full blur-3xl"
style={{ background: `radial-gradient(circle, ${toRGBA(soft, .35)}, transparent 60%)` }} />
<div className="blob absolute -bottom-28 -right-20 w-[34rem] h-[34rem] rounded-full blur-[60px]"
style={{ background: `radial-gradient(circle, ${toRGBA(base, .28)}, transparent 60%)` }} />
<div className="blob absolute top-1/3 left-1/2 -translate-x-1/2 w-[22rem] h-[22rem] rounded-full blur-[50px]"
style={{ background: `radial-gradient(circle, ${toRGBA(pop, .22)}, transparent 60%)` }} />
<div className="absolute inset-0 opacity-40"
style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }} />
</div>
)
}
/* ============== PAGE ============== */
function RouteComponent() {
const { plan, asignaturasCount } = Route.useLoaderData() as LoaderData
const auth = useSupabaseAuth()
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
// Refs para animaciones
const headerRef = useRef<HTMLDivElement>(null)
const statsRef = useRef<HTMLDivElement>(null)
const fieldsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// Header intro
if (headerRef.current) {
const ctx = gsap.context(() => {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
tl.from('.hdr-icon', { y: 12, opacity: 0, duration: .5 })
.from('.hdr-title', { y: 8, opacity: 0, duration: .4 }, '-=.25')
.from('.hdr-chips > *', { y: 6, opacity: 0, stagger: .06, duration: .35 }, '-=.25')
}, headerRef)
return () => ctx.revert()
}
}, [])
useEffect(() => {
// Stats y campos con ScrollTrigger
if (statsRef.current) {
const ctx = gsap.context(() => {
gsap.from('.kv', {
y: 14, opacity: 0, stagger: .08, duration: .4,
scrollTrigger: { trigger: statsRef.current, start: 'top 85%' }
})
}, statsRef)
return () => ctx.revert()
}
}, [])
useEffect(() => {
if (fieldsRef.current) {
const ctx = gsap.context(() => {
gsap.utils.toArray<HTMLElement>('.long-field').forEach((el, i) => {
gsap.from(el, {
y: 22, opacity: 0, duration: .45, delay: i * 0.03,
scrollTrigger: { trigger: el, start: 'top 90%' }
})
})
}, fieldsRef)
return () => ctx.revert()
}
}, [])
return (
<div className="relative p-6 space-y-6">
{/* Mesh global */}
<GradientMesh color={fac?.color} />
<nav className="relative text-sm text-neutral-500">
<Link to="/planes" className="hover:underline">Planes de estudio</Link>
<span className="mx-1">/</span>
<span className="text-primary">{plan.nombre}</span>
</nav>
{/* Header con acciones y brillo */}
<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">
<span className="hdr-icon inline-flex items-center justify-center rounded-2xl border px-3 py-2 bg-white/70"
style={{ borderColor: accent.borderColor as string }}>
<IconComp className="w-6 h-6" />
</span>
<div className="min-w-0">
<CardTitle className="hdr-title truncate">{plan.nombre}</CardTitle>
<div className="hdr-chips text-xs text-neutral-600 truncate">
{showCarrera && plan.carreras?.nombre ? `Carrera: ${plan.carreras.nombre}` : null}
{showFacultad && fac?.nombre ? `${showCarrera ? ' · ' : ''}Facultad: ${fac.nombre}` : null}
</div>
</div>
</div>
<div className="hdr-chips flex flex-wrap items-center gap-2">
{plan.estado && (
<Badge variant="outline" className="bg-white/60" style={{ borderColor: accent.borderColor }}>
{plan.estado}
</Badge>
)}
<Link
to="/asignaturas"
search={{ planId: plan.id }}
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
>
<Icons.BookOpen className="w-4 h-4" /> Ver asignaturas
</Link>
<div className='flex gap-2'>
<EditPlanButton plan={plan} />
<AdjustAIButton plan={plan} />
</div>
</div>
</CardHeader>
{/* stats */}
<CardContent ref={statsRef} className="relative z-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KV className="kv" label="Nivel" value={plan.nivel} />
<KV className="kv" label="Duración" value={plan.duracion} />
<KV className="kv" label="Créditos" value={plan.total_creditos} />
<KV className="kv" label="Asignaturas" value={asignaturasCount} />
<KV className="kv" label="Creado" value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : '—'} />
</CardContent>
</Card>
<AcademicSections planId={plan.id} plan={plan} color={fac?.color} />
</div>
)
}
/* ===== UI bits ===== */
function KV({ label, value, className = '' }: { label: string; value?: string | number | null; className?: string }) {
return (
<div className={`rounded-xl border p-4 shadow-sm hover:shadow-md transition-shadow ${className}`}>
<div className="text-xs text-neutral-500">{label}</div>
<div className="text-base font-medium">{value ?? '—'}</div>
</div>
)
}
/* ===== Editar ===== */
function EditPlanButton({ plan }: { plan: PlanFull }) {
const [open, setOpen] = useState(false)
const [form, setForm] = useState<Partial<PlanFull>>({})
const [saving, setSaving] = useState(false)
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)
}
return (
<>
<Button variant="secondary" onClick={() => { setForm(plan); setOpen(true) }}>
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Editar plan</DialogTitle>
<DialogDescription>Actualiza datos básicos.</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<Field label="Nombre"><Input value={form.nombre ?? ''} onChange={(e) => setForm({ ...form, nombre: e.target.value })} /></Field>
<Field label="Nivel"><Input value={form.nivel ?? ''} onChange={(e) => setForm({ ...form, nivel: e.target.value })} /></Field>
<Field label="Duración"><Input value={form.duracion ?? ''} onChange={(e) => setForm({ ...form, duracion: e.target.value })} /></Field>
<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>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
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>
)
}
/* ===== Ajustar IA ===== */
function AdjustAIButton({ plan }: { plan: PlanFull }) {
const [open, setOpen] = useState(false)
const [prompt, setPrompt] = useState('')
const [loading, setLoading] = useState(false)
async function apply() {
setLoading(true)
await fetch('https://genesis-engine.apps.lci.ulsa.mx/ajustar/plan', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, plan }),
}).catch(() => { })
setLoading(false)
setOpen(false)
}
return (
<>
<Button onClick={() => setOpen(true)}>
<Icons.Sparkles className="w-4 h-4 mr-2" /> Ajustar con IA
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Ajustar con IA</DialogTitle>
<DialogDescription>Describe cómo quieres modificar el plan actual.</DialogDescription>
</DialogHeader>
<Textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Ej.: Enfatiza ciberseguridad y proyectos prácticos…" className="min-h-[120px]" />
<DialogFooter>
<Button onClick={apply} disabled={!prompt.trim() || loading}>
{loading ? 'Aplicando…' : 'Aplicar ajuste'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/* ===== Skeleton ===== */
function Pulse({ className = '' }: { className?: string }) {
return <div className={`animate-pulse bg-neutral-200 rounded-xl ${className}`} />
}
function PageSkeleton() {
return (
<div className="p-6 space-y-6">
<div className="border rounded-2xl p-6">
<div className="flex items-center gap-3">
<Pulse className="w-10 h-10" />
<div className="flex-1 space-y-2">
<Pulse className="h-5 w-64" />
<Pulse className="h-3 w-48" />
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 mt-4">
{Array.from({ length: 5 }).map((_, i) => <Pulse key={i} className="h-14" />)}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => <Pulse key={i} className="h-40" />)}
</div>
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { supabase } from "@/auth/supabase"
import * as Icons from "lucide-react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
type PlanDetail = {
id: string
nombre: string
nivel: string | null
duracion: string | null
total_creditos: number | null
estado: string | null
carreras: {
id: string
nombre: string
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
} | null
}
export const Route = createFileRoute('/_authenticated/plan/$planId/modal')({
component: RouteComponent,
loader: async ({ params }) => {
const { data, error } = await supabase
.from('plan_estudios')
.select(`
id, nombre, nivel, duracion, total_creditos, estado,
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
`)
.eq('id', params.planId)
.single()
if (error) throw error
return data
},
})
function gradientFrom(color?: string | null) {
const base = (color && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)) ? color : "#2563eb"
return `linear-gradient(135deg, ${base} 0%, ${base}CC 45%, ${base}99 75%, ${base}66 100%)`
}
function RouteComponent() {
const plan = Route.useLoaderData() as PlanDetail
const router = useRouter()
const fac = plan.carreras?.facultades
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
const headerBg = { background: gradientFrom(fac?.color) }
return (
<Dialog
open
onOpenChange={() =>
router.navigate({
to: '/plan/$planId',
params: { planId: plan.id },
replace: true,
})
}
>
<DialogContent className="max-w-2xl p-0 overflow-hidden" aria-describedby="">
{/* Header con color/ícono de facultad */}
<div className="p-6 text-white" style={headerBg}>
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center rounded-2xl bg-white/15 backdrop-blur px-3 py-2">
<IconComp className="w-6 h-6" />
</span>
<div className="min-w-0">
<DialogHeader>
<DialogTitle className="truncate">{plan.nombre}</DialogTitle>
</DialogHeader>
<div className="text-xs opacity-90 truncate">
{plan.carreras?.nombre ?? "—"} {fac?.nombre ? `· ${fac?.nombre}` : ""}
</div>
</div>
{plan.estado && (
<Badge variant="outline" className="ml-auto bg-white/10 text-white border-white/40">
{plan.estado}
</Badge>
)}
</div>
</div>
{/* Cuerpo */}
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-neutral-500">Nivel:</span> <span className="font-medium">{plan.nivel ?? "—"}</span></div>
<div><span className="text-neutral-500">Duración:</span> <span className="font-medium">{plan.duracion ?? "—"}</span></div>
<div><span className="text-neutral-500">Créditos:</span> <span className="font-medium">{plan.total_creditos ?? "—"}</span></div>
<div><span className="text-neutral-500">Facultad:</span> <span className="font-medium">{fac?.nombre ?? "—"}</span></div>
</div>
<div className="flex gap-2">
<a
href={`/_authenticated/planes/${plan.id}`}
className="inline-flex items-center gap-2 rounded-xl bg-black text-white px-4 py-2 hover:opacity-90"
>
<Icons.FileText className="w-4 h-4" /> Ver ficha
</a>
<a
href={`/_authenticated/asignaturas?planId=${plan.id}`}
className="inline-flex items-center gap-2 rounded-xl border px-4 py-2 hover:bg-neutral-50"
>
<Icons.BookOpen className="w-4 h-4" /> Ver asignaturas
</a>
</div>
</div>
</DialogContent>
</Dialog>
)
}