diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 5dee928..35ddd03 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -759,18 +759,24 @@ const getProductDetailsAnalytics = async (productId, range = {}) => { 'produto_id = $1', 'data_pedido_date IS NOT NULL' ]; + const variantParams = [normalizedProductId]; + const variantFilters = ['data_pedido_date IS NOT NULL']; if (normalizedStart) { periodParams.push(normalizedStart); periodFilters.push(`data_pedido_date >= $${periodParams.length}::date`); + variantParams.push(normalizedStart); + variantFilters.push(`data_pedido_date >= $${variantParams.length}::date`); } if (normalizedEnd) { periodParams.push(normalizedEnd); periodFilters.push(`data_pedido_date <= $${periodParams.length}::date`); + variantParams.push(normalizedEnd); + variantFilters.push(`data_pedido_date <= $${variantParams.length}::date`); } - const [summaryResult, periodResult] = await Promise.all([ + const [summaryResult, periodResult, variantResult] = await Promise.all([ pool.query(` WITH selected_product AS ( SELECT $1::text as id @@ -778,7 +784,8 @@ const getProductDetailsAnalytics = async (productId, range = {}) => { stock_info AS ( SELECT produto_id as id, - MAX(NULLIF(nome, '')) as name + MAX(NULLIF(nome, '')) as name, + COALESCE(MAX(saldo), 0) as stock FROM stock WHERE produto_id = $1 GROUP BY produto_id @@ -795,7 +802,8 @@ const getProductDetailsAnalytics = async (productId, range = {}) => { SELECT selected_product.id, COALESCE(stock_info.name, order_info.name, 'Unknown') as name, - COALESCE(order_info.price, 0) as price + COALESCE(order_info.price, 0) as price, + COALESCE(stock_info.stock, 0) as stock FROM selected_product LEFT JOIN stock_info ON stock_info.id = selected_product.id LEFT JOIN order_info ON order_info.id = selected_product.id @@ -805,7 +813,8 @@ const getProductDetailsAnalytics = async (productId, range = {}) => { SELECT EXTRACT(HOUR FROM created_at AT TIME ZONE 'America/Sao_Paulo')::int as hour, COALESCE(SUM(quantidade), 0) as quantity_sold, - COALESCE(SUM(quantidade * valor_unitario), 0) as revenue + COALESCE(SUM(quantidade * valor_unitario), 0) as revenue, + COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count FROM orders WHERE ${periodFilters.join(' AND ')} GROUP BY hour @@ -815,12 +824,35 @@ const getProductDetailsAnalytics = async (productId, range = {}) => { data_pedido_date, MAX(data_pedido) as date_label, COALESCE(SUM(quantidade), 0) as quantity_sold, - COALESCE(SUM(quantidade * valor_unitario), 0) as revenue + COALESCE(SUM(quantidade * valor_unitario), 0) as revenue, + COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count FROM orders WHERE ${periodFilters.join(' AND ')} GROUP BY data_pedido_date ORDER BY data_pedido_date ASC; - `, periodParams) + `, periodParams), + pool.query(` + WITH selected_base AS ( + SELECT COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as base_name + FROM orders + WHERE produto_id = $1 + AND data_pedido_date IS NOT NULL + ORDER BY data_pedido_date DESC NULLS LAST, data_pedido DESC NULLS LAST + LIMIT 1 + ) + SELECT + produto_id as id, + MAX(COALESCE(NULLIF(produto_descricao, ''), 'Unknown')) as name, + COALESCE(SUM(quantidade), 0) as quantity_sold, + COALESCE(SUM(quantidade * valor_unitario), 0) as revenue, + COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count + FROM orders + WHERE ${variantFilters.join(' AND ')} + AND COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') = (SELECT base_name FROM selected_base) + GROUP BY produto_id + ORDER BY quantity_sold DESC, revenue DESC, name ASC + LIMIT 8; + `, variantParams) ]); const summary = summaryResult.rows[0]; @@ -829,17 +861,43 @@ const getProductDetailsAnalytics = async (productId, range = {}) => { const chartData = useHourlyChart ? Array.from({ length: 24 }, (_, hour) => { const row = periodResult.rows.find(item => toNumber(item.hour) === hour); + const quantitySold = row ? toNumber(row.quantity_sold) : 0; + const revenue = row ? toNumber(row.revenue) : 0; + const orderCount = row ? toNumber(row.order_count) : 0; + return { date: `${String(hour).padStart(2, '0')}h`, - value: row ? toNumber(row.quantity_sold) : 0 + value: quantitySold, + quantitySold, + revenue, + orderCount, + averageTicket: orderCount ? revenue / orderCount : 0 }; }) - : periodResult.rows.map(row => ({ - date: row.date_label || getDateOnly(row.data_pedido_date) || '', - value: toNumber(row.quantity_sold) - })); + : periodResult.rows.map(row => { + const quantitySold = toNumber(row.quantity_sold); + const revenue = toNumber(row.revenue); + const orderCount = toNumber(row.order_count); + + return { + date: row.date_label || getDateOnly(row.data_pedido_date) || '', + value: quantitySold, + quantitySold, + revenue, + orderCount, + averageTicket: orderCount ? revenue / orderCount : 0 + }; + }); const totalSold = periodResult.rows.reduce((sum, row) => sum + toNumber(row.quantity_sold), 0); const totalRevenue = periodResult.rows.reduce((sum, row) => sum + toNumber(row.revenue), 0); + const totalOrders = periodResult.rows.reduce((sum, row) => sum + toNumber(row.order_count), 0); + const variantBreakdown = variantResult.rows.map(row => ({ + id: row.id, + name: row.name, + quantitySold: toNumber(row.quantity_sold), + revenue: toNumber(row.revenue), + orderCount: toNumber(row.order_count) + })); return { range: { @@ -849,11 +907,15 @@ const getProductDetailsAnalytics = async (productId, range = {}) => { productInfo: { id: summary.id, name: summary.name, - price: toNumber(summary.price) + price: toNumber(summary.price), + stock: toNumber(summary.stock) }, chartData, totalSold, - totalRevenue + totalRevenue, + totalOrders, + averageTicket: totalOrders ? totalRevenue / totalOrders : 0, + variantBreakdown }; }; diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 2bda131..76ad62d 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -526,24 +526,44 @@ test('getProductDetailsAnalytics returns product identity and period chart witho rows: [{ id: '919483307', name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G', - price: 11.9 + price: 11.9, + stock: 11731 }] }; } + if (sql.includes('selected_base AS')) { + assert.match(sql, /COALESCE\(\s+CASE/); + assert.match(sql, /GROUP BY produto_id/); + assert.deepEqual(params, ['919483307', '2026-06-01', '2026-06-22']); + return { + rows: [ + { + id: '919483307', + name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G', + quantity_sold: 3, + revenue: 35.7, + order_count: 1 + } + ] + }; + } + return { rows: [ { data_pedido_date: '2026-06-01', date_label: '01-06-2026', quantity_sold: 3, - revenue: 35.7 + revenue: 35.7, + order_count: 1 }, { data_pedido_date: '2026-06-02', date_label: '02-06-2026', quantity_sold: 2, - revenue: 23.8 + revenue: 23.8, + order_count: 1 } ] }; @@ -552,7 +572,7 @@ test('getProductDetailsAnalytics returns product identity and period chart witho try { const details = await getProductDetailsAnalytics('919483307', { start: '2026-06-01', end: '2026-06-22' }); - assert.equal(calls.length, 2); + assert.equal(calls.length, 3); assert.match(calls[0].sql, /WITH selected_product AS/); assert.match(calls[0].sql, /WHERE produto_id = \$1/); assert.deepEqual(calls[0].params, ['919483307']); @@ -563,14 +583,26 @@ test('getProductDetailsAnalytics returns product identity and period chart witho assert.deepEqual(details.productInfo, { id: '919483307', name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G', - price: 11.9 + price: 11.9, + stock: 11731 }); assert.deepEqual(details.chartData, [ - { date: '01-06-2026', value: 3 }, - { date: '02-06-2026', value: 2 } + { date: '01-06-2026', value: 3, quantitySold: 3, revenue: 35.7, orderCount: 1, averageTicket: 35.7 }, + { date: '02-06-2026', value: 2, quantitySold: 2, revenue: 23.8, orderCount: 1, averageTicket: 23.8 } ]); assert.equal(details.totalSold, 5); assert.equal(details.totalRevenue, 59.5); + assert.equal(details.totalOrders, 2); + assert.equal(details.averageTicket, 29.75); + assert.deepEqual(details.variantBreakdown, [ + { + id: '919483307', + name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G', + quantitySold: 3, + revenue: 35.7, + orderCount: 1 + } + ]); } finally { pool.query = originalQuery; } @@ -585,7 +617,8 @@ test('getProductDetailsAnalytics keeps known products visible with zero period s rows: [{ id: 'stock-only', name: 'Produto sem venda no período', - price: 0 + price: 0, + stock: 12 }] }; } @@ -600,6 +633,7 @@ test('getProductDetailsAnalytics keeps known products visible with zero period s assert.equal(details.totalSold, 0); assert.equal(details.totalRevenue, 0); assert.deepEqual(details.chartData, []); + assert.deepEqual(details.variantBreakdown, []); } finally { pool.query = originalQuery; } @@ -614,16 +648,21 @@ test('getProductDetailsAnalytics groups single-day chart by hour', async () => { rows: [{ id: '919483307', name: 'Produto com venda por hora', - price: 11.9 + price: 11.9, + stock: 4 }] }; } + if (sql.includes('selected_base AS')) { + return { rows: [] }; + } + assert.match(sql, /EXTRACT\(HOUR FROM created_at AT TIME ZONE 'America\/Sao_Paulo'\)::int as hour/); return { rows: [ - { hour: 9, quantity_sold: 2, revenue: 23.8 }, - { hour: 18, quantity_sold: 3, revenue: 35.7 } + { hour: 9, quantity_sold: 2, revenue: 23.8, order_count: 1 }, + { hour: 18, quantity_sold: 3, revenue: 35.7, order_count: 1 } ] }; }; @@ -632,8 +671,8 @@ test('getProductDetailsAnalytics groups single-day chart by hour', async () => { const details = await getProductDetailsAnalytics('919483307', { start: '2026-06-22', end: '2026-06-22' }); assert.equal(details.chartData.length, 24); - assert.deepEqual(details.chartData[9], { date: '09h', value: 2 }); - assert.deepEqual(details.chartData[18], { date: '18h', value: 3 }); + assert.deepEqual(details.chartData[9], { date: '09h', value: 2, quantitySold: 2, revenue: 23.8, orderCount: 1, averageTicket: 23.8 }); + assert.deepEqual(details.chartData[18], { date: '18h', value: 3, quantitySold: 3, revenue: 35.7, orderCount: 1, averageTicket: 35.7 }); assert.equal(details.totalSold, 5); assert.equal(details.totalRevenue, 59.5); } finally { diff --git a/src/analytics/products.ts b/src/analytics/products.ts index e6090b5..9d8c2b8 100644 --- a/src/analytics/products.ts +++ b/src/analytics/products.ts @@ -8,6 +8,8 @@ export interface ProductSummary { revenue: number; lastPrice: number; stock: number; + firstSaleDate?: string | null; + lastSaleDate?: string | null; } export interface ProductDetailsMetrics { diff --git a/src/pages/ProductDetails.tsx b/src/pages/ProductDetails.tsx index 5a22b86..0ddc68d 100644 --- a/src/pages/ProductDetails.tsx +++ b/src/pages/ProductDetails.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useParams, Link, useOutletContext } from 'react-router-dom'; -import { ArrowLeft, Package, DollarSign } from 'lucide-react'; +import { ArrowLeft, Package, DollarSign, ReceiptText, Warehouse } from 'lucide-react'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import DateRangePicker from '../components/DateRangePicker'; import RefreshStatus from '../components/RefreshStatus'; @@ -11,6 +11,9 @@ const CHART_GRID_COLOR = 'var(--chart-grid)'; const CHART_AXIS_COLOR = 'var(--chart-axis)'; const CHART_CURSOR_COLOR = 'var(--chart-cursor)'; const CHART_DETAIL_BAR_COLOR = 'var(--chart-detail-bar)'; +const VARIANT_BAR_COLOR = '#52DFA0'; + +type ProductChartMetric = 'quantity' | 'revenue' | 'ticket'; const formatDateKey = (date: Date) => { const year = date.getFullYear(); @@ -21,19 +24,36 @@ const formatDateKey = (date: Date) => { type CustomTooltipProps = { active?: boolean; - payload?: Array<{ value: number }>; + payload?: Array<{ + value: number; + payload?: ProductDetailsAnalytics['chartData'][number] & { selectedValue?: number }; + }>; label?: string; + metric: ProductChartMetric; + formatCurrency: (value: number) => string; + formatNumber: (value: number) => string; }; -const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { +const CustomTooltip = ({ active, payload, label, metric, formatCurrency, formatNumber }: CustomTooltipProps) => { if (active && payload && payload.length) { + const point = payload[0].payload; + const value = payload[0].value; + const displayValue = metric === 'quantity' ? `${formatNumber(value)} un.` : formatCurrency(value); + return (
{label}
-Vendas: {payload[0].value}
+{displayValue}
+ {point && ( +Unidades: {formatNumber(point.quantitySold ?? point.value ?? 0)}
+Receita: {formatCurrency(point.revenue ?? 0)}
+Pedidos: {formatNumber(point.orderCount ?? 0)}
+Unidades Vendidas
-{totalSold}
+{formatNumber(totalSold)}
Ticket Médio
+{formatCurrency(averageTicket)}
+Estoque
+{formatNumber(productInfo.stock)}
+{selectedMetric.subtitle}
+Tamanhos, cores ou SKUs parecidos no mesmo período.
+