From 6a414a5bf4d3fc8c7a4ac22c52bb79ce30b4d9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 30 Jun 2026 14:30:14 -0300 Subject: [PATCH] Add hourly seller chart for single-day ranges --- backend/services/analyticsService.js | 42 ++++++++++++++++++++++++- backend/test/analyticsService.test.js | 11 ++++++- src/analytics/dashboard.ts | 43 ++++++++++++++++++++++++-- src/dataService.ts | 3 +- src/pages/Dashboard.tsx | 44 +++++++++++++++++++-------- src/types.ts | 7 +++++ 6 files changed, 133 insertions(+), 17 deletions(-) diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index a4850d6..87e1a48 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -498,7 +498,7 @@ const buildRfmClients = (baseClients) => { const getDashboardAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); - const [totalsResult, salesResult, revenueResult, sellerRevenueResult, sellerOrdersResult, sellerRevenueByDateResult] = await Promise.all([ + const [totalsResult, salesResult, revenueResult, sellerRevenueResult, sellerOrdersResult, sellerRevenueByDateResult, sellerRevenueByHourResult] = await Promise.all([ pool.query(` SELECT COALESCE(SUM(quantidade * valor_unitario), 0) as total_revenue, @@ -605,6 +605,39 @@ const getDashboardAnalytics = async (range = {}) => { 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), + 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, + EXTRACT(HOUR FROM created_at AT TIME ZONE 'America/Sao_Paulo')::int as order_hour, + 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.order_hour as hour, + 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.order_hour + ORDER BY seller_orders.order_hour ASC, value DESC; `, params) ]); @@ -647,6 +680,13 @@ const getDashboardAnalytics = async (range = {}) => { date: row.date, value: toNumber(row.value), orders: toNumber(row.orders) + })), + sellerRevenueByHour: sellerRevenueByHourResult.rows.map(row => ({ + name: row.name, + id: row.id, + hour: toNumber(row.hour), + value: toNumber(row.value), + orders: toNumber(row.orders) })) }; }; diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 5b5707c..5857949 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -159,6 +159,12 @@ test('getDashboardAnalytics includes seller revenue and order metrics', async () }; } + if (sql.includes('order_hour')) { + return { + rows: [{ id: '977226210', name: 'KEDMA DA SILVA', hour: 14, value: 300, orders: 1 }] + }; + } + if (sql.includes('seller_orders') && sql.includes('SUM(quantidade * valor_unitario)')) { return { rows: [{ id: '977226210', name: 'KEDMA DA SILVA', value: 750 }] @@ -187,7 +193,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, 6); + assert.equal(calls.length, 7); calls.forEach(call => { assert.deepEqual(call.params, ['2026-06-01', '2026-06-24']); }); @@ -203,6 +209,9 @@ test('getDashboardAnalytics includes seller revenue and order metrics', async () assert.deepEqual(dashboard.sellerRevenueByDate, [ { id: '977226210', name: 'KEDMA DA SILVA', date: '2026-06-05', value: 400, orders: 2 } ]); + assert.deepEqual(dashboard.sellerRevenueByHour, [ + { id: '977226210', name: 'KEDMA DA SILVA', hour: 14, value: 300, orders: 1 } + ]); } finally { pool.query = originalQuery; } diff --git a/src/analytics/dashboard.ts b/src/analytics/dashboard.ts index 25b4239..e483b17 100644 --- a/src/analytics/dashboard.ts +++ b/src/analytics/dashboard.ts @@ -32,6 +32,11 @@ export interface SellerDateRevenueMetric extends ChartProductMetric { orders: number; } +export interface SellerHourRevenueMetric extends ChartProductMetric { + hour: number; + orders: number; +} + export interface DashboardMetrics { totalRevenue: number; totalOrders: number; @@ -41,6 +46,7 @@ export interface DashboardMetrics { revenueBySeller: ChartProductMetric[]; ordersBySeller: ChartProductMetric[]; sellerRevenueByDate: SellerDateRevenueMetric[]; + sellerRevenueByHour: SellerHourRevenueMetric[]; } export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetrics => { @@ -49,7 +55,8 @@ export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetr ...metrics.revenueByProduct.map(product => product.name), ...(metrics.revenueBySeller || []).map(seller => seller.name), ...(metrics.ordersBySeller || []).map(seller => seller.name), - ...(metrics.sellerRevenueByDate || []).map(seller => seller.name) + ...(metrics.sellerRevenueByDate || []).map(seller => seller.name), + ...(metrics.sellerRevenueByHour || []).map(seller => seller.name) ])).sort(); const productColors = displayProducts.reduce>((colors, name) => { colors[name] = getProductColor(name); @@ -83,6 +90,12 @@ export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetr name: formatDisplayName(removeTrailingSellerId(seller.name)), orders: seller.orders || 0, fill: productColors[seller.name] + })), + sellerRevenueByHour: (metrics.sellerRevenueByHour || []).map(seller => ({ + ...seller, + name: formatDisplayName(removeTrailingSellerId(seller.name)), + orders: seller.orders || 0, + fill: productColors[seller.name] })) }; }; @@ -96,7 +109,9 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa const productNameIdMap: Record = {}; const sellerRevenueMap: Record = {}; const sellerRevenueByDateMap: Record> = {}; + const sellerRevenueByHourMap: Record> = {}; const sellerOrderKeysByDateMap: Record>> = {}; + const sellerOrderKeysByHourMap: Record>> = {}; const sellerOrderKeysMap: Record> = {}; const sellerIdMap: Record = {}; @@ -118,6 +133,8 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa const dateKey = Number.isNaN(parsedOrderDate.getTime()) ? order.Data_Pedido : parsedOrderDate.toISOString().slice(0, 10); + const orderTimestamp = order.Recebido_Em ? new Date(order.Recebido_Em) : null; + const orderHour = orderTimestamp && !Number.isNaN(orderTimestamp.getTime()) ? orderTimestamp.getHours() : null; sellerIdMap[sellerName] = sellerId; sellerRevenueMap[sellerName] = (sellerRevenueMap[sellerName] || 0) + itemRevenue; @@ -126,6 +143,13 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa if (!sellerOrderKeysByDateMap[sellerName]) sellerOrderKeysByDateMap[sellerName] = {}; if (!sellerOrderKeysByDateMap[sellerName][dateKey]) sellerOrderKeysByDateMap[sellerName][dateKey] = new Set(); sellerOrderKeysByDateMap[sellerName][dateKey].add(orderKey); + if (orderHour !== null) { + if (!sellerRevenueByHourMap[sellerName]) sellerRevenueByHourMap[sellerName] = {}; + sellerRevenueByHourMap[sellerName][orderHour] = (sellerRevenueByHourMap[sellerName][orderHour] || 0) + itemRevenue; + if (!sellerOrderKeysByHourMap[sellerName]) sellerOrderKeysByHourMap[sellerName] = {}; + if (!sellerOrderKeysByHourMap[sellerName][orderHour]) sellerOrderKeysByHourMap[sellerName][orderHour] = new Set(); + sellerOrderKeysByHourMap[sellerName][orderHour].add(orderKey); + } if (!sellerOrderKeysMap[sellerName]) sellerOrderKeysMap[sellerName] = new Set(); sellerOrderKeysMap[sellerName].add(orderKey); } @@ -182,6 +206,20 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa })) )); + const sellerRevenueByHour = topSellerRevenueNames.flatMap(name => ( + Object.keys(sellerRevenueByHourMap[name] || {}) + .map(Number) + .sort((a, b) => a - b) + .map(hour => ({ + name, + id: sellerIdMap[name], + hour, + value: sellerRevenueByHourMap[name][hour], + orders: sellerOrderKeysByHourMap[name]?.[hour]?.size || 0, + fill: getProductColor(name) + })) + )); + return { totalRevenue: revenue, totalOrders: totalItems, @@ -190,6 +228,7 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa revenueByProduct, revenueBySeller, ordersBySeller, - sellerRevenueByDate + sellerRevenueByDate, + sellerRevenueByHour }; }; diff --git a/src/dataService.ts b/src/dataService.ts index 2ba7af0..1a544c2 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -180,7 +180,8 @@ export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: Ca metrics && Array.isArray(metrics.revenueBySeller) && Array.isArray(metrics.ordersBySeller) && - Array.isArray(metrics.sellerRevenueByDate) + Array.isArray(metrics.sellerRevenueByDate) && + Array.isArray(metrics.sellerRevenueByHour) ); } }); diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index b241ccb..d9f1704 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -37,6 +37,8 @@ const formatDateLabel = (value: string) => { return new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date); }; +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'); @@ -319,7 +321,7 @@ const Dashboard = () => { return () => clearInterval(intervalId); }, [dateRange, loadDashboardMetrics, refreshInterval]); - const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct, revenueBySeller, ordersBySeller, sellerRevenueByDate } = useMemo(() => { + const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct, revenueBySeller, ordersBySeller, sellerRevenueByDate, sellerRevenueByHour } = useMemo(() => { if (serverMetrics) return applyDashboardColors(serverMetrics); return buildDashboardMetrics(ordersData, dateRange); }, [dateRange, ordersData, serverMetrics]); @@ -340,10 +342,21 @@ const Dashboard = () => { const sellerMetricConfig = sellerMetricOptions.find(option => option.key === sellerMetric) || sellerMetricOptions[0]; const sellerTimeSeries = useMemo(() => { - if (!sellerRevenueByDate.length && revenueBySeller.length) { + const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end); + const isHourly = isSingleDayRange && sellerRevenueByHour.length > 0; + const activeTrendPoints = isHourly + ? sellerRevenueByHour.map(point => ({ + ...point, + date: formatHourKey(point.hour) + })) + : sellerRevenueByDate; + + if (!activeTrendPoints.length && revenueBySeller.length) { const startDate = formatDateKey(dateRange.start); const endDate = formatDateKey(dateRange.end); - const chartDates = startDate === endDate ? [endDate] : [startDate, endDate]; + const chartDates = isSingleDayRange && isHourly + ? Array.from({ length: 24 }, (_, hour) => formatHourKey(hour)) + : startDate === endDate ? [endDate] : [startDate, endDate]; const sellersById = new Map>(); revenueBySeller.forEach(seller => { @@ -395,13 +408,13 @@ const Dashboard = () => { return row; }); - return { sellers, chartData, isFallback: true }; + return { sellers, chartData, isFallback: true, isHourly }; } const sellersById = new Map>(); const valuesByDate = new Map>(); - sellerRevenueByDate.forEach(point => { + activeTrendPoints.forEach(point => { const id = point.id || point.name; const existing = sellersById.get(id); sellersById.set(id, { @@ -439,19 +452,23 @@ const Dashboard = () => { seriesKey: `seller_${index}` })); - const chartData: SellerTimeSeriesChartData[] = [...valuesByDate.entries()] - .sort(([dateA], [dateB]) => dateA.localeCompare(dateB)) - .map(([date, values]) => { + 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); + 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]); + return { sellers, chartData, isFallback: false, isHourly }; + }, [dateRange.end, dateRange.start, ordersBySeller, revenueBySeller, sellerColorMap, sellerMetric, sellerRevenueByDate, sellerRevenueByHour]); const focusedSeller = focusedSellerId ? sellerTimeSeries.sellers.find(seller => seller.id === focusedSellerId) : null; const effectiveFocusedSellerId = focusedSeller?.id || null; @@ -530,6 +547,8 @@ const Dashboard = () => {

{focusedSeller ? `Foco em ${focusedSeller.name}` + : sellerTimeSeries.isHourly + ? 'Evolução por horário no dia selecionado.' : sellerTimeSeries.isFallback ? sellerMetricConfig.fallbackDescription : sellerMetricConfig.trendDescription} @@ -584,7 +603,8 @@ const Dashboard = () => { fontSize={11} tickLine={false} axisLine={false} - minTickGap={18} + minTickGap={sellerTimeSeries.isHourly ? 10 : 18} + interval={sellerTimeSeries.isHourly ? 2 : undefined} tickFormatter={formatDateTick} /> ; + sellerRevenueByHour?: Array<{ + name: string; + id: string; + hour: number; + value: number; + orders?: number; + }>; } export interface ProductAnalyticsItem {