546 lines
25 KiB
TypeScript
546 lines
25 KiB
TypeScript
"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>
|
|
);
|
|
}
|