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:
@@ -1,14 +1,13 @@
|
||||
// routes/_authenticated/usuarios.tsx
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useSuspenseQuery, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
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 { 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 {
|
||||
@@ -20,6 +19,7 @@ import { SupabaseClient } from "@supabase/supabase-js"
|
||||
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/* -------------------- Tipos -------------------- */
|
||||
type AdminUser = {
|
||||
id: string
|
||||
email: string | null
|
||||
@@ -27,58 +27,28 @@ type AdminUser = {
|
||||
last_sign_in_at: string | null
|
||||
user_metadata: any
|
||||
app_metadata: any
|
||||
banned_until?: string | null // NEW: lo usamos en UI
|
||||
banned_until?: string | null
|
||||
}
|
||||
|
||||
// NEW: constantes auxiliares
|
||||
const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const
|
||||
|
||||
// NEW: agrega director_facultad; mantenemos planeacion por compat
|
||||
const ROLES = [
|
||||
"lci",
|
||||
"vicerrectoria",
|
||||
"director_facultad", // NEW
|
||||
"director_facultad",
|
||||
"secretario_academico",
|
||||
"jefe_carrera",
|
||||
"planeacion",
|
||||
] as const
|
||||
export type Role = typeof ROLES[number]
|
||||
|
||||
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"
|
||||
},
|
||||
director_facultad: { // NEW
|
||||
label: "Director(a) de Facultad",
|
||||
Icon: Building2,
|
||||
className: "bg-purple-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"
|
||||
}
|
||||
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" },
|
||||
director_facultad: { label: "Director(a) de Facultad", Icon: Building2, className: "bg-purple-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 }) {
|
||||
@@ -86,46 +56,56 @@ function RolePill({ role }: { role: Role }) {
|
||||
if (!meta) return null
|
||||
const { Icon, className, label } = meta
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`}
|
||||
title={label}
|
||||
>
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`} title={label}>
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------- Query Keys & Fetcher -------------------- */
|
||||
const usersKeys = {
|
||||
root: ["usuarios"] as const,
|
||||
list: () => [...usersKeys.root, "list"] as const,
|
||||
}
|
||||
|
||||
async function fetchUsers(): Promise<AdminUser[]> {
|
||||
// ⚠️ Dev only: service role en cliente
|
||||
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
|
||||
const { data } = await admin.auth.admin.listUsers()
|
||||
return (data?.users ?? []) as AdminUser[]
|
||||
}
|
||||
|
||||
const usersOptions = () =>
|
||||
queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 })
|
||||
|
||||
/* -------------------- Ruta -------------------- */
|
||||
export const Route = createFileRoute("/_authenticated/usuarios")({
|
||||
component: RouteComponent,
|
||||
loader: async () => {
|
||||
// ⚠️ Dev only: service role en cliente
|
||||
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[] }
|
||||
}
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
await queryClient.ensureQueryData(usersOptions())
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
/* -------------------- Página -------------------- */
|
||||
function RouteComponent() {
|
||||
const auth = useSupabaseAuth()
|
||||
const router = useRouter()
|
||||
const { data } = Route.useLoaderData()
|
||||
const qc = useQueryClient()
|
||||
const { data } = useSuspenseQuery(usersOptions())
|
||||
|
||||
const [q, setQ] = useState("")
|
||||
const [editing, setEditing] = useState<AdminUser | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
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;
|
||||
role?: Role
|
||||
claims_admin?: boolean
|
||||
nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string
|
||||
facultad_id?: string | null
|
||||
carrera_id?: string | null
|
||||
}>({})
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createSaving, setCreateSaving] = useState(false)
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
const [createForm, setCreateForm] = useState<{
|
||||
email: string
|
||||
@@ -138,123 +118,138 @@ function RouteComponent() {
|
||||
}>({ email: "", password: "" })
|
||||
|
||||
function genPassword() {
|
||||
const s = Array.from(crypto.getRandomValues(new Uint32Array(4)))
|
||||
.map(n => n.toString(36)).join("")
|
||||
const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("")
|
||||
return s.slice(0, 14)
|
||||
}
|
||||
|
||||
|
||||
// NEW: helpers nombramientos
|
||||
async function upsertNombramiento(opts: {
|
||||
user_id: string,
|
||||
puesto: "director_facultad" | "secretario_academico" | "jefe_carrera",
|
||||
facultad_id?: string | null,
|
||||
carrera_id?: string | null
|
||||
}) {
|
||||
// cierra vigentes del mismo scope y puesto
|
||||
if (opts.puesto === "jefe_carrera") {
|
||||
if (!opts.carrera_id) throw new Error("Selecciona carrera")
|
||||
await supabase.from("nombramientos")
|
||||
.update({ hasta: new Date().toISOString().slice(0, 10) })
|
||||
.eq("puesto", "jefe_carrera")
|
||||
.eq("carrera_id", opts.carrera_id)
|
||||
.is("hasta", null)
|
||||
} else {
|
||||
if (!opts.facultad_id) throw new Error("Selecciona facultad")
|
||||
await supabase.from("nombramientos")
|
||||
.update({ hasta: new Date().toISOString().slice(0, 10) })
|
||||
.eq("puesto", opts.puesto)
|
||||
.eq("facultad_id", opts.facultad_id)
|
||||
.is("hasta", null)
|
||||
}
|
||||
// inserta vigente
|
||||
const { error } = await supabase.from("nombramientos").insert({
|
||||
user_id: opts.user_id,
|
||||
puesto: opts.puesto,
|
||||
facultad_id: opts.facultad_id ?? null,
|
||||
carrera_id: opts.carrera_id ?? null,
|
||||
desde: new Date().toISOString().slice(0, 10),
|
||||
hasta: null
|
||||
})
|
||||
if (error) throw error
|
||||
/* ---------- Mutations ---------- */
|
||||
const invalidateAll = async () => {
|
||||
await qc.invalidateQueries({ queryKey: usersKeys.root })
|
||||
router.invalidate()
|
||||
}
|
||||
|
||||
// NEW: ban/unban directo (deja que el trigger “rebalance” haga lo suyo)
|
||||
async function toggleBan(u: AdminUser) {
|
||||
try {
|
||||
const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
|
||||
const payload = banned
|
||||
? { banned_until: null }
|
||||
: { banned_until: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString() }
|
||||
const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
|
||||
const upsertNombramiento = useMutation({
|
||||
mutationFn: async (opts: {
|
||||
user_id: string
|
||||
puesto: "director_facultad" | "secretario_academico" | "jefe_carrera"
|
||||
facultad_id?: string | null
|
||||
carrera_id?: string | null
|
||||
}) => {
|
||||
// cierra vigentes
|
||||
if (opts.puesto === "jefe_carrera") {
|
||||
if (!opts.carrera_id) throw new Error("Selecciona carrera")
|
||||
await supabase
|
||||
.from("nombramientos")
|
||||
.update({ hasta: new Date().toISOString().slice(0, 10) })
|
||||
.eq("puesto", "jefe_carrera")
|
||||
.eq("carrera_id", opts.carrera_id)
|
||||
.is("hasta", null)
|
||||
} else {
|
||||
if (!opts.facultad_id) throw new Error("Selecciona facultad")
|
||||
await supabase
|
||||
.from("nombramientos")
|
||||
.update({ hasta: new Date().toISOString().slice(0, 10) })
|
||||
.eq("puesto", opts.puesto)
|
||||
.eq("facultad_id", opts.facultad_id)
|
||||
.is("hasta", null)
|
||||
}
|
||||
const { error } = await supabase.from("nombramientos").insert({
|
||||
user_id: opts.user_id,
|
||||
puesto: opts.puesto,
|
||||
facultad_id: opts.facultad_id ?? null,
|
||||
carrera_id: opts.carrera_id ?? null,
|
||||
desde: new Date().toISOString().slice(0, 10),
|
||||
hasta: null,
|
||||
})
|
||||
if (error) throw error
|
||||
toast.success(banned ? "Usuario desbaneado" : "Usuario baneado")
|
||||
router.invalidate()
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(e?.message || "Error al cambiar estado de baneo")
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || "Error al registrar nombramiento"),
|
||||
})
|
||||
|
||||
async function createUserNow() {
|
||||
if (!createForm.email?.trim()) { toast.error("Correo requerido"); return }
|
||||
try {
|
||||
const adminClient = new SupabaseClient(
|
||||
import.meta.env.VITE_SUPABASE_URL,
|
||||
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY
|
||||
)
|
||||
const toggleBan = useMutation({
|
||||
mutationFn: async (u: AdminUser) => {
|
||||
const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
|
||||
const payload = banned ? { banned_until: null } : { banned_until: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString() }
|
||||
const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
|
||||
if (error) throw new Error(error.message)
|
||||
return !banned
|
||||
},
|
||||
onSuccess: async (isBanned) => {
|
||||
toast.success(isBanned ? "Usuario baneado" : "Usuario desbaneado")
|
||||
await invalidateAll()
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || "Error al cambiar estado de baneo"),
|
||||
})
|
||||
|
||||
setCreateSaving(true)
|
||||
const password = createForm.password?.trim() || genPassword()
|
||||
const { error, data } = await adminClient.auth.admin.createUser({
|
||||
email: createForm.email.trim(),
|
||||
const createUser = useMutation({
|
||||
mutationFn: async (payload: typeof createForm) => {
|
||||
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
|
||||
const password = payload.password?.trim() || genPassword()
|
||||
const { error, data } = await admin.auth.admin.createUser({
|
||||
email: payload.email.trim(),
|
||||
password,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
nombre: createForm.nombre ?? "",
|
||||
apellidos: createForm.apellidos ?? "",
|
||||
title: createForm.title ?? "",
|
||||
clave: createForm.clave ?? "",
|
||||
avatar: createForm.avatar ?? ""
|
||||
nombre: payload.nombre ?? "",
|
||||
apellidos: payload.apellidos ?? "",
|
||||
title: payload.title ?? "",
|
||||
clave: payload.clave ?? "",
|
||||
avatar: payload.avatar ?? "",
|
||||
},
|
||||
app_metadata: {
|
||||
role: createForm.role,
|
||||
claims_admin: !!createForm.claims_admin,
|
||||
facultad_id: createForm.facultad_id ?? null,
|
||||
carrera_id: createForm.carrera_id ?? null
|
||||
}
|
||||
role: payload.role,
|
||||
claims_admin: !!payload.claims_admin,
|
||||
facultad_id: payload.facultad_id ?? null,
|
||||
carrera_id: payload.carrera_id ?? null,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
|
||||
// NEW: si es rol jerárquico => crea nombramiento
|
||||
if (error) throw new Error(error.message)
|
||||
const uid = data.user?.id
|
||||
if (uid && createForm.role && (SCOPED_ROLES as readonly string[]).includes(createForm.role)) {
|
||||
if (createForm.role === "director_facultad") {
|
||||
if (!createForm.facultad_id) throw new Error("Selecciona facultad")
|
||||
await upsertNombramiento({ user_id: uid, puesto: "director_facultad", facultad_id: createForm.facultad_id })
|
||||
} else if (createForm.role === "secretario_academico") {
|
||||
if (!createForm.facultad_id) throw new Error("Selecciona facultad")
|
||||
await upsertNombramiento({ user_id: uid, puesto: "secretario_academico", facultad_id: createForm.facultad_id })
|
||||
} else if (createForm.role === "jefe_carrera") {
|
||||
if (!createForm.facultad_id || !createForm.carrera_id) throw new Error("Selecciona facultad y carrera")
|
||||
await upsertNombramiento({
|
||||
user_id: uid, puesto: "jefe_carrera",
|
||||
facultad_id: createForm.facultad_id, carrera_id: createForm.carrera_id
|
||||
})
|
||||
if (uid && payload.role && (SCOPED_ROLES as readonly string[]).includes(payload.role)) {
|
||||
if (payload.role === "director_facultad") {
|
||||
if (!payload.facultad_id) throw new Error("Selecciona facultad")
|
||||
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "director_facultad", facultad_id: payload.facultad_id })
|
||||
} else if (payload.role === "secretario_academico") {
|
||||
if (!payload.facultad_id) throw new Error("Selecciona facultad")
|
||||
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "secretario_academico", facultad_id: payload.facultad_id })
|
||||
} else if (payload.role === "jefe_carrera") {
|
||||
if (!payload.facultad_id || !payload.carrera_id) throw new Error("Selecciona facultad y carrera")
|
||||
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "jefe_carrera", facultad_id: payload.facultad_id, carrera_id: payload.carrera_id })
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success("Usuario creado")
|
||||
setCreateOpen(false)
|
||||
setCreateForm({ email: "", password: "" })
|
||||
router.invalidate()
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(e?.message || "No se pudo crear el usuario")
|
||||
} finally {
|
||||
setCreateSaving(false)
|
||||
}
|
||||
}
|
||||
await invalidateAll()
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || "No se pudo crear el usuario"),
|
||||
})
|
||||
|
||||
const saveUser = useMutation({
|
||||
mutationFn: async ({ u, f }: { u: AdminUser; f: typeof form }) => {
|
||||
// 1) Actualiza metadatos (tu Edge Function; placeholder aquí)
|
||||
// await fetch('/functions/update-user', { method: 'POST', body: JSON.stringify({ id: u.id, ...f }) })
|
||||
// Simula éxito:
|
||||
// 2) Nombramiento si aplica
|
||||
if (f.role && (SCOPED_ROLES as readonly string[]).includes(f.role)) {
|
||||
if (f.role === "director_facultad") {
|
||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "director_facultad", facultad_id: f.facultad_id! })
|
||||
} else if (f.role === "secretario_academico") {
|
||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "secretario_academico", facultad_id: f.facultad_id! })
|
||||
} else if (f.role === "jefe_carrera") {
|
||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "jefe_carrera", facultad_id: f.facultad_id!, carrera_id: f.carrera_id! })
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success("Cambios guardados")
|
||||
setEditing(null)
|
||||
await invalidateAll()
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || "No se pudo guardar"),
|
||||
})
|
||||
|
||||
if (!auth.claims?.claims_admin) {
|
||||
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
|
||||
@@ -263,12 +258,12 @@ function RouteComponent() {
|
||||
const filtered = useMemo(() => {
|
||||
const t = q.trim().toLowerCase()
|
||||
if (!t) return data
|
||||
return data.filter(u => {
|
||||
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))
|
||||
.some((v) => String(v).toLowerCase().includes(t))
|
||||
})
|
||||
}, [q, data])
|
||||
|
||||
@@ -287,7 +282,6 @@ function RouteComponent() {
|
||||
})
|
||||
}
|
||||
|
||||
// NEW: validación de scope por rol antes de guardar
|
||||
function validateScopeForSave(): string | null {
|
||||
if (!editing) return "Sin usuario"
|
||||
if (form.role === "director_facultad" || form.role === "secretario_academico") {
|
||||
@@ -299,58 +293,14 @@ function RouteComponent() {
|
||||
return null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing) return
|
||||
const scopeErr = validateScopeForSave()
|
||||
if (scopeErr) { toast.error(scopeErr); return }
|
||||
|
||||
setSaving(true)
|
||||
// 1) Actualiza metadatos via Edge Function existente (mantengo tu flujo)
|
||||
const error = true;
|
||||
if (error) {
|
||||
setSaving(false)
|
||||
console.error(error)
|
||||
toast.error("No se pudo guardar")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 2) NEW: si es rol jerárquico => upsert nombramiento (dispara exclusividad + ban via triggers)
|
||||
if (form.role && (SCOPED_ROLES as readonly string[]).includes(form.role)) {
|
||||
if (form.role === "director_facultad") {
|
||||
await upsertNombramiento({ user_id: editing.id, puesto: "director_facultad", facultad_id: form.facultad_id! })
|
||||
} else if (form.role === "secretario_academico") {
|
||||
await upsertNombramiento({ user_id: editing.id, puesto: "secretario_academico", facultad_id: form.facultad_id! })
|
||||
} else if (form.role === "jefe_carrera") {
|
||||
await upsertNombramiento({
|
||||
user_id: editing.id, puesto: "jefe_carrera",
|
||||
facultad_id: form.facultad_id!, carrera_id: form.carrera_id!
|
||||
})
|
||||
}
|
||||
}
|
||||
toast.success("Cambios guardados")
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(e?.message || "Error al registrar nombramiento")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
router.invalidate(); setEditing(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="grid gap-3 sm:grid-cols-2 sm:items-center">
|
||||
<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-full"
|
||||
/>
|
||||
<Button variant="outline" size="icon" title="Recargar" onClick={() => router.invalidate()}>
|
||||
<Input placeholder="Buscar por nombre, email o rol…" value={q} onChange={(e) => setQ(e.target.value)} className="w-full" />
|
||||
<Button variant="outline" size="icon" title="Recargar" onClick={invalidateAll}>
|
||||
<RefreshCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
|
||||
@@ -361,94 +311,55 @@ function RouteComponent() {
|
||||
|
||||
<CardContent>
|
||||
<div className="grid gap-3">
|
||||
{filtered.map(u => {
|
||||
{filtered.map((u) => {
|
||||
const m = u.user_metadata || {}
|
||||
const a = u.app_metadata || {}
|
||||
const roleCode: Role | undefined = a.role
|
||||
const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now() // NEW
|
||||
const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
|
||||
return (
|
||||
<div key={u.id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 sm:p-4 hover:shadow-sm transition">
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<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"
|
||||
/>
|
||||
<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-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
|
||||
</div>
|
||||
<div className="font-medium truncate">{m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
|
||||
{roleCode && <RolePill role={roleCode} />}
|
||||
{a.claims_admin ? (
|
||||
<Badge className="gap-1" variant="secondary">
|
||||
<ShieldCheck className="w-3 h-3" /> Admin
|
||||
</Badge>
|
||||
<Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Admin</Badge>
|
||||
) : (
|
||||
<Badge className="gap-1" variant="outline">
|
||||
<ShieldAlert className="w-3 h-3" /> Usuario
|
||||
</Badge>
|
||||
<Badge className="gap-1" variant="outline"><ShieldAlert className="w-3 h-3" /> Usuario</Badge>
|
||||
)}
|
||||
{/* NEW: estado ban */}
|
||||
<Badge variant={banned ? "destructive" as any : "secondary"} className="gap-1">
|
||||
<Badge variant={banned ? ("destructive" as any) : "secondary"} className="gap-1">
|
||||
{banned ? <BanIcon className="w-3 h-3" /> : <Check className="w-3 h-3" />} {banned ? "Baneado" : "Activo"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* NEW: toggle ban/unban */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleBan(u)}
|
||||
title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
|
||||
className="hidden sm:inline-flex"
|
||||
>
|
||||
<BanIcon className="w-4 h-4 mr-1" />
|
||||
{banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
|
||||
<Button variant="outline" size="sm" onClick={() => toggleBan.mutate(u)} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
|
||||
<BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden sm:inline-flex shrink-0"
|
||||
onClick={() => openEdit(u)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
|
||||
<Pencil className="w-4 h-4 mr-1" /> Editar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] sm:text-xs text-neutral-600">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Mail className="w-3 h-3" /> {u.email ?? "—"}
|
||||
</span>
|
||||
<span className="hidden xs:inline">
|
||||
Creado: {new Date(u.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="hidden md:inline">
|
||||
Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1"><Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
|
||||
<span className="hidden xs:inline">Creado: {new Date(u.created_at).toLocaleDateString()}</span>
|
||||
<span className="hidden md:inline">Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile actions */}
|
||||
<div className="sm:hidden self-start shrink-0 flex gap-1">
|
||||
<Button variant="outline" size="icon" onClick={() => toggleBan(u)} aria-label="Ban/Unban">
|
||||
<BanIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar">
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><BanIcon className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Pencil className="w-4 h-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!filtered.length && (
|
||||
<div className="text-sm text-neutral-500 text-center py-10">Sin usuarios</div>
|
||||
)}
|
||||
{!filtered.length && <div className="text-sm text-neutral-500 text-center py-10">Sin usuarios</div>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -458,60 +369,31 @@ function RouteComponent() {
|
||||
<DialogContent className="w-[min(92vw,720px)] sm:max-w-xl md: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>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 => {
|
||||
setForm((s) => {
|
||||
const role = v as Role
|
||||
if (role === "jefe_carrera") {
|
||||
return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
|
||||
}
|
||||
if (role === "secretario_academico" || role === "director_facultad") {
|
||||
return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
|
||||
}
|
||||
if (role === "jefe_carrera") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
|
||||
if (role === "secretario_academico" || role === "director_facultad") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
|
||||
return { ...s, role, facultad_id: null, carrera_id: null }
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecciona un rol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-[--radix-select-trigger-width] max-w-[min(92vw,28rem)] max-h-72 overflow-auto"
|
||||
>
|
||||
{ROLES.map(code => {
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="Selecciona un rol" /></SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" align="start" className="w-[--radix-select-trigger-width] max-w-[min(92vw,28rem)] 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>
|
||||
<span className="inline-flex items-center gap-2"><Icon className="w-4 h-4" /> {meta.label}</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
@@ -519,43 +401,30 @@ function RouteComponent() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* DIRECTOR/SECRETARIO: facultad */}
|
||||
{(form.role === "secretario_academico" || form.role === "director_facultad") && (
|
||||
<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 }))}
|
||||
/>
|
||||
<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 requiere <strong>Facultad</strong>.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JEFE DE CARRERA: ambos */}
|
||||
{form.role === "jefe_carrera" && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 md:col-span-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={form.facultad_id ?? ""}
|
||||
onChange={(id) => setForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
<CarreraCombobox facultadId={form.facultad_id ?? ""} value={form.carrera_id ?? ""} onChange={(id) => setForm((s) => ({ ...s, carrera_id: id }))} disabled={!form.facultad_id} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Permisos</Label>
|
||||
<Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm(s => ({ ...s, claims_admin: v === 'true' }))}>
|
||||
<Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm((s) => ({ ...s, claims_admin: v === 'true' }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Administrador</SelectItem>
|
||||
@@ -566,7 +435,16 @@ function RouteComponent() {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const scopeErr = validateScopeForSave()
|
||||
if (scopeErr || !editing) { toast.error(scopeErr || 'Sin usuario'); return }
|
||||
saveUser.mutate({ u: editing, f: form })
|
||||
}}
|
||||
disabled={saveUser.isPending}
|
||||
>
|
||||
{saveUser.isPending ? "Guardando…" : "Guardar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -579,60 +457,31 @@ function RouteComponent() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Correo</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={(e) => setCreateForm(s => ({ ...s, email: e.target.value }))}
|
||||
placeholder="usuario@lasalle.mx"
|
||||
/>
|
||||
<Input type="email" value={createForm.email} onChange={(e) => setCreateForm((s) => ({ ...s, email: e.target.value }))} placeholder="usuario@lasalle.mx" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Contraseña temporal</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type={showPwd ? "text" : "password"}
|
||||
value={createForm.password}
|
||||
onChange={(e) => setCreateForm(s => ({ ...s, password: e.target.value }))}
|
||||
placeholder="Se generará si la dejas vacía"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateForm(s => ({ ...s, password: genPassword() }))}>
|
||||
Generar
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setShowPwd(v => !v)} aria-label="Mostrar u ocultar">
|
||||
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Input type={showPwd ? "text" : "password"} value={createForm.password} onChange={(e) => setCreateForm((s) => ({ ...s, password: e.target.value }))} placeholder="Se generará si la dejas vacía" />
|
||||
<Button type="button" variant="outline" onClick={() => setCreateForm((s) => ({ ...s, password: genPassword() }))}>Generar</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setShowPwd((v) => !v)} aria-label="Mostrar u ocultar">{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Nombre</Label>
|
||||
<Input value={createForm.nombre ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, nombre: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Apellidos</Label>
|
||||
<Input value={createForm.apellidos ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, apellidos: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Título</Label>
|
||||
<Input value={createForm.title ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, title: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Clave</Label>
|
||||
<Input value={createForm.clave ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, clave: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Avatar (URL)</Label>
|
||||
<Input value={createForm.avatar ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, avatar: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1"><Label>Nombre</Label><Input value={createForm.nombre ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, nombre: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Apellidos</Label><Input value={createForm.apellidos ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, apellidos: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Título</Label><Input value={createForm.title ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, title: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Clave</Label><Input value={createForm.clave ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, clave: e.target.value }))} /></div>
|
||||
<div className="space-y-1 md:col-span-2"><Label>Avatar (URL)</Label><Input value={createForm.avatar ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, avatar: e.target.value }))} /></div>
|
||||
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Rol</Label>
|
||||
<Select
|
||||
value={createForm.role ?? ""}
|
||||
onValueChange={(v) => {
|
||||
setCreateForm(s => {
|
||||
setCreateForm((s) => {
|
||||
const role = v as Role
|
||||
if (role === "jefe_carrera") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
|
||||
if (role === "secretario_academico" || role === "director_facultad") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
|
||||
@@ -642,7 +491,7 @@ function RouteComponent() {
|
||||
>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="Selecciona un rol" /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{ROLES.map(code => {
|
||||
{ROLES.map((code) => {
|
||||
const M = ROLE_META[code]; const I = M.Icon
|
||||
return (
|
||||
<SelectItem key={code} value={code} className="whitespace-normal text-sm leading-snug py-2">
|
||||
@@ -657,10 +506,7 @@ function RouteComponent() {
|
||||
{(createForm.role === "secretario_academico" || createForm.role === "director_facultad") && (
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={createForm.facultad_id ?? ""}
|
||||
onChange={(id) => setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
|
||||
/>
|
||||
<FacultadCombobox value={createForm.facultad_id ?? ""} onChange={(id) => setCreateForm((s) => ({ ...s, facultad_id: id, carrera_id: null }))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -668,26 +514,18 @@ function RouteComponent() {
|
||||
<div className="grid gap-4 md:col-span-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={createForm.facultad_id ?? ""}
|
||||
onChange={(id) => setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
|
||||
/>
|
||||
<FacultadCombobox value={createForm.facultad_id ?? ""} onChange={(id) => setCreateForm((s) => ({ ...s, facultad_id: id, carrera_id: "" }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Carrera</Label>
|
||||
<CarreraCombobox
|
||||
facultadId={createForm.facultad_id ?? ""}
|
||||
value={createForm.carrera_id ?? ""}
|
||||
onChange={(id) => setCreateForm(s => ({ ...s, carrera_id: id }))}
|
||||
disabled={!createForm.facultad_id}
|
||||
/>
|
||||
<CarreraCombobox facultadId={createForm.facultad_id ?? ""} value={createForm.carrera_id ?? ""} onChange={(id) => setCreateForm((s) => ({ ...s, carrera_id: id }))} disabled={!createForm.facultad_id} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Permisos</Label>
|
||||
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm(s => ({ ...s, claims_admin: v === "true" }))}>
|
||||
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm((s) => ({ ...s, claims_admin: v === "true" }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Administrador</SelectItem>
|
||||
@@ -699,13 +537,12 @@ function RouteComponent() {
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={createUserNow} disabled={!createForm.email || createSaving}>
|
||||
{createSaving ? "Creando…" : "Crear usuario"}
|
||||
<Button onClick={() => createUser.mutate(createForm)} disabled={!createForm.email || createUser.isPending}>
|
||||
{createUser.isPending ? "Creando…" : "Crear usuario"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user