From 901752ebc57d0ded22874ce0c05de6fd7d999bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 26 Jun 2026 14:53:49 -0300 Subject: [PATCH] Refine seller dashboard chart --- backend/services/analyticsService.js | 42 +- backend/test/analyticsService.test.js | 11 +- src/analytics/dashboard.ts | 45 +- src/dataService.ts | 7 +- src/pages/Dashboard.tsx | 585 ++++++++++++++++++-------- src/types.ts | 7 + 6 files changed, 513 insertions(+), 184 deletions(-) diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 2a3ff78..7ae9ce3 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -469,7 +469,7 @@ const buildRfmClients = (baseClients) => { const getDashboardAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); - const [totalsResult, salesResult, revenueResult, sellerRevenueResult, sellerOrdersResult] = await Promise.all([ + const [totalsResult, salesResult, revenueResult, sellerRevenueResult, sellerOrdersResult, sellerRevenueByDateResult] = await Promise.all([ pool.query(` SELECT COALESCE(SUM(quantidade * valor_unitario), 0) as total_revenue, @@ -543,6 +543,39 @@ const getDashboardAnalytics = async (range = {}) => { GROUP BY seller_key ORDER BY value DESC LIMIT 10; + `, params), + pool.query(` + WITH seller_orders AS ( + SELECT + COALESCE(${SELLER_ID_SQL}, 'name:' || ${SELLER_NAME_SQL}) as seller_key, + COALESCE(${SELLER_NAME_SQL}, ${SELLER_ID_SQL}, 'Sem vendedor') as seller_name, + data_pedido_date, + quantidade, + valor_unitario, + pedido_id, + data_pedido, + valor_pedido + FROM orders + ${whereClause} + AND (${SELLER_ID_SQL} IS NOT NULL OR ${SELLER_NAME_SQL} IS NOT NULL) + ), + top_sellers AS ( + SELECT seller_key + FROM seller_orders + GROUP BY seller_key + ORDER BY COALESCE(SUM(quantidade * valor_unitario), 0) DESC + LIMIT 8 + ) + SELECT + seller_orders.seller_key as id, + MAX(seller_orders.seller_name) as name, + seller_orders.data_pedido_date::text as date, + COALESCE(SUM(seller_orders.quantidade * seller_orders.valor_unitario), 0) as value, + COUNT(DISTINCT COALESCE(NULLIF(seller_orders.pedido_id, ''), seller_orders.data_pedido || '_' || seller_orders.valor_pedido::text))::int as orders + FROM seller_orders + INNER JOIN top_sellers ON top_sellers.seller_key = seller_orders.seller_key + GROUP BY seller_orders.seller_key, seller_orders.data_pedido_date + ORDER BY seller_orders.data_pedido_date ASC, value DESC; `, params) ]); @@ -578,6 +611,13 @@ const getDashboardAnalytics = async (range = {}) => { name: row.name, id: row.id, value: toNumber(row.value) + })), + sellerRevenueByDate: sellerRevenueByDateResult.rows.map(row => ({ + name: row.name, + id: row.id, + date: row.date, + value: toNumber(row.value), + orders: toNumber(row.orders) })) }; }; diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index a1e10fe..fe8d7a9 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -153,6 +153,12 @@ test('getDashboardAnalytics includes seller revenue and order metrics', async () }; } + if (sql.includes('data_pedido_date::text as date')) { + return { + rows: [{ id: '977226210', name: 'KEDMA DA SILVA', date: '2026-06-05', value: 400, orders: 2 }] + }; + } + if (sql.includes('seller_orders') && sql.includes('SUM(quantidade * valor_unitario)')) { return { rows: [{ id: '977226210', name: 'KEDMA DA SILVA', value: 750 }] @@ -181,7 +187,7 @@ test('getDashboardAnalytics includes seller revenue and order metrics', async () const sellerRevenueQuery = calls.find(call => call.sql.includes('seller_orders') && call.sql.includes('SUM(quantidade * valor_unitario)')); const sellerOrdersQuery = calls.find(call => call.sql.includes('seller_orders') && call.sql.includes('COUNT(DISTINCT')); - assert.equal(calls.length, 5); + assert.equal(calls.length, 6); calls.forEach(call => { assert.deepEqual(call.params, ['2026-06-01', '2026-06-24']); }); @@ -194,6 +200,9 @@ test('getDashboardAnalytics includes seller revenue and order metrics', async () assert.deepEqual(dashboard.ordersBySeller, [ { id: '977226210', name: 'KEDMA DA SILVA', value: 3 } ]); + assert.deepEqual(dashboard.sellerRevenueByDate, [ + { id: '977226210', name: 'KEDMA DA SILVA', date: '2026-06-05', value: 400, orders: 2 } + ]); } finally { pool.query = originalQuery; } diff --git a/src/analytics/dashboard.ts b/src/analytics/dashboard.ts index e182635..25b4239 100644 --- a/src/analytics/dashboard.ts +++ b/src/analytics/dashboard.ts @@ -1,5 +1,5 @@ import type { DashboardAnalytics, DateRange, OrderData } from '../types'; -import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue } from './orders'; +import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue, parseOrderDate } from './orders'; import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters'; const COLORS = [ @@ -27,6 +27,11 @@ export interface ChartProductMetric { fill: string; } +export interface SellerDateRevenueMetric extends ChartProductMetric { + date: string; + orders: number; +} + export interface DashboardMetrics { totalRevenue: number; totalOrders: number; @@ -35,6 +40,7 @@ export interface DashboardMetrics { revenueByProduct: ChartProductMetric[]; revenueBySeller: ChartProductMetric[]; ordersBySeller: ChartProductMetric[]; + sellerRevenueByDate: SellerDateRevenueMetric[]; } export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetrics => { @@ -42,7 +48,8 @@ export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetr ...metrics.salesByProduct.map(product => product.name), ...metrics.revenueByProduct.map(product => product.name), ...(metrics.revenueBySeller || []).map(seller => seller.name), - ...(metrics.ordersBySeller || []).map(seller => seller.name) + ...(metrics.ordersBySeller || []).map(seller => seller.name), + ...(metrics.sellerRevenueByDate || []).map(seller => seller.name) ])).sort(); const productColors = displayProducts.reduce>((colors, name) => { colors[name] = getProductColor(name); @@ -70,6 +77,12 @@ export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetr ...seller, name: formatDisplayName(removeTrailingSellerId(seller.name)), fill: productColors[seller.name] + })), + sellerRevenueByDate: (metrics.sellerRevenueByDate || []).map(seller => ({ + ...seller, + name: formatDisplayName(removeTrailingSellerId(seller.name)), + orders: seller.orders || 0, + fill: productColors[seller.name] })) }; }; @@ -82,6 +95,8 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa const productRevenueMap: Record = {}; const productNameIdMap: Record = {}; const sellerRevenueMap: Record = {}; + const sellerRevenueByDateMap: Record> = {}; + const sellerOrderKeysByDateMap: Record>> = {}; const sellerOrderKeysMap: Record> = {}; const sellerIdMap: Record = {}; @@ -99,8 +114,18 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa productRevenueMap[productName] = (productRevenueMap[productName] || 0) + itemRevenue; if (sellerName) { + const parsedOrderDate = parseOrderDate(order.Data_Pedido); + const dateKey = Number.isNaN(parsedOrderDate.getTime()) + ? order.Data_Pedido + : parsedOrderDate.toISOString().slice(0, 10); + sellerIdMap[sellerName] = sellerId; sellerRevenueMap[sellerName] = (sellerRevenueMap[sellerName] || 0) + itemRevenue; + if (!sellerRevenueByDateMap[sellerName]) sellerRevenueByDateMap[sellerName] = {}; + sellerRevenueByDateMap[sellerName][dateKey] = (sellerRevenueByDateMap[sellerName][dateKey] || 0) + itemRevenue; + if (!sellerOrderKeysByDateMap[sellerName]) sellerOrderKeysByDateMap[sellerName] = {}; + if (!sellerOrderKeysByDateMap[sellerName][dateKey]) sellerOrderKeysByDateMap[sellerName][dateKey] = new Set(); + sellerOrderKeysByDateMap[sellerName][dateKey].add(orderKey); if (!sellerOrderKeysMap[sellerName]) sellerOrderKeysMap[sellerName] = new Set(); sellerOrderKeysMap[sellerName].add(orderKey); } @@ -144,6 +169,19 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa fill: getProductColor(name) })); + const sellerRevenueByDate = topSellerRevenueNames.flatMap(name => ( + Object.keys(sellerRevenueByDateMap[name] || {}) + .sort() + .map(date => ({ + name, + id: sellerIdMap[name], + date, + value: sellerRevenueByDateMap[name][date], + orders: sellerOrderKeysByDateMap[name]?.[date]?.size || 0, + fill: getProductColor(name) + })) + )); + return { totalRevenue: revenue, totalOrders: totalItems, @@ -151,6 +189,7 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa salesByProduct, revenueByProduct, revenueBySeller, - ordersBySeller + ordersBySeller, + sellerRevenueByDate }; }; diff --git a/src/dataService.ts b/src/dataService.ts index abf41d7..2ba7af0 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -176,7 +176,12 @@ export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: Ca ...options, shouldCache: (data) => { const metrics = data as DashboardAnalytics | null; - return Boolean(metrics && Array.isArray(metrics.revenueBySeller) && Array.isArray(metrics.ordersBySeller)); + return Boolean( + metrics && + Array.isArray(metrics.revenueBySeller) && + Array.isArray(metrics.ordersBySeller) && + Array.isArray(metrics.sellerRevenueByDate) + ); } }); }; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index f2c1319..5881b9c 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useOutletContext, useNavigate } from 'react-router-dom'; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LabelList } from 'recharts'; +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'; @@ -11,9 +11,36 @@ const formatCurrency = (value: number) => { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); }; -const truncateLabel = (value: string, maxLength = 22) => { - if (value.length <= maxLength) return value; - return `${value.slice(0, maxLength - 1)}...`; +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 = [ @@ -35,7 +62,73 @@ 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)'; -const CHART_LABEL_COLOR = 'var(--chart-label)'; + +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)` @@ -68,33 +161,17 @@ const DashboardSkeleton = () => ( ))} -
- {[0, 1].map(item => ( -
-
-
- {[0, 1, 2, 3, 4, 5].map(row => ( -
-
-
-
- ))} -
-
- ))} -
-
-
-
- {[0, 1, 2, 3, 4].map(row => ( -
-
-
-
-
-
- ))} +
+
+
+
+
+
+ {[0, 1, 2, 3, 4, 5].map(row => ( +
+ ))} +
@@ -119,6 +196,8 @@ type ChartTooltipPayload = { value: number; name?: string; color?: string; + stroke?: string; + dataKey?: string; payload?: { fill?: string; }; @@ -134,7 +213,7 @@ type CustomTooltipProps = { const CustomTooltip = ({ active, payload, label, isCurrency, valueLabel }: CustomTooltipProps) => { if (active && payload && payload.length) { - const color = payload[0].payload?.fill || payload[0].color || 'var(--chart-detail-bar)'; + 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:'); @@ -151,6 +230,57 @@ const CustomTooltip = ({ active, payload, label, isCurrency, valueLabel }: Custo 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<{ @@ -162,6 +292,8 @@ const Dashboard = () => { }>(); 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); @@ -186,7 +318,7 @@ const Dashboard = () => { return () => clearInterval(intervalId); }, [dateRange, loadDashboardMetrics, refreshInterval]); - const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct, revenueBySeller, ordersBySeller } = useMemo(() => { + const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct, revenueBySeller, ordersBySeller, sellerRevenueByDate } = useMemo(() => { if (serverMetrics) return applyDashboardColors(serverMetrics); return buildDashboardMetrics(ordersData, dateRange); }, [dateRange, ordersData, serverMetrics]); @@ -204,55 +336,124 @@ const Dashboard = () => { return colorMap; }, [ordersBySeller, revenueBySeller]); - const revenueBySellerChartData = useMemo(() => { - return revenueBySeller.map((seller) => ({ - ...seller, - fill: sellerColorMap.get(seller.id || seller.name) || seller.fill - })); - }, [revenueBySeller, sellerColorMap]); + const sellerMetricConfig = sellerMetricOptions.find(option => option.key === sellerMetric) || sellerMetricOptions[0]; - const sellerPerformanceRows = useMemo(() => { - const rowsBySeller = new Map(); + 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 key = seller.id || seller.name; - rowsBySeller.set(seller.id || seller.name, { - id: key, - name: seller.name, - revenue: seller.value, - orders: 0, - fill: sellerColorMap.get(key) || seller.fill + 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) + }); + } }); - ordersBySeller.forEach((seller) => { - const key = seller.id || seller.name; - const existing = rowsBySeller.get(key); - rowsBySeller.set(key, { - id: key, - name: existing?.name || seller.name, - revenue: existing?.revenue || 0, - orders: seller.value, - fill: existing?.fill || sellerColorMap.get(key) || seller.fill - }); - }); - - return [...rowsBySeller.values()] - .sort((a, b) => b.revenue - a.revenue) - .slice(0, 10); - }, [ordersBySeller, revenueBySeller, sellerColorMap]); - - const ticketBySellerChartData = useMemo(() => { - return sellerPerformanceRows - .filter((seller) => seller.orders > 0) - .map((seller) => ({ - id: seller.id, - name: seller.name, - value: seller.revenue / seller.orders, - fill: seller.fill + const sellers: SellerTimeSeriesSeller[] = [...sellersById.values()] + .map(seller => ({ + ...seller, + total: getSellerMetricValue(sellerMetric, seller.revenue, seller.orders), + seriesKey: '' })) - .sort((a, b) => b.value - a.value); - }, [sellerPerformanceRows]); + .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 }); @@ -318,123 +519,151 @@ const Dashboard = () => {
-
-
-

Receita por Vendedor

-
- {revenueBySellerChartData.length ? ( - - - - `${Number(value) / 1000}k`} /> - truncateLabel(String(value))} - /> - } cursor={{ fill: CHART_CURSOR_COLOR }} /> - - {revenueBySellerChartData.map((entry) => ( - - ))} - formatCurrency(Number(value) || 0)} /> - - - - ) : ( -

Nenhuma venda com vendedor no período.

+
+
+
+

{sellerMetricConfig.title}

+

+ {focusedSeller + ? `Foco em ${focusedSeller.name}` + : sellerTimeSeries.isFallback + ? sellerMetricConfig.fallbackDescription + : sellerMetricConfig.trendDescription} +

+
+
+
+ {sellerMetricOptions.map(option => ( + + ))} +
+ {focusedSeller && ( + )}
-
-

Ticket Médio por Vendedor

-
- {ticketBySellerChartData.length ? ( + {sellerTimeSeries.sellers.length ? ( +
+
- - - `${Number(value) / 1000}k`} /> - + + {sellerTimeSeries.sellers.map(seller => ( + + + + + ))} + + + truncateLabel(String(value))} + minTickGap={18} + tickFormatter={formatDateTick} /> - } cursor={{ fill: CHART_CURSOR_COLOR }} /> - - {ticketBySellerChartData.map((entry) => ( - 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={false} /> - ))} - formatCurrency(Number(value) || 0)} /> - - + ); + })} + - ) : ( -

Nenhum ticket médio por vendedor no período.

- )} +
+ +
+ {sellerTimeSeries.sellers.map(seller => { + const isFocused = effectiveFocusedSellerId === seller.id; + const isDimmed = Boolean(effectiveFocusedSellerId && !isFocused); + return ( + + ); + })} +
-
+ ) : ( +
+

{sellerMetricConfig.emptyText}

+
+ )}
- {sellerPerformanceRows.length > 0 && ( -
-

Resumo por Vendedor

-
- - - - - - - - - - - {sellerPerformanceRows.map((seller) => ( - - - - - - - ))} - -
VendedorReceitaPedidosTicket médio
-
- - {seller.name} -
-
{formatCurrency(seller.revenue)}{seller.orders}{seller.orders ? formatCurrency(seller.revenue / seller.orders) : '-'}
-
-
- )} -

Produtos Mais Vendidos

diff --git a/src/types.ts b/src/types.ts index bf46083..df2ddd8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,13 @@ export interface DashboardAnalytics { id: string; value: number; }>; + sellerRevenueByDate?: Array<{ + name: string; + id: string; + date: string; + value: number; + orders?: number; + }>; } export interface ProductAnalyticsItem {