import { useCallback, useEffect, useMemo, useState } from 'react';
import { useOutletContext, useNavigate } from 'react-router-dom';
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import type { DashboardAnalytics, OrderData, DateRange } from '../types';
import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard';
import { fetchDashboardAnalytics } from '../dataService';
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
};
const formatCompactCurrency = (value: number) => {
const absValue = Math.abs(value);
const formatNumber = (nextValue: number) => Number.isInteger(nextValue)
? String(nextValue)
: nextValue.toLocaleString('pt-BR', { maximumFractionDigits: 1 });
if (absValue >= 1_000_000) return `${formatNumber(value / 1_000_000)}M`;
if (absValue >= 1_000) return `${formatNumber(value / 1_000)}k`;
return formatNumber(value);
};
const formatDateTick = (value: string) => {
if (!value) return '';
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit' }).format(date);
};
const formatDateLabel = (value: string) => {
if (!value) return '';
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
};
const formatDateKey = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const SELLER_COLORS = [
'#25C2FF',
'#52DFA0',
'#A06BFF',
'#FF8A63',
'#FFC247',
'#FF7A9B',
'#18D6B5',
'#FFA24A',
'#82B8FF',
'#B8E84D'
];
const BAR_FILL_OPACITY = 0.5;
const BAR_STROKE_OPACITY = 0.85;
const PIE_FILL_OPACITY = 0.68;
const CHART_GRID_COLOR = 'var(--chart-grid)';
const CHART_AXIS_COLOR = 'var(--chart-axis)';
const CHART_CURSOR_COLOR = 'var(--chart-cursor)';
type SellerMetricKey = 'revenue' | 'ticket' | 'orders';
type SellerTimeSeriesSeller = {
id: string;
seriesKey: string;
name: string;
fill: string;
total: number;
revenue: number;
orders: number;
};
type SellerTimeSeriesChartData = {
date: string;
[key: string]: string | number;
};
type SellerMetricConfig = {
key: SellerMetricKey;
label: string;
title: string;
fallbackDescription: string;
trendDescription: string;
emptyText: string;
formatValue: (value: number) => string;
formatTick: (value: number) => string;
};
const sellerMetricOptions: SellerMetricConfig[] = [
{
key: 'revenue',
label: 'Receita',
title: 'Receita por Vendedor',
fallbackDescription: 'Total por vendedor no período selecionado.',
trendDescription: 'Evolução por data no período selecionado.',
emptyText: 'Nenhuma receita com vendedor no período.',
formatValue: formatCurrency,
formatTick: formatCompactCurrency
},
{
key: 'ticket',
label: 'Ticket médio',
title: 'Ticket Médio por Vendedor',
fallbackDescription: 'Ticket médio por vendedor no período selecionado.',
trendDescription: 'Ticket médio por data no período selecionado.',
emptyText: 'Nenhum ticket médio com vendedor no período.',
formatValue: formatCurrency,
formatTick: formatCompactCurrency
},
{
key: 'orders',
label: 'Pedidos',
title: 'Pedidos por Vendedor',
fallbackDescription: 'Pedidos por vendedor no período selecionado.',
trendDescription: 'Pedidos por data no período selecionado.',
emptyText: 'Nenhum pedido com vendedor no período.',
formatValue: (value) => new Intl.NumberFormat('pt-BR').format(value),
formatTick: (value) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(value)
}
];
const getSellerMetricValue = (metric: SellerMetricKey, revenue: number, orders: number) => {
if (metric === 'orders') return orders;
if (metric === 'ticket') return orders ? revenue / orders : 0;
return revenue;
};
const getBarGlowStyle = (color: string) => ({
filter: `drop-shadow(0 0 3px ${color}40)`
});
const getPieGlowStyle = (color: string) => ({
filter: `drop-shadow(0 0 8px ${color}80)`,
cursor: 'pointer'
});
const kpiIconStyle = (color: string) => ({
backgroundColor: `${color}14`,
borderColor: `${color}26`,
color
});
const DashboardSkeleton = () => (
{[0, 1, 2].map(item => (
))}
{[0, 1, 2, 3, 4, 5].map(row => (
))}
{[0, 1].map(item => (
{[0, 1, 2, 3, 4, 5, 6, 7].map(bar => (
))}
))}
);
type ChartTooltipPayload = {
value: number;
name?: string;
color?: string;
stroke?: string;
dataKey?: string;
payload?: {
fill?: string;
};
};
type CustomTooltipProps = {
active?: boolean;
payload?: ChartTooltipPayload[];
label?: string;
isCurrency?: boolean;
valueLabel?: string;
};
const CustomTooltip = ({ active, payload, label, isCurrency, valueLabel }: CustomTooltipProps) => {
if (active && payload && payload.length) {
const color = payload[0].payload?.fill || payload[0].stroke || payload[0].color || 'var(--chart-detail-bar)';
const displayLabel = label || payload[0].name;
const value = isCurrency ? formatCurrency(payload[0].value) : payload[0].value;
const displayValueLabel = valueLabel || (isCurrency ? 'Receita:' : 'Vendas:');
return (
{displayLabel}
{displayValueLabel} {value}
);
}
return null;
};
type SellerMetricTooltipProps = {
active?: boolean;
payload?: ChartTooltipPayload[];
label?: string;
focusedSellerId: string | null;
sellers: SellerTimeSeriesSeller[];
metricConfig: SellerMetricConfig;
};
const SellerMetricTooltip = ({ active, payload, label, focusedSellerId, sellers, metricConfig }: SellerMetricTooltipProps) => {
if (!active || !payload?.length) return null;
const sellersByKey = new Map(sellers.map(seller => [seller.seriesKey, seller]));
const rows = payload
.map(item => {
const seller = sellersByKey.get(String(item.dataKey || ''));
return seller ? { seller, value: Number(item.value || 0) } : null;
})
.filter((item): item is { seller: SellerTimeSeriesSeller; value: number } => Boolean(item && item.value > 0))
.sort((a, b) => {
if (focusedSellerId) {
if (a.seller.id === focusedSellerId) return -1;
if (b.seller.id === focusedSellerId) return 1;
}
return b.value - a.value;
})
.slice(0, focusedSellerId ? 4 : 6);
return (
{formatDateLabel(String(label || ''))}
{rows.length ? rows.map(({ seller, value }) => (
{seller.name}
{metricConfig.formatValue(value)}
)) : (
Sem receita nesse dia.
)}
);
};
const Dashboard = () => {
const navigate = useNavigate();
const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void,
ordersData: OrderData[],
refreshInterval: number,
setRefreshInterval: (interval: number) => void,
}>();
const [serverMetrics, setServerMetrics] = useState(null);
const [isMetricsLoading, setIsMetricsLoading] = useState(true);
const [focusedSellerId, setFocusedSellerId] = useState(null);
const [sellerMetric, setSellerMetric] = useState('revenue');
const loadDashboardMetrics = useCallback(async (range: DateRange, options?: { force?: boolean }) => {
setIsMetricsLoading(true);
const metrics = await fetchDashboardAnalytics(range, options);
setServerMetrics(metrics);
setIsMetricsLoading(false);
}, []);
useEffect(() => {
// Dashboard metrics are synchronized with the selected server-side date range.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadDashboardMetrics(dateRange);
}, [dateRange, loadDashboardMetrics]);
useEffect(() => {
if (refreshInterval === 0) return;
const intervalId = setInterval(() => {
void loadDashboardMetrics(dateRange, { force: true });
}, refreshInterval);
return () => clearInterval(intervalId);
}, [dateRange, loadDashboardMetrics, refreshInterval]);
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct, revenueBySeller, ordersBySeller, sellerRevenueByDate } = useMemo(() => {
if (serverMetrics) return applyDashboardColors(serverMetrics);
return buildDashboardMetrics(ordersData, dateRange);
}, [dateRange, ordersData, serverMetrics]);
const sellerColorMap = useMemo(() => {
const colorMap = new Map();
[...revenueBySeller, ...ordersBySeller].forEach((seller) => {
const key = seller.id || seller.name;
if (!colorMap.has(key)) {
colorMap.set(key, SELLER_COLORS[colorMap.size % SELLER_COLORS.length]);
}
});
return colorMap;
}, [ordersBySeller, revenueBySeller]);
const sellerMetricConfig = sellerMetricOptions.find(option => option.key === sellerMetric) || sellerMetricOptions[0];
const sellerTimeSeries = useMemo(() => {
if (!sellerRevenueByDate.length && revenueBySeller.length) {
const startDate = formatDateKey(dateRange.start);
const endDate = formatDateKey(dateRange.end);
const chartDates = startDate === endDate ? [endDate] : [startDate, endDate];
const sellersById = new Map>();
revenueBySeller.forEach(seller => {
const id = seller.id || seller.name;
sellersById.set(id, {
id,
name: seller.name,
fill: sellerColorMap.get(id) || seller.fill,
revenue: seller.value,
orders: 0
});
});
ordersBySeller.forEach(seller => {
const id = seller.id || seller.name;
const existing = sellersById.get(id);
sellersById.set(id, {
id,
name: existing?.name || seller.name,
fill: existing?.fill || sellerColorMap.get(id) || seller.fill,
revenue: existing?.revenue || 0,
orders: seller.value
});
});
const sellers: SellerTimeSeriesSeller[] = [...sellersById.values()]
.map(seller => ({
...seller,
total: getSellerMetricValue(sellerMetric, seller.revenue, seller.orders),
seriesKey: ''
}))
.filter(seller => seller.total > 0)
.sort((a, b) => b.total - a.total)
.slice(0, 8)
.map((seller, index) => {
const id = seller.id || seller.name;
return {
...seller,
id,
seriesKey: `seller_${index}`
};
});
const chartData: SellerTimeSeriesChartData[] = chartDates.map(date => {
const row: SellerTimeSeriesChartData = { date };
sellers.forEach(seller => {
row[seller.seriesKey] = seller.total;
});
return row;
});
return { sellers, chartData, isFallback: true };
}
const sellersById = new Map>();
const valuesByDate = new Map>();
sellerRevenueByDate.forEach(point => {
const id = point.id || point.name;
const existing = sellersById.get(id);
sellersById.set(id, {
id,
name: point.name,
fill: sellerColorMap.get(id) || point.fill,
revenue: (existing?.revenue || 0) + point.value,
orders: (existing?.orders || 0) + (point.orders || 0)
});
if (!valuesByDate.has(point.date)) {
valuesByDate.set(point.date, new Map());
}
const dateValues = valuesByDate.get(point.date);
if (dateValues) {
const existingValue = dateValues.get(id);
dateValues.set(id, {
revenue: (existingValue?.revenue || 0) + point.value,
orders: (existingValue?.orders || 0) + (point.orders || 0)
});
}
});
const sellers: SellerTimeSeriesSeller[] = [...sellersById.values()]
.map(seller => ({
...seller,
total: getSellerMetricValue(sellerMetric, seller.revenue, seller.orders),
seriesKey: ''
}))
.filter(seller => seller.total > 0)
.sort((a, b) => b.total - a.total)
.slice(0, 8)
.map((seller, index) => ({
...seller,
seriesKey: `seller_${index}`
}));
const chartData: SellerTimeSeriesChartData[] = [...valuesByDate.entries()]
.sort(([dateA], [dateB]) => dateA.localeCompare(dateB))
.map(([date, values]) => {
const row: SellerTimeSeriesChartData = { date };
sellers.forEach(seller => {
const value = values.get(seller.id);
row[seller.seriesKey] = value ? getSellerMetricValue(sellerMetric, value.revenue, value.orders) : 0;
});
return row;
});
return { sellers, chartData, isFallback: false };
}, [dateRange.end, dateRange.start, ordersBySeller, revenueBySeller, sellerColorMap, sellerMetric, sellerRevenueByDate]);
const focusedSeller = focusedSellerId ? sellerTimeSeries.sellers.find(seller => seller.id === focusedSellerId) : null;
const effectiveFocusedSellerId = focusedSeller?.id || null;
const handleManualRefresh = () => {
void loadDashboardMetrics(dateRange, { force: true });
};
const shouldShowSkeleton = isMetricsLoading && !serverMetrics;
return (
Visão Geral
Resumo de vendas e performance dos produtos.
{shouldShowSkeleton ? (
) : (
<>
Receita Total
{formatCurrency(totalRevenue)}
Total de Produtos Vendidos
{totalOrders}
Ticket Médio (Por Item)
{formatCurrency(averageOrderValue)}
{sellerMetricConfig.title}
{focusedSeller
? `Foco em ${focusedSeller.name}`
: sellerTimeSeries.isFallback
? sellerMetricConfig.fallbackDescription
: sellerMetricConfig.trendDescription}
{sellerMetricOptions.map(option => (
))}
{focusedSeller && (
)}
{sellerTimeSeries.sellers.length ? (
{sellerTimeSeries.sellers.map(seller => (
))}
sellerMetricConfig.formatTick(Number(value))}
width={54}
/>
}
cursor={{ stroke: CHART_AXIS_COLOR, strokeDasharray: '4 4' }}
/>
{sellerTimeSeries.sellers.map(seller => {
const isFocused = effectiveFocusedSellerId === seller.id;
const isDimmed = Boolean(effectiveFocusedSellerId && !isFocused);
return (
setFocusedSellerId(current => current === seller.id ? null : seller.id)
}}
onClick={() => setFocusedSellerId(current => current === seller.id ? null : seller.id)}
style={{ cursor: 'pointer' }}
isAnimationActive
animationBegin={120}
animationDuration={700}
animationEasing="ease-out"
/>
);
})}
{sellerTimeSeries.sellers.map(seller => {
const isFocused = effectiveFocusedSellerId === seller.id;
const isDimmed = Boolean(effectiveFocusedSellerId && !isFocused);
return (
);
})}
) : (
{sellerMetricConfig.emptyText}
)}
Produtos Mais Vendidos
} cursor={{ fill: CHART_CURSOR_COLOR }} />
{ if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}>
{salesByProduct.map((entry) => (
|
))}
{salesByProduct.map((entry) => (
navigate(`/products/${entry.id}`)}>
{entry.name}
))}
Receita por Produto
{ if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}>
{revenueByProduct.map((entry) => (
|
))}
} cursor={{ fill: CHART_CURSOR_COLOR }} />
{revenueByProduct.map((entry) => (
navigate(`/products/${entry.id}`)}>
{entry.name}
))}
>
)}
);
};
export default Dashboard;