style: padroniza layout da listagem de usuários com design premium
This commit is contained in:
parent
326644f22f
commit
ffb055f6a0
1 changed files with 237 additions and 453 deletions
|
|
@ -18,16 +18,30 @@ import {
|
|||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Plus, Search, Trash2, Loader2, RefreshCw, Pencil, Eye, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Pencil,
|
||||
Eye,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Users,
|
||||
ShieldAlert,
|
||||
UserCheck,
|
||||
Clock
|
||||
} from "lucide-react"
|
||||
import { usersApi, adminCompaniesApi, type ApiUser, type AdminCompany } from "@/lib/api"
|
||||
import { getCurrentUser, isAdminUser } from "@/lib/auth"
|
||||
import { toast } from "sonner"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useTranslation } from "@/lib/i18n"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
const userDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
const userDateFormatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
dateStyle: "medium",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
|
|
@ -39,12 +53,10 @@ export default function AdminUsersPage() {
|
|||
const [page, setPage] = useState(1)
|
||||
const [totalUsers, setTotalUsers] = useState(0)
|
||||
|
||||
// Dialog States
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
|
||||
// Action States
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
|
@ -54,7 +66,6 @@ export default function AdminUsersPage() {
|
|||
const [companies, setCompanies] = useState<AdminCompany[]>([])
|
||||
const [currentUser, setCurrentUser] = useState<ApiUser | null>(null)
|
||||
|
||||
// Form Data
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
|
|
@ -68,7 +79,7 @@ export default function AdminUsersPage() {
|
|||
email: "",
|
||||
role: "",
|
||||
status: "",
|
||||
password: "", // Optional for edits
|
||||
password: "",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -79,7 +90,6 @@ export default function AdminUsersPage() {
|
|||
}
|
||||
setCurrentUser(user as ApiUser)
|
||||
loadUsers()
|
||||
|
||||
if (user?.role === 'superadmin') {
|
||||
loadCompanies()
|
||||
}
|
||||
|
|
@ -89,58 +99,43 @@ export default function AdminUsersPage() {
|
|||
try {
|
||||
const data = await adminCompaniesApi.list(undefined, 1, 100)
|
||||
setCompanies(data.data || [])
|
||||
} catch (error) {
|
||||
console.error("Error loading companies:", error)
|
||||
}
|
||||
} catch (error) { }
|
||||
}
|
||||
|
||||
const limit = 10
|
||||
const totalPages = Math.max(1, Math.ceil(totalUsers / limit))
|
||||
|
||||
const loadUsers = async (targetPage = page) => {
|
||||
console.log(`[USER_FLOW] Loading users page: ${targetPage}`)
|
||||
const pageNum = typeof targetPage === 'number' ? targetPage : page
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await usersApi.list({ page: targetPage, limit })
|
||||
console.log("[USER_FLOW] Users loaded:", data)
|
||||
const data = await usersApi.list({ page: pageNum, limit })
|
||||
setUsers(data?.data || [])
|
||||
setTotalUsers(data?.pagination?.total || 0)
|
||||
setPage(data?.pagination?.page || targetPage)
|
||||
setPage(data?.pagination?.page || pageNum)
|
||||
} catch (error) {
|
||||
console.error("[USER_FLOW] Error loading users:", error)
|
||||
toast.error(t('admin.users.messages.load_error'))
|
||||
toast.error("Erro ao carregar usuários")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
console.log("[USER_FLOW] Creating user with data:", formData)
|
||||
try {
|
||||
setCreating(true)
|
||||
const payload = {
|
||||
...formData,
|
||||
roles: [formData.role], // Helper for legacy backend needing array
|
||||
}
|
||||
await usersApi.create(payload)
|
||||
toast.success(t('admin.users.messages.create_success'))
|
||||
await usersApi.create({ ...formData, roles: [formData.role] })
|
||||
toast.success("Usuário criado com sucesso")
|
||||
setIsDialogOpen(false)
|
||||
setFormData({ name: "", email: "", password: "", role: "candidate", status: "active", companyId: "" })
|
||||
setPage(1)
|
||||
loadUsers(1)
|
||||
} catch (error) {
|
||||
console.error("[USER_FLOW] Error creating user:", error)
|
||||
const message = error instanceof Error && error.message
|
||||
? error.message
|
||||
: t('admin.users.messages.create_error')
|
||||
toast.error(message)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Erro ao criar usuário")
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleView = (user: ApiUser) => {
|
||||
console.log("[USER_FLOW] Viewing user:", user)
|
||||
const handleEdit = (user: ApiUser, isViewing = false) => {
|
||||
setSelectedUser(user)
|
||||
setEditFormData({
|
||||
name: user.name,
|
||||
|
|
@ -149,522 +144,311 @@ export default function AdminUsersPage() {
|
|||
status: user.status || "active",
|
||||
password: "",
|
||||
})
|
||||
setViewing(true)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleEdit = (user: ApiUser) => {
|
||||
console.log("[USER_FLOW] Editing user:", user)
|
||||
setSelectedUser(user)
|
||||
setEditFormData({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
status: user.status || "active",
|
||||
password: "",
|
||||
})
|
||||
setViewing(false)
|
||||
setViewing(isViewing)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!selectedUser) return
|
||||
console.log("[USER_FLOW] Updating user:", selectedUser.id, "with data:", editFormData)
|
||||
try {
|
||||
setUpdating(true)
|
||||
const payload: any = {
|
||||
...editFormData,
|
||||
roles: [editFormData.role],
|
||||
}
|
||||
// Remove empty password if not changing
|
||||
if (!payload.password) delete payload.password;
|
||||
|
||||
const payload: any = { ...editFormData, roles: [editFormData.role] }
|
||||
if (!payload.password) delete payload.password
|
||||
await usersApi.update(selectedUser.id, payload)
|
||||
toast.success(t('admin.users.messages.update_success'))
|
||||
toast.success("Usuário atualizado com sucesso")
|
||||
setIsEditDialogOpen(false)
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
console.error("[USER_FLOW] Error updating user:", error)
|
||||
toast.error(t('admin.users.messages.update_error'))
|
||||
toast.error("Erro ao atualizar usuário")
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteClick = (user: ApiUser) => {
|
||||
console.log("[USER_FLOW] Delete click for user:", user)
|
||||
setSelectedUser(user)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedUser) return
|
||||
console.log("[USER_FLOW] Confirming delete for user:", selectedUser.id)
|
||||
|
||||
try {
|
||||
setDeleting(true)
|
||||
await usersApi.delete(selectedUser.id)
|
||||
toast.success(t('admin.users.messages.delete_success'))
|
||||
|
||||
// UI Update logic
|
||||
if (users.length === 1 && page > 1) {
|
||||
setPage(page - 1)
|
||||
loadUsers(page - 1)
|
||||
} else {
|
||||
loadUsers(page)
|
||||
}
|
||||
toast.success("Usuário excluído com sucesso")
|
||||
loadUsers(page)
|
||||
setIsDeleteDialogOpen(false)
|
||||
setSelectedUser(null)
|
||||
} catch (error: any) {
|
||||
console.error("[USER_FLOW] Error deleting user:", error)
|
||||
// Error handling matching user request to see logs
|
||||
if (error.message && error.message.includes("403")) {
|
||||
toast.error("You don't have permission to delete this user (403)")
|
||||
} else {
|
||||
toast.error(t('admin.users.messages.delete_error'))
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Erro ao excluir usuário")
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleBadge = (role: string) => {
|
||||
const variants: any = {
|
||||
superadmin: "destructive",
|
||||
admin: "default",
|
||||
recruiter: "secondary",
|
||||
candidate: "outline"
|
||||
}
|
||||
return <Badge variant={variants[role] || "outline"} className="capitalize">{role}</Badge>
|
||||
}
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(user) =>
|
||||
user.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
const getRoleBadge = (role: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
superadmin: t('admin.users.roles.superadmin'),
|
||||
admin: t('admin.users.roles.admin'),
|
||||
recruiter: t('admin.users.roles.recruiter'),
|
||||
candidate: t('admin.users.roles.candidate'),
|
||||
company: t('admin.users.roles.admin') // Fallback for 'company' role legacy
|
||||
}
|
||||
const colors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
superadmin: "destructive",
|
||||
admin: "default",
|
||||
recruiter: "secondary",
|
||||
candidate: "outline",
|
||||
company: "default"
|
||||
}
|
||||
const label = labels[role] || role || "User"
|
||||
return <Badge variant={colors[role] || "outline"}>{label}</Badge>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 sm:space-y-8">
|
||||
<div className="container py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">{t('admin.users.title')}</h1>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mt-1">{t('admin.users.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => loadUsers()} disabled={loading}>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }}>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight">Gerenciar Usuários</h1>
|
||||
<p className="text-muted-foreground text-lg">Controle de acessos e permissões da plataforma</p>
|
||||
</motion.div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="lg" onClick={() => loadUsers()} disabled={loading} className="shadow-sm">
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
{t('admin.users.refresh')}
|
||||
Atualizar
|
||||
</Button>
|
||||
<Dialog open={isDialogOpen} onOpenChange={(open) => {
|
||||
setIsDialogOpen(open)
|
||||
if (!open) {
|
||||
setFormData({ name: "", email: "", password: "", role: "candidate", status: "active", companyId: "" })
|
||||
}
|
||||
}}>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('admin.users.new_user')}
|
||||
<Button size="lg" className="gap-2 shadow-md hover:shadow-lg transition-all">
|
||||
<Plus className="h-5 w-5" />
|
||||
Novo Usuário
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.users.create_dialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.users.create_dialog.description')}</DialogDescription>
|
||||
<DialogTitle>Criar Novo Usuário</DialogTitle>
|
||||
<DialogDescription>Preencha os dados para adicionar um novo membro.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">{t('admin.users.table.name')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t('admin.users.table.name')}
|
||||
/>
|
||||
<Label htmlFor="name">Nome Completo</Label>
|
||||
<Input id="name" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">{t('admin.users.table.email')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder={t('admin.users.table.email')}
|
||||
/>
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input id="email" type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder={t('admin.users.form.password')}
|
||||
/>
|
||||
<Label htmlFor="password">Senha</Label>
|
||||
<Input id="password" type="password" value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} />
|
||||
</div>
|
||||
{currentUser?.role === 'superadmin' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="company">{t('admin.users.form.company')}</Label>
|
||||
<Select value={formData.companyId} onValueChange={(v) => setFormData({ ...formData, companyId: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('admin.users.form.select_company')} />
|
||||
</SelectTrigger>
|
||||
<Label>Papel (Role)</Label>
|
||||
<Select value={formData.role} onValueChange={(v) => setFormData({ ...formData, role: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company.id} value={company.id}>
|
||||
{company.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="recruiter">Recrutador</SelectItem>
|
||||
<SelectItem value="candidate">Candidato</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={formData.status} onValueChange={(v) => setFormData({ ...formData, status: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Ativo</SelectItem>
|
||||
<SelectItem value="inactive">Inativo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="status">{t('admin.users.table.status')}</Label>
|
||||
<Select value={formData.status} onValueChange={(v) => setFormData({ ...formData, status: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('admin.users.form.status_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">{t('admin.users.statuses.active')}</SelectItem>
|
||||
<SelectItem value="inactive">{t('admin.users.statuses.inactive')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="role">{t('admin.users.table.role')}</Label>
|
||||
<Select value={formData.role} onValueChange={(v) => setFormData({ ...formData, role: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="superadmin">{t('admin.users.roles.superadmin')}</SelectItem>
|
||||
<SelectItem value="admin">{t('admin.users.roles.admin')}</SelectItem>
|
||||
<SelectItem value="recruiter">{t('admin.users.roles.recruiter')}</SelectItem>
|
||||
<SelectItem value="candidate">{t('admin.users.roles.candidate')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>{t('admin.users.create_dialog.cancel')}</Button>
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
{creating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{t('admin.users.create_dialog.submit')}
|
||||
Criar Usuário
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Edit / View Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent className="max-w-md max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{viewing ? t('admin.users.edit_dialog.title_view') : t('admin.users.edit_dialog.title_edit')}</DialogTitle>
|
||||
<DialogDescription>{viewing ? t('admin.users.edit_dialog.description_view') : t('admin.users.edit_dialog.description_edit')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">{t('admin.users.table.name')}</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
value={editFormData.name}
|
||||
onChange={(e) => setEditFormData({ ...editFormData, name: e.target.value })}
|
||||
placeholder={t('admin.users.table.name')}
|
||||
readOnly={viewing}
|
||||
disabled={viewing}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-email">{t('admin.users.table.email')}</Label>
|
||||
<Input
|
||||
id="edit-email"
|
||||
type="email"
|
||||
value={editFormData.email}
|
||||
onChange={(e) => setEditFormData({ ...editFormData, email: e.target.value })}
|
||||
placeholder={t('admin.users.table.email')}
|
||||
readOnly={viewing}
|
||||
disabled={viewing}
|
||||
/>
|
||||
</div>
|
||||
{!viewing && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-password">Password (Optional)</Label>
|
||||
<Input
|
||||
id="edit-password"
|
||||
type="password"
|
||||
value={editFormData.password}
|
||||
onChange={(e) => setEditFormData({ ...editFormData, password: e.target.value })}
|
||||
placeholder="Leave blank to keep current"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-role">{t('admin.users.table.role')}</Label>
|
||||
<Select
|
||||
value={editFormData.role}
|
||||
onValueChange={(v) => setEditFormData({ ...editFormData, role: v })}
|
||||
disabled={viewing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="superadmin">{t('admin.users.roles.superadmin')}</SelectItem>
|
||||
<SelectItem value="admin">{t('admin.users.roles.admin')}</SelectItem>
|
||||
<SelectItem value="recruiter">{t('admin.users.roles.recruiter')}</SelectItem>
|
||||
<SelectItem value="candidate">{t('admin.users.roles.candidate')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-status">{t('admin.users.table.status')}</Label>
|
||||
<Select
|
||||
value={editFormData.status}
|
||||
onValueChange={(v) => setEditFormData({ ...editFormData, status: v })}
|
||||
disabled={viewing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">{t('admin.users.statuses.active')}</SelectItem>
|
||||
<SelectItem value="inactive">{t('admin.users.statuses.inactive')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{viewing ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
|
||||
{t('admin.users.edit_dialog.close')}
|
||||
</Button>
|
||||
<Button onClick={() => setViewing(false)}>
|
||||
Edit
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
|
||||
{t('admin.users.create_dialog.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleUpdate} disabled={updating}>
|
||||
{updating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{t('admin.users.edit_dialog.save')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.users.delete_confirm.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('admin.users.delete_confirm.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{selectedUser && (
|
||||
<div className="text-sm border p-4 rounded-md bg-muted/50">
|
||||
<p><strong>Name:</strong> {selectedUser.name}</p>
|
||||
<p><strong>Email:</strong> {selectedUser.email}</p>
|
||||
<p><strong>ID:</strong> {selectedUser.id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||
{t('admin.users.delete_confirm.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
|
||||
{deleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{t('admin.users.delete_confirm.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-2 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>{t('admin.users.total')}</CardDescription>
|
||||
<CardTitle className="text-3xl">{totalUsers}</CardTitle>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="border-l-4 border-l-blue-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium">Total de Usuários</CardTitle>
|
||||
<Users className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{totalUsers}</div></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>{t('admin.users.admins')}</CardDescription>
|
||||
<CardTitle className="text-3xl">
|
||||
{users.filter((u) => u.role === "superadmin" || u.role === "admin" || u.role === "admin").length}
|
||||
</CardTitle>
|
||||
<Card className="border-l-4 border-l-red-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium">Administradores</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{users.filter(u => u.role === "admin" || u.role === "superadmin").length}</div></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>{t('admin.users.recruiters')}</CardDescription>
|
||||
<CardTitle className="text-3xl">{users.filter((u) => u.role === "recruiter").length}</CardTitle>
|
||||
<Card className="border-l-4 border-l-green-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium">Ativos agora</CardTitle>
|
||||
<UserCheck className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{users.filter(u => u.status === "active").length}</div></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>{t('admin.users.candidates')}</CardDescription>
|
||||
<CardTitle className="text-3xl">{users.filter((u) => u.role === "candidate" || u.role === "candidate").length}</CardTitle>
|
||||
<Card className="border-l-4 border-l-amber-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium">Novos (24h)</CardTitle>
|
||||
<Clock className="h-4 w-4 text-amber-500" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">--</div></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
{/* Main Content */}
|
||||
<Card className="border-0 shadow-xl overflow-hidden bg-card/50 backdrop-blur-sm">
|
||||
<CardHeader className="border-b bg-muted/30">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('admin.users.search_placeholder')}
|
||||
placeholder="Buscar usuários por nome ou e-mail..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
className="pl-10 h-11 border-muted-foreground/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="space-y-2 py-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center space-x-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<div className="p-8 space-y-4">
|
||||
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-16 w-full rounded-lg" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||
<Table className="min-w-[600px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('admin.users.table.name')}</TableHead>
|
||||
<TableHead>{t('admin.users.table.role')}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{t('admin.users.table.status')}</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">{t('admin.users.table.created')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.users.table.actions')}</TableHead>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead className="font-bold">Nome</TableHead>
|
||||
<TableHead className="font-bold">E-mail</TableHead>
|
||||
<TableHead className="font-bold">Papel</TableHead>
|
||||
<TableHead className="font-bold">Status</TableHead>
|
||||
<TableHead className="font-bold">Criado em</TableHead>
|
||||
<TableHead className="text-right font-bold">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center py-20 text-muted-foreground">Nenhum usuário encontrado</TableCell></TableRow>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<TableRow key={user.id} className="hover:bg-muted/30 transition-colors">
|
||||
<TableCell className="font-semibold">{user.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{user.email}</TableCell>
|
||||
<TableCell>{getRoleBadge(user.role)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.status === "active" ? "default" : "secondary"}>
|
||||
{user.status === "active" ? "Ativo" : "Inativo"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.created_at || (user as any).createdAt ? userDateFormatter.format(new Date(user.created_at || (user as any).createdAt)) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleEdit(user, true)} className="hover:text-primary">
|
||||
<Eye className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleEdit(user)} className="hover:text-blue-600" disabled={user.role === "superadmin"}>
|
||||
<Pencil className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="hover:text-destructive hover:bg-destructive/10" onClick={() => { setSelectedUser(user); setIsDeleteDialogOpen(true); }} disabled={user.role === "superadmin"}>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
{t('admin.users.table.no_users')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.name}</TableCell>
|
||||
<TableCell>{getRoleBadge(user.role)}</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
user.status?.toLowerCase() === "active"
|
||||
? "border-transparent bg-green-500/15 text-green-700 hover:bg-green-500/25 dark:text-green-400"
|
||||
: "border-transparent bg-red-500/15 text-red-700 hover:bg-red-500/25 dark:text-red-400"
|
||||
}
|
||||
>
|
||||
{user.status ? user.status.toUpperCase() : "UNKNOWN"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
{user.created_at || (user as any).createdAt ? userDateFormatter.format(new Date(user.created_at || (user as any).createdAt)) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleView(user)}
|
||||
title="View Details"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(user)}
|
||||
disabled={user.role === "superadmin"}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteClick(user)}
|
||||
disabled={user.role === "superadmin"}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 text-sm text-muted-foreground pt-4">
|
||||
<span className="text-center sm:text-left">
|
||||
{totalUsers === 0
|
||||
? t('admin.users.pagination.no_users_display')
|
||||
: t('admin.users.pagination.showing', { start: (page - 1) * limit + 1, end: Math.min(page * limit, totalUsers), total: totalUsers })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => loadUsers(page - 1)}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('admin.users.pagination.previous')}
|
||||
</Button>
|
||||
<span>
|
||||
{t('admin.users.pagination.page', { current: page, total: totalPages })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => loadUsers(page + 1)}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
{t('admin.users.pagination.next')}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="p-4 border-t bg-muted/10 flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Página <span className="text-foreground font-medium">{page}</span> de <span className="text-foreground font-medium">{totalPages}</span>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => loadUsers(page - 1)} disabled={page <= 1}>Anterior</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => loadUsers(page + 1)} disabled={page >= totalPages}>Próxima</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit / View Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{viewing ? "Detalhes do Usuário" : "Editar Usuário"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Nome</Label>
|
||||
<Input value={editFormData.name} onChange={(e) => setEditFormData({ ...editFormData, name: e.target.value })} readOnly={viewing} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>E-mail</Label>
|
||||
<Input value={editFormData.email} onChange={(e) => setEditFormData({ ...editFormData, email: e.target.value })} readOnly={viewing} />
|
||||
</div>
|
||||
{!viewing && (
|
||||
<div className="grid gap-2">
|
||||
<Label>Senha (Deixe em branco para não alterar)</Label>
|
||||
<Input type="password" value={editFormData.password} onChange={(e) => setEditFormData({ ...editFormData, password: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Papel</Label>
|
||||
<Select value={editFormData.role} onValueChange={(v) => setEditFormData({ ...editFormData, role: v })} disabled={viewing}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="recruiter">Recrutador</SelectItem>
|
||||
<SelectItem value="candidate">Candidato</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={editFormData.status} onValueChange={(v) => setEditFormData({ ...editFormData, status: v })} disabled={viewing}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Ativo</SelectItem>
|
||||
<SelectItem value="inactive">Inativo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>{viewing ? "Fechar" : "Cancelar"}</Button>
|
||||
{!viewing && (
|
||||
<Button onClick={handleUpdate} disabled={updating}>
|
||||
{updating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Salvar Alterações
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onClose={() => setIsDeleteDialogOpen(false)}
|
||||
onConfirm={confirmDelete}
|
||||
title="Excluir Usuário"
|
||||
description={`Tem certeza que deseja excluir ${selectedUser?.name}?`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue