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,624 @@
"use client";
import { Fragment, useEffect, useState, use } from 'react';
import { Tab, Menu, Transition } from '@headlessui/react';
import {
UserGroupIcon,
InformationCircleIcon,
CreditCardIcon,
ArrowLeftIcon,
PlusIcon,
MagnifyingGlassIcon,
FunnelIcon,
EllipsisVerticalIcon,
PencilIcon,
TrashIcon,
EnvelopeIcon,
PhoneIcon,
TagIcon,
CalendarIcon,
UserIcon,
ArrowDownTrayIcon,
BriefcaseIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { useToast } from '@/components/layout/ToastContext';
import KanbanBoard from '@/components/crm/KanbanBoard';
interface Lead {
id: string;
name: string;
email: string;
phone: string;
status: string;
created_at: string;
tags: string[];
}
interface Campaign {
id: string;
name: string;
description: string;
color: string;
customer_id: string;
customer_name: string;
lead_count: number;
created_at: string;
}
const STATUS_OPTIONS = [
{ value: 'novo', label: 'Novo', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
{ value: 'qualificado', label: 'Qualificado', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
{ value: 'negociacao', label: 'Em Negociação', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
{ value: 'convertido', label: 'Convertido', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' },
{ value: 'perdido', label: 'Perdido', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
];
function classNames(...classes: string[]) {
return classes.filter(Boolean).join(' ');
}
export default function CampaignDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const toast = useToast();
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [leads, setLeads] = useState<Lead[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [funnels, setFunnels] = useState<any[]>([]);
const [selectedFunnelId, setSelectedFunnelId] = useState<string>('');
useEffect(() => {
fetchCampaignDetails();
fetchCampaignLeads();
fetchFunnels();
}, [id]);
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 || []);
if (data.funnels?.length > 0) {
setSelectedFunnelId(data.funnels[0].id);
}
}
} catch (error) {
console.error('Error fetching funnels:', error);
}
};
const fetchCampaignDetails = async () => {
try {
const response = await fetch(`/api/crm/lists`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
const found = data.lists?.find((l: Campaign) => l.id === id);
if (found) {
setCampaign(found);
}
}
} catch (error) {
console.error('Error fetching campaign details:', error);
}
};
const fetchCampaignLeads = async () => {
try {
const response = await fetch(`/api/crm/lists/${id}/leads`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
setLeads(data.leads || []);
}
} catch (error) {
console.error('Error fetching leads:', error);
} finally {
setLoading(false);
}
};
const filteredLeads = leads.filter(lead =>
(lead.name?.toLowerCase() || '').includes(searchTerm.toLowerCase()) ||
(lead.email?.toLowerCase() || '').includes(searchTerm.toLowerCase())
);
const handleExport = async (format: 'csv' | 'xlsx' | 'json') => {
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/crm/leads/export?format=${format}&campaign_id=${id}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `leads-${campaign?.name || 'campaign'}.${format === 'xlsx' ? 'xlsx' : format}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Exportado com sucesso!');
} else {
toast.error('Erro ao exportar leads');
}
} catch (error) {
console.error('Export error:', error);
toast.error('Erro ao exportar');
}
};
if (loading && !campaign) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500"></div>
</div>
);
}
if (!campaign) {
return (
<div className="p-8 text-center">
<h2 className="text-2xl font-bold text-zinc-900 dark:text-white">Campanha não encontrada</h2>
<Link href="/crm/campanhas" className="mt-4 inline-flex items-center text-brand-500 hover:underline">
<ArrowLeftIcon className="w-4 h-4 mr-2" />
Voltar para Campanhas
</Link>
</div>
);
}
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col gap-4">
<Link
href="/crm/campanhas"
className="inline-flex items-center text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-300 transition-colors"
>
<ArrowLeftIcon className="w-4 h-4 mr-2" />
Voltar para Campanhas
</Link>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center text-white shadow-lg"
style={{ backgroundColor: campaign.color }}
>
<UserGroupIcon className="w-8 h-8" />
</div>
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">
{campaign.name}
</h1>
<div className="flex items-center gap-2 mt-1">
{campaign.customer_name ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider bg-brand-50 text-brand-700 dark:bg-brand-900/20 dark:text-brand-400 border border-brand-100 dark:border-brand-800/50">
{campaign.customer_name}
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700">
Geral
</span>
)}
<span className="text-zinc-400 text-xs"></span>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{leads.length} leads vinculados
</span>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="relative inline-block text-left">
<Menu>
<Menu.Button className="inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors">
<ArrowDownTrayIcon className="w-4 h-4" />
Exportar
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 mt-2 w-48 origin-top-right divide-y divide-zinc-100 dark:divide-zinc-800 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-50 border border-zinc-200 dark:border-zinc-800">
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleExport('csv')}
className={`${active ? 'bg-zinc-50 dark:bg-zinc-800' : ''} group flex w-full items-center rounded-lg px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300`}
>
Exportar como CSV
</button>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleExport('xlsx')}
className={`${active ? 'bg-zinc-50 dark:bg-zinc-800' : ''} group flex w-full items-center rounded-lg px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300`}
>
Exportar como Excel
</button>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleExport('json')}
className={`${active ? 'bg-zinc-50 dark:bg-zinc-800' : ''} group flex w-full items-center rounded-lg px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300`}
>
Exportar como JSON
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
<button className="px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors">
Editar Campanha
</button>
<Link
href={`/crm/leads/importar?campaign=${campaign.id}`}
className="inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
style={{ background: 'var(--gradient)' }}
>
<PlusIcon className="w-4 h-4" />
Importar Leads
</Link>
</div>
</div>
</div>
{/* Tabs */}
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-zinc-100 dark:bg-zinc-800/50 p-1 max-w-lg">
<Tab className={({ selected }) =>
classNames(
'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all duration-200',
'ring-white ring-opacity-60 ring-offset-2 ring-offset-brand-400 focus:outline-none',
selected
? 'bg-white dark:bg-zinc-900 text-brand-600 dark:text-brand-400 shadow-sm'
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-white/[0.12]'
)
}>
<div className="flex items-center justify-center gap-2">
<FunnelIcon className="w-4 h-4" />
Monitoramento
</div>
</Tab>
<Tab className={({ selected }) =>
classNames(
'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all duration-200',
'ring-white ring-opacity-60 ring-offset-2 ring-offset-brand-400 focus:outline-none',
selected
? 'bg-white dark:bg-zinc-900 text-brand-600 dark:text-brand-400 shadow-sm'
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-white/[0.12]'
)
}>
<div className="flex items-center justify-center gap-2">
<UserGroupIcon className="w-4 h-4" />
Leads
</div>
</Tab>
<Tab className={({ selected }) =>
classNames(
'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all duration-200',
'ring-white ring-opacity-60 ring-offset-2 ring-offset-brand-400 focus:outline-none',
selected
? 'bg-white dark:bg-zinc-900 text-brand-600 dark:text-brand-400 shadow-sm'
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-white/[0.12]'
)
}>
<div className="flex items-center justify-center gap-2">
<InformationCircleIcon className="w-4 h-4" />
Informações
</div>
</Tab>
<Tab className={({ selected }) =>
classNames(
'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all duration-200',
'ring-white ring-opacity-60 ring-offset-2 ring-offset-brand-400 focus:outline-none',
selected
? 'bg-white dark:bg-zinc-900 text-brand-600 dark:text-brand-400 shadow-sm'
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-white/[0.12]'
)
}>
<div className="flex items-center justify-center gap-2">
<CreditCardIcon className="w-4 h-4" />
Pagamentos
</div>
</Tab>
</Tab.List>
<Tab.Panels className="mt-6">
{/* Monitoramento Panel */}
<Tab.Panel className="space-y-6">
{funnels.length > 0 ? (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="p-2 bg-brand-50 dark:bg-brand-900/20 rounded-lg">
<FunnelIcon className="h-5 w-5 text-brand-600 dark:text-brand-400" />
</div>
<div>
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider">Monitoramento de Leads</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400">Acompanhe o progresso dos leads desta campanha no funil.</p>
</div>
</div>
<div className="flex items-center gap-3">
<label className="text-xs font-bold text-zinc-500 uppercase">Funil:</label>
<select
value={selectedFunnelId}
onChange={(e) => setSelectedFunnelId(e.target.value)}
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg px-3 py-1.5 text-sm font-medium focus:ring-2 focus:ring-brand-500/20 outline-none"
>
{funnels.map(f => (
<option key={f.id} value={f.id}>{f.name}</option>
))}
</select>
</div>
</div>
<div className="flex-1 min-h-[600px]">
<KanbanBoard funnelId={selectedFunnelId} campaignId={id} />
</div>
</div>
) : (
<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 configurado
</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
Configure um funil de vendas para começar a monitorar os leads desta campanha.
</p>
<Link href="/crm/funis" className="mt-4 text-brand-600 font-medium hover:underline">
Configurar Funis
</Link>
</div>
)}
</Tab.Panel>
{/* Leads Panel */}
<Tab.Panel className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<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 leads nesta campanha..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<button className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors">
<FunnelIcon className="w-4 h-4" />
Filtros
</button>
</div>
</div>
{filteredLeads.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">
<UserGroupIcon className="w-8 h-8 text-zinc-400" />
</div>
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
Nenhum lead encontrado
</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
{searchTerm ? 'Nenhum lead corresponde à sua busca.' : 'Esta campanha ainda não possui leads vinculados.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredLeads.map((lead) => (
<div key={lead.id} className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-zinc-900 dark:text-white truncate">
{lead.name || 'Sem nome'}
</h3>
<span className={classNames(
'inline-block px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full mt-1',
STATUS_OPTIONS.find(s => s.value === lead.status)?.color || 'bg-zinc-100 text-zinc-800'
)}>
{STATUS_OPTIONS.find(s => s.value === lead.status)?.label || lead.status}
</span>
</div>
<button className="p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded text-zinc-400">
<EllipsisVerticalIcon className="w-5 h-5" />
</button>
</div>
<div className="space-y-2 text-sm">
{lead.email && (
<div className="flex items-center gap-2 text-zinc-600 dark:text-zinc-400">
<EnvelopeIcon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{lead.email}</span>
</div>
)}
{lead.phone && (
<div className="flex items-center gap-2 text-zinc-600 dark:text-zinc-400">
<PhoneIcon className="w-4 h-4 flex-shrink-0" />
<span>{lead.phone}</span>
</div>
)}
</div>
<div className="mt-4 pt-4 border-t border-zinc-100 dark:border-zinc-800 flex items-center justify-between">
<div className="flex items-center gap-1 text-[10px] text-zinc-400 uppercase font-bold tracking-widest">
<CalendarIcon className="w-3 h-3" />
{new Date(lead.created_at).toLocaleDateString('pt-BR')}
</div>
<button className="text-xs font-semibold text-brand-600 dark:text-brand-400 hover:underline">
Ver Detalhes
</button>
</div>
</div>
))}
</div>
)}
</Tab.Panel>
{/* Info Panel */}
<Tab.Panel>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<div className="p-6 border-b border-zinc-100 dark:border-zinc-800">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Detalhes da Campanha</h3>
</div>
<div className="p-6 space-y-6">
<div>
<label className="block text-xs font-bold text-zinc-400 uppercase tracking-widest mb-2">Descrição</label>
<p className="text-zinc-600 dark:text-zinc-400">
{campaign.description || 'Nenhuma descrição fornecida para esta campanha.'}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label className="block text-xs font-bold text-zinc-400 uppercase tracking-widest mb-2">Data de Criação</label>
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
<CalendarIcon className="w-5 h-5 text-zinc-400" />
{new Date(campaign.created_at).toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' })}
</div>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 uppercase tracking-widest mb-2">Cor de Identificação</label>
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full shadow-sm" style={{ backgroundColor: campaign.color }}></div>
<span className="text-zinc-900 dark:text-white font-medium">{campaign.color}</span>
</div>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<div className="p-6 border-b border-zinc-100 dark:border-zinc-800">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Configurações de Integração</h3>
</div>
<div className="p-6">
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 border border-zinc-200 dark:border-zinc-700">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-5 h-5 text-brand-500 mt-0.5" />
<div>
<h4 className="text-sm font-bold text-zinc-900 dark:text-white">Webhook de Entrada</h4>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">
Use este endpoint para enviar leads automaticamente de outras plataformas (Typeform, Elementor, etc).
</p>
<div className="mt-3 flex items-center gap-2">
<code className="flex-1 block p-2 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded text-[10px] text-zinc-600 dark:text-zinc-400 overflow-x-auto">
https://api.aggios.app/v1/webhooks/leads/{campaign.id}
</code>
<button className="p-2 text-zinc-400 hover:text-brand-500 transition-colors">
<TagIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 p-6">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Cliente Responsável</h3>
{campaign.customer_id ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center text-brand-600 dark:text-brand-400">
<UserIcon className="w-6 h-6" />
</div>
<div>
<p className="text-sm font-bold text-zinc-900 dark:text-white">{campaign.customer_name}</p>
<p className="text-xs text-zinc-500">Cliente Ativo</p>
</div>
</div>
<Link
href={`/crm/clientes?id=${campaign.customer_id}`}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-zinc-50 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-xs font-bold rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
>
<BriefcaseIcon className="w-4 h-4" />
Ver Perfil do Cliente
</Link>
</div>
) : (
<div className="text-center py-4">
<p className="text-sm text-zinc-500">Esta é uma campanha geral da agência.</p>
</div>
)}
</div>
<div className="bg-gradient-to-br from-brand-500 to-brand-600 rounded-2xl p-6 text-white shadow-lg">
<h3 className="text-lg font-bold mb-2">Resumo de Performance</h3>
<div className="space-y-4 mt-4">
<div className="flex justify-between items-end">
<span className="text-xs text-brand-100">Total de Leads</span>
<span className="text-2xl font-bold">{leads.length}</span>
</div>
<div className="w-full bg-white/20 rounded-full h-1.5">
<div className="bg-white h-1.5 rounded-full" style={{ width: '65%' }}></div>
</div>
<p className="text-[10px] text-brand-100">
+12% em relação ao mês passado
</p>
</div>
</div>
</div>
</div>
</Tab.Panel>
{/* Payments Panel */}
<Tab.Panel>
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<div className="p-12 text-center">
<div className="w-20 h-20 bg-zinc-50 dark:bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-6">
<CreditCardIcon className="w-10 h-10 text-zinc-400" />
</div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-white mb-2">Módulo de Pagamentos</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-md mx-auto mb-8">
Em breve você poderá gerenciar orçamentos, faturas e pagamentos vinculados diretamente a esta campanha.
</p>
<button className="px-6 py-3 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 font-bold rounded-xl hover:opacity-90 transition-opacity">
Solicitar Acesso Antecipado
</button>
</div>
</div>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
);
}

View File

@@ -0,0 +1,622 @@
"use client";
import { Fragment, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Menu, Transition } from '@headlessui/react';
import ConfirmDialog from '@/components/layout/ConfirmDialog';
import { useToast } from '@/components/layout/ToastContext';
import Pagination from '@/components/layout/Pagination';
import { useCRMFilter } from '@/contexts/CRMFilterContext';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import SearchableSelect from '@/components/form/SearchableSelect';
import {
ListBulletIcon,
TrashIcon,
PencilIcon,
EllipsisVerticalIcon,
MagnifyingGlassIcon,
PlusIcon,
XMarkIcon,
UserGroupIcon,
EyeIcon,
CalendarIcon,
RectangleStackIcon,
} from '@heroicons/react/24/outline';
interface List {
id: string;
tenant_id: string;
customer_id: string;
customer_name: string;
funnel_id?: string;
name: string;
description: string;
color: string;
customer_count: number;
lead_count: number;
created_at: string;
updated_at: string;
}
interface Funnel {
id: string;
name: string;
}
interface Customer {
id: string;
name: string;
company: string;
}
const COLORS = [
{ name: 'Azul', value: '#3B82F6' },
{ name: 'Verde', value: '#10B981' },
{ name: 'Roxo', value: '#8B5CF6' },
{ name: 'Rosa', value: '#EC4899' },
{ name: 'Laranja', value: '#F97316' },
{ name: 'Amarelo', value: '#EAB308' },
{ name: 'Vermelho', value: '#EF4444' },
{ name: 'Cinza', value: '#6B7280' },
];
function CampaignsContent() {
const router = useRouter();
const toast = useToast();
const { selectedCustomerId } = useCRMFilter();
console.log('📢 CampaignsPage render, selectedCustomerId:', selectedCustomerId);
const [lists, setLists] = useState<List[]>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [funnels, setFunnels] = useState<Funnel[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingList, setEditingList] = useState<List | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [listToDelete, setListToDelete] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [formData, setFormData] = useState({
name: '',
description: '',
color: COLORS[0].value,
customer_id: '',
funnel_id: '',
});
useEffect(() => {
console.log('🔄 CampaignsPage useEffect triggered by selectedCustomerId:', selectedCustomerId);
fetchLists();
fetchCustomers();
fetchFunnels();
}, [selectedCustomerId]);
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);
}
};
const fetchCustomers = async () => {
try {
const response = await fetch('/api/crm/customers', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
setCustomers(data.customers || []);
}
} catch (error) {
console.error('Error fetching customers:', error);
}
};
const fetchLists = async () => {
try {
setLoading(true);
const url = selectedCustomerId
? `/api/crm/lists?customer_id=${selectedCustomerId}`
: '/api/crm/lists';
console.log(`📊 Fetching campaigns from: ${url}`);
const response = await fetch(url, {
cache: 'no-store',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
console.log('📊 Campaigns data received:', data);
setLists(data.lists || []);
}
} catch (error) {
console.error('Error fetching campaigns:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const url = editingList
? `/api/crm/lists/${editingList.id}`
: '/api/crm/lists';
const method = editingList ? 'PUT' : 'POST';
try {
const response = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
if (response.ok) {
toast.success(
editingList ? 'Campanha atualizada' : 'Campanha criada',
editingList ? 'A campanha foi atualizada com sucesso.' : 'A nova campanha foi criada com sucesso.'
);
fetchLists();
handleCloseModal();
} else {
const error = await response.json();
toast.error('Erro', error.message || 'Não foi possível salvar a campanha.');
}
} catch (error) {
console.error('Error saving campaign:', error);
toast.error('Erro', 'Ocorreu um erro ao salvar a campanha.');
}
};
const handleNewCampaign = () => {
setEditingList(null);
setFormData({
name: '',
description: '',
color: COLORS[0].value,
customer_id: selectedCustomerId || '',
funnel_id: '',
});
setIsModalOpen(true);
};
const handleEdit = (list: List) => {
setEditingList(list);
setFormData({
name: list.name,
description: list.description,
color: list.color,
customer_id: list.customer_id || '',
funnel_id: list.funnel_id || '',
});
setIsModalOpen(true);
};
const handleDeleteClick = (id: string) => {
setListToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (!listToDelete) return;
try {
const response = await fetch(`/api/crm/lists/${listToDelete}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
setLists(lists.filter(l => l.id !== listToDelete));
toast.success('Campanha excluída', 'A campanha foi excluída com sucesso.');
} else {
toast.error('Erro ao excluir', 'Não foi possível excluir a campanha.');
}
} catch (error) {
console.error('Error deleting campaign:', error);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir a campanha.');
} finally {
setConfirmOpen(false);
setListToDelete(null);
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingList(null);
setFormData({
name: '',
description: '',
color: COLORS[0].value,
customer_id: '',
funnel_id: '',
});
};
const filteredLists = lists.filter((list) => {
const searchLower = searchTerm.toLowerCase();
return (
(list.name?.toLowerCase() || '').includes(searchLower) ||
(list.description?.toLowerCase() || '').includes(searchLower)
);
});
const totalPages = Math.ceil(filteredLists.length / itemsPerPage);
const paginatedLists = filteredLists.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
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">Campanhas</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Organize seus leads e rastreie a origem de cada um
</p>
</div>
<button
onClick={handleNewCampaign}
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" />
Nova Campanha
</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 campanhas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Table */}
{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>
) : filteredLists.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">
<ListBulletIcon className="w-8 h-8 text-zinc-400" />
</div>
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
Nenhuma campanha encontrada
</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
{searchTerm ? 'Nenhuma campanha corresponde à sua busca.' : 'Comece criando sua primeira campanha.'}
</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">Campanha</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Cliente Vinculado</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Leads</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Criada em</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">
{paginatedLists.map((list) => (
<tr
key={list.id}
onClick={() => router.push(`/crm/campanhas/${list.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"
style={{ backgroundColor: list.color }}
>
<ListBulletIcon className="w-5 h-5" />
</div>
<div>
<div className="text-sm font-semibold text-zinc-900 dark:text-white">
{list.name}
</div>
{list.description && (
<div className="text-xs text-zinc-500 dark:text-zinc-400 truncate max-w-[200px]">
{list.description}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{list.customer_name ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-brand-50 text-brand-700 dark:bg-brand-900/20 dark:text-brand-400 border border-brand-100 dark:border-brand-800/50">
{list.customer_name}
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700">
Geral
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-1.5">
<UserGroupIcon className="w-4 h-4 text-zinc-400" />
<span className="text-sm font-bold text-zinc-900 dark:text-white">{list.lead_count || 0}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-zinc-600 dark:text-zinc-400">
<div className="flex items-center gap-1.5">
<CalendarIcon className="w-4 h-4 text-zinc-400" />
{new Date(list.created_at).toLocaleDateString('pt-BR')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => router.push(`/crm/campanhas/${list.id}`)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20 rounded-lg hover:bg-brand-100 dark:hover:bg-brand-900/40 transition-all"
title="Monitorar Leads"
>
<RectangleStackIcon className="w-4 h-4" />
MONITORAR
</button>
<button
onClick={() => router.push(`/crm/campanhas/${list.id}`)}
className="p-2 text-zinc-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors"
title="Ver Detalhes"
>
<EyeIcon className="w-5 h-5" />
</button>
<Menu as="div" className="relative inline-block text-left">
<Menu.Button className="p-2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors">
<EllipsisVerticalIcon className="w-5 h-5" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 mt-2 w-48 origin-top-right divide-y divide-zinc-100 dark:divide-zinc-800 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-50 border border-zinc-200 dark:border-zinc-800">
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleEdit(list)}
className={`${active ? 'bg-zinc-50 dark:bg-zinc-800' : ''
} group flex w-full items-center rounded-lg px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300`}
>
<PencilIcon className="mr-2 h-4 w-4 text-zinc-400" />
Editar
</button>
)}
</Menu.Item>
</div>
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleDeleteClick(list.id)}
className={`${active ? 'bg-red-50 dark:bg-red-900/20' : ''
} group flex w-full items-center rounded-lg px-3 py-2 text-sm text-red-600 dark:text-red-400`}
>
<TrashIcon className="mr-2 h-4 w-4" />
Excluir
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={filteredLists.length}
itemsPerPage={itemsPerPage}
onPageChange={setCurrentPage}
/>
</div>
)}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity" onClick={handleCloseModal}></div>
<div className="relative transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-zinc-200 dark:border-zinc-800">
<div className="absolute right-0 top-0 pr-6 pt-6">
<button
type="button"
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={handleCloseModal}
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 sm:p-8">
<div className="flex items-start gap-4 mb-6">
<div
className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl shadow-lg"
style={{ backgroundColor: formData.color }}
>
<ListBulletIcon className="h-6 w-6 text-white" />
</div>
<div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">
{editingList ? 'Editar Campanha' : 'Nova Campanha'}
</h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{editingList ? 'Atualize as informações da campanha.' : 'Crie uma nova campanha para organizar seus leads.'}
</p>
</div>
</div>
<div className="space-y-4">
<SearchableSelect
label="Cliente Vinculado"
options={customers.map(c => ({
id: c.id,
name: c.name,
subtitle: c.company || undefined
}))}
value={formData.customer_id}
onChange={(value) => setFormData({ ...formData, customer_id: value || '' })}
placeholder="Nenhum cliente (Geral)"
emptyText="Nenhum cliente encontrado"
helperText="Vincule esta campanha a um cliente específico para melhor organização."
/>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Nome da Campanha *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Ex: Black Friday 2025"
required
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Descrição
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Descreva o propósito desta campanha"
rows={3}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent resize-none transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
Cor
</label>
<div className="grid grid-cols-8 gap-2">
{COLORS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => setFormData({ ...formData, color: color.value })}
className={`w-10 h-10 rounded-lg transition-all ${formData.color === color.value
? 'ring-2 ring-offset-2 ring-zinc-400 dark:ring-zinc-600 scale-110'
: 'hover:scale-105'
}`}
style={{ backgroundColor: color.value }}
title={color.name}
/>
))}
</div>
</div>
<SearchableSelect
label="Funil de Vendas"
options={funnels.map(f => ({
id: f.id,
name: f.name
}))}
value={formData.funnel_id}
onChange={(value) => setFormData({ ...formData, funnel_id: value || '' })}
placeholder="Nenhum funil selecionado"
emptyText="Nenhum funil encontrado. Crie um funil primeiro."
helperText="Leads desta campanha seguirão as etapas do funil selecionado."
/>
</div>
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-zinc-700 flex gap-3">
<button
type="button"
onClick={handleCloseModal}
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Cancelar
</button>
<button
type="submit"
className="flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-all shadow-lg hover:shadow-xl"
style={{ background: 'var(--gradient)' }}
>
{editingList ? 'Atualizar' : 'Criar Campanha'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setListToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Excluir Campanha"
message="Tem certeza que deseja excluir esta campanha? Os leads não serão excluídos, apenas removidos da campanha."
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
</div>
);
}
export default function CampaignsPage() {
return (
<SolutionGuard requiredSolution="crm">
<CampaignsContent />
</SolutionGuard>
);
}