- Backend NestJS com 12 módulos - Frontend React com dashboard e gestão - Manuais técnico e de negócios (MD + PDF) - Workflow de aprovação com alçadas - RBAC com 6 perfis de acesso
358 lines
14 KiB
TypeScript
358 lines
14 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import {
|
|
Users,
|
|
Search,
|
|
Plus,
|
|
Edit2,
|
|
Trash2,
|
|
Shield,
|
|
X,
|
|
Loader2,
|
|
Mail,
|
|
User,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Key
|
|
} from 'lucide-react'
|
|
import api from '../services/api'
|
|
|
|
interface Usuario {
|
|
id: number;
|
|
nome: string;
|
|
email: string;
|
|
perfil: string;
|
|
perfil_id: number;
|
|
ativo?: boolean;
|
|
ultimo_acesso?: string;
|
|
}
|
|
|
|
const perfilConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
|
'admin': { label: 'Administrador', color: '#E65100', icon: <Shield className="w-4 h-4" /> },
|
|
'administrador': { label: 'Administrador', color: '#E65100', icon: <Shield className="w-4 h-4" /> },
|
|
'gestor': { label: 'Gestor', color: '#1A237E', icon: <Users className="w-4 h-4" /> },
|
|
'financeiro': { label: 'Financeiro', color: '#2E7D32', icon: <Key className="w-4 h-4" /> },
|
|
'solicitante': { label: 'Solicitante', color: '#7B1FA2', icon: <User className="w-4 h-4" /> },
|
|
}
|
|
|
|
const mockUsuarios: Usuario[] = [
|
|
{ id: 1, nome: 'Admin Sistema', email: 'admin@hefesto.com', perfil: 'Administrador', perfil_id: 1, ativo: true, ultimo_acesso: '2024-01-16 14:30' },
|
|
{ id: 2, nome: 'João Santos', email: 'joao.santos@hefesto.com', perfil: 'Gestor', perfil_id: 2, ativo: true, ultimo_acesso: '2024-01-16 10:15' },
|
|
{ id: 3, nome: 'Ana Oliveira', email: 'ana.oliveira@hefesto.com', perfil: 'Financeiro', perfil_id: 3, ativo: true, ultimo_acesso: '2024-01-15 16:45' },
|
|
{ id: 4, nome: 'Maria Silva', email: 'maria.silva@hefesto.com', perfil: 'Solicitante', perfil_id: 4, ativo: true, ultimo_acesso: '2024-01-16 09:00' },
|
|
{ id: 5, nome: 'Carlos Lima', email: 'carlos.lima@hefesto.com', perfil: 'Solicitante', perfil_id: 4, ativo: false, ultimo_acesso: '2024-01-10 11:30' },
|
|
]
|
|
|
|
export default function Usuarios() {
|
|
const [loading, setLoading] = useState(true)
|
|
const [usuarios, setUsuarios] = useState<Usuario[]>([])
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
const [filterPerfil, setFilterPerfil] = useState('todos')
|
|
const [showModal, setShowModal] = useState(false)
|
|
const [selectedUser, setSelectedUser] = useState<Usuario | null>(null)
|
|
const [formData, setFormData] = useState({ nome: '', email: '', perfil_id: 4, senha: '' })
|
|
|
|
useEffect(() => {
|
|
// Simulate loading
|
|
setTimeout(() => {
|
|
setUsuarios(mockUsuarios)
|
|
setLoading(false)
|
|
}, 500)
|
|
}, [])
|
|
|
|
const filteredUsuarios = usuarios.filter(user => {
|
|
const matchesSearch = user.nome.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
user.email.toLowerCase().includes(searchTerm.toLowerCase())
|
|
const matchesPerfil = filterPerfil === 'todos' || user.perfil.toLowerCase() === filterPerfil
|
|
return matchesSearch && matchesPerfil
|
|
})
|
|
|
|
const handleOpenModal = (user?: Usuario) => {
|
|
if (user) {
|
|
setSelectedUser(user)
|
|
setFormData({ nome: user.nome, email: user.email, perfil_id: user.perfil_id, senha: '' })
|
|
} else {
|
|
setSelectedUser(null)
|
|
setFormData({ nome: '', email: '', perfil_id: 4, senha: '' })
|
|
}
|
|
setShowModal(true)
|
|
}
|
|
|
|
const handleCloseModal = () => {
|
|
setShowModal(false)
|
|
setSelectedUser(null)
|
|
setFormData({ nome: '', email: '', perfil_id: 4, senha: '' })
|
|
}
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
// Mock save
|
|
if (selectedUser) {
|
|
setUsuarios(usuarios.map(u => u.id === selectedUser.id ? { ...u, ...formData, perfil: getPerfilLabel(formData.perfil_id) } : u))
|
|
} else {
|
|
const newUser: Usuario = {
|
|
id: Date.now(),
|
|
...formData,
|
|
perfil: getPerfilLabel(formData.perfil_id),
|
|
ativo: true,
|
|
ultimo_acesso: 'Nunca'
|
|
}
|
|
setUsuarios([...usuarios, newUser])
|
|
}
|
|
handleCloseModal()
|
|
}
|
|
|
|
const getPerfilLabel = (id: number) => {
|
|
const perfis: Record<number, string> = { 1: 'Administrador', 2: 'Gestor', 3: 'Financeiro', 4: 'Solicitante' }
|
|
return perfis[id] || 'Solicitante'
|
|
}
|
|
|
|
const getPerfilConfig = (perfil: string) => {
|
|
const key = perfil.toLowerCase()
|
|
return perfilConfig[key] || { label: perfil, color: '#757575', icon: <User className="w-4 h-4" /> }
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-96">
|
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fade-in">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">Usuários</h1>
|
|
<p className="text-gray mt-1">Gerencie os usuários do sistema</p>
|
|
</div>
|
|
<button
|
|
onClick={() => handleOpenModal()}
|
|
className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center"
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
Novo Usuário
|
|
</button>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
<div className="card">
|
|
<p className="text-gray text-sm">Total</p>
|
|
<p className="text-2xl font-bold text-text">{usuarios.length}</p>
|
|
</div>
|
|
<div className="card">
|
|
<p className="text-gray text-sm">Ativos</p>
|
|
<p className="text-2xl font-bold text-green-600">{usuarios.filter(u => u.ativo !== false).length}</p>
|
|
</div>
|
|
<div className="card">
|
|
<p className="text-gray text-sm">Admins</p>
|
|
<p className="text-2xl font-bold text-primary">{usuarios.filter(u => u.perfil.toLowerCase().includes('admin')).length}</p>
|
|
</div>
|
|
<div className="card">
|
|
<p className="text-gray text-sm">Inativos</p>
|
|
<p className="text-2xl font-bold text-gray">{usuarios.filter(u => u.ativo === false).length}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="card">
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar por nome ou e-mail..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="input-field pl-12"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{['todos', 'administrador', 'gestor', 'financeiro', 'solicitante'].map((filter) => (
|
|
<button
|
|
key={filter}
|
|
onClick={() => setFilterPerfil(filter)}
|
|
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
|
filterPerfil === filter
|
|
? 'bg-primary text-white'
|
|
: 'bg-gray-100 text-gray hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="card !p-0 overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="table-header">
|
|
<tr>
|
|
<th className="table-cell">Usuário</th>
|
|
<th className="table-cell">E-mail</th>
|
|
<th className="table-cell">Perfil</th>
|
|
<th className="table-cell">Último Acesso</th>
|
|
<th className="table-cell text-center">Status</th>
|
|
<th className="table-cell text-center">Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredUsuarios.map((user) => {
|
|
const config = getPerfilConfig(user.perfil)
|
|
return (
|
|
<tr key={user.id} className="table-row">
|
|
<td className="table-cell">
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className="w-10 h-10 rounded-xl flex items-center justify-center text-white font-semibold"
|
|
style={{ backgroundColor: config.color }}
|
|
>
|
|
{user.nome.charAt(0).toUpperCase()}
|
|
</div>
|
|
<span className="font-medium">{user.nome}</span>
|
|
</div>
|
|
</td>
|
|
<td className="table-cell">
|
|
<div className="flex items-center gap-2">
|
|
<Mail className="w-4 h-4 text-gray" />
|
|
<span>{user.email}</span>
|
|
</div>
|
|
</td>
|
|
<td className="table-cell">
|
|
<span
|
|
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold"
|
|
style={{ backgroundColor: `${config.color}15`, color: config.color }}
|
|
>
|
|
{config.icon}
|
|
{config.label}
|
|
</span>
|
|
</td>
|
|
<td className="table-cell text-gray text-sm">
|
|
{user.ultimo_acesso}
|
|
</td>
|
|
<td className="table-cell text-center">
|
|
{user.ativo !== false ? (
|
|
<span className="badge-success inline-flex items-center gap-1">
|
|
<CheckCircle2 className="w-3 h-3" />
|
|
Ativo
|
|
</span>
|
|
) : (
|
|
<span className="badge-neutral inline-flex items-center gap-1">
|
|
<XCircle className="w-3 h-3" />
|
|
Inativo
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="table-cell">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<button
|
|
onClick={() => handleOpenModal(user)}
|
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors"
|
|
>
|
|
<Edit2 className="w-4 h-4" />
|
|
</button>
|
|
<button className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{filteredUsuarios.length === 0 && (
|
|
<div className="text-center py-12">
|
|
<Users className="w-12 h-12 text-gray-light mx-auto mb-4" />
|
|
<p className="text-gray">Nenhum usuário encontrado</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Modal */}
|
|
{showModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
|
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl animate-fade-in">
|
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
|
<h2 className="text-xl font-semibold text-text">
|
|
{selectedUser ? 'Editar Usuário' : 'Novo Usuário'}
|
|
</h2>
|
|
<button
|
|
onClick={handleCloseModal}
|
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text mb-2">Nome</label>
|
|
<input
|
|
type="text"
|
|
value={formData.nome}
|
|
onChange={(e) => setFormData({ ...formData, nome: e.target.value })}
|
|
className="input-field"
|
|
placeholder="Nome completo"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text mb-2">E-mail</label>
|
|
<input
|
|
type="email"
|
|
value={formData.email}
|
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
className="input-field"
|
|
placeholder="email@exemplo.com"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text mb-2">Perfil</label>
|
|
<select
|
|
value={formData.perfil_id}
|
|
onChange={(e) => setFormData({ ...formData, perfil_id: Number(e.target.value) })}
|
|
className="input-field"
|
|
>
|
|
<option value={1}>Administrador</option>
|
|
<option value={2}>Gestor</option>
|
|
<option value={3}>Financeiro</option>
|
|
<option value={4}>Solicitante</option>
|
|
</select>
|
|
</div>
|
|
{!selectedUser && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-text mb-2">Senha</label>
|
|
<input
|
|
type="password"
|
|
value={formData.senha}
|
|
onChange={(e) => setFormData({ ...formData, senha: e.target.value })}
|
|
className="input-field"
|
|
placeholder="••••••"
|
|
required={!selectedUser}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex gap-3 pt-4">
|
|
<button type="button" onClick={handleCloseModal} className="btn-ghost flex-1">
|
|
Cancelar
|
|
</button>
|
|
<button type="submit" className="btn-primary flex-1">
|
|
{selectedUser ? 'Salvar' : 'Criar Usuário'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|