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, Line } from 'recharts'; import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; import RefreshStatus from '../components/RefreshStatus'; import type { DashboardAnalytics, OrderData, DateRange } from '../types'; import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard'; import { fetchDashboardAnalytics } from '../dataService'; import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getDateBucketKey, getMovingAverageWindow, type DateBucket } from '../chartUtils'; 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 formatHourKey = (hour: number) => `${String(hour).padStart(2, '0')}h`; 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; movingAverage?: number; [key: string]: string | number | undefined; }; 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 normalizeSellerGraphName = (name: string) => ( name .normalize('NFD') .replace(/\p{Diacritic}/gu, '') .trim() .toLowerCase() ); const shouldHideSellerFromGraph = (name: string) => { const normalizedName = normalizeSellerGraphName(name); return normalizedName === 'pos vendas'; }; 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; isHourly: boolean; dateBucket: DateBucket; }; const SellerMetricTooltip = ({ active, payload, label, focusedSellerId, sellers, metricConfig, isHourly, dateBucket }: 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 (

{isHourly ? String(label || '') : formatDateBucketLongLabel(String(label || ''), dateBucket)}

{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 [selectedSellerBucket, setSelectedSellerBucket] = useState(null); const [showSellerTrend, setShowSellerTrend] = useState(false); 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, sellerRevenueByHour } = useMemo(() => { if (serverMetrics) return applyDashboardColors(serverMetrics); return buildDashboardMetrics(ordersData, dateRange); }, [dateRange, ordersData, serverMetrics]); const chartRevenueBySeller = useMemo( () => revenueBySeller.filter(seller => !shouldHideSellerFromGraph(seller.name)), [revenueBySeller] ); const chartOrdersBySeller = useMemo( () => ordersBySeller.filter(seller => !shouldHideSellerFromGraph(seller.name)), [ordersBySeller] ); const chartSellerRevenueByDate = useMemo( () => sellerRevenueByDate.filter(seller => !shouldHideSellerFromGraph(seller.name)), [sellerRevenueByDate] ); const chartSellerRevenueByHour = useMemo( () => sellerRevenueByHour.filter(seller => !shouldHideSellerFromGraph(seller.name)), [sellerRevenueByHour] ); const sellerColorMap = useMemo(() => { const colorMap = new Map(); [...chartRevenueBySeller, ...chartOrdersBySeller].forEach((seller) => { const key = seller.id || seller.name; if (!colorMap.has(key)) { colorMap.set(key, SELLER_COLORS[colorMap.size % SELLER_COLORS.length]); } }); return colorMap; }, [chartOrdersBySeller, chartRevenueBySeller]); const sellerMetricConfig = sellerMetricOptions.find(option => option.key === sellerMetric) || sellerMetricOptions[0]; const sellerTimeSeries = useMemo(() => { const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end); const isHourly = isSingleDayRange && chartSellerRevenueByHour.length > 0; const dateBucket = getAutoDateBucket(dateRange); const activeTrendPoints = isHourly ? chartSellerRevenueByHour.map(point => ({ ...point, date: formatHourKey(point.hour) })) : chartSellerRevenueByDate; if (!activeTrendPoints.length && chartRevenueBySeller.length) { const startDate = formatDateKey(dateRange.start); const endDate = formatDateKey(dateRange.end); const chartDates = isSingleDayRange && isHourly ? Array.from({ length: 24 }, (_, hour) => formatHourKey(hour)) : startDate === endDate ? [endDate] : [startDate, endDate]; const sellersById = new Map>(); chartRevenueBySeller.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 }); }); chartOrdersBySeller.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, isHourly, dateBucket, movingAverageWindow: 0 }; } const sellersById = new Map>(); const valuesByDate = new Map>(); activeTrendPoints.forEach(point => { const id = point.id || point.name; const bucketKey = isHourly ? point.date : getDateBucketKey(point.date, dateBucket); 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(bucketKey)) { valuesByDate.set(bucketKey, new Map()); } const dateValues = valuesByDate.get(bucketKey); 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 chartKeys = isHourly ? Array.from({ length: 24 }, (_, hour) => formatHourKey(hour)) : [...valuesByDate.keys()].sort((dateA, dateB) => dateA.localeCompare(dateB)); const chartData: SellerTimeSeriesChartData[] = chartKeys .map(date => { const values = valuesByDate.get(date); 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; }); const movingAverageWindow = isHourly ? 0 : getMovingAverageWindow(dateBucket); const totals = chartData.map(row => ( sellers.reduce((total, seller) => total + Number(row[seller.seriesKey] || 0), 0) )); if (movingAverageWindow > 1) { chartData.forEach((row, index) => { row.movingAverage = averageRecentValues(totals, index, movingAverageWindow); }); } return { sellers, chartData, isFallback: false, isHourly, dateBucket, movingAverageWindow }; }, [chartOrdersBySeller, chartRevenueBySeller, chartSellerRevenueByDate, chartSellerRevenueByHour, dateRange, sellerColorMap, sellerMetric]); const focusedSeller = focusedSellerId ? sellerTimeSeries.sellers.find(seller => seller.id === focusedSellerId) : null; const effectiveFocusedSellerId = focusedSeller?.id || null; const selectedSellerRow = selectedSellerBucket ? sellerTimeSeries.chartData.find(row => row.date === selectedSellerBucket) : null; const selectedSellerBreakdown = selectedSellerRow ? sellerTimeSeries.sellers .map(seller => ({ seller, value: Number(selectedSellerRow[seller.seriesKey] || 0) })) .filter(item => item.value > 0) .sort((a, b) => b.value - a.value) : []; const selectedSellerBucketLabel = selectedSellerRow ? sellerTimeSeries.isHourly ? selectedSellerRow.date : formatDateBucketLongLabel(selectedSellerRow.date, sellerTimeSeries.dateBucket) : ''; const handleManualRefresh = () => { void loadDashboardMetrics(dateRange, { force: true }); }; const shouldShowSkeleton = isMetricsLoading && !serverMetrics; const isRefreshing = isMetricsLoading && Boolean(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.isHourly ? 'Evolução por horário no dia selecionado.' : sellerTimeSeries.isFallback ? sellerMetricConfig.fallbackDescription : sellerTimeSeries.dateBucket === 'day' ? sellerMetricConfig.trendDescription : sellerTimeSeries.dateBucket === 'week' ? 'Evolução agrupada por semana no período selecionado.' : 'Evolução agrupada por mês no período selecionado.'}

{!sellerTimeSeries.isHourly && !sellerTimeSeries.isFallback && sellerTimeSeries.movingAverageWindow > 1 && ( )}
{sellerMetricOptions.map(option => ( ))}
{focusedSeller && ( )}
{sellerTimeSeries.sellers.length ? (
{ if (event?.activeLabel) setSelectedSellerBucket(String(event.activeLabel)); }} > {sellerTimeSeries.sellers.map(seller => ( ))} ( sellerTimeSeries.isHourly ? String(value) : formatDateBucketLabel(String(value), sellerTimeSeries.dateBucket) )} /> sellerMetricConfig.formatTick(Number(value))} width={54} /> } cursor={{ stroke: CHART_AXIS_COLOR, strokeDasharray: '4 4' }} /> {showSellerTrend && !sellerTimeSeries.isHourly && !sellerTimeSeries.isFallback && sellerTimeSeries.movingAverageWindow > 1 && ( )} {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" /> ); })}
{selectedSellerRow && (

{selectedSellerBucketLabel}

Quebra por vendedor no ponto selecionado.

{selectedSellerBreakdown.length ? (
{selectedSellerBreakdown.slice(0, 6).map(({ seller, value }) => ( ))}
) : (

Sem valores nesse ponto.

)}
)}
{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;