Files
aggios.app/front-end-agency/app/(agency)/crm/funis/page.tsx

457 lines
24 KiB
TypeScript

"use client";
import { useState, useEffect } from 'react';
import { FunnelIcon, PlusIcon, TrashIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useRouter } from 'next/navigation';
import { useToast } from '@/components/layout/ToastContext';
import Modal from '@/components/layout/Modal';
import ConfirmDialog from '@/components/layout/ConfirmDialog';
interface Funnel {
id: string;
name: string;
description: string;
is_default: boolean;
}
const FUNNEL_TEMPLATES = [
{
name: 'Vendas Padrão',
description: 'Funil clássico para prospecção e fechamento de negócios.',
stages: [
{ name: 'Novo Lead', color: '#3b82f6' },
{ name: 'Qualificado', color: '#10b981' },
{ name: 'Reunião Agendada', color: '#f59e0b' },
{ name: 'Proposta Enviada', color: '#6366f1' },
{ name: 'Negociação', color: '#8b5cf6' },
{ name: 'Fechado / Ganho', color: '#22c55e' },
{ name: 'Perdido', color: '#ef4444' }
]
},
{
name: 'Onboarding de Clientes',
description: 'Acompanhamento após a venda até o sucesso do cliente.',
stages: [
{ name: 'Contrato Assinado', color: '#10b981' },
{ name: 'Briefing', color: '#3b82f6' },
{ name: 'Setup Inicial', color: '#6366f1' },
{ name: 'Treinamento', color: '#f59e0b' },
{ name: 'Lançamento', color: '#8b5cf6' },
{ name: 'Sucesso', color: '#22c55e' }
]
},
{
name: 'Suporte / Atendimento',
description: 'Gestão de chamados e solicitações de clientes.',
stages: [
{ name: 'Aberto', color: '#ef4444' },
{ name: 'Em Atendimento', color: '#f59e0b' },
{ name: 'Aguardando Cliente', color: '#3b82f6' },
{ name: 'Resolvido', color: '#10b981' },
{ name: 'Fechado', color: '#71717a' }
]
}
];
export default function FunisPage() {
const router = useRouter();
const [funnels, setFunnels] = useState<Funnel[]>([]);
const [campaigns, setCampaigns] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isFunnelModalOpen, setIsFunnelModalOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [confirmOpen, setConfirmOpen] = useState(false);
const [funnelToDelete, setFunnelToDelete] = useState<string | null>(null);
const [funnelForm, setFunnelForm] = useState({
name: '',
description: '',
template_index: -1,
campaign_id: ''
});
const toast = useToast();
useEffect(() => {
fetchFunnels();
fetchCampaigns();
}, []);
const fetchCampaigns = async () => {
try {
const response = await fetch('/api/crm/lists', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (response.ok) {
const data = await response.json();
setCampaigns(data.lists || []);
}
} catch (error) {
console.error('Erro ao buscar campanhas:', error);
}
};
const fetchFunnels = async () => {
try {
const response = await fetch('/api/crm/funnels', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (response.ok) {
const data = await response.json();
setFunnels(data.funnels || []);
}
} catch (error) {
console.error('Error fetching funnels:', error);
toast.error('Erro ao carregar funis');
} finally {
setLoading(false);
}
};
const handleCreateFunnel = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
const response = await fetch('/api/crm/funnels', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
name: funnelForm.name,
description: funnelForm.description,
is_default: funnels.length === 0
})
});
if (response.ok) {
const data = await response.json();
const newFunnelId = data.id;
// Se selecionou uma campanha, vincular o funil a ela
if (funnelForm.campaign_id) {
const campaign = campaigns.find(c => c.id === funnelForm.campaign_id);
if (campaign) {
await fetch(`/api/crm/lists/${campaign.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
...campaign,
funnel_id: newFunnelId
})
});
}
}
// Se escolheu um template, criar as etapas
if (funnelForm.template_index >= 0) {
const template = FUNNEL_TEMPLATES[funnelForm.template_index];
for (let i = 0; i < template.stages.length; i++) {
const s = template.stages[i];
await fetch(`/api/crm/funnels/${newFunnelId}/stages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
name: s.name,
color: s.color,
order_index: i
})
});
}
}
toast.success('Funil criado com sucesso');
setIsFunnelModalOpen(false);
setFunnelForm({ name: '', description: '', template_index: -1, campaign_id: '' });
fetchFunnels();
router.push(`/crm/funis/${newFunnelId}`);
}
} catch (error) {
toast.error('Erro ao criar funil');
} finally {
setIsSaving(false);
}
};
const handleDeleteFunnel = async () => {
if (!funnelToDelete) return;
try {
const response = await fetch(`/api/crm/funnels/${funnelToDelete}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (response.ok) {
toast.success('Funil excluído com sucesso');
setFunnels(funnels.filter(f => f.id !== funnelToDelete));
} else {
toast.error('Erro ao excluir funil');
}
} catch (error) {
toast.error('Erro ao excluir funil');
} finally {
setConfirmOpen(false);
setFunnelToDelete(null);
}
};
const filteredFunnels = funnels.filter(f =>
f.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(f.description || '').toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">Funis de Vendas</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Gerencie seus funis e acompanhe o progresso dos leads
</p>
</div>
<button
onClick={() => setIsFunnelModalOpen(true)}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
style={{ background: 'var(--gradient)' }}
>
<PlusIcon className="w-4 h-4" />
Novo Funil
</button>
</div>
{/* Search */}
<div className="relative w-full lg:w-96">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg leading-5 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-1 focus:ring-[var(--brand-color)] focus:border-[var(--brand-color)] sm:text-sm transition duration-150 ease-in-out"
placeholder="Buscar funis..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Content */}
{loading ? (
<div className="flex items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
</div>
) : filteredFunnels.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 text-center p-8">
<div className="w-16 h-16 bg-zinc-50 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4">
<FunnelIcon className="w-8 h-8 text-zinc-400" />
</div>
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
Nenhum funil encontrado
</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
{searchTerm ? 'Nenhum funil corresponde à sua busca.' : 'Comece criando seu primeiro funil de vendas.'}
</p>
</div>
) : (
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800">
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Funil</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Etapas</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Status</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{filteredFunnels.map((funnel) => (
<tr
key={funnel.id}
onClick={() => router.push(`/crm/funis/${funnel.id}`)}
className="group hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors cursor-pointer"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white shadow-sm bg-gradient-to-br from-brand-500 to-brand-600">
<FunnelIcon className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-zinc-900 dark:text-white flex items-center gap-2">
{funnel.name}
{funnel.is_default && (
<span className="inline-block px-1.5 py-0.5 text-[10px] font-bold text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded">
PADRÃO
</span>
)}
</div>
{funnel.description && (
<div className="text-sm text-zinc-500 dark:text-zinc-400 truncate max-w-md">
{funnel.description}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-zinc-700 dark:text-zinc-300">
Clique para ver
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
Ativo
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={(e) => {
e.stopPropagation();
setFunnelToDelete(funnel.id);
setConfirmOpen(true);
}}
className="text-zinc-400 hover:text-red-600 transition-colors p-2"
title="Excluir"
>
<TrashIcon className="w-5 h-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Modal Criar Funil */}
<Modal
isOpen={isFunnelModalOpen}
onClose={() => setIsFunnelModalOpen(false)}
title="Criar Novo Funil"
maxWidth="2xl"
>
<form onSubmit={handleCreateFunnel} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="space-y-1">
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Nome do Funil</label>
<input
type="text"
required
className="w-full px-4 py-2.5 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
placeholder="Ex: Vendas High Ticket"
value={funnelForm.name}
onChange={e => setFunnelForm({ ...funnelForm, name: e.target.value })}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Descrição (Opcional)</label>
<textarea
rows={3}
className="w-full px-4 py-2.5 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none resize-none"
placeholder="Para que serve este funil?"
value={funnelForm.description}
onChange={e => setFunnelForm({ ...funnelForm, description: e.target.value })}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Vincular à Campanha (Opcional)</label>
<select
className="w-full px-4 py-2.5 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
value={funnelForm.campaign_id}
onChange={e => setFunnelForm({ ...funnelForm, campaign_id: e.target.value })}
>
<option value="">Nenhuma campanha selecionada</option>
{campaigns.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
</div>
<div className="space-y-4">
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Escolha um Template</label>
<div className="space-y-2 max-h-[250px] overflow-y-auto pr-2 scrollbar-thin">
{FUNNEL_TEMPLATES.map((template, idx) => (
<button
key={idx}
type="button"
onClick={() => setFunnelForm({ ...funnelForm, template_index: idx })}
className={`w-full p-4 text-left rounded-xl border transition-all ${funnelForm.template_index === idx
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-900/10 ring-1 ring-brand-500'
: 'border-zinc-200 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="font-bold text-sm text-zinc-900 dark:text-white">{template.name}</span>
</div>
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 leading-relaxed">
{template.description}
</p>
<div className="mt-2 flex gap-1">
{template.stages.slice(0, 4).map((s, i) => (
<div key={i} className="h-1 w-4 rounded-full" style={{ backgroundColor: s.color }}></div>
))}
{template.stages.length > 4 && <span className="text-[8px] text-zinc-400">+{template.stages.length - 4}</span>}
</div>
</button>
))}
<button
type="button"
onClick={() => setFunnelForm({ ...funnelForm, template_index: -1 })}
className={`w-full p-4 text-left rounded-xl border transition-all ${funnelForm.template_index === -1
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-900/10 ring-1 ring-brand-500'
: 'border-zinc-200 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700'
}`}
>
<span className="font-bold text-sm text-zinc-900 dark:text-white">Personalizado</span>
<p className="text-[10px] text-zinc-500 dark:text-zinc-400">Comece com um funil vazio e crie suas próprias etapas.</p>
</button>
</div>
</div>
</div>
<div className="flex justify-end gap-3 pt-6 border-t border-zinc-100 dark:border-zinc-800">
<button
type="button"
onClick={() => setIsFunnelModalOpen(false)}
className="px-6 py-2.5 text-sm font-bold text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={isSaving}
className="px-6 py-2.5 text-sm font-bold text-white rounded-xl transition-all disabled:opacity-50"
style={{ background: 'var(--gradient)' }}
>
{isSaving ? 'Criando...' : 'Criar Funil'}
</button>
</div>
</form>
</Modal>
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setFunnelToDelete(null);
}}
onConfirm={handleDeleteFunnel}
title="Excluir Funil"
message="Tem certeza que deseja excluir este funil e todas as suas etapas? Leads vinculados a este funil ficarão órfãos."
confirmText="Excluir"
cancelText="Cancelar"
/>
</div>
);
}