Some checks are pending
/ Deploy to Cloudflare Pages (push) Waiting to run
- Crea RequireRole component (Approach B — router sin auth context)
- AdminLayout: elimina useEffect + spinner + null check, usa <RequireRole roles={['platform_admin']}>
- ImportPage: elimina useEffect guard + return null client_viewer, usa <RequireRole roles={['platform_admin','member','client_editor']}>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
633 lines
24 KiB
TypeScript
633 lines
24 KiB
TypeScript
/**
|
|
* Limitaciones conocidas:
|
|
* - "Reactivar" restaura siempre a 'client_editor' — no se guarda el rol previo a la suspensión.
|
|
*/
|
|
import { useEffect, useState } from 'react'
|
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
import { UserX, UserPlus, MoreVertical, Trash2 } from 'lucide-react'
|
|
import { supabase } from '@/lib/supabase'
|
|
import { useAuth } from '@/auth/AuthContext'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetFooter,
|
|
} from '@/components/ui/sheet'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { RoleBadge } from './RoleBadge'
|
|
|
|
interface AdminUser {
|
|
id: string
|
|
name: string | null
|
|
company: string | null
|
|
platform_role: string
|
|
org_id: string | null
|
|
avatar_url: string | null
|
|
organization: { name: string; is_provider: boolean } | null
|
|
email?: string | null
|
|
}
|
|
|
|
interface OrgOption {
|
|
id: string
|
|
name: string
|
|
}
|
|
|
|
// ─── ConfirmDialog usando @radix-ui/react-dialog (sin @radix-ui/react-alert-dialog) ──
|
|
|
|
interface ConfirmDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
title: string
|
|
description: string
|
|
confirmLabel?: string
|
|
onConfirm: () => void
|
|
loading?: boolean
|
|
destructive?: boolean
|
|
}
|
|
|
|
function ConfirmDialog({
|
|
open, onOpenChange, title, description,
|
|
confirmLabel = 'Confirmar',
|
|
onConfirm, loading = false, destructive = false,
|
|
}: ConfirmDialogProps) {
|
|
return (
|
|
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
|
<DialogPrimitive.Portal>
|
|
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
|
<DialogPrimitive.Content
|
|
className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-xl shadow-xl p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
|
onInteractOutside={(e) => e.preventDefault()}
|
|
>
|
|
<DialogPrimitive.Title className="text-base font-semibold text-slate-900">
|
|
{title}
|
|
</DialogPrimitive.Title>
|
|
<DialogPrimitive.Description className="mt-2 text-sm text-slate-600">
|
|
{description}
|
|
</DialogPrimitive.Description>
|
|
<div className="flex justify-end gap-2 mt-6">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={loading}
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={destructive ? 'destructive' : 'default'}
|
|
onClick={onConfirm}
|
|
disabled={loading}
|
|
>
|
|
{loading ? 'Procesando…' : confirmLabel}
|
|
</Button>
|
|
</div>
|
|
</DialogPrimitive.Content>
|
|
</DialogPrimitive.Portal>
|
|
</DialogPrimitive.Root>
|
|
)
|
|
}
|
|
|
|
// ─── Página principal ─────────────────────────────────────────────────────────
|
|
|
|
const INVITE_ROLES = ['client_editor', 'client_viewer', 'member'] as const
|
|
type InviteRole = typeof INVITE_ROLES[number]
|
|
|
|
const CHANGE_ROLES = ['platform_admin', 'member', 'client_editor', 'client_viewer', 'guest'] as const
|
|
|
|
export function UsersPage() {
|
|
const { user: adminUser } = useAuth()
|
|
const { toast } = useToast()
|
|
|
|
const [users, setUsers] = useState<AdminUser[]>([])
|
|
const [orgs, setOrgs] = useState<OrgOption[]>([])
|
|
const [loadingUsers, setLoadingUsers] = useState(true)
|
|
const [filterOrgId, setFilterOrgId] = useState<string>('all')
|
|
|
|
// Invite sheet
|
|
const [inviteOpen, setInviteOpen] = useState(false)
|
|
const [inviteName, setInviteName] = useState('')
|
|
const [inviteEmail, setInviteEmail] = useState('')
|
|
const [inviteRole, setInviteRole] = useState<InviteRole>('client_editor')
|
|
const [inviteOrgName, setInviteOrgName] = useState('')
|
|
const [inviteError, setInviteError] = useState<string | null>(null)
|
|
const [inviteSuccess, setInviteSuccess] = useState<string | null>(null)
|
|
const [inviting, setInviting] = useState(false)
|
|
|
|
// Change role sheet
|
|
const [changeRoleUser, setChangeRoleUser] = useState<AdminUser | null>(null)
|
|
const [changeRoleOpen, setChangeRoleOpen] = useState(false)
|
|
const [newRole, setNewRole] = useState<string>('')
|
|
const [changingRole, setChangingRole] = useState(false)
|
|
const [changeRoleError, setChangeRoleError] = useState<string | null>(null)
|
|
|
|
// Confirmations
|
|
const [suspendTarget, setSuspendTarget] = useState<AdminUser | null>(null)
|
|
const [suspending, setSuspending] = useState(false)
|
|
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
|
|
const [deleting, setDeleting] = useState(false)
|
|
const [reactivateTarget, setReactivateTarget] = useState<AdminUser | null>(null)
|
|
const [reactivating, setReactivating] = useState(false)
|
|
|
|
const fetchUsers = async () => {
|
|
const query = supabase
|
|
.from('users')
|
|
.select('id, name, company, platform_role, org_id, avatar_url, organization:organizations(name, is_provider)')
|
|
.order('name')
|
|
const finalQuery = filterOrgId !== 'all'
|
|
? query.eq('org_id', filterOrgId)
|
|
: query
|
|
|
|
const [{ data }, { data: emailData }] = await Promise.all([
|
|
finalQuery,
|
|
supabase.functions.invoke('list-users'),
|
|
])
|
|
|
|
const emailMap = new Map<string, string | null>()
|
|
if (emailData) {
|
|
for (const u of (emailData as Array<{ id: string; email: string | null }>) ?? []) {
|
|
emailMap.set(u.id, u.email ?? null)
|
|
}
|
|
}
|
|
|
|
const usersWithEmail = ((data as AdminUser[] | null) ?? []).map((u) => ({
|
|
...u,
|
|
email: emailMap.get(u.id) ?? null,
|
|
}))
|
|
|
|
setUsers(usersWithEmail)
|
|
setLoadingUsers(false)
|
|
}
|
|
|
|
const fetchOrgs = async () => {
|
|
const { data } = await supabase
|
|
.from('organizations')
|
|
.select('id, name')
|
|
.order('name')
|
|
setOrgs((data as OrgOption[] | null) ?? [])
|
|
}
|
|
|
|
useEffect(() => {
|
|
void fetchOrgs()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
setLoadingUsers(true)
|
|
void fetchUsers()
|
|
}, [filterOrgId]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// ── Invite user ──────────────────────────────────────────────────────────
|
|
|
|
const openInvite = () => {
|
|
setInviteName('')
|
|
setInviteEmail('')
|
|
setInviteRole('client_editor')
|
|
setInviteOrgName('')
|
|
setInviteError(null)
|
|
setInviteSuccess(null)
|
|
setInviteOpen(true)
|
|
}
|
|
|
|
const handleInvite = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
setInviteError(null)
|
|
setInviteSuccess(null)
|
|
if (!inviteName.trim()) { setInviteError('El nombre es requerido'); return }
|
|
if (!inviteEmail.trim() || !inviteEmail.includes('@')) { setInviteError('Email inválido'); return }
|
|
if ((inviteRole === 'client_editor' || inviteRole === 'client_viewer') && !inviteOrgName.trim()) {
|
|
setInviteError('La organización es requerida para este rol')
|
|
return
|
|
}
|
|
setInviting(true)
|
|
const { error } = await supabase.functions.invoke('invite-user', {
|
|
body: {
|
|
email: inviteEmail.trim(),
|
|
name: inviteName.trim(),
|
|
platform_role: inviteRole,
|
|
org_name: inviteRole === 'member' ? 'InQuality' : inviteOrgName.trim(),
|
|
},
|
|
})
|
|
if (error) {
|
|
setInviteError(typeof error === 'object' && 'message' in error ? String(error.message) : String(error))
|
|
setInviting(false)
|
|
} else {
|
|
setInviteSuccess(`Invitación enviada a ${inviteEmail.trim()}`)
|
|
setInviting(false)
|
|
void fetchUsers()
|
|
}
|
|
}
|
|
|
|
// ── Change role ──────────────────────────────────────────────────────────
|
|
|
|
const openChangeRole = (u: AdminUser) => {
|
|
setChangeRoleUser(u)
|
|
setNewRole(u.platform_role)
|
|
setChangeRoleError(null)
|
|
setChangeRoleOpen(true)
|
|
}
|
|
|
|
const handleChangeRole = async () => {
|
|
if (!changeRoleUser || !newRole) return
|
|
setChangingRole(true)
|
|
const { error } = await supabase
|
|
.from('users')
|
|
.update({ platform_role: newRole })
|
|
.eq('id', changeRoleUser.id)
|
|
if (error) {
|
|
setChangeRoleError(error.message)
|
|
setChangingRole(false)
|
|
} else {
|
|
setChangeRoleOpen(false)
|
|
setChangingRole(false)
|
|
void fetchUsers()
|
|
}
|
|
}
|
|
|
|
// ── Suspend (revoke-user vía Edge Function) ───────────────────────────────
|
|
|
|
const handleSuspend = async () => {
|
|
if (!suspendTarget) return
|
|
setSuspending(true)
|
|
const { error } = await supabase.functions.invoke('revoke-user', {
|
|
body: { userId: suspendTarget.id },
|
|
})
|
|
if (error) {
|
|
console.error('Error al suspender usuario:', error)
|
|
}
|
|
setSuspendTarget(null)
|
|
setSuspending(false)
|
|
void fetchUsers()
|
|
}
|
|
|
|
// ── Hard delete vía Edge Function delete-user ─────────────────────────────
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteTarget) return
|
|
setDeleting(true)
|
|
const { error } = await supabase.functions.invoke('delete-user', {
|
|
body: { userId: deleteTarget.id },
|
|
})
|
|
if (error) {
|
|
toast({
|
|
title: 'Error al eliminar usuario',
|
|
description: typeof error === 'object' && 'message' in error
|
|
? String(error.message)
|
|
: 'Ocurrió un error inesperado.',
|
|
variant: 'destructive',
|
|
})
|
|
} else {
|
|
toast({ title: 'Usuario eliminado permanentemente' })
|
|
}
|
|
setDeleteTarget(null)
|
|
setDeleting(false)
|
|
void fetchUsers()
|
|
}
|
|
|
|
// ── Reactivate (restore to client_editor — rol previo no se guarda) ───────
|
|
|
|
const handleReactivate = async () => {
|
|
if (!reactivateTarget) return
|
|
setReactivating(true)
|
|
await supabase
|
|
.from('users')
|
|
.update({ platform_role: 'client_editor' })
|
|
.eq('id', reactivateTarget.id)
|
|
setReactivateTarget(null)
|
|
setReactivating(false)
|
|
void fetchUsers()
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
const displayedUsers = users
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center justify-between mb-5">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-lg font-semibold text-slate-900">Usuarios</h2>
|
|
<Select value={filterOrgId} onValueChange={setFilterOrgId}>
|
|
<SelectTrigger className="w-48 h-8 text-xs">
|
|
<SelectValue placeholder="Filtrar por org" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Todas las organizaciones</SelectItem>
|
|
{orgs.map((o) => (
|
|
<SelectItem key={o.id} value={o.id}>{o.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button size="sm" onClick={openInvite}>
|
|
<UserPlus className="h-4 w-4 mr-1.5" />
|
|
Invitar usuario
|
|
</Button>
|
|
</div>
|
|
|
|
{loadingUsers ? (
|
|
<TableSkeleton rows={6} />
|
|
) : displayedUsers.length === 0 ? (
|
|
<EmptyState
|
|
icon={<UserX className="h-10 w-10 text-slate-300" />}
|
|
message={
|
|
filterOrgId !== 'all'
|
|
? 'No hay usuarios en esta organización.'
|
|
: 'No hay usuarios registrados aún.'
|
|
}
|
|
/>
|
|
) : (
|
|
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-slate-50 border-b border-slate-200">
|
|
<tr>
|
|
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Nombre</th>
|
|
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Email</th>
|
|
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Rol</th>
|
|
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Organización</th>
|
|
<th className="py-2.5 px-4" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{displayedUsers.map((u) => {
|
|
const isSelf = adminUser?.id === u.id
|
|
const isPending = !u.name && !u.avatar_url
|
|
|
|
return (
|
|
<tr key={u.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
|
|
<td className="py-3 px-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`font-medium ${u.platform_role === 'guest' ? 'text-slate-400' : 'text-slate-900'}`}>
|
|
{u.name || <span className="italic text-slate-400">Sin nombre</span>}
|
|
</span>
|
|
{isPending && (
|
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
|
|
Invitación pendiente
|
|
</span>
|
|
)}
|
|
{isSelf && (
|
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-slate-100 text-slate-500">
|
|
Tú
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-4 text-slate-500 text-xs font-mono">
|
|
{u.email ?? '—'}
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<RoleBadge role={u.platform_role} />
|
|
</td>
|
|
<td className="py-3 px-4 text-slate-500">
|
|
{u.organization?.name ?? '—'}
|
|
</td>
|
|
<td className="py-3 px-4 text-right">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
disabled={isSelf}
|
|
title={isSelf ? 'No puedes modificar tu propio usuario' : undefined}
|
|
>
|
|
<MoreVertical className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => openChangeRole(u)}>
|
|
Cambiar rol
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
{u.platform_role === 'guest' ? (
|
|
<DropdownMenuItem onClick={() => setReactivateTarget(u)}>
|
|
Reactivar usuario
|
|
</DropdownMenuItem>
|
|
) : (
|
|
<DropdownMenuItem
|
|
className="text-amber-600 focus:text-amber-600"
|
|
onClick={() => setSuspendTarget(u)}
|
|
>
|
|
Suspender
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem
|
|
className="text-destructive focus:text-destructive"
|
|
onClick={() => setDeleteTarget(u)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
|
Eliminar permanentemente
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── Sheet: Invitar usuario ──────────────────────────────────────── */}
|
|
<Sheet open={inviteOpen} onOpenChange={setInviteOpen}>
|
|
<SheetContent>
|
|
<SheetHeader>
|
|
<SheetTitle>Invitar usuario</SheetTitle>
|
|
</SheetHeader>
|
|
<form onSubmit={handleInvite} className="mt-5 flex flex-col gap-4">
|
|
<Field label="Nombre completo" id="invite-name">
|
|
<Input
|
|
id="invite-name"
|
|
value={inviteName}
|
|
onChange={(e) => setInviteName(e.target.value)}
|
|
placeholder="Ej. Ana Martínez"
|
|
disabled={inviting}
|
|
autoFocus
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Email" id="invite-email">
|
|
<Input
|
|
id="invite-email"
|
|
type="email"
|
|
value={inviteEmail}
|
|
onChange={(e) => setInviteEmail(e.target.value)}
|
|
placeholder="ana@empresa.com"
|
|
disabled={inviting}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Rol" id="invite-role">
|
|
<Select
|
|
value={inviteRole}
|
|
onValueChange={(v) => setInviteRole(v as InviteRole)}
|
|
disabled={inviting}
|
|
>
|
|
<SelectTrigger id="invite-role">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="client_editor">Client Editor</SelectItem>
|
|
<SelectItem value="client_viewer">Client Viewer</SelectItem>
|
|
<SelectItem value="member">Member (InQuality)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
|
|
{inviteRole === 'member' ? (
|
|
<Field label="Organización" id="invite-org-member">
|
|
<Input id="invite-org-member" value="InQuality" disabled />
|
|
</Field>
|
|
) : (
|
|
<Field label="Organización" id="invite-org">
|
|
<Input
|
|
id="invite-org"
|
|
list="orgs-datalist"
|
|
value={inviteOrgName}
|
|
onChange={(e) => setInviteOrgName(e.target.value)}
|
|
placeholder="Nombre de la organización"
|
|
disabled={inviting}
|
|
/>
|
|
<datalist id="orgs-datalist">
|
|
{orgs.map((o) => (
|
|
<option key={o.id} value={o.name} />
|
|
))}
|
|
</datalist>
|
|
</Field>
|
|
)}
|
|
|
|
{inviteError && <p className="text-xs text-red-600">{inviteError}</p>}
|
|
{inviteSuccess && <p className="text-xs text-emerald-600 font-medium">{inviteSuccess}</p>}
|
|
|
|
<SheetFooter className="mt-2">
|
|
<Button type="submit" disabled={inviting}>
|
|
{inviting ? 'Enviando invitación…' : 'Enviar invitación'}
|
|
</Button>
|
|
</SheetFooter>
|
|
</form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{/* ─── Sheet: Cambiar rol ──────────────────────────────────────────── */}
|
|
<Sheet open={changeRoleOpen} onOpenChange={setChangeRoleOpen}>
|
|
<SheetContent>
|
|
<SheetHeader>
|
|
<SheetTitle>Cambiar rol — {changeRoleUser?.name}</SheetTitle>
|
|
</SheetHeader>
|
|
<div className="mt-5 flex flex-col gap-4">
|
|
<Field label="Nuevo rol" id="change-role-select">
|
|
<Select value={newRole} onValueChange={setNewRole} disabled={changingRole}>
|
|
<SelectTrigger id="change-role-select">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CHANGE_ROLES.map((r) => (
|
|
<SelectItem key={r} value={r}>{r}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
{changeRoleError && <p className="text-xs text-red-600">{changeRoleError}</p>}
|
|
<SheetFooter className="mt-2">
|
|
<Button onClick={handleChangeRole} disabled={changingRole}>
|
|
{changingRole ? 'Guardando…' : 'Guardar rol'}
|
|
</Button>
|
|
</SheetFooter>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{/* ─── ConfirmDialog: Suspender ────────────────────────────────────── */}
|
|
<ConfirmDialog
|
|
open={!!suspendTarget}
|
|
onOpenChange={(open) => !open && setSuspendTarget(null)}
|
|
title={`¿Suspender a ${suspendTarget?.name ?? 'este usuario'}?`}
|
|
description="No podrá acceder a la plataforma mientras esté suspendido. Podés reactivarlo después."
|
|
confirmLabel="Suspender"
|
|
onConfirm={handleSuspend}
|
|
loading={suspending}
|
|
destructive
|
|
/>
|
|
|
|
{/* ─── ConfirmDialog: Eliminar permanentemente ────────────────────── */}
|
|
<ConfirmDialog
|
|
open={!!deleteTarget}
|
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
|
title={`¿Eliminar permanentemente a ${deleteTarget?.name ?? 'este usuario'}?`}
|
|
description="Esta acción no puede deshacerse. El usuario perderá acceso inmediatamente y sus datos de autenticación serán eliminados de forma permanente."
|
|
confirmLabel="Eliminar permanentemente"
|
|
onConfirm={handleDelete}
|
|
loading={deleting}
|
|
destructive
|
|
/>
|
|
|
|
{/* ─── ConfirmDialog: Reactivar ────────────────────────────────────── */}
|
|
<ConfirmDialog
|
|
open={!!reactivateTarget}
|
|
onOpenChange={(open) => !open && setReactivateTarget(null)}
|
|
title={`¿Reactivar a ${reactivateTarget?.name ?? 'este usuario'}?`}
|
|
description="Se le asignará el rol Client Editor. (Nota: el rol original no se conservó al momento de la suspensión.)"
|
|
confirmLabel="Reactivar"
|
|
onConfirm={handleReactivate}
|
|
loading={reactivating}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function Field({ label, id, children }: { label: string; id: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor={id}>{label}</Label>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TableSkeleton({ rows }: { rows: number }) {
|
|
return (
|
|
<div className="rounded-lg border border-slate-200 overflow-hidden">
|
|
<div className="bg-slate-50 border-b border-slate-200 h-10" />
|
|
<div className="flex flex-col divide-y divide-slate-100">
|
|
{Array.from({ length: rows }).map((_, i) => (
|
|
<div key={i} className="flex gap-4 px-4 py-3 bg-white items-center">
|
|
<Skeleton className="h-4 flex-1" />
|
|
<Skeleton className="h-4 w-40" />
|
|
<Skeleton className="h-5 w-20 rounded" />
|
|
<Skeleton className="h-4 w-28" />
|
|
<Skeleton className="h-6 w-6 rounded" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
|
{icon}
|
|
<p className="text-sm text-slate-500 max-w-xs">{message}</p>
|
|
</div>
|
|
)
|
|
}
|