feat: versão 1.5 - CRM Beta com leads, funis, campanhas e portal do cliente
This commit is contained in:
426
front-end-agency/app/(agency)/crm/funis/[id]/page.tsx
Normal file
426
front-end-agency/app/(agency)/crm/funis/[id]/page.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { FunnelIcon, Cog6ToothIcon, TrashIcon, PencilIcon, CheckIcon, ChevronUpIcon, ChevronDownIcon, RectangleStackIcon, ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||
import KanbanBoard from '@/components/crm/KanbanBoard';
|
||||
import { useToast } from '@/components/layout/ToastContext';
|
||||
import Modal from '@/components/layout/Modal';
|
||||
import ConfirmDialog from '@/components/layout/ConfirmDialog';
|
||||
|
||||
interface Stage {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
interface Funnel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export default function FunnelDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const funnelId = params.id as string;
|
||||
const [funnel, setFunnel] = useState<Funnel | null>(null);
|
||||
const [stages, setStages] = useState<Stage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editingStageId, setEditingStageId] = useState<string | null>(null);
|
||||
const [confirmStageOpen, setConfirmStageOpen] = useState(false);
|
||||
const [stageToDelete, setStageToDelete] = useState<string | null>(null);
|
||||
const [newStageForm, setNewStageForm] = useState({ name: '', color: '#3b82f6' });
|
||||
const [editStageForm, setEditStageForm] = useState<{ id: string; name: string; color: string }>({ id: '', name: '', color: '' });
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchFunnel();
|
||||
fetchStages();
|
||||
}, [funnelId]);
|
||||
|
||||
const fetchFunnel = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/crm/funnels/${funnelId}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setFunnel(data.funnel);
|
||||
} else {
|
||||
toast.error('Funil não encontrado');
|
||||
router.push('/crm/funis');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching funnel:', error);
|
||||
toast.error('Erro ao carregar funil');
|
||||
router.push('/crm/funis');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStages = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/crm/funnels/${funnelId}/stages`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStages((data.stages || []).sort((a: Stage, b: Stage) => a.order_index - b.order_index));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching stages:', error);
|
||||
toast.error('Erro ao carregar etapas');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddStage = async () => {
|
||||
if (!newStageForm.name.trim()) {
|
||||
toast.error('Digite o nome da etapa');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/crm/funnels/${funnelId}/stages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: newStageForm.name,
|
||||
color: newStageForm.color,
|
||||
order_index: stages.length
|
||||
})
|
||||
});
|
||||
if (response.ok) {
|
||||
toast.success('Etapa criada');
|
||||
setNewStageForm({ name: '', color: '#3b82f6' });
|
||||
fetchStages();
|
||||
// Notificar o KanbanBoard para refetch
|
||||
window.dispatchEvent(new Event('kanban-refresh'));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erro ao criar etapa');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStage = async () => {
|
||||
if (!editStageForm.name.trim()) {
|
||||
toast.error('Nome não pode estar vazio');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/crm/funnels/${funnelId}/stages/${editStageForm.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: editStageForm.name,
|
||||
color: editStageForm.color,
|
||||
order_index: stages.find(s => s.id === editStageForm.id)?.order_index || 0
|
||||
})
|
||||
});
|
||||
if (response.ok) {
|
||||
toast.success('Etapa atualizada');
|
||||
setEditingStageId(null);
|
||||
fetchStages();
|
||||
window.dispatchEvent(new Event('kanban-refresh'));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erro ao atualizar etapa');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteStage = async () => {
|
||||
if (!stageToDelete) return;
|
||||
try {
|
||||
const response = await fetch(`/api/crm/funnels/${funnelId}/stages/${stageToDelete}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
toast.success('Etapa excluída');
|
||||
fetchStages();
|
||||
window.dispatchEvent(new Event('kanban-refresh'));
|
||||
} else {
|
||||
toast.error('Erro ao excluir etapa');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erro ao excluir etapa');
|
||||
} finally {
|
||||
setConfirmStageOpen(false);
|
||||
setStageToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveStage = async (stageId: string, direction: 'up' | 'down') => {
|
||||
const idx = stages.findIndex(s => s.id === stageId);
|
||||
if (idx === -1) return;
|
||||
if (direction === 'up' && idx === 0) return;
|
||||
if (direction === 'down' && idx === stages.length - 1) return;
|
||||
|
||||
const newStages = [...stages];
|
||||
const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
[newStages[idx], newStages[targetIdx]] = [newStages[targetIdx], newStages[idx]];
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
newStages.map((s, i) =>
|
||||
fetch(`/api/crm/funnels/${funnelId}/stages/${s.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ ...s, order_index: i })
|
||||
})
|
||||
)
|
||||
);
|
||||
fetchStages();
|
||||
window.dispatchEvent(new Event('kanban-refresh'));
|
||||
} catch (error) {
|
||||
toast.error('Erro ao reordenar etapas');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!funnel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push('/crm/funis')}
|
||||
className="p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
title="Voltar"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 text-zinc-700 dark:text-zinc-300" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight flex items-center gap-2">
|
||||
{funnel.name}
|
||||
{funnel.is_default && (
|
||||
<span className="inline-block px-2 py-0.5 text-xs font-bold text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded">
|
||||
PADRÃO
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
{funnel.description && (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
{funnel.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsSettingsModalOpen(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<Cog6ToothIcon className="w-4 h-4" />
|
||||
Configurar Etapas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Kanban */}
|
||||
{stages.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">
|
||||
<RectangleStackIcon className="w-8 h-8 text-zinc-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
|
||||
Nenhuma etapa configurada
|
||||
</h3>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto mb-4">
|
||||
Configure as etapas do funil para começar a gerenciar seus leads.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsSettingsModalOpen(true)}
|
||||
className="inline-flex items-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)' }}
|
||||
>
|
||||
<Cog6ToothIcon className="w-4 h-4" />
|
||||
Configurar Etapas
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<KanbanBoard
|
||||
funnelId={funnelId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal Configurações */}
|
||||
<Modal
|
||||
isOpen={isSettingsModalOpen}
|
||||
onClose={() => setIsSettingsModalOpen(false)}
|
||||
title="Configurar Etapas do Funil"
|
||||
maxWidth="2xl"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Nova Etapa */}
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-xl space-y-3">
|
||||
<h3 className="text-sm font-bold text-zinc-700 dark:text-zinc-300">Nova Etapa</h3>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nome da etapa"
|
||||
className="w-full px-4 py-2.5 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
|
||||
value={newStageForm.name}
|
||||
onChange={e => setNewStageForm({ ...newStageForm, name: e.target.value })}
|
||||
onKeyPress={e => e.key === 'Enter' && handleAddStage()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={newStageForm.color}
|
||||
onChange={e => setNewStageForm({ ...newStageForm, color: e.target.value })}
|
||||
className="w-12 h-10 rounded-lg cursor-pointer"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddStage}
|
||||
className="px-4 py-2.5 text-sm font-bold text-white rounded-xl transition-all"
|
||||
style={{ background: 'var(--gradient)' }}
|
||||
>
|
||||
Adicionar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Etapas */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-bold text-zinc-700 dark:text-zinc-300">Etapas Configuradas</h3>
|
||||
{stages.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-500 dark:text-zinc-400">
|
||||
Nenhuma etapa configurada. Adicione a primeira etapa acima.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-2 scrollbar-thin">
|
||||
{stages.map((stage, idx) => (
|
||||
<div
|
||||
key={stage.id}
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4 flex items-center gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => handleMoveStage(stage.id, 'up')}
|
||||
disabled={idx === 0}
|
||||
className="p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronUpIcon className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveStage(stage.id, 'down')}
|
||||
disabled={idx === stages.length - 1}
|
||||
className="p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronDownIcon className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editingStageId === stage.id ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-3 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
|
||||
value={editStageForm.name}
|
||||
onChange={e => setEditStageForm({ ...editStageForm, name: e.target.value })}
|
||||
onKeyPress={e => e.key === 'Enter' && handleUpdateStage()}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={editStageForm.color}
|
||||
onChange={e => setEditStageForm({ ...editStageForm, color: e.target.value })}
|
||||
className="w-12 h-10 rounded-lg cursor-pointer"
|
||||
/>
|
||||
<button
|
||||
onClick={handleUpdateStage}
|
||||
className="p-2 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg"
|
||||
>
|
||||
<CheckIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="w-6 h-6 rounded-lg shadow-sm"
|
||||
style={{ backgroundColor: stage.color }}
|
||||
></div>
|
||||
<span className="flex-1 font-medium text-zinc-900 dark:text-white">{stage.name}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingStageId(stage.id);
|
||||
setEditStageForm({ id: stage.id, name: stage.name, color: stage.color });
|
||||
}}
|
||||
className="p-2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg"
|
||||
>
|
||||
<PencilIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStageToDelete(stage.id);
|
||||
setConfirmStageOpen(true);
|
||||
}}
|
||||
className="p-2 text-zinc-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg"
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<button
|
||||
onClick={() => setIsSettingsModalOpen(false)}
|
||||
className="px-6 py-2.5 text-sm font-bold text-white rounded-xl transition-all"
|
||||
style={{ background: 'var(--gradient)' }}
|
||||
>
|
||||
Concluir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmStageOpen}
|
||||
onClose={() => {
|
||||
setConfirmStageOpen(false);
|
||||
setStageToDelete(null);
|
||||
}}
|
||||
onConfirm={handleDeleteStage}
|
||||
title="Excluir Etapa"
|
||||
message="Tem certeza que deseja excluir esta etapa? Leads nesta etapa permanecerão no funil mas sem uma etapa definida."
|
||||
confirmText="Excluir"
|
||||
cancelText="Cancelar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,456 @@
|
||||
"use client";
|
||||
|
||||
import { FunnelIcon } from '@heroicons/react/24/outline';
|
||||
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 h-full flex items-center justify-center">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600">
|
||||
<FunnelIcon className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Funis de Vendas
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Esta funcionalidade está em desenvolvimento
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex gap-1">
|
||||
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-blue-600" style={{ animationDelay: '0ms' }}></span>
|
||||
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-blue-600" style={{ animationDelay: '150ms' }}></span>
|
||||
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-blue-600" style={{ animationDelay: '300ms' }}></span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">
|
||||
Em breve
|
||||
</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user