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,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>
)
}