427 lines
21 KiB
TypeScript
427 lines
21 KiB
TypeScript
"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>
|
|
);
|
|
}
|