Files
aggios.app/front-end-agency/components/team/TeamManagement.tsx

571 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from 'react';
import { Button, Dialog, Input } from '@/components/ui';
import { Toaster, toast } from 'react-hot-toast';
import {
UserPlusIcon,
TrashIcon,
XMarkIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
interface Collaborator {
id: string;
email: string;
name: string;
agency_role: string;
created_at: string;
collaborator_created_at?: string;
}
interface InviteRequest {
email: string;
name: string;
}
export default function TeamManagement() {
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
const [loading, setLoading] = useState(true);
const [showInviteDialog, setShowInviteDialog] = useState(false);
const [showDirectCreateDialog, setShowDirectCreateDialog] = useState(false);
const [showActionMenu, setShowActionMenu] = useState(false);
const [inviteForm, setInviteForm] = useState<InviteRequest>({
email: '',
name: '',
});
const [inviting, setInviting] = useState(false);
const [tempPassword, setTempPassword] = useState('');
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [passwordDialogMode, setPasswordDialogMode] = useState<'invite' | 'direct'>('invite');
const [removingId, setRemovingId] = useState<string | null>(null);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [isOwner, setIsOwner] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Buscar colaboradores
const fetchCollaborators = async () => {
try {
setLoading(true);
const token = localStorage.getItem('token');
const response = await fetch('/api/agency/collaborators', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
if (response.status === 403) {
// Usuário não é owner
setIsOwner(false);
setErrorMessage('Apenas o dono da agência pode gerenciar colaboradores');
setCollaborators([]);
return;
}
throw new Error('Erro ao carregar colaboradores');
}
setIsOwner(true);
setErrorMessage(null);
const data = await response.json();
setCollaborators(data || []);
} catch (error) {
console.error('Erro ao carregar colaboradores:', error);
setErrorMessage('Erro ao carregar colaboradores');
toast.error('Erro ao carregar colaboradores');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCollaborators();
}, []);
// Fechar menu de ações ao clicar fora
useEffect(() => {
const handleClickOutside = () => setShowActionMenu(false);
if (showActionMenu) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [showActionMenu]);
// Convidar colaborador
const handleInvite = async () => {
if (!inviteForm.email || !inviteForm.name) {
toast.error('Preencha todos os campos');
return;
}
try {
setInviting(true);
const token = localStorage.getItem('token');
const response = await fetch('/api/agency/collaborators/invite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(inviteForm),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Erro ao convidar colaborador');
}
const data = await response.json();
setTempPassword(data.temporary_password);
setPasswordDialogMode('invite');
setShowPasswordDialog(true);
setShowInviteDialog(false);
setInviteForm({ email: '', name: '' });
// Recarregar colaboradores
await fetchCollaborators();
} catch (error) {
console.error('Erro ao convidar:', error);
toast.error(error instanceof Error ? error.message : 'Erro ao convidar colaborador');
} finally {
setInviting(false);
}
};
// Criar colaborador diretamente
const handleDirectCreate = async () => {
if (!inviteForm.email || !inviteForm.name) {
toast.error('Preencha todos os campos');
return;
}
try {
setInviting(true);
const token = localStorage.getItem('token');
const response = await fetch('/api/agency/collaborators/invite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(inviteForm),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Erro ao criar colaborador');
}
const data = await response.json();
setTempPassword(data.temporary_password);
setPasswordDialogMode('direct');
setShowPasswordDialog(true);
setShowDirectCreateDialog(false);
setInviteForm({ email: '', name: '' });
// Recarregar colaboradores
await fetchCollaborators();
} catch (error) {
console.error('Erro ao criar:', error);
toast.error(error instanceof Error ? error.message : 'Erro ao criar colaborador');
} finally {
setInviting(false);
}
};
// Remover colaborador
const handleRemove = async () => {
if (!removingId) return;
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/agency/collaborators/remove?id=${removingId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Erro ao remover colaborador');
}
toast.success('Colaborador removido com sucesso');
setShowRemoveDialog(false);
setRemovingId(null);
await fetchCollaborators();
} catch (error) {
console.error('Erro ao remover:', error);
toast.error('Erro ao remover colaborador');
}
};
const copyPassword = () => {
navigator.clipboard.writeText(tempPassword);
toast.success('Senha copiada para a área de transferência');
};
const formatDate = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString('pt-BR');
} catch {
return '-';
}
};
if (loading) {
return (
<div className="space-y-4">
<div className="h-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
<div className="h-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);
}
return (
<>
<Toaster position="top-right" />
<div className="space-y-6">
{/* Mensagem de Erro se não for owner */}
{!isOwner && errorMessage && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-300">
{errorMessage}
</p>
</div>
)}
{/* Cabeçalho */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Gerenciamento de Equipe
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Adicione e gerencie colaboradores com acesso ao sistema
</p>
</div>
<div className="relative">
<Button
variant="primary"
onClick={() => setShowActionMenu(!showActionMenu)}
className="flex items-center gap-2"
disabled={!isOwner}
>
<UserPlusIcon className="w-4 h-4" />
Adicionar Colaborador
</Button>
{showActionMenu && (
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50">
<button
onClick={() => {
setShowInviteDialog(true);
setShowActionMenu(false);
}}
className="w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors border-b border-gray-200 dark:border-gray-700"
>
<p className="font-medium text-gray-900 dark:text-white text-sm">
Convidar por Email
</p>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
Enviar convite por email com senha temporária
</p>
</button>
<button
onClick={() => {
setShowDirectCreateDialog(true);
setShowActionMenu(false);
}}
className="w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<p className="font-medium text-gray-900 dark:text-white text-sm">
Criar sem Convite
</p>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
Criar colaborador e copiar senha manualmente
</p>
</button>
</div>
)}
</div>
</div>
{/* Lista de Colaboradores */}
{collaborators.length === 0 ? (
<div className="text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-dashed border-gray-300 dark:border-gray-600">
<UserPlusIcon className="w-12 h-12 mx-auto text-gray-400 mb-4" />
<p className="text-gray-600 dark:text-gray-400 mb-4">
Nenhum colaborador adicionado ainda
</p>
<Button
variant="secondary"
onClick={() => setShowInviteDialog(true)}
>
Convidar o Primeiro Colaborador
</Button>
</div>
) : (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">
Nome
</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">
Email
</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">
Função
</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">
Data de Adição
</th>
<th className="px-6 py-3 text-right text-sm font-semibold text-gray-900 dark:text-white">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{collaborators.map((collaborator) => (
<tr
key={collaborator.id}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-white font-medium">
{collaborator.name}
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
{collaborator.email}
</td>
<td className="px-6 py-4 text-sm">
<span className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${collaborator.agency_role === 'owner'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
}`}>
{collaborator.agency_role === 'owner' ? 'Dono' : 'Colaborador'}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
{formatDate(collaborator.collaborator_created_at || collaborator.created_at)}
</td>
<td className="px-6 py-4 text-right">
{collaborator.agency_role !== 'owner' && (
<button
onClick={() => {
setRemovingId(collaborator.id);
setShowRemoveDialog(true);
}}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 transition-colors"
>
<TrashIcon className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Informação sobre Permissões */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-300">
<p className="font-medium mb-1">Permissões dos Colaboradores:</p>
<ul className="list-disc list-inside space-y-0.5">
<li>Podem visualizar leads e clientes</li>
<li>Não podem editar ou remover dados</li>
<li>Permissões gerenciadas exclusivamente pelo dono</li>
</ul>
</div>
</div>
</div>
</div>
{/* Dialog: Convidar Colaborador */}
<Dialog
isOpen={showInviteDialog}
onClose={() => setShowInviteDialog(false)}
title="Convidar Colaborador"
>
<div className="space-y-4">
<Input
label="Nome"
placeholder="Nome completo do colaborador"
value={inviteForm.name}
onChange={(e) => setInviteForm({ ...inviteForm, name: e.target.value })}
disabled={inviting}
/>
<Input
label="Email"
type="email"
placeholder="email@exemplo.com"
value={inviteForm.email}
onChange={(e) => setInviteForm({ ...inviteForm, email: e.target.value })}
disabled={inviting}
/>
<div className="flex gap-3 justify-end pt-4">
<Button
variant="secondary"
onClick={() => setShowInviteDialog(false)}
disabled={inviting}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleInvite}
disabled={inviting || !inviteForm.email || !inviteForm.name}
>
{inviting ? 'Convidando...' : 'Convidar'}
</Button>
</div>
</div>
</Dialog>
{/* Dialog: Senha Temporária */}
<Dialog
isOpen={showPasswordDialog}
onClose={() => setShowPasswordDialog(false)}
title={passwordDialogMode === 'invite' ? 'Colaborador Convidado com Sucesso' : 'Colaborador Criado com Sucesso'}
>
<div className="space-y-4">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex gap-3">
<CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" />
<p className="text-sm text-green-800 dark:text-green-300">
{passwordDialogMode === 'invite'
? 'Colaborador criado com sucesso! Um email com a senha temporária foi enviado.'
: 'Colaborador criado com sucesso! Copie a senha abaixo e compartilhe com segurança.'}
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-900 dark:text-white">
Senha Temporária
</label>
<div className="flex gap-2">
<input
type="text"
value={tempPassword}
readOnly
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white font-mono text-sm"
/>
<Button
variant="secondary"
onClick={copyPassword}
className="px-4"
>
Copiar
</Button>
</div>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 text-sm text-yellow-800 dark:text-yellow-300">
{passwordDialogMode === 'invite'
? 'O colaborador deverá alterar a senha no primeiro acesso.'
: 'Compartilhe esta senha com segurança. O colaborador deverá alterá-la no primeiro acesso.'}
</div>
<div className="flex justify-end pt-4">
<Button
variant="primary"
onClick={() => setShowPasswordDialog(false)}
>
OK
</Button>
</div>
</div>
</Dialog>
{/* Dialog: Criar Colaborador Direto */}
<Dialog
isOpen={showDirectCreateDialog}
onClose={() => setShowDirectCreateDialog(false)}
title="Criar Colaborador (Sem Email)"
>
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 flex gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-800 dark:text-blue-300">
O colaborador será criado imediatamente. Você receberá a senha para compartilhar manualmente.
</p>
</div>
<Input
label="Nome"
placeholder="Nome completo do colaborador"
value={inviteForm.name}
onChange={(e) => setInviteForm({ ...inviteForm, name: e.target.value })}
disabled={inviting}
/>
<Input
label="Email"
type="email"
placeholder="email@exemplo.com"
value={inviteForm.email}
onChange={(e) => setInviteForm({ ...inviteForm, email: e.target.value })}
disabled={inviting}
/>
<div className="flex gap-3 justify-end pt-4">
<Button
variant="secondary"
onClick={() => setShowDirectCreateDialog(false)}
disabled={inviting}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleDirectCreate}
disabled={inviting || !inviteForm.email || !inviteForm.name}
>
{inviting ? 'Criando...' : 'Criar Colaborador'}
</Button>
</div>
</div>
</Dialog>
{/* Dialog: Remover Colaborador */}
<Dialog
isOpen={showRemoveDialog}
onClose={() => setShowRemoveDialog(false)}
title="Remover Colaborador"
>
<div className="space-y-4">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0" />
<p className="text-sm text-red-800 dark:text-red-300">
Tem certeza que deseja remover este colaborador? Ele perderá o acesso ao sistema imediatamente.
</p>
</div>
<div className="flex gap-3 justify-end pt-4">
<Button
variant="secondary"
onClick={() => setShowRemoveDialog(false)}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleRemove}
className="bg-red-600 hover:bg-red-700"
>
Remover
</Button>
</div>
</div>
</Dialog>
</>
);
}