From 11f02080c5b2c8996d0551b271ca95f6ffff0c51 Mon Sep 17 00:00:00 2001 From: Maria Fernanda Date: Thu, 30 Apr 2026 15:25:36 -0300 Subject: [PATCH 1/8] =?UTF-8?q?refactor(users):=20reestrutura=20edi=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20usu=C3=A1rios=20e=20perfis=20de=20acesso?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/router.jsx | 6 +- src/features/users/api/user-service.js | 35 +- src/features/users/hooks/useRolesQuery.js | 10 + .../users/hooks/useUpdateUserRolesMutation.js | 14 + src/features/users/pages/EditarAtendente.jsx | 464 ++++++++++++++++++ src/features/users/pages/EditarCliente.jsx | 385 +++++++++++++++ src/features/users/pages/EditarUsuario.jsx | 310 ------------ src/features/users/pages/Usuarios.jsx | 39 +- src/features/users/utils/role-utils.js | 26 +- 9 files changed, 935 insertions(+), 354 deletions(-) create mode 100644 src/features/users/hooks/useRolesQuery.js create mode 100644 src/features/users/hooks/useUpdateUserRolesMutation.js create mode 100644 src/features/users/pages/EditarAtendente.jsx create mode 100644 src/features/users/pages/EditarCliente.jsx delete mode 100644 src/features/users/pages/EditarUsuario.jsx diff --git a/src/app/router.jsx b/src/app/router.jsx index a5389bf..4fd341b 100644 --- a/src/app/router.jsx +++ b/src/app/router.jsx @@ -10,7 +10,8 @@ import Dashboard from '@/features/dashboard/pages/Dashboard' import Chat from '@/features/chat/pages/Chat' import Usuarios from '@/features/users/pages/Usuarios' import CadastrarUsuario from '@/features/users/pages/CadastrarUsuario' -import EditarUsuario from '@/features/users/pages/EditarUsuario' +import EditarCliente from '@/features/users/pages/EditarCliente' +import EditarAtendente from '@/features/users/pages/EditarAtendente' import Chamados from '@/features/ticket/pages/Chamados' import AberturaChamado from '@/features/ticket/pages/AberturaChamado' import ModificarChamado from '@/features/ticket/pages/ModificarChamado' @@ -37,7 +38,8 @@ export function AppRouter() { }> } /> } /> - } /> + } /> + } /> diff --git a/src/features/users/api/user-service.js b/src/features/users/api/user-service.js index 41b3b8c..33b1054 100644 --- a/src/features/users/api/user-service.js +++ b/src/features/users/api/user-service.js @@ -1,18 +1,9 @@ import { http } from '@/lib/http' function normalizeListResponse(data) { - if (Array.isArray(data)) { - return data - } - - if (Array.isArray(data?.items)) { - return data.items - } - - if (Array.isArray(data?.data)) { - return data.data - } - + if (Array.isArray(data)) return data + if (Array.isArray(data?.data)) return data.data + if (Array.isArray(data?.items)) return data.items return [] } @@ -30,6 +21,13 @@ export async function getUserById(userId) { return normalizeObjectResponse(data) } +export async function getRoles() { + const { data } = await http.get('/roles') + if (Array.isArray(data?.data)) return data.data + if (Array.isArray(data)) return data + return [] +} + export async function createUser(payload) { const { data } = await http.post('/users', payload) return normalizeObjectResponse(data) @@ -38,4 +36,17 @@ export async function createUser(payload) { export async function patchUser({ userId, payload }) { const { data } = await http.patch(`/users/${userId}`, payload) return normalizeObjectResponse(data) +} + +export async function deleteUser(userId) { + const { data } = await http.delete(`/users/${userId}`) + return normalizeObjectResponse(data) +} + +export async function patchUserRoles({ userId, addRoleIds = [], removeRoleIds = [] }) { + const { data } = await http.patch(`/users/${userId}/roles`, { + add_role_ids: addRoleIds, + remove_role_ids: removeRoleIds, + }) + return normalizeObjectResponse(data) } \ No newline at end of file diff --git a/src/features/users/hooks/useRolesQuery.js b/src/features/users/hooks/useRolesQuery.js new file mode 100644 index 0000000..632f947 --- /dev/null +++ b/src/features/users/hooks/useRolesQuery.js @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' +import { getRoles } from '@/features/users/api/user-service' + +export function useRolesQuery() { + return useQuery({ + queryKey: ['roles'], + queryFn: getRoles, + staleTime: 1000 * 60 * 10, + }) +} \ No newline at end of file diff --git a/src/features/users/hooks/useUpdateUserRolesMutation.js b/src/features/users/hooks/useUpdateUserRolesMutation.js new file mode 100644 index 0000000..4d66eb3 --- /dev/null +++ b/src/features/users/hooks/useUpdateUserRolesMutation.js @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { patchUserRoles } from '@/features/users/api/user-service' + +export function usePatchUserRolesMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: patchUserRoles, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + queryClient.invalidateQueries({ queryKey: ['users', variables.userId] }) + } + }) +} \ No newline at end of file diff --git a/src/features/users/pages/EditarAtendente.jsx b/src/features/users/pages/EditarAtendente.jsx new file mode 100644 index 0000000..e2bda35 --- /dev/null +++ b/src/features/users/pages/EditarAtendente.jsx @@ -0,0 +1,464 @@ +import { useEffect, useRef, useState } from 'react' +import { + LayoutDashboard, + Users, + Ticket, + User as UserIcon, + LogOut, + MessageSquare, + Save, + ArrowLeft, + Loader2, + ShieldCheck, + BarChart3, + Settings, + Briefcase, + AlertTriangle, + CheckCircle2, + Pencil, +} from 'lucide-react' +import { useNavigate, useParams } from 'react-router-dom' +import { useAuthStore } from '@/stores/auth-stores' +import { useUserQuery } from '@/features/users/hooks/useUserQuery' +import { usePatchUserMutation } from '@/features/users/hooks/usePatchUserMutation' +import { usePatchUserRolesMutation } from '@/features/users/hooks/useUpdateUserRolesMutation' +import { getRoleInfo } from '@/features/users/utils/role-utils' + +const CARGO_OPTIONS = [ + { key: 'admin', label: 'Gerente', roleId: 1, description: 'Supervisiona toda a equipe e finanças.', icon: }, + { key: 'agent', label: 'Operador', roleId: 3, description: 'Gerencia os tickets e atende clientes.', icon: }, + { key: 'user', label: 'Suporte', roleId: 2, description: 'Auxilia clientes e fecha tickets.', icon: }, +] + +const PERMISSIONS_CONFIG = [ + { key: 'manage_users', label: 'Gerenciar Usuários', description: 'Capacidade de criar, editar e desativar perfis.', icon: }, + { key: 'manage_tickets', label: 'Gerenciar Chamados', description: 'Atribuição de chamados e resolução de tickets.', icon: }, + { key: 'view_reports', label: 'Ver Relatórios', description: 'Acesso ao dashboard de métricas e relatórios.', icon: }, + { key: 'system_settings', label: 'Configurações do Sistema', description: 'Alteração de preferências globais da plataforma.', icon: }, +] + +export default function EditarAtendente() { + const navigate = useNavigate() + const { userId } = useParams() + const clearSession = useAuthStore((state) => state.clearSession) + const [menuPerfilAberto, setMenuPerfilAberto] = useState(false) + const menuRef = useRef(null) + + const userQuery = useUserQuery(userId) + const patchUserMutation = usePatchUserMutation() + const patchUserRolesMutation = usePatchUserRolesMutation() + + useEffect(() => { + function handleClickOutside(event) { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setMenuPerfilAberto(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + function handleLogout() { + clearSession() + navigate('/login', { replace: true }) + } + + if (userQuery.isLoading) { + return ( +
+ Carregando... +
+ ) + } + + if (userQuery.isError || !userQuery.data) { + return ( +
+ Erro ao carregar usuário +
+ ) + } + + return ( + + ) +} + +function EditarAtendenteForm({ + user, + userId, + menuPerfilAberto, + setMenuPerfilAberto, + menuRef, + onLogout, + navigate, + patchUserMutation, + patchUserRolesMutation, +}) { + const initialRole = getRoleInfo(user) + const initials = getInitials(user.name || user.username) + const initialCargo = CARGO_OPTIONS.find((c) => c.key === initialRole.key) ? initialRole.key : 'agent' + + const [nome, setNome] = useState(user.name || '') + const [email, setEmail] = useState(user.email || '') + const [isActive, setIsActive] = useState(Boolean(user.is_active ?? user.isActive)) + const [selectedCargo, setSelectedCargo] = useState(initialCargo) + const [editandoNome, setEditandoNome] = useState(false) + const [permissions, setPermissions] = useState({ + manage_users: user.permissions?.manage_users ?? false, + manage_tickets: user.permissions?.manage_tickets ?? true, + view_reports: user.permissions?.view_reports ?? true, + system_settings: user.permissions?.system_settings ?? false, + }) + const [errorMessage, setErrorMessage] = useState('') + const [showDangerConfirm, setShowDangerConfirm] = useState(false) + + const isSaving = patchUserMutation.isPending || patchUserRolesMutation.isPending + + function togglePermission(key) { + setPermissions((prev) => ({ ...prev, [key]: !prev[key] })) + } + + async function handleUpdate(event) { + event.preventDefault() + setErrorMessage('') + + try { + // 1. Atualiza dados do usuário + await patchUserMutation.mutateAsync({ + userId, + payload: { + email: email.trim().toLowerCase(), + name: nome.trim(), + username: user.username, + oauth_provider: user.oauth_provider ?? 'local', + oauth_provider_id: user.oauth_provider_id ?? `local_${user.id}`, + is_active: isActive, + is_verified: user.is_verified ?? false, + }, + }) + + // 2. Atualiza o role via PATCH /users/{user_id}/roles + const selectedCargoOption = CARGO_OPTIONS.find((c) => c.key === selectedCargo) + const initialCargoOption = CARGO_OPTIONS.find((c) => c.key === initialCargo) + if (selectedCargoOption && selectedCargoOption.key !== initialCargo) { + await patchUserRolesMutation.mutateAsync({ + userId, + addRoleIds: [selectedCargoOption.roleId], + removeRoleIds: initialCargoOption ? [initialCargoOption.roleId] : [], + }) + } + + navigate('/usuarios', { replace: true }) + } catch (error) { + const detail = error.response?.data?.detail + const message = + detail?.[0]?.msg || + error.response?.data?.message || + String(detail || '') || + 'Erro ao atualizar atendente.' + setErrorMessage(message) + } + } + + async function handleRevogar() { + try { + await patchUserMutation.mutateAsync({ + userId, + payload: { + email: user.email, + name: user.name, + username: user.username, + oauth_provider: user.oauth_provider ?? 'local', + oauth_provider_id: user.oauth_provider_id ?? `local_${user.id}`, + is_active: false, + is_verified: user.is_verified ?? false, + }, + }) + navigate('/usuarios', { replace: true }) + } catch { + setShowDangerConfirm(false) + } + } + + const ultimoAcesso = user.last_login + ? new Date(user.last_login).toLocaleDateString('pt-BR', { + day: '2-digit', month: '2-digit', year: '2-digit', + hour: '2-digit', minute: '2-digit', + }) + : 'Hoje, às 14:23' + + return ( +
+ + +
+
+
+
+ + {menuPerfilAberto && ( +
+ +
+ )} +
+
+ +
+
+
+

Configurações do Atendente

+
+ + +
+
+ +
+
+ + {/* Coluna esquerda */} +
+
+
+ {initials} +
+ + {editandoNome ? ( + setNome(e.target.value)} + onBlur={() => setEditandoNome(false)} + className="text-sm font-bold text-gray-900 text-center border-b border-[#BD3B0F] outline-none w-full mb-1" + /> + ) : ( +
+

{nome || user.username}

+ +
+ )} + + setEmail(e.target.value)} + className="text-xs text-gray-400 text-center border-b border-transparent hover:border-gray-200 focus:border-[#BD3B0F] outline-none w-full mb-2 transition-colors" + /> + + + VERIFICADO + + +
+
+ Status da Conta + +
+
+ Status + + {isActive ? 'Ativo' : 'Inativo'} + +
+
+ Último Acesso + {ultimoAcesso} +
+
+
+ + {/* Zona de perigo */} +
+
+ +

Zona de Perigo

+
+

+ Ações críticas que afetam permanentemente o perfil deste atendente na plataforma. +

+ {showDangerConfirm ? ( +
+

Tem certeza? Esta ação desativará o usuário.

+
+ + +
+
+ ) : ( + + )} +
+
+ + {/* Coluna direita */} +
+ + {/* Atribuição de Cargo*/} +
+
+ +

Atribuição de Cargo

+
+

+ Selecione o cargo do atendente. A alteração será salva ao clicar em "Salvar Alterações". +

+
+ {CARGO_OPTIONS.map((cargo) => { + const isSelected = selectedCargo === cargo.key + return ( + + ) + })} +
+
+ + {/* Permissões — visual */} +
+
+ +

Permissões do Sistema

+
+

Gerenciadas automaticamente pelo cargo selecionado.

+
+ {PERMISSIONS_CONFIG.map((perm) => ( +
+
+
{perm.icon}
+
+

{perm.label}

+

{perm.description}

+
+
+ +
+ ))} +
+
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+
+
+
+
+
+
+ ) +} + +function getInitials(name) { + return name?.split(' ').map((p) => p[0]).join('').toUpperCase().slice(0, 2) || '??' +} + +function NavItem({ icon, label, active, onClick }) { + return ( + + ) +} \ No newline at end of file diff --git a/src/features/users/pages/EditarCliente.jsx b/src/features/users/pages/EditarCliente.jsx new file mode 100644 index 0000000..50809cb --- /dev/null +++ b/src/features/users/pages/EditarCliente.jsx @@ -0,0 +1,385 @@ +import { useEffect, useRef, useState } from 'react' +import { + LayoutDashboard, + Users, + Ticket, + User as UserIcon, + LogOut, + MessageSquare, + Save, + ArrowLeft, + Loader2, + Building2, + ShieldAlert, + StickyNote, + Package, +} from 'lucide-react' +import { useNavigate, useParams } from 'react-router-dom' +import { useAuthStore } from '@/stores/auth-stores' +import { useUserQuery } from '@/features/users/hooks/useUserQuery' +import { usePatchUserMutation } from '@/features/users/hooks/usePatchUserMutation' + +export default function EditarCliente() { + const navigate = useNavigate() + const { userId } = useParams() + const clearSession = useAuthStore((state) => state.clearSession) + const [menuPerfilAberto, setMenuPerfilAberto] = useState(false) + const menuRef = useRef(null) + + const userQuery = useUserQuery(userId) + const patchUserMutation = usePatchUserMutation() + + useEffect(() => { + function handleClickOutside(event) { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setMenuPerfilAberto(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + function handleLogout() { + clearSession() + navigate('/login', { replace: true }) + } + + if (userQuery.isLoading) { + return ( +
+ Carregando... +
+ ) + } + + if (userQuery.isError || !userQuery.data) { + return ( +
+ Erro ao carregar usuário +
+ ) + } + + return ( + + ) +} + +function EditarClienteForm({ user, userId, menuPerfilAberto, setMenuPerfilAberto, menuRef, onLogout, navigate, patchUserMutation }) { + const isActiveInitial = Boolean(user.is_active ?? user.isActive) + const initials = getInitials(user.name || user.username) + + const [nome, setNome] = useState(user.name || '') + const [email, setEmail] = useState(user.email || '') + const [isActive, setIsActive] = useState(isActiveInitial) + const [notasInternas, setNotasInternas] = useState(user.internal_notes || '') + const [produtoContratado, setProdutoContratado] = useState(user.contracted_product || '') + const [dataExpiracao, setDataExpiracao] = useState(user.contract_expiration || '') + const [errorMessage, setErrorMessage] = useState('') + const [showSuspendConfirm, setShowSuspendConfirm] = useState(false) + + async function handleUpdate(event) { + event.preventDefault() + setErrorMessage('') + + // Apenas campos aceitos pelo PATCH /api/users/{id} + const payload = { + email: email.trim().toLowerCase(), + name: nome.trim(), + username: user.username, + oauth_provider: user.oauth_provider ?? 'local', + oauth_provider_id: user.oauth_provider_id ?? `local_${user.id}`, + is_active: isActive, + is_verified: user.is_verified ?? false, + } + + try { + await patchUserMutation.mutateAsync({ userId, payload }) + navigate('/usuarios', { replace: true }) + } catch (error) { + const detail = error.response?.data?.detail + const message = + detail?.[0]?.msg || + error.response?.data?.message || + String(detail || '') || + 'Erro ao atualizar usuário.' + setErrorMessage(message) + } + } + + function handleToggleSuspend() { + setIsActive((prev) => !prev) + setShowSuspendConfirm(false) + } + + return ( +
+ + +
+
+
+
+ + {menuPerfilAberto && ( +
+ +
+ )} +
+
+ +
+
+ + {/* Header card */} +
+
+
+ {initials} +
+
+
+

{nome || user.username}

+ + {isActive ? 'ATIVO' : 'SUSPENSO'} + +
+

+ Cliente desde{' '} + {user.created_at + ? new Date(user.created_at).toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' }) + : 'data não disponível'} +

+
+
+
+ + +
+
+ +
+
+ + {/* Coluna esquerda */} +
+
+
+ +

Dados Corporativos

+
+
+
+ + setNome(e.target.value)} + className="w-full px-3.5 py-2.5 border border-gray-200 rounded-lg text-sm text-gray-800 outline-none focus:border-[#BD3B0F] transition-colors" + placeholder="Nome do responsável" + /> +
+
+ + setEmail(e.target.value)} + className="w-full px-3.5 py-2.5 border border-gray-200 rounded-lg text-sm text-gray-800 outline-none focus:border-[#BD3B0F] transition-colors" + placeholder="email@empresa.com" + /> +
+
+
+
+ + setProdutoContratado(e.target.value)} + className="w-full px-3.5 py-2.5 border border-gray-200 rounded-lg text-sm text-gray-800 outline-none focus:border-[#BD3B0F] transition-colors" + placeholder="Ex: Nexus Enterprise Pro" + /> +
+
+ + setDataExpiracao(e.target.value)} + className="w-full px-3.5 py-2.5 border border-gray-200 rounded-lg text-sm text-gray-800 outline-none focus:border-[#BD3B0F] transition-colors" + /> +
+
+ +
+ +
+
+ +

Notas Internas

+
+