All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m32s
- Updated DB schema to support multiple funnels (funnels table) and their stages (funnel_stages table).
- Added funnel_id to teams table to link teams to specific funnels.
- Redesigned /admin/funnels page ('Meus Funis') to allow creating multiple funnels, managing their stages, and assigning them to teams.
- Updated Dashboard, UserDetail, and AttendanceDetail to dynamically load the correct funnel based on the selected team or user's assigned team.
365 lines
19 KiB
TypeScript
365 lines
19 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Layers, Plus, Edit, Trash2, ChevronUp, ChevronDown, Loader2, X, Users } from 'lucide-react';
|
|
import { getFunnels, createFunnel, updateFunnel, deleteFunnel, createFunnelStage, updateFunnelStage, deleteFunnelStage, getTeams } from '../services/dataService';
|
|
import { FunnelDef, FunnelStageDef } from '../types';
|
|
|
|
export const Funnels: React.FC = () => {
|
|
const [funnels, setFunnels] = useState<FunnelDef[]>([]);
|
|
const [teams, setTeams] = useState<any[]>([]);
|
|
const [selectedFunnelId, setSelectedFunnelId] = useState<string | null>(null);
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [editingStage, setEditingStage] = useState<FunnelStageDef | null>(null);
|
|
const [formData, setFormData] = useState({ name: '', color_class: '' });
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
// Funnel creation state
|
|
const [isFunnelModalOpen, setIsFunnelModalOpen] = useState(false);
|
|
const [funnelName, setFunnelName] = useState('');
|
|
|
|
const tenantId = localStorage.getItem('ctms_tenant_id') || '';
|
|
|
|
const PRESET_COLORS = [
|
|
{ label: 'Cinza (Neutro)', value: 'bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-dark-input dark:text-dark-muted dark:border-dark-border' },
|
|
{ label: 'Azul (Início)', value: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800' },
|
|
{ label: 'Roxo (Meio)', value: 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800' },
|
|
{ label: 'Laranja (Atenção)', value: 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:border-orange-800' },
|
|
{ label: 'Verde (Sucesso)', value: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800' },
|
|
{ label: 'Vermelho (Perda)', value: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800' },
|
|
];
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
const [fetchedFunnels, fetchedTeams] = await Promise.all([
|
|
getFunnels(tenantId),
|
|
getTeams(tenantId)
|
|
]);
|
|
setFunnels(fetchedFunnels);
|
|
setTeams(fetchedTeams);
|
|
if (!selectedFunnelId && fetchedFunnels.length > 0) {
|
|
setSelectedFunnelId(fetchedFunnels[0].id);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [tenantId]);
|
|
|
|
const selectedFunnel = funnels.find(f => f.id === selectedFunnelId);
|
|
|
|
// --- Funnel Handlers ---
|
|
const handleCreateFunnel = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setIsSaving(true);
|
|
try {
|
|
const res = await createFunnel({ name: funnelName, tenantId });
|
|
setSelectedFunnelId(res.id);
|
|
setIsFunnelModalOpen(false);
|
|
setFunnelName('');
|
|
loadData();
|
|
} catch (err) {
|
|
alert("Erro ao criar funil.");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteFunnel = async (id: string) => {
|
|
if (funnels.length <= 1) {
|
|
alert("Você precisa ter pelo menos um funil ativo.");
|
|
return;
|
|
}
|
|
if (confirm('Tem certeza que deseja excluir este funil e todas as suas etapas?')) {
|
|
await deleteFunnel(id);
|
|
setSelectedFunnelId(null);
|
|
loadData();
|
|
}
|
|
};
|
|
|
|
const handleToggleTeam = async (teamId: string) => {
|
|
if (!selectedFunnel) return;
|
|
const currentTeamIds = selectedFunnel.teamIds || [];
|
|
const newTeamIds = currentTeamIds.includes(teamId)
|
|
? currentTeamIds.filter(id => id !== teamId)
|
|
: [...currentTeamIds, teamId];
|
|
|
|
// Optimistic
|
|
const newFunnels = [...funnels];
|
|
const idx = newFunnels.findIndex(f => f.id === selectedFunnel.id);
|
|
newFunnels[idx].teamIds = newTeamIds;
|
|
setFunnels(newFunnels);
|
|
|
|
await updateFunnel(selectedFunnel.id, { teamIds: newTeamIds });
|
|
};
|
|
|
|
// --- Stage Handlers ---
|
|
const handleSaveStage = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!selectedFunnel) return;
|
|
setIsSaving(true);
|
|
try {
|
|
if (editingStage) {
|
|
await updateFunnelStage(editingStage.id, formData);
|
|
} else {
|
|
await createFunnelStage(selectedFunnel.id, { ...formData, order_index: selectedFunnel.stages.length });
|
|
}
|
|
setIsModalOpen(false);
|
|
loadData();
|
|
} catch (err) {
|
|
alert("Erro ao salvar etapa.");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteStage = async (id: string) => {
|
|
if (confirm('Tem certeza que deseja excluir esta etapa?')) {
|
|
await deleteFunnelStage(id);
|
|
loadData();
|
|
}
|
|
};
|
|
|
|
const handleMoveStage = async (index: number, direction: 'up' | 'down') => {
|
|
if (!selectedFunnel) return;
|
|
const stages = selectedFunnel.stages;
|
|
if (direction === 'up' && index === 0) return;
|
|
if (direction === 'down' && index === stages.length - 1) return;
|
|
|
|
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
|
|
|
const tempOrder = stages[index].order_index;
|
|
stages[index].order_index = stages[newIndex].order_index;
|
|
stages[newIndex].order_index = tempOrder;
|
|
|
|
setFunnels([...funnels]); // trigger re-render
|
|
|
|
await Promise.all([
|
|
updateFunnelStage(stages[index].id, { order_index: stages[index].order_index }),
|
|
updateFunnelStage(stages[newIndex].id, { order_index: stages[newIndex].order_index })
|
|
]);
|
|
};
|
|
|
|
const openStageModal = (stage?: FunnelStageDef) => {
|
|
if (stage) {
|
|
setEditingStage(stage);
|
|
setFormData({ name: stage.name, color_class: stage.color_class });
|
|
} else {
|
|
setEditingStage(null);
|
|
setFormData({ name: '', color_class: PRESET_COLORS[0].value });
|
|
}
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
if (loading && funnels.length === 0) return <div className="p-12 flex justify-center"><Loader2 className="animate-spin text-zinc-400" size={32} /></div>;
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto space-y-6 pb-12 transition-colors duration-300 flex flex-col md:flex-row gap-8">
|
|
|
|
{/* Sidebar: Funnels List */}
|
|
<div className="w-full md:w-64 shrink-0 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-zinc-50">Meus Funis</h2>
|
|
<button onClick={() => setIsFunnelModalOpen(true)} className="p-1.5 bg-zinc-100 dark:bg-dark-bg text-zinc-600 dark:text-dark-muted rounded-lg hover:bg-zinc-200 dark:hover:bg-dark-border transition-colors">
|
|
<Plus size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
{funnels.map(f => (
|
|
<button
|
|
key={f.id}
|
|
onClick={() => setSelectedFunnelId(f.id)}
|
|
className={`text-left px-4 py-3 rounded-xl text-sm font-medium transition-all ${selectedFunnelId === f.id ? 'bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 shadow-md' : 'bg-white dark:bg-dark-card text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-dark-bg border border-zinc-200 dark:border-dark-border'}`}
|
|
>
|
|
<div className="flex justify-between items-center">
|
|
<span>{f.name}</span>
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${selectedFunnelId === f.id ? 'bg-white/20 dark:bg-black/20' : 'bg-zinc-100 dark:bg-dark-bg'}`}>{f.stages?.length || 0}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content: Selected Funnel Details */}
|
|
<div className="flex-1 space-y-6">
|
|
{selectedFunnel ? (
|
|
<>
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 border-b border-zinc-200 dark:border-dark-border pb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 tracking-tight">{selectedFunnel.name}</h1>
|
|
<p className="text-zinc-500 dark:text-zinc-400 text-sm">Gerencie as etapas deste funil e quais times o utilizam.</p>
|
|
</div>
|
|
<button onClick={() => handleDeleteFunnel(selectedFunnel.id)} className="text-red-500 hover:text-red-700 bg-red-50 dark:bg-red-900/20 px-3 py-2 rounded-lg text-sm font-semibold transition-colors flex items-center gap-2">
|
|
<Trash2 size={16} /> Excluir Funil
|
|
</button>
|
|
</div>
|
|
|
|
{/* Teams Assignment */}
|
|
<div className="bg-white dark:bg-dark-card rounded-2xl border border-zinc-200 dark:border-dark-border shadow-sm overflow-hidden">
|
|
<div className="px-6 py-4 border-b border-zinc-100 dark:border-dark-border bg-zinc-50/50 dark:bg-dark-bg/50">
|
|
<h3 className="font-bold text-zinc-900 dark:text-zinc-50 flex items-center gap-2">
|
|
<Users className="text-brand-yellow" size={18} /> Times Atribuídos
|
|
</h3>
|
|
</div>
|
|
<div className="p-6">
|
|
{teams.length === 0 ? (
|
|
<p className="text-sm text-zinc-500">Nenhum time cadastrado na organização.</p>
|
|
) : (
|
|
<div className="flex flex-wrap gap-3">
|
|
{teams.map(t => {
|
|
const isAssigned = selectedFunnel.teamIds?.includes(t.id);
|
|
return (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => handleToggleTeam(t.id)}
|
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${isAssigned ? 'bg-brand-yellow/10 border-brand-yellow text-zinc-900 dark:text-zinc-100' : 'bg-white dark:bg-dark-input border-zinc-200 dark:border-dark-border text-zinc-500 dark:text-zinc-400 hover:border-zinc-300 dark:hover:border-zinc-700'}`}
|
|
>
|
|
{t.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<p className="text-xs text-zinc-400 mt-4">Times não atribuídos a um funil específico usarão o Funil Padrão.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stages */}
|
|
<div className="bg-white dark:bg-dark-card rounded-2xl border border-zinc-200 dark:border-dark-border shadow-sm overflow-hidden">
|
|
<div className="px-6 py-4 border-b border-zinc-100 dark:border-dark-border bg-zinc-50/50 dark:bg-dark-bg/50 flex justify-between items-center">
|
|
<h3 className="font-bold text-zinc-900 dark:text-zinc-50 flex items-center gap-2">
|
|
<Layers className="text-brand-yellow" size={18} /> Etapas do Funil
|
|
</h3>
|
|
<button onClick={() => openStageModal()} className="text-sm font-bold text-brand-yellow hover:underline flex items-center gap-1">
|
|
<Plus size={16} /> Nova Etapa
|
|
</button>
|
|
</div>
|
|
|
|
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
|
|
{selectedFunnel.stages?.sort((a,b) => a.order_index - b.order_index).map((f, index) => (
|
|
<div key={f.id} className="p-4 flex items-center justify-between group hover:bg-zinc-50 dark:hover:bg-dark-bg transition-colors">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex flex-col gap-1">
|
|
<button onClick={() => handleMoveStage(index, 'up')} disabled={index === 0} className="p-1 text-zinc-300 hover:text-zinc-900 dark:hover:text-white disabled:opacity-30 transition-colors">
|
|
<ChevronUp size={16} />
|
|
</button>
|
|
<button onClick={() => handleMoveStage(index, 'down')} disabled={index === selectedFunnel.stages.length - 1} className="p-1 text-zinc-300 hover:text-zinc-900 dark:hover:text-white disabled:opacity-30 transition-colors">
|
|
<ChevronDown size={16} />
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<span className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide border ${f.color_class}`}>
|
|
{f.name}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<button onClick={() => openStageModal(f)} className="p-2 text-zinc-400 hover:text-brand-yellow hover:bg-zinc-100 dark:hover:bg-dark-input rounded-lg transition-colors">
|
|
<Edit size={16} />
|
|
</button>
|
|
<button onClick={() => handleDeleteStage(f.id)} className="p-2 text-zinc-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors">
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{(!selectedFunnel.stages || selectedFunnel.stages.length === 0) && (
|
|
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted">Nenhuma etapa configurada neste funil.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="p-12 text-center text-zinc-500">Selecione ou crie um funil.</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Funnel Creation Modal */}
|
|
{isFunnelModalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm">
|
|
<div className="bg-white dark:bg-dark-card rounded-xl shadow-xl w-full max-w-sm overflow-hidden animate-in fade-in zoom-in duration-200">
|
|
<div className="px-6 py-4 border-b border-zinc-100 dark:border-dark-border flex justify-between items-center bg-zinc-50/50 dark:bg-dark-bg/50">
|
|
<h3 className="font-bold text-zinc-900 dark:text-zinc-50">Novo Funil</h3>
|
|
<button onClick={() => setIsFunnelModalOpen(false)} className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"><X size={20} /></button>
|
|
</div>
|
|
<form onSubmit={handleCreateFunnel} className="p-6 space-y-4">
|
|
<div>
|
|
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Nome do Funil</label>
|
|
<input
|
|
type="text"
|
|
value={funnelName}
|
|
onChange={e => setFunnelName(e.target.value)}
|
|
placeholder="Ex: Vendas B2B"
|
|
className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-brand-yellow/20 outline-none transition-all"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="pt-4 flex justify-end gap-3 mt-6 border-t border-zinc-100 dark:border-dark-border pt-6">
|
|
<button type="button" onClick={() => setIsFunnelModalOpen(false)} className="px-4 py-2 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-dark-border rounded-lg text-sm font-medium transition-colors">Cancelar</button>
|
|
<button type="submit" disabled={isSaving || !funnelName.trim()} className="px-6 py-2 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-lg text-sm font-bold flex items-center gap-2 hover:opacity-90 transition-all shadow-sm disabled:opacity-70">
|
|
{isSaving ? <Loader2 className="animate-spin" size={16} /> : 'Criar Funil'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stage Modal */}
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm">
|
|
<div className="bg-white dark:bg-dark-card rounded-xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
|
|
<div className="px-6 py-4 border-b border-zinc-100 dark:border-dark-border flex justify-between items-center bg-zinc-50/50 dark:bg-dark-bg/50">
|
|
<h3 className="font-bold text-zinc-900 dark:text-zinc-50">{editingStage ? 'Editar Etapa' : 'Nova Etapa'}</h3>
|
|
<button onClick={() => setIsModalOpen(false)} className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"><X size={20} /></button>
|
|
</div>
|
|
<form onSubmit={handleSaveStage} className="p-6 space-y-4">
|
|
<div>
|
|
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Nome da Etapa</label>
|
|
<input
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={e => setFormData({...formData, name: e.target.value})}
|
|
placeholder="Ex: Qualificação"
|
|
className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-brand-yellow/20 outline-none transition-all"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-2 block">Cor Visual</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{PRESET_COLORS.map((color, i) => (
|
|
<label key={i} className={`flex items-center gap-2 p-2 border rounded-lg cursor-pointer transition-all ${formData.color_class === color.value ? 'border-brand-yellow bg-yellow-50/50 dark:bg-yellow-900/10' : 'border-zinc-200 dark:border-dark-border hover:bg-zinc-50 dark:hover:bg-dark-input'}`}>
|
|
<input
|
|
type="radio"
|
|
name="color"
|
|
value={color.value}
|
|
checked={formData.color_class === color.value}
|
|
onChange={(e) => setFormData({...formData, color_class: e.target.value})}
|
|
className="sr-only"
|
|
/>
|
|
<span className={`w-3 h-3 rounded-full border ${color.value.split(' ')[0]} ${color.value.split(' ')[2]}`}></span>
|
|
<span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">{color.label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 flex justify-end gap-3 mt-6 border-t border-zinc-100 dark:border-dark-border pt-6">
|
|
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-dark-border rounded-lg text-sm font-medium transition-colors">Cancelar</button>
|
|
<button type="submit" disabled={isSaving} className="px-6 py-2 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-lg text-sm font-bold flex items-center gap-2 hover:opacity-90 transition-all shadow-sm disabled:opacity-70">
|
|
{isSaving ? <Loader2 className="animate-spin" size={16} /> : 'Salvar Etapa'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|