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:
@@ -203,7 +203,7 @@ function RouteComponent() {
|
||||
{recientes.length === 0 && <li className="text-sm text-neutral-500">Sin actividad</li>}
|
||||
{recientes.map((r) => (
|
||||
<li key={`${r.tipo}-${r.id}`} className="flex items-center justify-between gap-3">
|
||||
<Link to={`/${r.tipo === 'plan' ? 'planes' : 'asignaturas'}/${r.id}`} className="truncate hover:underline">
|
||||
<Link to={`/${r.tipo === 'plan' ? 'plan' : 'plan'}/$planId`} className="truncate hover:underline" params={{planId: r.id}}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{r.tipo === 'plan' ? <Icons.ScrollText className="w-4 h-4" /> : <Icons.BookOpen className="w-4 h-4" />}
|
||||
{r.nombre ?? '—'}
|
||||
|
||||
359
src/routes/_authenticated/plan/$planId.tsx
Normal file
359
src/routes/_authenticated/plan/$planId.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
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
|
||||
@@ -18,19 +20,16 @@ type PlanDetail = {
|
||||
} | null
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/planes/$planId/modal")({
|
||||
export const Route = createFileRoute('/_authenticated/plan/$planId/modal')({
|
||||
component: RouteComponent,
|
||||
loader: async ({ params }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("plan_estudios")
|
||||
.from('plan_estudios')
|
||||
.select(`
|
||||
id, nombre, nivel, duracion, total_creditos, estado,
|
||||
carreras (
|
||||
id, nombre,
|
||||
facultades:facultades ( id, nombre, color, icon )
|
||||
)
|
||||
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
|
||||
`)
|
||||
.eq("id", params.planId)
|
||||
.eq('id', params.planId)
|
||||
.single()
|
||||
if (error) throw error
|
||||
return data
|
||||
@@ -45,14 +44,22 @@ function gradientFrom(color?: string | null) {
|
||||
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: "/planes", replace: true })}>
|
||||
<DialogContent className="max-w-2xl p-0 overflow-hidden">
|
||||
<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">
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import * as Icons from "lucide-react"
|
||||
import { Plus, RefreshCcw, Building2, ScrollText, BookOpen } from "lucide-react"
|
||||
import { Plus, RefreshCcw, Building2 } from "lucide-react"
|
||||
|
||||
export type PlanDeEstudios = {
|
||||
id: string; nombre: string; nivel: string | null; duracion: string | null;
|
||||
@@ -48,15 +48,32 @@ function hexToRgb(hex?: string | null): [number, number, number] {
|
||||
const n = parseInt(v, 16)
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||
}
|
||||
function softCardStyles(color?: string | null) {
|
||||
/* ---------- helpers ---------- */
|
||||
function chipTint(color?: string | null) {
|
||||
const [r, g, b] = hexToRgb(color)
|
||||
return {
|
||||
// borde + velo muy sutil del color de la facultad
|
||||
borderColor: `rgba(${r},${g},${b},.28)`,
|
||||
background: `linear-gradient(180deg, rgba(${r},${g},${b},.15), rgba(${r},${g},${b},.02))`,
|
||||
borderColor: `rgba(${r},${g},${b},.30)`,
|
||||
background: `rgba(${r},${g},${b},.10)`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
|
||||
function InfoChip({
|
||||
icon, label, tint,
|
||||
}: { icon: React.ReactNode; label: string; tint?: string | null }) {
|
||||
const style = tint ? chipTint(tint) : undefined
|
||||
return (
|
||||
<span
|
||||
title={label}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-lg border px-2.5 py-1 text-xs leading-none bg-white/70 text-neutral-800"
|
||||
style={style}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function RouteComponent() {
|
||||
const auth = useSupabaseAuth()
|
||||
const [q, setQ] = useState("")
|
||||
@@ -99,13 +116,14 @@ function RouteComponent() {
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered?.map((p) => {
|
||||
const fac = p.carreras?.facultades
|
||||
const styles = softCardStyles(fac?.color)
|
||||
const styles = chipTint(fac?.color)
|
||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Building2
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={p.id}
|
||||
to="/planes/$planId/modal"
|
||||
to="/plan/$planId/modal"
|
||||
mask={{ to: '/plan/$planId', params: { planId: p.id } }}
|
||||
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
params={{ planId: p.id }}
|
||||
style={styles}
|
||||
@@ -124,23 +142,37 @@ function RouteComponent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{showCarrera && p.carreras?.nombre && (
|
||||
<Badge variant="secondary" className="border text-neutral-700 bg-white/70 w-fit">
|
||||
<ScrollText className="mr-1 h-3 w-3" /> {p.carreras?.nombre}
|
||||
</Badge>
|
||||
)}
|
||||
{showFacultad && fac?.nombre && (
|
||||
<Badge variant="outline" className="bg-white/60 w-fit" style={{ borderColor: styles.borderColor }}>
|
||||
<BookOpen className="mr-1 h-3 w-3" /> {fac?.nombre}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Dentro del map de tarjetas, sustituye SOLO el footer inferior */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{/* grupo izquierdo: chips (wrap si no caben) */}
|
||||
<div className="min-w-0 flex-1 flex flex-wrap items-center gap-2">
|
||||
{showCarrera && p.carreras?.nombre && (
|
||||
<InfoChip
|
||||
icon={<Icons.GraduationCap className="h-3 w-3" />}
|
||||
label={p.carreras.nombre}
|
||||
/>
|
||||
)}
|
||||
{showFacultad && fac?.nombre && (
|
||||
<InfoChip
|
||||
icon={<Icons.Building2 className="h-3 w-3" />}
|
||||
label={fac.nombre}
|
||||
tint={fac.color} // tinte sutil por facultad
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* derecha: estado */}
|
||||
{p.estado && (
|
||||
<Badge variant="outline" className="ml-auto bg-white/60" style={{ borderColor: styles.borderColor }}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-white/60"
|
||||
style={{ borderColor: (chipTint(fac?.color).borderColor as string) }}
|
||||
>
|
||||
{p.estado}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/planes/$planId')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/_authenticated/planes/$planId"!</div>
|
||||
}
|
||||
351
src/routes/_authenticated/usuarios.tsx
Normal file
351
src/routes/_authenticated/usuarios.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
// routes/_authenticated/usuarios.tsx
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||
import { useMemo, useState } from "react"
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||
import {
|
||||
RefreshCcw, ShieldCheck, ShieldAlert, Pencil, Mail, CheckCircle2, XCircle,
|
||||
Cpu, Building2, ScrollText, GraduationCap, GanttChart
|
||||
} from "lucide-react"
|
||||
import { SupabaseClient } from "@supabase/supabase-js"
|
||||
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
||||
|
||||
/* ---------- Tipos ---------- */
|
||||
type AdminUser = {
|
||||
id: string
|
||||
email: string | null
|
||||
created_at: string
|
||||
last_sign_in_at: string | null
|
||||
user_metadata: any
|
||||
app_metadata: any
|
||||
}
|
||||
|
||||
const ROLES = ["lci", "vicerrectoria", "secretario_academico", "jefe_carrera", "planeacion"] as const
|
||||
export type Role = typeof ROLES[number]
|
||||
|
||||
/* ---------- Meta bonita de roles (codificado internamente) ---------- */
|
||||
const ROLE_META: Record<Role, {
|
||||
label: string
|
||||
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||||
className: string
|
||||
}> = {
|
||||
lci: {
|
||||
label: "Laboratorio de Cómputo de Ingeniería",
|
||||
Icon: Cpu,
|
||||
className: "bg-neutral-900 text-white"
|
||||
},
|
||||
vicerrectoria: {
|
||||
label: "Vicerrectoría Académica",
|
||||
Icon: Building2,
|
||||
className: "bg-indigo-600 text-white"
|
||||
},
|
||||
secretario_academico: {
|
||||
label: "Secretario Académico",
|
||||
Icon: ScrollText,
|
||||
className: "bg-emerald-600 text-white"
|
||||
},
|
||||
jefe_carrera: {
|
||||
label: "Jefe de Carrera",
|
||||
Icon: GraduationCap,
|
||||
className: "bg-orange-600 text-white"
|
||||
},
|
||||
planeacion: {
|
||||
label: "Planeación Curricular",
|
||||
Icon: GanttChart,
|
||||
className: "bg-sky-600 text-white"
|
||||
}
|
||||
}
|
||||
|
||||
function RolePill({ role }: { role: Role }) {
|
||||
const meta = ROLE_META[role]
|
||||
if (!meta) return null
|
||||
const { Icon, className, label } = meta
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] ${className}`}>
|
||||
<Icon className="h-3 w-3" /> {label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- Página ---------- */
|
||||
export const Route = createFileRoute("/_authenticated/usuarios")({
|
||||
component: RouteComponent,
|
||||
loader: async () => {
|
||||
// ⚠️ Asumes service role en cliente (mejor mover a Edge Function en producción)
|
||||
const supabsaeAdmin = new SupabaseClient(
|
||||
import.meta.env.VITE_SUPABASE_URL,
|
||||
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY,
|
||||
)
|
||||
const { data: data_users } = await supabsaeAdmin.auth.admin.listUsers()
|
||||
return { data: data_users.users as AdminUser[] }
|
||||
}
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const auth = useSupabaseAuth()
|
||||
const router = useRouter()
|
||||
const { data } = Route.useLoaderData()
|
||||
const [q, setQ] = useState("")
|
||||
const [editing, setEditing] = useState<AdminUser | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// state del formulario
|
||||
const [form, setForm] = useState<{
|
||||
role?: Role;
|
||||
claims_admin?: boolean;
|
||||
nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string;
|
||||
facultad_id?: string | null;
|
||||
carrera_id?: string | null;
|
||||
}>({})
|
||||
|
||||
if (!auth.claims?.claims_admin) {
|
||||
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const t = q.trim().toLowerCase()
|
||||
if (!t) return data
|
||||
return data.filter(u => {
|
||||
const role: Role | undefined = u.app_metadata?.role
|
||||
const label = role ? ROLE_META[role]?.label : ""
|
||||
return [u.email, u.user_metadata?.nombre, u.user_metadata?.apellidos, label]
|
||||
.filter(Boolean)
|
||||
.some(v => String(v).toLowerCase().includes(t))
|
||||
})
|
||||
}, [q, data])
|
||||
|
||||
function openEdit(u: AdminUser) {
|
||||
setEditing(u)
|
||||
setForm({
|
||||
role: u.app_metadata?.role,
|
||||
claims_admin: !!u.app_metadata?.claims_admin,
|
||||
nombre: u.user_metadata?.nombre ?? "",
|
||||
apellidos: u.user_metadata?.apellidos ?? "",
|
||||
title: u.user_metadata?.title ?? "",
|
||||
clave: u.user_metadata?.clave ?? "",
|
||||
avatar: u.user_metadata?.avatar ?? "",
|
||||
facultad_id: u.app_metadata?.facultad_id ?? null,
|
||||
carrera_id: u.app_metadata?.carrera_id ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing) return
|
||||
setSaving(true)
|
||||
const { error } = await supabase.functions.invoke("admin-update-user", {
|
||||
body: {
|
||||
id: editing.id,
|
||||
app_metadata: {
|
||||
role: form.role,
|
||||
claims_admin: form.claims_admin,
|
||||
facultad_id: form.facultad_id ?? null,
|
||||
carrera_id: form.carrera_id ?? null,
|
||||
},
|
||||
user_metadata: {
|
||||
nombre: form.nombre, apellidos: form.apellidos, title: form.title,
|
||||
clave: form.clave, avatar: form.avatar
|
||||
}
|
||||
}
|
||||
})
|
||||
setSaving(false)
|
||||
if (error) { console.error(error); return }
|
||||
router.invalidate(); setEditing(null)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Usuarios</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input placeholder="Buscar por nombre, email o rol…" value={q} onChange={e => setQ(e.target.value)} className="w-72" />
|
||||
<Button variant="outline" size="icon" title="Recargar" onClick={() => router.invalidate()}>
|
||||
<RefreshCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3">
|
||||
{filtered.map(u => {
|
||||
const m = u.user_metadata || {}
|
||||
const a = u.app_metadata || {}
|
||||
const roleCode: Role | undefined = a.role
|
||||
return (
|
||||
<div key={u.id} className="flex items-center gap-4 rounded-2xl border p-3">
|
||||
<img
|
||||
src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || 'U')}`}
|
||||
alt="" className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium truncate">
|
||||
{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
|
||||
</div>
|
||||
{roleCode && <RolePill role={roleCode} />}
|
||||
{a.claims_admin ? (
|
||||
<Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Administrador</Badge>
|
||||
) : (
|
||||
<Badge className="gap-1" variant="outline"><ShieldAlert className="w-3 h-3" /> Usuario</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-600 flex flex-wrap items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1"><Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
|
||||
<span>Creado: {new Date(u.created_at).toLocaleDateString()}</span>
|
||||
<span>Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span>
|
||||
{m.email_verified ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600"><CheckCircle2 className="w-3 h-3" /> Verificado</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-neutral-500"><XCircle className="w-3 h-3" /> No verificado</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}>
|
||||
<Pencil className="w-4 h-4 mr-1" /> Editar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!filtered.length && <div className="text-sm text-neutral-500 text-center py-10">Sin usuarios</div>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dialog de edición */}
|
||||
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
|
||||
<DialogContent className="w-[min(92vw,720px)] sm:max-w-2xl">
|
||||
<DialogHeader><DialogTitle>Editar usuario</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Nombre</Label>
|
||||
<Input value={form.nombre ?? ""} onChange={(e) => setForm(s => ({ ...s, nombre: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Apellidos</Label>
|
||||
<Input value={form.apellidos ?? ""} onChange={(e) => setForm(s => ({ ...s, apellidos: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Título</Label>
|
||||
<Input value={form.title ?? ""} onChange={(e) => setForm(s => ({ ...s, title: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Clave</Label>
|
||||
<Input value={form.clave ?? ""} onChange={(e) => setForm(s => ({ ...s, clave: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Avatar (URL)</Label>
|
||||
<Input value={form.avatar ?? ""} onChange={(e) => setForm(s => ({ ...s, avatar: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Rol</Label>
|
||||
<Select
|
||||
value={form.role ?? ""}
|
||||
onValueChange={(v) => {
|
||||
setForm(s => {
|
||||
const role = v as Role
|
||||
// limpiar/aplicar campos según rol
|
||||
if (role === "jefe_carrera") {
|
||||
return { ...s, role, /* conserva si ya venían */ facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
|
||||
}
|
||||
if (role === "secretario_academico") {
|
||||
return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
|
||||
}
|
||||
return { ...s, role, facultad_id: null, carrera_id: null }
|
||||
})
|
||||
}}
|
||||
>
|
||||
{/* Hace que el popper herede ancho del trigger y no se salga */}
|
||||
<SelectTrigger className="w-full sm:w-[420px]">
|
||||
<SelectValue placeholder="Selecciona un rol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="min-w-fit max-w-full max-h-72 overflow-auto"
|
||||
>
|
||||
{ROLES.map(code => {
|
||||
const meta = ROLE_META[code]; const Icon = meta.Icon
|
||||
return (
|
||||
<SelectItem
|
||||
key={code}
|
||||
value={code}
|
||||
className="whitespace-normal text-sm leading-snug py-2"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Icon className="w-4 h-4" />
|
||||
{meta.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Solo SECRETARIO: facultad */}
|
||||
{/* SECRETARIO: solo facultad */}
|
||||
{form.role === "secretario_academico" && (
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={form.facultad_id ?? ""}
|
||||
onChange={(id) => setForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
|
||||
/>
|
||||
<p className="text-[11px] text-neutral-500">Este rol solo requiere <strong>Facultad</strong>.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JEFE DE CARRERA: ambos */}
|
||||
{form.role === "jefe_carrera" && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={form.facultad_id ?? ""}
|
||||
onChange={(id) => setForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Carrera</Label>
|
||||
<CarreraCombobox
|
||||
facultadId={form.facultad_id ?? ""}
|
||||
value={form.carrera_id ?? ""}
|
||||
onChange={(id) => setForm(s => ({ ...s, carrera_id: id }))}
|
||||
disabled={!form.facultad_id}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Permisos</Label>
|
||||
<Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm(s => ({ ...s, claims_admin: v === 'true' }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Administrador</SelectItem>
|
||||
<SelectItem value="false">Usuario</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user