feat: versão 1.5 - CRM Beta com leads, funis, campanhas e portal do cliente

This commit is contained in:
Erik Silva
2025-12-24 17:36:52 -03:00
parent 99d828869a
commit dfb91c8ba5
98 changed files with 18255 additions and 1465 deletions

View File

@@ -0,0 +1,570 @@
"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>
</>
);
}