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;