diff --git a/backend/routes/analyticsRoutes.js b/backend/routes/analyticsRoutes.js index d8d2765..5fee1b5 100644 --- a/backend/routes/analyticsRoutes.js +++ b/backend/routes/analyticsRoutes.js @@ -5,6 +5,7 @@ const { getClientDetailsAnalytics, getDashboardAnalytics, getProductAnalytics, + getProductDetailsAnalytics, getRfmAnalytics } = require('../services/analyticsService'); @@ -33,6 +34,21 @@ router.get('/analytics/products', verifyToken, async (req, res) => { } }); +router.get('/analytics/products/:productId/details', verifyToken, async (req, res) => { + try { + const details = await getProductDetailsAnalytics(req.params.productId, getRange(req.query)); + if (!details) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.json(details); + } catch (error) { + console.error('Error fetching product details analytics:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + router.get('/analytics/clients', verifyToken, async (req, res) => { try { res.json(await getClientAnalytics(getRange(req.query))); diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index c835b84..1b42c6f 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -452,6 +452,98 @@ const getProductAnalytics = async (range = {}) => { })); }; +const getProductDetailsAnalytics = async (productId, range = {}) => { + const normalizedProductId = String(productId || '').trim(); + if (!normalizedProductId) return null; + + const normalizedStart = normalizeDateParam(range.start); + const normalizedEnd = normalizeDateParam(range.end); + const periodParams = [normalizedProductId]; + const periodFilters = [ + 'produto_id = $1', + 'data_pedido_date IS NOT NULL' + ]; + + if (normalizedStart) { + periodParams.push(normalizedStart); + periodFilters.push(`data_pedido_date >= $${periodParams.length}::date`); + } + + if (normalizedEnd) { + periodParams.push(normalizedEnd); + periodFilters.push(`data_pedido_date <= $${periodParams.length}::date`); + } + + const [summaryResult, periodResult] = await Promise.all([ + pool.query(` + WITH selected_product AS ( + SELECT $1::text as id + ), + stock_info AS ( + SELECT + produto_id as id, + MAX(NULLIF(nome, '')) as name + FROM stock + WHERE produto_id = $1 + GROUP BY produto_id + ), + order_info AS ( + SELECT + produto_id as id, + (ARRAY_AGG(COALESCE(NULLIF(produto_descricao, ''), 'Unknown') ORDER BY data_pedido_date DESC NULLS LAST, data_pedido DESC NULLS LAST))[1] as name, + (ARRAY_AGG(valor_unitario ORDER BY data_pedido_date DESC NULLS LAST, data_pedido DESC NULLS LAST))[1] as price + FROM orders + WHERE produto_id = $1 + GROUP BY produto_id + ) + SELECT + selected_product.id, + COALESCE(stock_info.name, order_info.name, 'Unknown') as name, + COALESCE(order_info.price, 0) as price + 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 + WHERE stock_info.id IS NOT NULL OR order_info.id IS NOT NULL; + `, [normalizedProductId]), + pool.query(` + SELECT + data_pedido_date, + MAX(data_pedido) as date_label, + COALESCE(SUM(quantidade), 0) as quantity_sold, + COALESCE(SUM(quantidade * valor_unitario), 0) as revenue + FROM orders + WHERE ${periodFilters.join(' AND ')} + GROUP BY data_pedido_date + ORDER BY data_pedido_date ASC; + `, periodParams) + ]); + + const summary = summaryResult.rows[0]; + if (!summary) return null; + + const chartData = periodResult.rows.map(row => ({ + date: row.date_label || getDateOnly(row.data_pedido_date) || '', + value: toNumber(row.quantity_sold) + })); + 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); + + return { + range: { + start: normalizedStart, + end: normalizedEnd + }, + productInfo: { + id: summary.id, + name: summary.name, + price: toNumber(summary.price) + }, + chartData, + totalSold, + totalRevenue + }; +}; + const getClientAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); const result = await pool.query(` @@ -837,6 +929,7 @@ module.exports = { getRfmSegment, getClientAnalytics, getDashboardAnalytics, + getProductDetailsAnalytics, getProductAnalytics, normalizeDateParam, scoreTertile diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 4cce14b..8a0e433 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -12,6 +12,7 @@ const { getClientDetailsAnalytics, getPreviousDate, getProductAnalytics, + getProductDetailsAnalytics, getRecencyScore, getRfmAnalytics, getRfmSegment, @@ -392,6 +393,97 @@ test('getProductAnalytics returns exact product rows with stock and latest price } }); +test('getProductDetailsAnalytics returns product identity and period chart without raw order download', async () => { + const originalQuery = pool.query; + const calls = []; + + pool.query = async (sql, params = []) => { + calls.push({ sql, params }); + + if (sql.includes('selected_product AS')) { + return { + rows: [{ + id: '919483307', + name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G', + price: 11.9 + }] + }; + } + + return { + rows: [ + { + data_pedido_date: '2026-06-01', + date_label: '01-06-2026', + quantity_sold: 3, + revenue: 35.7 + }, + { + data_pedido_date: '2026-06-02', + date_label: '02-06-2026', + quantity_sold: 2, + revenue: 23.8 + } + ] + }; + }; + + try { + const details = await getProductDetailsAnalytics('919483307', { start: '2026-06-01', end: '2026-06-22' }); + + assert.equal(calls.length, 2); + assert.match(calls[0].sql, /WITH selected_product AS/); + assert.match(calls[0].sql, /WHERE produto_id = \$1/); + assert.deepEqual(calls[0].params, ['919483307']); + assert.match(calls[1].sql, /produto_id = \$1/); + assert.match(calls[1].sql, /data_pedido_date >= \$2::date/); + assert.match(calls[1].sql, /data_pedido_date <= \$3::date/); + assert.deepEqual(calls[1].params, ['919483307', '2026-06-01', '2026-06-22']); + assert.deepEqual(details.productInfo, { + id: '919483307', + name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G', + price: 11.9 + }); + assert.deepEqual(details.chartData, [ + { date: '01-06-2026', value: 3 }, + { date: '02-06-2026', value: 2 } + ]); + assert.equal(details.totalSold, 5); + assert.equal(details.totalRevenue, 59.5); + } finally { + pool.query = originalQuery; + } +}); + +test('getProductDetailsAnalytics keeps known products visible with zero period sales', async () => { + const originalQuery = pool.query; + + pool.query = async (sql) => { + if (sql.includes('selected_product AS')) { + return { + rows: [{ + id: 'stock-only', + name: 'Produto sem venda no período', + price: 0 + }] + }; + } + + return { rows: [] }; + }; + + try { + const details = await getProductDetailsAnalytics('stock-only', { start: '2026-06-01', end: '2026-06-22' }); + + assert.equal(details.productInfo.id, 'stock-only'); + assert.equal(details.totalSold, 0); + assert.equal(details.totalRevenue, 0); + assert.deepEqual(details.chartData, []); + } finally { + pool.query = originalQuery; + } +}); + test('getClientAnalytics returns opaque client tokens', async () => { const originalQuery = pool.query; const calls = []; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 39ae4b6..fb0eaed 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,13 +1,14 @@ -import { useCallback, useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { Outlet, Link, useLocation } from 'react-router-dom'; -import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone, Grid3X3, Shield } from 'lucide-react'; -import type { DateRange, OrderData, StockData } from '../types'; -import { fetchData, fetchStock, isSuperAdmin, logout } from '../dataService'; +import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield } from 'lucide-react'; +import type { DateRange, OrderData } from '../types'; +import { isSuperAdmin, logout } from '../dataService'; import { rangeForLastDays } from '../dateRanges'; +const emptyOrdersData: OrderData[] = []; + const Layout = () => { const location = useLocation(); - const needsRawData = location.pathname.startsWith('/products/'); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { return localStorage.getItem('graph_sidebar_collapsed') === 'true'; }); @@ -23,43 +24,11 @@ const Layout = () => { return rangeForLastDays(30); }); - const [ordersData, setOrdersData] = useState([]); - const [stockData, setStockData] = useState([]); - const [isLoading, setIsLoading] = useState(needsRawData); const [refreshInterval, setRefreshInterval] = useState(() => { const saved = localStorage.getItem('nexstar_refresh_interval'); return saved ? Number(saved) : 0; }); - const loadData = useCallback(async (showLoading = false) => { - if (showLoading) setIsLoading(true); - try { - const [data, stock] = await Promise.all([fetchData(), fetchStock()]); - setOrdersData(data); - setStockData(stock); - } finally { - if (showLoading) setIsLoading(false); - } - }, []); - - useEffect(() => { - if (!needsRawData) return; - - // Product pages still depend on raw orders until their API migration is complete. - // eslint-disable-next-line react-hooks/set-state-in-effect - void loadData(true); - }, [loadData, needsRawData]); - - useEffect(() => { - if (refreshInterval === 0 || !needsRawData) return; - - const intervalId = setInterval(() => { - loadData(false); - }, refreshInterval); - - return () => clearInterval(intervalId); - }, [loadData, needsRawData, refreshInterval]); - useEffect(() => { localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString()); }, [refreshInterval]); @@ -170,13 +139,7 @@ const Layout = () => { {/* Content Area */}
- {needsRawData && isLoading && ( -
- - Atualizando dados -
- )} - +
diff --git a/src/dataService.ts b/src/dataService.ts index 88e1248..27f6f56 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, RfmAnalytics, StockData } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, RfmAnalytics, StockData } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -139,6 +139,21 @@ export const fetchProductAnalytics = async (dateRange: DateRange): Promise => { + try { + const params = new URLSearchParams({ + start: formatDateParam(dateRange.start), + end: formatDateParam(dateRange.end) + }); + const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/details?${params.toString()}`); + if (!response.ok) return null; + return await response.json(); + } catch (error) { + console.error('Fetch product details analytics failed', error); + return null; + } +}; + export const fetchRfmAnalytics = async (dateRange: DateRange): Promise => { try { const params = new URLSearchParams({ diff --git a/src/pages/ProductDetails.tsx b/src/pages/ProductDetails.tsx index 8686d06..1302cf4 100644 --- a/src/pages/ProductDetails.tsx +++ b/src/pages/ProductDetails.tsx @@ -1,10 +1,10 @@ -import { useMemo } from 'react'; +import { useEffect, useState } from 'react'; import { useParams, Link, useOutletContext } from 'react-router-dom'; import { ArrowLeft, Package, DollarSign } from 'lucide-react'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import DateRangePicker from '../components/DateRangePicker'; -import type { OrderData, DateRange } from '../types'; -import { buildProductDetailsMetrics } from '../analytics/products'; +import type { DateRange, ProductDetailsAnalytics } from '../types'; +import { fetchProductDetailsAnalytics } from '../dataService'; type CustomTooltipProps = { active?: boolean; @@ -26,25 +26,46 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { const ProductDetails = () => { const { id } = useParams<{ id: string }>(); - const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{ + const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange, - setDateRange: (range: DateRange) => void, - ordersData: OrderData[], - isDataLoading: boolean, - refreshInterval: number, - setRefreshInterval: (interval: number) => void, - loadData: (showLoading?: boolean) => void + setDateRange: (range: DateRange) => void }>(); + const [details, setDetails] = useState(null); + const [isLoading, setIsLoading] = useState(true); - const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => { - return buildProductDetailsMetrics(ordersData, id, dateRange); - }, [id, dateRange, ordersData]); + useEffect(() => { + let isMounted = true; + + const loadProductDetails = async () => { + if (!id) { + if (isMounted) { + setDetails(null); + setIsLoading(false); + } + return; + } + + setIsLoading(true); + const productDetails = await fetchProductDetailsAnalytics(id, dateRange); + + if (isMounted) { + setDetails(productDetails); + setIsLoading(false); + } + }; + + void loadProductDetails(); + + return () => { + isMounted = false; + }; + }, [dateRange, id]); const formatCurrency = (value: number) => { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); }; - if (!productInfo && isDataLoading) { + if (isLoading) { return (

Carregando produto...

@@ -52,7 +73,7 @@ const ProductDetails = () => { ); } - if (!productInfo) { + if (!details?.productInfo) { return (

Produto não encontrado.

@@ -61,6 +82,8 @@ const ProductDetails = () => { ); } + const { productInfo, chartData, totalSold, totalRevenue } = details; + return (
diff --git a/src/types.ts b/src/types.ts index f7da0d5..1b4d9fc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -81,6 +81,24 @@ export interface ProductAnalyticsItem { lastSaleDate: string | null; } +export interface ProductDetailsAnalytics { + range: { + start: string | null; + end: string | null; + }; + productInfo: { + id: string; + name: string; + price: number; + }; + chartData: Array<{ + date: string; + value: number; + }>; + totalSold: number; + totalRevenue: number; +} + export interface ClientAnalyticsItem { customerKey: string; clientToken: string;