feat: versão 1.5 - CRM Beta com leads, funis, campanhas e portal do cliente
This commit is contained in:
226
front-end-agency/components/crm/CRMCustomerFilter.tsx
Normal file
226
front-end-agency/components/crm/CRMCustomerFilter.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, Fragment } from 'react';
|
||||
import { useCRMFilter } from '@/contexts/CRMFilterContext';
|
||||
import { Combobox, Transition } from '@headlessui/react';
|
||||
import {
|
||||
FunnelIcon,
|
||||
XMarkIcon,
|
||||
CheckIcon,
|
||||
ChevronUpDownIcon,
|
||||
MagnifyingGlassIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
company?: string;
|
||||
logo_url?: string;
|
||||
}
|
||||
|
||||
export function CRMCustomerFilter() {
|
||||
const { selectedCustomerId, setSelectedCustomerId, customers, setCustomers, loading, setLoading } = useCRMFilter();
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
console.log('🔍 CRMCustomerFilter render, selectedCustomerId:', selectedCustomerId);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers();
|
||||
}, []);
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
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);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearFilter = () => {
|
||||
setSelectedCustomerId(null);
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
const selectedCustomer = customers.find(c => c.id === selectedCustomerId);
|
||||
|
||||
const filteredCustomers =
|
||||
query === ''
|
||||
? customers
|
||||
: customers.filter((customer: Customer) => {
|
||||
const nameMatch = customer.name.toLowerCase().includes(query.toLowerCase());
|
||||
const companyMatch = customer.company?.toLowerCase().includes(query.toLowerCase());
|
||||
return nameMatch || companyMatch;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:flex items-center gap-1 text-gray-400 mr-1">
|
||||
<FunnelIcon className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider">Filtro CRM</span>
|
||||
</div>
|
||||
|
||||
<Combobox
|
||||
value={selectedCustomerId}
|
||||
onChange={(value) => {
|
||||
console.log('🎯 CRMCustomerFilter: Selecting customer ID:', value);
|
||||
setSelectedCustomerId(value);
|
||||
setQuery('');
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="relative w-full min-w-[320px]">
|
||||
<Combobox.Input
|
||||
className="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 py-2.5 pl-10 pr-10 text-sm leading-5 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:bg-white dark:focus:bg-gray-800 transition-all duration-200"
|
||||
displayValue={(customerId: string) => {
|
||||
const customer = customers.find(c => c.id === customerId);
|
||||
if (!customer) return '';
|
||||
return customer.company
|
||||
? `${customer.name} (${customer.company})`
|
||||
: customer.name;
|
||||
}}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Pesquisar por nome ou empresa..."
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
{selectedCustomer?.logo_url ? (
|
||||
<img
|
||||
src={selectedCustomer.logo_url}
|
||||
className="h-5 w-5 rounded-full object-cover border border-gray-200 dark:border-gray-700"
|
||||
alt=""
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MagnifyingGlassIcon
|
||||
className="h-4 w-4 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon
|
||||
className="h-5 w-5 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Combobox.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
afterLeave={() => setQuery('')}
|
||||
>
|
||||
<Combobox.Options className="absolute z-50 mt-2 max-h-80 w-full overflow-auto rounded-xl bg-white dark:bg-gray-800 py-1 text-base shadow-2xl ring-1 ring-black/5 dark:ring-white/10 focus:outline-none sm:text-sm border border-gray-100 dark:border-gray-700">
|
||||
<Combobox.Option
|
||||
value={null}
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none py-3 pl-10 pr-4 ${active
|
||||
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-900 dark:text-brand-100'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span className={`block truncate ${selected ? 'font-semibold' : 'font-normal'}`}>
|
||||
Todos os Clientes (Visão Geral)
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-brand-600">
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-gray-400 uppercase tracking-widest border-t border-gray-50 dark:border-gray-700/50 mt-1">
|
||||
Clientes Disponíveis
|
||||
</div>
|
||||
|
||||
{filteredCustomers.length === 0 && query !== '' ? (
|
||||
<div className="relative cursor-default select-none py-4 px-4 text-center text-gray-500 dark:text-gray-400">
|
||||
<p className="text-sm">Nenhum cliente encontrado</p>
|
||||
<p className="text-xs mt-1">Tente outro termo de busca</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredCustomers.map((customer: Customer) => (
|
||||
<Combobox.Option
|
||||
key={customer.id}
|
||||
value={customer.id}
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none py-3 pl-10 pr-4 transition-colors ${active
|
||||
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-900 dark:text-brand-100'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
{customer.logo_url ? (
|
||||
<img
|
||||
src={customer.logo_url}
|
||||
alt={customer.name}
|
||||
className="w-8 h-8 rounded-full object-cover border border-gray-200 dark:border-gray-700"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(customer.name)}&background=random`;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center text-brand-700 dark:text-brand-300 text-xs font-bold">
|
||||
{customer.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className={`block truncate ${selected ? 'font-semibold text-brand-700 dark:text-brand-400' : 'font-medium'}`}>
|
||||
{customer.name}
|
||||
</span>
|
||||
{customer.company && (
|
||||
<span className={`block truncate text-xs ${active ? 'text-brand-600/70 dark:text-brand-400/70' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
{customer.company}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-brand-600">
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Combobox>
|
||||
|
||||
{selectedCustomerId && (
|
||||
<button
|
||||
onClick={handleClearFilter}
|
||||
className="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-400 hover:text-red-600 rounded-xl transition-all duration-200 flex-shrink-0 border border-transparent hover:border-red-100 dark:hover:border-red-900/30"
|
||||
title="Limpar filtro"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
545
front-end-agency/components/crm/KanbanBoard.tsx
Normal file
545
front-end-agency/components/crm/KanbanBoard.tsx
Normal file
@@ -0,0 +1,545 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useToast } from '@/components/layout/ToastContext';
|
||||
import Modal from '@/components/layout/Modal';
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
PlusIcon,
|
||||
UserIcon,
|
||||
EnvelopeIcon,
|
||||
PhoneIcon,
|
||||
Bars2Icon,
|
||||
TagIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
CalendarIcon,
|
||||
ClockIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface Stage {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
interface Lead {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
stage_id: string;
|
||||
funnel_id: string;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
status?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface KanbanBoardProps {
|
||||
funnelId: string;
|
||||
campaignId?: string;
|
||||
}
|
||||
|
||||
export default function KanbanBoard({ funnelId, campaignId }: KanbanBoardProps) {
|
||||
const [stages, setStages] = useState<Stage[]>([]);
|
||||
const [leads, setLeads] = useState<Lead[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [draggedLeadId, setDraggedLeadId] = useState<string | null>(null);
|
||||
const [dropTargetStageId, setDropTargetStageId] = useState<string | null>(null);
|
||||
const [movingLeadId, setMovingLeadId] = useState<string | null>(null);
|
||||
|
||||
// Modal states
|
||||
const [isLeadModalOpen, setIsLeadModalOpen] = useState(false);
|
||||
const [selectedLead, setSelectedLead] = useState<Lead | null>(null);
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [targetStageId, setTargetStageId] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
notes: '',
|
||||
tags: ''
|
||||
});
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (funnelId) {
|
||||
fetchData();
|
||||
}
|
||||
}, [funnelId, campaignId]);
|
||||
|
||||
// Refetch quando houver alterações externas (ex: criação de etapa no modal de configurações)
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => {
|
||||
console.log('KanbanBoard: External refresh triggered');
|
||||
fetchData();
|
||||
};
|
||||
window.addEventListener('kanban-refresh', handleRefresh);
|
||||
return () => window.removeEventListener('kanban-refresh', handleRefresh);
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
console.log('KanbanBoard: Fetching data for funnel:', funnelId, 'campaign:', campaignId);
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers = { 'Authorization': `Bearer ${token}` };
|
||||
|
||||
const [stagesRes, leadsRes] = await Promise.all([
|
||||
fetch(`/api/crm/funnels/${funnelId}/stages`, { headers }),
|
||||
campaignId
|
||||
? fetch(`/api/crm/lists/${campaignId}/leads`, { headers })
|
||||
: fetch(`/api/crm/leads`, { headers })
|
||||
]);
|
||||
|
||||
if (stagesRes.ok && leadsRes.ok) {
|
||||
const stagesData = await stagesRes.json();
|
||||
const leadsData = await leadsRes.json();
|
||||
|
||||
console.log('KanbanBoard: Received stages:', stagesData.stages?.length);
|
||||
console.log('KanbanBoard: Received leads:', leadsData.leads?.length);
|
||||
|
||||
setStages(stagesData.stages || []);
|
||||
setLeads(leadsData.leads || []);
|
||||
} else {
|
||||
console.error('KanbanBoard: API Error', stagesRes.status, leadsRes.status);
|
||||
toast.error('Erro ao carregar dados do servidor');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching kanban data:', error);
|
||||
toast.error('Erro de conexão ao carregar monitoramento');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const moveLead = async (leadId: string, newStageId: string) => {
|
||||
setMovingLeadId(leadId);
|
||||
// Optimistic update
|
||||
const originalLeads = [...leads];
|
||||
setLeads(prev => prev.map(l => l.id === leadId ? { ...l, stage_id: newStageId } : l));
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/crm/leads/${leadId}/stage`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ funnel_id: funnelId, stage_id: newStageId })
|
||||
});
|
||||
|
||||
console.log('KanbanBoard: Move lead response:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
setLeads(originalLeads);
|
||||
toast.error('Erro ao mover lead');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error moving lead:', error);
|
||||
setLeads(originalLeads);
|
||||
toast.error('Erro ao mover lead');
|
||||
} finally {
|
||||
setMovingLeadId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, leadId: string) => {
|
||||
console.log('KanbanBoard: Drag Start', leadId);
|
||||
setDraggedLeadId(leadId);
|
||||
e.dataTransfer.setData('text/plain', leadId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
|
||||
// Add a slight delay to make the original item semi-transparent
|
||||
const currentTarget = e.currentTarget as HTMLElement;
|
||||
setTimeout(() => {
|
||||
if (currentTarget) currentTarget.style.opacity = '0.4';
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleDragEnd = (e: React.DragEvent) => {
|
||||
console.log('KanbanBoard: Drag End');
|
||||
const currentTarget = e.currentTarget as HTMLElement;
|
||||
if (currentTarget) currentTarget.style.opacity = '1';
|
||||
setDraggedLeadId(null);
|
||||
setDropTargetStageId(null);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, stageId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (dropTargetStageId !== stageId) {
|
||||
setDropTargetStageId(stageId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, stageId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Use state if dataTransfer is empty (fallback)
|
||||
const leadId = e.dataTransfer.getData('text/plain') || draggedLeadId;
|
||||
|
||||
console.log('KanbanBoard: Drop', { leadId, stageId });
|
||||
setDropTargetStageId(null);
|
||||
|
||||
if (!leadId) {
|
||||
console.error('KanbanBoard: No leadId found');
|
||||
return;
|
||||
}
|
||||
|
||||
const lead = leads.find(l => l.id === leadId);
|
||||
if (lead && lead.stage_id !== stageId) {
|
||||
console.log('KanbanBoard: Moving lead', leadId, 'to stage', stageId);
|
||||
moveLead(leadId, stageId);
|
||||
} else {
|
||||
console.log('KanbanBoard: Lead already in stage or not found', { lead, stageId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLead = (stageId: string) => {
|
||||
setTargetStageId(stageId);
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
notes: '',
|
||||
tags: ''
|
||||
});
|
||||
setIsAddModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditLead = (lead: Lead) => {
|
||||
setSelectedLead(lead);
|
||||
setFormData({
|
||||
name: lead.name || '',
|
||||
email: lead.email || '',
|
||||
phone: lead.phone || '',
|
||||
notes: lead.notes || '',
|
||||
tags: lead.tags?.join(', ') || ''
|
||||
});
|
||||
setIsLeadModalOpen(true);
|
||||
};
|
||||
|
||||
const saveLead = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const isEditing = !!selectedLead;
|
||||
const url = isEditing ? `/api/crm/leads/${selectedLead.id}` : '/api/crm/leads';
|
||||
const method = isEditing ? 'PUT' : 'POST';
|
||||
|
||||
const payload = {
|
||||
...formData,
|
||||
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t),
|
||||
funnel_id: funnelId,
|
||||
stage_id: isEditing ? selectedLead.stage_id : targetStageId,
|
||||
status: isEditing ? selectedLead.status : 'novo'
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(isEditing ? 'Lead atualizado' : 'Lead criado');
|
||||
setIsAddModalOpen(false);
|
||||
setIsLeadModalOpen(false);
|
||||
fetchData();
|
||||
} else {
|
||||
toast.error('Erro ao salvar lead');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving lead:', error);
|
||||
toast.error('Erro de conexão');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-6 overflow-x-auto pb-4 h-full scrollbar-thin scrollbar-thumb-zinc-300"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{stages.map(stage => (
|
||||
<div
|
||||
key={stage.id}
|
||||
className={`flex-shrink-0 w-80 flex flex-col rounded-2xl transition-all duration-200 h-full border border-zinc-200/50 ${dropTargetStageId === stage.id
|
||||
? 'bg-brand-50/50 ring-2 ring-brand-500/30'
|
||||
: 'bg-white'
|
||||
}`}
|
||||
onDragOver={(e) => handleDragOver(e, stage.id)}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
setDropTargetStageId(stage.id);
|
||||
}}
|
||||
onDrop={(e) => handleDrop(e, stage.id)}
|
||||
>
|
||||
{/* Header da Coluna */}
|
||||
<div className="p-4 flex items-center justify-between sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-1.5 h-5 rounded-full"
|
||||
style={{ backgroundColor: stage.color }}
|
||||
></div>
|
||||
<div>
|
||||
<h3 className="font-bold text-zinc-900 text-xs uppercase tracking-widest">
|
||||
{stage.name}
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-400 font-bold">
|
||||
{leads.filter(l => l.stage_id === stage.id).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="p-1.5 text-zinc-400 hover:text-zinc-600 hover:bg-zinc-50 rounded-lg transition-colors">
|
||||
<EllipsisVerticalIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lista de Cards */}
|
||||
<div className="px-3 pb-3 flex-1 overflow-y-auto space-y-3 scrollbar-thin scrollbar-thumb-zinc-200">
|
||||
{leads.filter(l => l.stage_id === stage.id).map(lead => (
|
||||
<div
|
||||
key={lead.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, lead.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => handleEditLead(lead)}
|
||||
className={`bg-white p-4 rounded-xl shadow-sm border border-zinc-200 hover:shadow-md hover:border-brand-300 transition-all duration-200 cursor-grab active:cursor-grabbing group relative select-none ${draggedLeadId === lead.id ? 'ring-2 ring-brand-500 ring-offset-2' : ''
|
||||
} ${movingLeadId === lead.id ? 'opacity-50 grayscale' : ''}`}
|
||||
>
|
||||
{movingLeadId === lead.id && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-xl z-10">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-brand-500"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-zinc-100 flex items-center justify-center">
|
||||
<UserIcon className="w-3.5 h-3.5 text-zinc-500" />
|
||||
</div>
|
||||
<h4 className="font-bold text-zinc-900 text-sm leading-tight">
|
||||
{lead.name || 'Sem nome'}
|
||||
</h4>
|
||||
</div>
|
||||
<Bars2Icon className="w-4 h-4 text-zinc-300 group-hover:text-zinc-400 transition-colors" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{lead.email && (
|
||||
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
|
||||
<EnvelopeIcon className="h-3 w-3" />
|
||||
<span className="truncate">{lead.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{lead.phone && (
|
||||
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
|
||||
<PhoneIcon className="h-3 w-3" />
|
||||
<span>{lead.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lead.tags && lead.tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{lead.tags.slice(0, 2).map((tag, i) => (
|
||||
<span key={i} className="px-1.5 py-0.5 bg-zinc-100 text-zinc-600 text-[9px] font-bold rounded uppercase tracking-wider">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{lead.tags.length > 2 && (
|
||||
<span className="text-[9px] font-bold text-zinc-400">+{lead.tags.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badge de Status (Opcional) */}
|
||||
<div className="mt-4 pt-3 border-t border-zinc-100 flex items-center justify-between">
|
||||
<span className="text-[9px] font-bold text-zinc-400 uppercase tracking-tighter">
|
||||
#{lead.id.slice(0, 6)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{lead.notes && (
|
||||
<ChatBubbleLeftRightIcon className="h-3 w-3 text-brand-500" />
|
||||
)}
|
||||
<div className="w-5 h-5 rounded-full border border-white bg-brand-100 flex items-center justify-center">
|
||||
<span className="text-[7px] font-bold text-brand-600">AG</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{leads.filter(l => l.stage_id === stage.id).length === 0 && (
|
||||
<div className="py-8 flex flex-col items-center justify-center border-2 border-dashed border-zinc-200 rounded-xl">
|
||||
<span className="text-[9px] font-bold text-zinc-400 uppercase tracking-widest">
|
||||
Vazio
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer da Coluna */}
|
||||
{campaignId && (
|
||||
<div className="p-3 sticky bottom-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddLead(stage.id);
|
||||
}}
|
||||
className="w-full py-2 text-[10px] font-bold text-zinc-400 dark:text-zinc-500 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-white dark:hover:bg-zinc-800 rounded-xl flex items-center justify-center gap-2 transition-all duration-200 border border-transparent hover:border-zinc-200 dark:hover:border-zinc-700"
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
NOVO LEAD
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Modal de Adicionar/Editar Lead */}
|
||||
<Modal
|
||||
isOpen={isAddModalOpen || isLeadModalOpen}
|
||||
onClose={() => {
|
||||
setIsAddModalOpen(false);
|
||||
setIsLeadModalOpen(false);
|
||||
setSelectedLead(null);
|
||||
}}
|
||||
title={isAddModalOpen ? 'Novo Lead' : 'Detalhes do Lead'}
|
||||
maxWidth="lg"
|
||||
>
|
||||
<form onSubmit={saveLead} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Nome</label>
|
||||
<div className="relative">
|
||||
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-zinc-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
|
||||
placeholder="Nome do lead"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">E-mail</label>
|
||||
<div className="relative">
|
||||
<EnvelopeIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400" />
|
||||
<input
|
||||
type="email"
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-zinc-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
|
||||
placeholder="email@exemplo.com"
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Telefone</label>
|
||||
<div className="relative">
|
||||
<PhoneIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-zinc-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
|
||||
placeholder="(00) 00000-0000"
|
||||
value={formData.phone}
|
||||
onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Tags (separadas por vírgula)</label>
|
||||
<div className="relative">
|
||||
<TagIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-zinc-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none"
|
||||
placeholder="vendas, urgente, frio"
|
||||
value={formData.tags}
|
||||
onChange={e => setFormData({ ...formData, tags: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase ml-1">Notas de Acompanhamento</label>
|
||||
<div className="relative">
|
||||
<ChatBubbleLeftRightIcon className="absolute left-3 top-3 h-4 w-4 text-zinc-400" />
|
||||
<textarea
|
||||
rows={4}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-zinc-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-500/20 outline-none resize-none"
|
||||
placeholder="Descreva o histórico ou próximas ações..."
|
||||
value={formData.notes}
|
||||
onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedLead && (
|
||||
<div className="p-4 bg-white rounded-xl border border-zinc-100 grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
<span>Criado em: {new Date(selectedLead.created_at || '').toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||
<ClockIcon className="h-4 w-4" />
|
||||
<span>ID: {selectedLead.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsAddModalOpen(false);
|
||||
setIsLeadModalOpen(false);
|
||||
setSelectedLead(null);
|
||||
}}
|
||||
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-8 py-2.5 bg-brand-600 hover:bg-brand-700 text-white text-sm font-bold rounded-xl shadow-lg shadow-brand-500/20 transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSaving && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>}
|
||||
{isAddModalOpen ? 'CRIAR LEAD' : 'SALVAR ALTERAÇÕES'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user