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.
457 lines
18 KiB
TypeScript
457 lines
18 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, PieChart, Pie, Legend
|
|
} from 'recharts';
|
|
import {
|
|
Users, Clock, Phone, TrendingUp, Filter
|
|
} from 'lucide-react';
|
|
import { getAttendances, getUsers, getTeams, getUserById, getFunnels } from '../services/dataService';
|
|
import { COLORS } from '../constants';
|
|
import { Attendance, DashboardFilter, FunnelStage, User, FunnelStageDef } from '../types';
|
|
import { KPICard } from '../components/KPICard';
|
|
import { DateRangePicker } from '../components/DateRangePicker';
|
|
import { SellersTable } from '../components/SellersTable';
|
|
import { ProductLists } from '../components/ProductLists';
|
|
|
|
// Interface for seller statistics accumulator
|
|
interface SellerStats {
|
|
total: number;
|
|
converted: number;
|
|
scoreSum: number;
|
|
count: number;
|
|
timeSum: number;
|
|
}
|
|
|
|
export const Dashboard: React.FC = () => {
|
|
const [loading, setLoading] = useState(true);
|
|
const [data, setData] = useState<Attendance[]>([]);
|
|
const [prevData, setPrevData] = useState<Attendance[]>([]);
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [teams, setTeams] = useState<any[]>([]);
|
|
const [funnelDefs, setFunnelDefs] = useState<FunnelStageDef[]>([]);
|
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
|
|
|
const [filters, setFilters] = useState<DashboardFilter>({
|
|
dateRange: {
|
|
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // Last 30 days
|
|
end: new Date(),
|
|
},
|
|
userId: 'all',
|
|
teamId: 'all',
|
|
funnelStage: 'all',
|
|
origin: 'all',
|
|
});
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const tenantId = localStorage.getItem('ctms_tenant_id');
|
|
const storedUserId = localStorage.getItem('ctms_user_id');
|
|
if (!tenantId) return;
|
|
|
|
// Calculate previous date range for accurate period-over-period trend
|
|
const duration = filters.dateRange.end.getTime() - filters.dateRange.start.getTime();
|
|
const prevStart = new Date(filters.dateRange.start.getTime() - duration);
|
|
const prevEnd = new Date(filters.dateRange.start.getTime());
|
|
const prevFilters = { ...filters, dateRange: { start: prevStart, end: prevEnd } };
|
|
|
|
// Fetch users, attendances, teams, funnels and current user in parallel
|
|
const [fetchedUsers, fetchedData, prevFetchedData, fetchedTeams, fetchedFunnels, me] = await Promise.all([
|
|
getUsers(tenantId),
|
|
getAttendances(tenantId, filters),
|
|
getAttendances(tenantId, prevFilters),
|
|
getTeams(tenantId),
|
|
getFunnels(tenantId),
|
|
storedUserId ? getUserById(storedUserId) : null
|
|
]);
|
|
|
|
setUsers(fetchedUsers);
|
|
setData(fetchedData);
|
|
setPrevData(prevFetchedData);
|
|
setTeams(fetchedTeams);
|
|
if (me) setCurrentUser(me);
|
|
|
|
// Determine which funnel to display
|
|
const targetTeamId = filters.teamId !== 'all' ? filters.teamId : (me?.team_id || null);
|
|
let activeFunnel = fetchedFunnels[0]; // fallback to first/default
|
|
if (targetTeamId) {
|
|
const matchedFunnel = fetchedFunnels.find(f => f.teamIds?.includes(targetTeamId));
|
|
if (matchedFunnel) activeFunnel = matchedFunnel;
|
|
}
|
|
|
|
setFunnelDefs(activeFunnel && activeFunnel.stages ? activeFunnel.stages.sort((a: any, b: any) => a.order_index - b.order_index) : []);
|
|
|
|
} catch (error) {
|
|
console.error("Error loading dashboard data:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchData();
|
|
}, [filters]);
|
|
|
|
// --- Metrics Calculations ---
|
|
const totalLeads = data.length;
|
|
const avgScore = data.length > 0 ? (data.reduce((acc, curr) => acc + curr.score, 0) / data.length).toFixed(1) : "0";
|
|
const avgResponseTime = data.length > 0 ? (data.reduce((acc, curr) => acc + curr.first_response_time_min, 0) / data.length).toFixed(0) : "0";
|
|
const avgHandleTime = data.length > 0 ? (data.reduce((acc, curr) => acc + curr.handling_time_min, 0) / data.length).toFixed(0) : "0";
|
|
|
|
// --- Dynamic Trends Calculation ---
|
|
const trends = useMemo(() => {
|
|
if (data.length === 0 && prevData.length === 0) {
|
|
return { leads: null, score: null, resp: null };
|
|
}
|
|
|
|
const calcAvg = (arr: Attendance[], key: keyof Attendance) =>
|
|
arr.length ? arr.reduce((acc, curr) => acc + (curr[key] as number), 0) / arr.length : 0;
|
|
|
|
// Leads Trend (%)
|
|
const recentLeads = data.length;
|
|
const prevLeads = prevData.length;
|
|
let leadsTrend = 0;
|
|
if (prevLeads > 0) {
|
|
leadsTrend = ((recentLeads - prevLeads) / prevLeads) * 100;
|
|
} else if (recentLeads > 0) {
|
|
leadsTrend = 100;
|
|
}
|
|
|
|
// Score Trend (Absolute point difference)
|
|
const recentScore = calcAvg(data, 'score');
|
|
const prevScore = calcAvg(prevData, 'score');
|
|
let scoreTrend = 0;
|
|
if (prevData.length > 0) {
|
|
scoreTrend = recentScore - prevScore;
|
|
} else {
|
|
scoreTrend = recentScore;
|
|
}
|
|
|
|
// Response Time Trend (%)
|
|
const recentResp = calcAvg(data, 'first_response_time_min');
|
|
const prevResp = calcAvg(prevData, 'first_response_time_min');
|
|
let respTrend = 0;
|
|
if (prevResp > 0) {
|
|
respTrend = ((recentResp - prevResp) / prevResp) * 100;
|
|
} else if (recentResp > 0) {
|
|
respTrend = 100; // Time increased from 0, which is worse
|
|
}
|
|
|
|
return { leads: leadsTrend, score: scoreTrend, resp: respTrend };
|
|
}, [data, prevData]);
|
|
|
|
// --- Chart Data: Funnel ---
|
|
const funnelData = useMemo(() => {
|
|
const counts = data.reduce((acc, curr) => {
|
|
acc[curr.funnel_stage] = (acc[curr.funnel_stage] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
if (funnelDefs.length > 0) {
|
|
return funnelDefs.map(stage => ({
|
|
name: stage.name,
|
|
value: counts[stage.name] || 0,
|
|
color: stage.color_class.split(' ')[0].replace('bg-', '') // Extract base color name for Recharts
|
|
}));
|
|
}
|
|
|
|
// Fallback if funnels aren't loaded yet
|
|
const stagesOrder = [
|
|
FunnelStage.NO_CONTACT,
|
|
FunnelStage.IDENTIFICATION,
|
|
FunnelStage.NEGOTIATION,
|
|
FunnelStage.WON,
|
|
FunnelStage.LOST
|
|
];
|
|
return stagesOrder.map(stage => ({
|
|
name: stage,
|
|
value: counts[stage] || 0
|
|
}));
|
|
}, [data, funnelDefs]);
|
|
|
|
// --- Chart Data: Origin ---
|
|
const originData = useMemo(() => {
|
|
const origins = data.reduce((acc, curr) => {
|
|
acc[curr.origin] = (acc[curr.origin] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
// Ensure type safety for value in sort
|
|
return (Object.entries(origins) as [string, number][])
|
|
.map(([name, value]) => ({ name, value }))
|
|
.sort((a, b) => b.value - a.value);
|
|
}, [data]);
|
|
|
|
// --- Table Data: Sellers Ranking ---
|
|
const sellersRanking = useMemo(() => {
|
|
const stats = data.reduce<Record<string, SellerStats>>((acc, curr) => {
|
|
if (!acc[curr.user_id]) {
|
|
acc[curr.user_id] = { total: 0, converted: 0, scoreSum: 0, count: 0, timeSum: 0 };
|
|
}
|
|
acc[curr.user_id].total += 1;
|
|
if (curr.converted) acc[curr.user_id].converted += 1;
|
|
acc[curr.user_id].scoreSum += curr.score;
|
|
acc[curr.user_id].timeSum += curr.first_response_time_min;
|
|
acc[curr.user_id].count += 1;
|
|
return acc;
|
|
}, {});
|
|
|
|
return Object.entries(stats)
|
|
.map(([userId, s]) => {
|
|
const stat = s as SellerStats;
|
|
const user = users.find(u => u.id === userId);
|
|
if (!user) return null;
|
|
return {
|
|
user,
|
|
total: stat.total,
|
|
avgScore: (stat.scoreSum / stat.count).toFixed(1),
|
|
conversionRate: ((stat.converted / stat.total) * 100).toFixed(1),
|
|
responseTime: (stat.timeSum / stat.count).toFixed(0)
|
|
};
|
|
})
|
|
.filter((item): item is NonNullable<typeof item> => item !== null);
|
|
}, [data, users]);
|
|
|
|
// --- Lists Data: Products ---
|
|
const productStats = useMemo(() => {
|
|
const requested: Record<string, number> = {};
|
|
const sold: Record<string, number> = {};
|
|
|
|
data.forEach(d => {
|
|
if (d.product_requested) requested[d.product_requested] = (requested[d.product_requested] || 0) + 1;
|
|
if (d.product_sold) sold[d.product_sold] = (sold[d.product_sold] || 0) + 1;
|
|
});
|
|
|
|
const formatList = (record: Record<string, number>, total: number) =>
|
|
Object.entries(record)
|
|
.map(([name, count]) => ({ name, count, percentage: Math.round((count / (total || 1)) * 100) }))
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, 5);
|
|
|
|
const totalReq = Object.values(requested).reduce((a, b) => a + b, 0);
|
|
const totalSold = Object.values(sold).reduce((a, b) => a + b, 0);
|
|
|
|
return {
|
|
requested: formatList(requested, totalReq),
|
|
sold: formatList(sold, totalSold)
|
|
};
|
|
}, [data]);
|
|
|
|
|
|
const handleFilterChange = (key: keyof DashboardFilter, value: any) => {
|
|
setFilters(prev => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
if (loading && data.length === 0) {
|
|
return <div className="flex h-full items-center justify-center text-zinc-400 dark:text-dark-muted p-12">Carregando Dashboard...</div>;
|
|
}
|
|
|
|
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'super_admin' || currentUser?.role === 'manager';
|
|
|
|
return (
|
|
<div className="space-y-6 pb-8 transition-colors duration-300">
|
|
{/* Filters Bar */}
|
|
<div className="bg-white dark:bg-dark-card p-4 rounded-xl shadow-sm border border-zinc-100 dark:border-dark-border flex flex-col gap-4">
|
|
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
|
|
<div className="flex items-center gap-2 text-zinc-500 dark:text-dark-muted font-medium">
|
|
<Filter size={18} />
|
|
<span>Filtros:</span>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
|
|
<DateRangePicker
|
|
dateRange={filters.dateRange}
|
|
onChange={(range) => handleFilterChange('dateRange', range)}
|
|
/>
|
|
|
|
{isAdmin && (
|
|
<>
|
|
<select
|
|
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all"
|
|
value={filters.userId}
|
|
onChange={(e) => handleFilterChange('userId', e.target.value)}
|
|
>
|
|
<option value="all">Todos Usuários</option>
|
|
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
|
</select>
|
|
|
|
{currentUser?.role !== 'manager' && (
|
|
<select
|
|
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all"
|
|
value={filters.teamId}
|
|
onChange={(e) => handleFilterChange('teamId', e.target.value)}
|
|
>
|
|
<option value="all">Todas Equipes</option>
|
|
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
|
</select>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<select
|
|
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all"
|
|
value={filters.funnelStage}
|
|
onChange={(e) => handleFilterChange('funnelStage', e.target.value)}
|
|
>
|
|
<option value="all">Todas Etapas</option>
|
|
{funnelDefs.length > 0 ? funnelDefs.map(stage => (
|
|
<option key={stage.id} value={stage.name}>{stage.name}</option>
|
|
)) : Object.values(FunnelStage).map(stage => (
|
|
<option key={stage} value={stage}>{stage}</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all"
|
|
value={filters.origin}
|
|
onChange={(e) => handleFilterChange('origin', e.target.value)}
|
|
>
|
|
<option value="all">Todas Origens</option>
|
|
<option value="WhatsApp">WhatsApp</option>
|
|
<option value="Instagram">Instagram</option>
|
|
<option value="Website">Website</option>
|
|
<option value="LinkedIn">LinkedIn</option>
|
|
<option value="Indicação">Indicação</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI Cards */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<KPICard
|
|
title="Total de Leads"
|
|
value={totalLeads}
|
|
trend={trends.leads > 0 ? 'up' : trends.leads < 0 ? 'down' : 'neutral'}
|
|
trendValue={`${Math.abs(trends.leads).toFixed(1)}%`}
|
|
icon={Users}
|
|
colorClass="text-brand-yellow"
|
|
/>
|
|
<KPICard
|
|
title="Nota Média Qualidade"
|
|
value={avgScore}
|
|
subValue="/ 100"
|
|
trend={trends.score > 0 ? 'up' : trends.score < 0 ? 'down' : 'neutral'}
|
|
trendValue={Math.abs(trends.score).toFixed(1)}
|
|
icon={TrendingUp}
|
|
colorClass="text-zinc-500"
|
|
/>
|
|
<KPICard
|
|
title="Média 1ª Resposta"
|
|
value={`${avgResponseTime}m`}
|
|
trend={trends.resp < 0 ? 'up' : trends.resp > 0 ? 'down' : 'neutral'} // Faster response is better (up)
|
|
trendValue={`${Math.abs(trends.resp).toFixed(1)}%`}
|
|
icon={Clock}
|
|
colorClass="text-orange-500"
|
|
/>
|
|
<KPICard
|
|
title="Tempo Médio Atend."
|
|
value={`${avgHandleTime}m`}
|
|
subValue="por lead"
|
|
icon={Phone}
|
|
colorClass="text-green-600"
|
|
/>
|
|
</div>
|
|
|
|
{/* Charts Section */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Funnel Chart */}
|
|
<div className="lg:col-span-2 bg-white dark:bg-dark-card p-6 rounded-2xl shadow-sm border border-zinc-100 dark:border-dark-border min-h-[400px]">
|
|
<h3 className="text-lg font-bold text-zinc-800 dark:text-dark-text mb-6">Funil de Vendas</h3>
|
|
<div className="h-[300px] w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart
|
|
data={funnelData}
|
|
layout="vertical"
|
|
margin={{ top: 5, right: 30, left: 40, bottom: 5 }}
|
|
barSize={32}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} stroke="#f1f5f9" className="dark:opacity-5" />
|
|
<XAxis type="number" hide />
|
|
<YAxis
|
|
dataKey="name"
|
|
type="category"
|
|
width={120}
|
|
tick={{fill: '#71717a', fontSize: 13, fontWeight: 500}}
|
|
className="dark:fill-dark-muted"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<Tooltip
|
|
cursor={{fill: '#f8fafc', opacity: 0.05}}
|
|
formatter={(value: any) => [value, 'Quantidade']}
|
|
contentStyle={{
|
|
borderRadius: '12px',
|
|
border: 'none',
|
|
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)',
|
|
backgroundColor: '#1a1a1a',
|
|
color: '#ededed'
|
|
}}
|
|
itemStyle={{ color: '#ededed' }}
|
|
/>
|
|
<Bar dataKey="value" radius={[0, 6, 6, 0]}>
|
|
{funnelData.map((entry, index) => (
|
|
<Cell
|
|
key={`cell-${index}`}
|
|
fill={COLORS.funnel[entry.name as keyof typeof COLORS.funnel] || '#cbd5e1'}
|
|
/>
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Origin Pie Chart */}
|
|
<div className="bg-white dark:bg-dark-card p-6 rounded-2xl shadow-sm border border-zinc-100 dark:border-dark-border min-h-[400px] flex flex-col">
|
|
<h3 className="text-lg font-bold text-zinc-800 dark:text-dark-text mb-2">Origem dos Leads</h3>
|
|
<div className="flex-1 min-h-0">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={originData}
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={70}
|
|
outerRadius={100}
|
|
paddingAngle={5}
|
|
dataKey="value"
|
|
stroke="none"
|
|
>
|
|
{originData.map((entry, index) => (
|
|
<Cell
|
|
key={`cell-${index}`}
|
|
fill={COLORS.origins[entry.name as keyof typeof COLORS.origins] || COLORS.charts[index % COLORS.charts.length]}
|
|
/>
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
formatter={(value: any) => [value, 'Leads']}
|
|
contentStyle={{
|
|
borderRadius: '12px',
|
|
backgroundColor: '#1a1a1a',
|
|
border: 'none',
|
|
color: '#ededed'
|
|
}}
|
|
itemStyle={{ color: '#ededed' }}
|
|
/>
|
|
<Legend
|
|
verticalAlign="bottom"
|
|
height={80}
|
|
iconType="circle"
|
|
iconSize={10}
|
|
wrapperStyle={{ fontSize: '12px', paddingTop: '20px' }}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Ranking Table */}
|
|
<SellersTable data={sellersRanking} />
|
|
|
|
{/* Product Lists */}
|
|
<ProductLists requested={productStats.requested} sold={productStats.sold} />
|
|
</div>
|
|
);
|
|
};
|