diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index a3c0d96..54430c6 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -43,6 +43,13 @@ const CUSTOMER_IDENTITY_CTE = ` `; const CUSTOMER_KEY_SQL = 'customer_key'; const TRAILING_SELLER_ID_SQL_PATTERN = '[[:space:]]*#([0-9]+)[[:space:]]*$'; +const SELLER_ID_SQL = ` + COALESCE( + NULLIF(TRIM(id_vendedor), ''), + substring(NULLIF(TRIM(nome_vendedor), '') from '${TRAILING_SELLER_ID_SQL_PATTERN}') + ) +`; +const SELLER_NAME_SQL = `NULLIF(TRIM(regexp_replace(COALESCE(nome_vendedor, ''), '${TRAILING_SELLER_ID_SQL_PATTERN}', '')), '')`; const getClientTokenSecret = () => ( process.env.CLIENT_TOKEN_SECRET || @@ -462,7 +469,7 @@ const buildRfmClients = (baseClients) => { const getDashboardAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); - const [totalsResult, salesResult, revenueResult] = await Promise.all([ + const [totalsResult, salesResult, revenueResult, sellerRevenueResult, sellerOrdersResult] = await Promise.all([ pool.query(` SELECT COALESCE(SUM(quantidade * valor_unitario), 0) as total_revenue, @@ -492,6 +499,50 @@ const getDashboardAnalytics = async (range = {}) => { GROUP BY name 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, + 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) + ) + SELECT + seller_key as id, + MAX(seller_name) as name, + COALESCE(SUM(quantidade * valor_unitario), 0) as value + FROM seller_orders + 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, + pedido_id, + data_pedido, + valor_pedido + FROM orders + ${whereClause} + AND (${SELLER_ID_SQL} IS NOT NULL OR ${SELLER_NAME_SQL} IS NOT NULL) + ) + SELECT + seller_key as id, + MAX(seller_name) as name, + COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as value + FROM seller_orders + GROUP BY seller_key + ORDER BY value DESC + LIMIT 10; `, params) ]); @@ -517,6 +568,16 @@ const getDashboardAnalytics = async (range = {}) => { name: row.name, id: row.id, value: toNumber(row.value) + })), + revenueBySeller: sellerRevenueResult.rows.map(row => ({ + name: row.name, + id: row.id, + value: toNumber(row.value) + })), + ordersBySeller: sellerOrdersResult.rows.map(row => ({ + name: row.name, + id: row.id, + value: toNumber(row.value) })) }; }; @@ -717,11 +778,8 @@ const getClientFilterOptions = async () => { pool.query(` WITH seller_options AS ( SELECT - COALESCE( - NULLIF(TRIM(id_vendedor), ''), - substring(NULLIF(TRIM(nome_vendedor), '') from '${TRAILING_SELLER_ID_SQL_PATTERN}') - ) as id, - NULLIF(TRIM(regexp_replace(COALESCE(nome_vendedor, ''), '${TRAILING_SELLER_ID_SQL_PATTERN}', '')), '') as name + ${SELLER_ID_SQL} as id, + ${SELLER_NAME_SQL} as name FROM orders WHERE ( NULLIF(TRIM(id_vendedor), '') IS NOT NULL diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 230dae7..ca4a40c 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -11,6 +11,7 @@ const { getClientAnalytics, getClientDetailsAnalytics, getClientFilterOptions, + getDashboardAnalytics, getPreviousDate, getProductAnalytics, getProductDetailsAnalytics, @@ -135,6 +136,69 @@ test('getPreviousDate returns the calendar day before an ISO date', () => { assert.equal(getPreviousDate('invalid'), null); }); +test('getDashboardAnalytics includes seller revenue and order metrics', async () => { + const originalQuery = pool.query; + const calls = []; + + pool.query = async (sql, params = []) => { + calls.push({ sql, params }); + + if (sql.includes('total_revenue')) { + return { + rows: [{ + total_revenue: 1000, + total_items: 25, + order_line_count: 5 + }] + }; + } + + if (sql.includes('seller_orders') && sql.includes('SUM(quantidade * valor_unitario)')) { + return { + rows: [{ id: '977226210', name: 'KEDMA DA SILVA', value: 750 }] + }; + } + + if (sql.includes('seller_orders') && sql.includes('COUNT(DISTINCT')) { + return { + rows: [{ id: '977226210', name: 'KEDMA DA SILVA', value: 3 }] + }; + } + + if (sql.includes('SUM(quantidade), 0) as value')) { + return { + rows: [{ id: 'P1', name: 'Produto A', value: 10 }] + }; + } + + return { + rows: [{ id: 'P1', name: 'Produto A', value: 1000 }] + }; + }; + + try { + const dashboard = await getDashboardAnalytics({ start: '2026-06-01', end: '2026-06-24' }); + 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); + calls.forEach(call => { + assert.deepEqual(call.params, ['2026-06-01', '2026-06-24']); + }); + assert.match(sellerRevenueQuery.sql, /substring\(NULLIF\(TRIM\(nome_vendedor\), ''\) from/); + assert.match(sellerRevenueQuery.sql, /regexp_replace\(COALESCE\(nome_vendedor, ''\)/); + assert.match(sellerOrdersQuery.sql, /COUNT\(DISTINCT COALESCE/); + assert.deepEqual(dashboard.revenueBySeller, [ + { id: '977226210', name: 'KEDMA DA SILVA', value: 750 } + ]); + assert.deepEqual(dashboard.ordersBySeller, [ + { id: '977226210', name: 'KEDMA DA SILVA', value: 3 } + ]); + } finally { + pool.query = originalQuery; + } +}); + test('client tokens are opaque and stable for customer keys', () => { const phoneKey = '(16) 99103-6131'; const nameKey = 'name:Cliente Sem Fone'; diff --git a/src/analytics/dashboard.ts b/src/analytics/dashboard.ts index 885db28..b9597b9 100644 --- a/src/analytics/dashboard.ts +++ b/src/analytics/dashboard.ts @@ -1,5 +1,6 @@ import type { DashboardAnalytics, DateRange, OrderData } from '../types'; import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue } from './orders'; +import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters'; const COLORS = [ '#10b981', '#3b82f6', '#8b5cf6', '#f43f5e', '#f97316', @@ -32,12 +33,16 @@ export interface DashboardMetrics { averageOrderValue: number; salesByProduct: ChartProductMetric[]; revenueByProduct: ChartProductMetric[]; + revenueBySeller: ChartProductMetric[]; + ordersBySeller: ChartProductMetric[]; } export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetrics => { const displayProducts = Array.from(new Set([ ...metrics.salesByProduct.map(product => product.name), - ...metrics.revenueByProduct.map(product => product.name) + ...metrics.revenueByProduct.map(product => product.name), + ...(metrics.revenueBySeller || []).map(seller => seller.name), + ...(metrics.ordersBySeller || []).map(seller => seller.name) ])).sort(); const productColors = displayProducts.reduce>((colors, name) => { colors[name] = getProductColor(name); @@ -55,6 +60,16 @@ export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetr revenueByProduct: metrics.revenueByProduct.map(product => ({ ...product, fill: productColors[product.name] + })), + revenueBySeller: (metrics.revenueBySeller || []).map(seller => ({ + ...seller, + name: formatDisplayName(removeTrailingSellerId(seller.name)), + fill: productColors[seller.name] + })), + ordersBySeller: (metrics.ordersBySeller || []).map(seller => ({ + ...seller, + name: formatDisplayName(removeTrailingSellerId(seller.name)), + fill: productColors[seller.name] })) }; }; @@ -66,20 +81,35 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa const productSalesMap: Record = {}; const productRevenueMap: Record = {}; const productNameIdMap: Record = {}; + const sellerRevenueMap: Record = {}; + const sellerOrderKeysMap: Record> = {}; + const sellerIdMap: Record = {}; filteredData.forEach(order => { const itemRevenue = getOrderItemRevenue(order); const productName = getBaseProductName(order.Descricao_Produto); + const sellerName = formatDisplayName(removeTrailingSellerId(order.nome_vendedor || '')); + const sellerId = order.id_vendedor || sellerName; + const orderKey = order.ID_Pedido || `${order.Nome_Cliente}_${order.Data_Pedido}_${order.Valor_Pedido}`; revenue += itemRevenue; totalItems += order.Quantidade; productNameIdMap[productName] = order.ID_Produto; productSalesMap[productName] = (productSalesMap[productName] || 0) + order.Quantidade; productRevenueMap[productName] = (productRevenueMap[productName] || 0) + itemRevenue; + + if (sellerName) { + sellerIdMap[sellerName] = sellerId; + sellerRevenueMap[sellerName] = (sellerRevenueMap[sellerName] || 0) + itemRevenue; + if (!sellerOrderKeysMap[sellerName]) sellerOrderKeysMap[sellerName] = new Set(); + sellerOrderKeysMap[sellerName].add(orderKey); + } }); const topSalesNames = Object.keys(productSalesMap).sort((a, b) => productSalesMap[b] - productSalesMap[a]).slice(0, 10); const topRevenueNames = Object.keys(productRevenueMap).sort((a, b) => productRevenueMap[b] - productRevenueMap[a]).slice(0, 10); + const topSellerRevenueNames = Object.keys(sellerRevenueMap).sort((a, b) => sellerRevenueMap[b] - sellerRevenueMap[a]).slice(0, 10); + const topSellerOrderNames = Object.keys(sellerOrderKeysMap).sort((a, b) => sellerOrderKeysMap[b].size - sellerOrderKeysMap[a].size).slice(0, 10); const displayProducts = Array.from(new Set([...topSalesNames, ...topRevenueNames])).sort(); const productColors = displayProducts.reduce>((colors, name) => { colors[name] = getProductColor(name); @@ -100,11 +130,27 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa fill: productColors[name] })); + const revenueBySeller = topSellerRevenueNames.map(name => ({ + name, + id: sellerIdMap[name], + value: sellerRevenueMap[name], + fill: getProductColor(name) + })); + + const ordersBySeller = topSellerOrderNames.map(name => ({ + name, + id: sellerIdMap[name], + value: sellerOrderKeysMap[name].size, + fill: getProductColor(name) + })); + return { totalRevenue: revenue, totalOrders: totalItems, averageOrderValue: revenue / (filteredData.length || 1), salesByProduct, - revenueByProduct + revenueByProduct, + revenueBySeller, + ordersBySeller }; }; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index abd2571..5151bef 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -25,18 +25,19 @@ type CustomTooltipProps = { payload?: ChartTooltipPayload[]; label?: string; isCurrency?: boolean; + valueLabel?: string; }; -const CustomTooltip = ({ active, payload, label, isCurrency }: CustomTooltipProps) => { +const CustomTooltip = ({ active, payload, label, isCurrency, valueLabel }: CustomTooltipProps) => { if (active && payload && payload.length) { const color = payload[0].payload?.fill || payload[0].color || '#9ECAE1'; const displayLabel = label || payload[0].name; const value = isCurrency ? formatCurrency(payload[0].value) : payload[0].value; - const valueLabel = isCurrency ? 'Receita:' : 'Vendas:'; + const displayValueLabel = valueLabel || (isCurrency ? 'Receita:' : 'Vendas:'); return (

{displayLabel}

-

{valueLabel} {value}

+

{displayValueLabel} {value}

); } @@ -78,7 +79,7 @@ const Dashboard = () => { return () => clearInterval(intervalId); }, [dateRange, loadDashboardMetrics, refreshInterval]); - const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct } = useMemo(() => { + const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct, revenueBySeller, ordersBySeller } = useMemo(() => { if (serverMetrics) return applyDashboardColors(serverMetrics); return buildDashboardMetrics(ordersData, dateRange); }, [dateRange, ordersData, serverMetrics]); @@ -148,6 +149,76 @@ const Dashboard = () => { +
+
+

Receita por Vendedor

+
+ {revenueBySeller.length ? ( + + + + + + } cursor={{ fill: '#222222' }} /> + + {revenueBySeller.map((entry) => ( + + ))} + + + + ) : ( +

Nenhuma venda com vendedor no perĂ­odo.

+ )} +
+
+ {revenueBySeller.map((entry) => ( +
+
+ + {entry.name} +
+ {formatCurrency(entry.value)} +
+ ))} +
+
+ +
+

Pedidos por Vendedor

+
+ {ordersBySeller.length ? ( + + + + + + } cursor={{ fill: '#222222' }} /> + + {ordersBySeller.map((entry) => ( + + ))} + + + + ) : ( +

Nenhum pedido com vendedor no perĂ­odo.

+ )} +
+
+ {ordersBySeller.map((entry) => ( +
+
+ + {entry.name} +
+ {entry.value} +
+ ))} +
+
+
+

Produtos Mais Vendidos

diff --git a/src/types.ts b/src/types.ts index e7d4426..bf46083 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,6 +56,7 @@ export interface CreateUserResult { export interface DashboardAnalytics { totalRevenue: number; totalOrders: number; + orderLineCount?: number; averageOrderValue: number; salesByProduct: Array<{ name: string; @@ -67,6 +68,16 @@ export interface DashboardAnalytics { id: string; value: number; }>; + revenueBySeller?: Array<{ + name: string; + id: string; + value: number; + }>; + ordersBySeller?: Array<{ + name: string; + id: string; + value: number; + }>; } export interface ProductAnalyticsItem {