346 lines
21 KiB
TypeScript
346 lines
21 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { Shield, UserCheck, UserX, Users, Search, RefreshCw, Crown, Eye, User as UserIcon, Info, Image as ImageIcon, Box, Database } from 'lucide-react';
|
|
import { clsx } from 'clsx';
|
|
import type { AppUser, UserRole } from '../types';
|
|
import { useAuth } from '../context/useAuth';
|
|
import api from '../services/api';
|
|
import { GeometrySettings } from '../components/admin/GeometrySettings';
|
|
import { BackupRestore } from '../components/admin/BackupRestore';
|
|
|
|
const roleLabels: Record<UserRole, { label: string; color: string; icon: React.ReactNode }> = {
|
|
admin: { label: 'Administrador', color: 'bg-amber-500/20 text-amber-400 border-amber-500/30', icon: <Crown size={14} /> },
|
|
user: { label: 'Usuário', color: 'bg-primary/20 text-primary border-primary/30', icon: <UserIcon size={14} /> },
|
|
guest: { label: 'Convidado', color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: <Eye size={14} /> },
|
|
};
|
|
|
|
export const AdminDashboard: React.FC = () => {
|
|
const { isAdmin, appUser } = useAuth();
|
|
const [users, setUsers] = useState<AppUser[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [filterRole, setFilterRole] = useState<UserRole | 'all'>('all');
|
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = useState<'users' | 'organization' | 'settings' | 'stock' | 'backup'>('users');
|
|
|
|
const fetchUsers = useCallback(async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const response = await api.get('/users');
|
|
setUsers(response.data.map((u: any) => ({ ...u, id: u._id || u.id })));
|
|
} catch (error) {
|
|
console.error('Error fetching users:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchUsers();
|
|
}, [fetchUsers]);
|
|
|
|
const handleRoleChange = async (userId: string, newRole: UserRole) => {
|
|
setActionLoading(userId);
|
|
try {
|
|
const response = await api.patch(`/users/${userId}/role`, { role: newRole });
|
|
const updated = response.data;
|
|
setUsers(users.map(u => u.id === userId ? { ...updated, id: updated._id || updated.id } : u));
|
|
} catch (error: any) {
|
|
console.error('Error updating role:', error);
|
|
alert(error.response?.data?.error || 'Erro ao atualizar role');
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleToggleBan = async (userId: string, isBanned: boolean) => {
|
|
setActionLoading(userId);
|
|
try {
|
|
const response = await api.patch(`/users/${userId}/ban`, { isBanned });
|
|
const updated = response.data;
|
|
setUsers(users.map(u => u.id === userId ? { ...updated, id: updated._id || updated.id } : u));
|
|
} catch (error: any) {
|
|
console.error('Error toggling ban:', error);
|
|
alert(error.response?.data?.error || 'Erro ao alterar status');
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const filteredUsers = users.filter(u => {
|
|
const matchesSearch = (u.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(u.email || '').toLowerCase().includes(searchTerm.toLowerCase());
|
|
const matchesRole = filterRole === 'all' || u.role === filterRole;
|
|
return matchesSearch && matchesRole;
|
|
});
|
|
|
|
if (!isAdmin()) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
|
<Shield size={64} className="text-error/50" />
|
|
<h1 className="text-2xl font-bold text-text-main">Acesso Negado</h1>
|
|
<p className="text-text-muted">Você não tem permissão para acessar esta página.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-3xl font-black text-text-main tracking-tight flex items-center gap-3">
|
|
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-lg shadow-amber-500/30">
|
|
<Shield size={24} className="text-white" />
|
|
</div>
|
|
Administração
|
|
</h1>
|
|
<p className="text-text-muted mt-2">Gestão de usuários e configurações do sistema</p>
|
|
</div>
|
|
{activeTab === 'users' && (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={fetchUsers}
|
|
disabled={isLoading}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-primary hover:bg-primary-dark text-white border border-primary-dark rounded-xl font-semibold transition-all disabled:opacity-50"
|
|
>
|
|
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
|
Atualizar
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tabs Navigation */}
|
|
<div className="flex p-1 bg-surface-soft rounded-xl border border-border/40 w-fit">
|
|
<button
|
|
onClick={() => setActiveTab('users')}
|
|
className={clsx(
|
|
"flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
|
|
activeTab === 'users'
|
|
? "bg-surface text-primary shadow-sm border border-border/40"
|
|
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
|
)}
|
|
>
|
|
<Users size={16} />
|
|
Usuários
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('settings')}
|
|
className={clsx(
|
|
"flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
|
|
activeTab === 'settings'
|
|
? "bg-surface text-primary shadow-sm border border-border/40"
|
|
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
|
)}
|
|
>
|
|
<Box size={16} />
|
|
Geometrias
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('backup')}
|
|
className={clsx(
|
|
"flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
|
|
activeTab === 'backup'
|
|
? "bg-surface text-primary shadow-sm border border-border/40"
|
|
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
|
)}
|
|
>
|
|
<Database size={16} />
|
|
Backup
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === 'users' ? (
|
|
<>
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
<div className="bg-surface rounded-2xl p-5 border border-border/40">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-primary/20 flex items-center justify-center">
|
|
<Users size={20} className="text-primary" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-black text-text-main">{users.length}</p>
|
|
<p className="text-xs text-text-muted font-medium">Total</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-surface rounded-2xl p-5 border border-border/40">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-amber-500/20 flex items-center justify-center">
|
|
<Crown size={20} className="text-amber-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-black text-text-main">{users.filter(u => u.role === 'admin').length}</p>
|
|
<p className="text-xs text-text-muted font-medium">Admins</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-surface rounded-2xl p-5 border border-border/40">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-green-500/20 flex items-center justify-center">
|
|
<UserCheck size={20} className="text-green-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-black text-text-main">{users.filter(u => u.role === 'user').length}</p>
|
|
<p className="text-xs text-text-muted font-medium">Usuários</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-surface rounded-2xl p-5 border border-border/40">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-red-500/20 flex items-center justify-center">
|
|
<UserX size={20} className="text-red-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-black text-text-main">{users.filter(u => u.isBanned).length}</p>
|
|
<p className="text-xs text-text-muted font-medium">Banidos</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
<div className="relative flex-1">
|
|
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar por nome ou email..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-12 pr-4 py-3 bg-surface border border-border/40 rounded-xl text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
|
/>
|
|
</div>
|
|
<select
|
|
value={filterRole}
|
|
onChange={(e) => setFilterRole(e.target.value as UserRole | 'all')}
|
|
aria-label="Filtrar por role"
|
|
className="px-4 py-3 bg-surface border border-border/40 rounded-xl text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
|
>
|
|
<option value="all">Todos os Roles</option>
|
|
<option value="admin">Administradores</option>
|
|
<option value="user">Usuários</option>
|
|
<option value="guest">Convidados</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Users Table */}
|
|
<div className="bg-surface rounded-2xl border border-border/40 overflow-hidden">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<RefreshCw size={32} className="animate-spin text-primary" />
|
|
</div>
|
|
) : filteredUsers.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-20 text-text-muted">
|
|
<Users size={48} className="mb-4 opacity-50" />
|
|
<p>Nenhum usuário encontrado</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-border/40 bg-surface-soft">
|
|
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Usuário</th>
|
|
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Email</th>
|
|
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Role</th>
|
|
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Status</th>
|
|
<th className="text-right px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border/40">
|
|
{filteredUsers.map((u) => {
|
|
const roleInfo = roleLabels[u.role];
|
|
const isCurrentUser = u.email === appUser?.email;
|
|
const isActionDisabled = actionLoading === u.id;
|
|
|
|
return (
|
|
<tr key={u.id} className={clsx("hover:bg-surface-hover transition-colors", u.isBanned && "opacity-60")}>
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-bold">
|
|
{(u.name || u.email || '?').charAt(0).toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold text-text-main">{u.name || 'Sem nome'}</p>
|
|
{isCurrentUser && (
|
|
<span className="text-xs text-primary font-medium">(Você)</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-text-secondary">{u.email}</td>
|
|
<td className="px-6 py-4">
|
|
<select
|
|
value={u.role}
|
|
onChange={(e) => handleRoleChange(u.id, e.target.value as UserRole)}
|
|
disabled={isCurrentUser || isActionDisabled || u.isBanned}
|
|
aria-label={`Alterar role de ${u.name}`}
|
|
className={clsx("px-3 py-1.5 rounded-lg border text-sm font-semibold transition-all bg-transparent disabled:opacity-50 disabled:cursor-not-allowed", roleInfo.color)}
|
|
>
|
|
<option value="guest">Convidado</option>
|
|
<option value="user">Usuário</option>
|
|
<option value="admin">Administrador</option>
|
|
</select>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
{u.isBanned ? (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 text-sm font-semibold">
|
|
<UserX size={14} />
|
|
Banido
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-green-500/20 text-green-400 border border-green-500/30 text-sm font-semibold">
|
|
<UserCheck size={14} />
|
|
Ativo
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
{!isCurrentUser && u.role !== 'admin' && (
|
|
<button
|
|
onClick={() => handleToggleBan(u.id, !u.isBanned)}
|
|
disabled={isActionDisabled}
|
|
className={clsx("px-4 py-2 rounded-xl text-sm font-semibold transition-all shadow-sm active:scale-95 disabled:opacity-50",
|
|
u.isBanned
|
|
? 'bg-green-500/10 text-green-500 hover:bg-green-500/20 border border-green-500/20'
|
|
: 'bg-red-500/10 text-red-500 hover:bg-red-500/20 border border-red-500/20'
|
|
)}
|
|
>
|
|
{isActionDisabled ? (
|
|
<RefreshCw size={16} className="animate-spin" />
|
|
) : u.isBanned ? (
|
|
'Desbanir'
|
|
) : (
|
|
'Banir'
|
|
)}
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : activeTab === 'settings' ? (
|
|
<GeometrySettings />
|
|
) : activeTab === 'backup' ? (
|
|
<BackupRestore />
|
|
) : (
|
|
<div className="bg-surface rounded-2xl border border-border/40 p-6">
|
|
<div className="text-center py-10">
|
|
<h2 className="text-xl font-bold text-text-main">Em breve</h2>
|
|
<p className="text-text-muted mt-2">Novas configurações serão adicionadas aqui.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminDashboard;
|