From 948931886e665991b08afc5f6a3bcfaa824c61c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 14 Jul 2026 13:55:59 -0300 Subject: [PATCH] Improve chart readability and drilldowns --- src/analytics/dashboard.ts | 15 ++- src/chartUtils.ts | 125 +++++++++++++++++++++++ src/pages/Dashboard.tsx | 147 +++++++++++++++++++++------ src/pages/ProductDetails.tsx | 189 ++++++++++++++++++++++++++++++----- 4 files changed, 419 insertions(+), 57 deletions(-) create mode 100644 src/chartUtils.ts diff --git a/src/analytics/dashboard.ts b/src/analytics/dashboard.ts index e483b17..832d592 100644 --- a/src/analytics/dashboard.ts +++ b/src/analytics/dashboard.ts @@ -1,6 +1,7 @@ import type { DashboardAnalytics, DateRange, OrderData } from '../types'; import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue, parseOrderDate } from './orders'; import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters'; +import { normalizeUnknownLabel } from '../chartUtils'; const COLORS = [ '#25C2FF', '#18D6B5', '#A06BFF', '#FF6B8A', '#FFC247', @@ -20,6 +21,10 @@ const getProductColor = (name: string): string => { return globalColorMap[name]; }; +const formatSellerDisplayName = (value: string) => ( + normalizeUnknownLabel(formatDisplayName(removeTrailingSellerId(value)), 'Sem vendedor') +); + export interface ChartProductMetric { name: string; id: string; @@ -77,23 +82,23 @@ export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetr })), revenueBySeller: (metrics.revenueBySeller || []).map(seller => ({ ...seller, - name: formatDisplayName(removeTrailingSellerId(seller.name)), + name: formatSellerDisplayName(seller.name), fill: productColors[seller.name] })), ordersBySeller: (metrics.ordersBySeller || []).map(seller => ({ ...seller, - name: formatDisplayName(removeTrailingSellerId(seller.name)), + name: formatSellerDisplayName(seller.name), fill: productColors[seller.name] })), sellerRevenueByDate: (metrics.sellerRevenueByDate || []).map(seller => ({ ...seller, - name: formatDisplayName(removeTrailingSellerId(seller.name)), + name: formatSellerDisplayName(seller.name), orders: seller.orders || 0, fill: productColors[seller.name] })), sellerRevenueByHour: (metrics.sellerRevenueByHour || []).map(seller => ({ ...seller, - name: formatDisplayName(removeTrailingSellerId(seller.name)), + name: formatSellerDisplayName(seller.name), orders: seller.orders || 0, fill: productColors[seller.name] })) @@ -118,7 +123,7 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa filteredData.forEach(order => { const itemRevenue = getOrderItemRevenue(order); const productName = getBaseProductName(order.Descricao_Produto); - const sellerName = formatDisplayName(removeTrailingSellerId(order.nome_vendedor || '')); + const sellerName = formatSellerDisplayName(order.nome_vendedor || ''); const sellerId = order.id_vendedor || sellerName; const orderKey = order.ID_Pedido || `${order.Nome_Cliente}_${order.Data_Pedido}_${order.Valor_Pedido}`; diff --git a/src/chartUtils.ts b/src/chartUtils.ts new file mode 100644 index 0000000..650e78c --- /dev/null +++ b/src/chartUtils.ts @@ -0,0 +1,125 @@ +import type { DateRange } from './types'; + +export type DateBucket = 'day' | 'week' | 'month'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export const getRangeDayCount = (dateRange: DateRange) => ( + Math.max(1, Math.ceil((dateRange.end.getTime() - dateRange.start.getTime()) / MS_PER_DAY) + 1) +); + +export const getAutoDateBucket = (dateRange: DateRange): DateBucket => { + const days = getRangeDayCount(dateRange); + if (days > 365) return 'month'; + if (days > 90) return 'week'; + return 'day'; +}; + +export const parseChartDate = (value: string): Date | null => { + if (!value) return null; + + const isoMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (isoMatch) { + const [, year, month, day] = isoMatch; + return new Date(Number(year), Number(month) - 1, Number(day)); + } + + const localMatch = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (localMatch) { + const [, day, month, year] = localMatch; + return new Date(Number(year), Number(month) - 1, Number(day)); + } + + const dashedLocalMatch = value.match(/^(\d{2})-(\d{2})-(\d{4})$/); + if (dashedLocalMatch) { + const [, day, month, year] = dashedLocalMatch; + return new Date(Number(year), Number(month) - 1, Number(day)); + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +export const formatChartDateKey = (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 startOfWeek = (date: Date) => { + const nextDate = new Date(date); + const day = nextDate.getDay(); + const offset = day === 0 ? -6 : 1 - day; + nextDate.setDate(nextDate.getDate() + offset); + nextDate.setHours(0, 0, 0, 0); + return nextDate; +}; + +export const getDateBucketKey = (value: string, bucket: DateBucket) => { + const date = parseChartDate(value); + if (!date) return value; + if (bucket === 'day') return formatChartDateKey(date); + if (bucket === 'week') return formatChartDateKey(startOfWeek(date)); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; +}; + +export const formatDateBucketLabel = (value: string, bucket: DateBucket) => { + if (bucket === 'month') { + const match = value.match(/^(\d{4})-(\d{2})$/); + if (!match) return value; + const [, year, month] = match; + return new Intl.DateTimeFormat('pt-BR', { month: 'short', year: '2-digit' }).format(new Date(Number(year), Number(month) - 1, 1)); + } + + const date = parseChartDate(value); + if (!date) return value; + if (bucket === 'week') { + const endDate = new Date(date); + endDate.setDate(endDate.getDate() + 6); + const formatter = new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit' }); + return `${formatter.format(date)}-${formatter.format(endDate)}`; + } + + return new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit' }).format(date); +}; + +export const formatDateBucketLongLabel = (value: string, bucket: DateBucket) => { + if (bucket === 'month') { + const match = value.match(/^(\d{4})-(\d{2})$/); + if (!match) return value; + const [, year, month] = match; + return new Intl.DateTimeFormat('pt-BR', { month: 'long', year: 'numeric' }).format(new Date(Number(year), Number(month) - 1, 1)); + } + + const date = parseChartDate(value); + if (!date) return value; + if (bucket === 'week') { + const endDate = new Date(date); + endDate.setDate(endDate.getDate() + 6); + const formatter = new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }); + return `${formatter.format(date)} - ${formatter.format(endDate)}`; + } + + return new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date); +}; + +export const getMovingAverageWindow = (bucket: DateBucket) => { + if (bucket === 'day') return 7; + if (bucket === 'week') return 4; + return 3; +}; + +export const averageRecentValues = (values: number[], endIndex: number, windowSize: number) => { + const startIndex = Math.max(0, endIndex - windowSize + 1); + const windowValues = values.slice(startIndex, endIndex + 1); + if (!windowValues.length) return 0; + return windowValues.reduce((total, value) => total + value, 0) / windowValues.length; +}; + +export const normalizeUnknownLabel = (value: string, fallback: string) => { + const normalized = String(value || '').replace(/\s+/g, ' ').trim(); + if (!normalized) return fallback; + if (/^(0|null|undefined|unknown|n\/a|-|sem nome)$/i.test(normalized)) return fallback; + return normalized; +}; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 1146fbd..cf1d2e6 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useOutletContext, useNavigate } from 'react-router-dom'; -import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; +import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Line } from 'recharts'; import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; import RefreshStatus from '../components/RefreshStatus'; import type { DashboardAnalytics, OrderData, DateRange } from '../types'; import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard'; import { fetchDashboardAnalytics } from '../dataService'; +import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getDateBucketKey, getMovingAverageWindow, type DateBucket } from '../chartUtils'; const formatCurrency = (value: number) => { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); @@ -23,20 +24,6 @@ const formatCompactCurrency = (value: number) => { 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 formatHourKey = (hour: number) => `${String(hour).padStart(2, '0')}h`; const formatDateKey = (date: Date) => { @@ -80,7 +67,8 @@ type SellerTimeSeriesSeller = { type SellerTimeSeriesChartData = { date: string; - [key: string]: string | number; + movingAverage?: number; + [key: string]: string | number | undefined; }; type SellerMetricConfig = { @@ -253,9 +241,11 @@ type SellerMetricTooltipProps = { focusedSellerId: string | null; sellers: SellerTimeSeriesSeller[]; metricConfig: SellerMetricConfig; + isHourly: boolean; + dateBucket: DateBucket; }; -const SellerMetricTooltip = ({ active, payload, label, focusedSellerId, sellers, metricConfig }: SellerMetricTooltipProps) => { +const SellerMetricTooltip = ({ active, payload, label, focusedSellerId, sellers, metricConfig, isHourly, dateBucket }: SellerMetricTooltipProps) => { if (!active || !payload?.length) return null; const sellersByKey = new Map(sellers.map(seller => [seller.seriesKey, seller])); @@ -279,7 +269,9 @@ const SellerMetricTooltip = ({ active, payload, label, focusedSellerId, sellers, className="min-w-56 rounded-xl border p-3 shadow-lg" style={{ backgroundColor: 'var(--chart-tooltip-bg)', borderColor: 'var(--chart-tooltip-border)' }} > -

{formatDateLabel(String(label || ''))}

+

+ {isHourly ? String(label || '') : formatDateBucketLongLabel(String(label || ''), dateBucket)} +

{rows.length ? rows.map(({ seller, value }) => (
@@ -310,6 +302,7 @@ const Dashboard = () => { const [isMetricsLoading, setIsMetricsLoading] = useState(true); const [focusedSellerId, setFocusedSellerId] = useState(null); const [sellerMetric, setSellerMetric] = useState('revenue'); + const [selectedSellerBucket, setSelectedSellerBucket] = useState(null); const loadDashboardMetrics = useCallback(async (range: DateRange, options?: { force?: boolean }) => { setIsMetricsLoading(true); @@ -374,6 +367,7 @@ const Dashboard = () => { const sellerTimeSeries = useMemo(() => { const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end); const isHourly = isSingleDayRange && chartSellerRevenueByHour.length > 0; + const dateBucket = getAutoDateBucket(dateRange); const activeTrendPoints = isHourly ? chartSellerRevenueByHour.map(point => ({ ...point, @@ -438,7 +432,7 @@ const Dashboard = () => { return row; }); - return { sellers, chartData, isFallback: true, isHourly }; + return { sellers, chartData, isFallback: true, isHourly, dateBucket, movingAverageWindow: 0 }; } const sellersById = new Map>(); @@ -446,6 +440,7 @@ const Dashboard = () => { activeTrendPoints.forEach(point => { const id = point.id || point.name; + const bucketKey = isHourly ? point.date : getDateBucketKey(point.date, dateBucket); const existing = sellersById.get(id); sellersById.set(id, { id, @@ -455,10 +450,10 @@ const Dashboard = () => { orders: (existing?.orders || 0) + (point.orders || 0) }); - if (!valuesByDate.has(point.date)) { - valuesByDate.set(point.date, new Map()); + if (!valuesByDate.has(bucketKey)) { + valuesByDate.set(bucketKey, new Map()); } - const dateValues = valuesByDate.get(point.date); + const dateValues = valuesByDate.get(bucketKey); if (dateValues) { const existingValue = dateValues.get(id); dateValues.set(id, { @@ -497,11 +492,38 @@ const Dashboard = () => { return row; }); - return { sellers, chartData, isFallback: false, isHourly }; - }, [chartOrdersBySeller, chartRevenueBySeller, chartSellerRevenueByDate, chartSellerRevenueByHour, dateRange.end, dateRange.start, sellerColorMap, sellerMetric]); + const movingAverageWindow = isHourly ? 0 : getMovingAverageWindow(dateBucket); + const totals = chartData.map(row => ( + sellers.reduce((total, seller) => total + Number(row[seller.seriesKey] || 0), 0) + )); + if (movingAverageWindow > 1) { + chartData.forEach((row, index) => { + row.movingAverage = averageRecentValues(totals, index, movingAverageWindow); + }); + } + + return { sellers, chartData, isFallback: false, isHourly, dateBucket, movingAverageWindow }; + }, [chartOrdersBySeller, chartRevenueBySeller, chartSellerRevenueByDate, chartSellerRevenueByHour, dateRange, sellerColorMap, sellerMetric]); const focusedSeller = focusedSellerId ? sellerTimeSeries.sellers.find(seller => seller.id === focusedSellerId) : null; const effectiveFocusedSellerId = focusedSeller?.id || null; + const selectedSellerRow = selectedSellerBucket + ? sellerTimeSeries.chartData.find(row => row.date === selectedSellerBucket) + : null; + const selectedSellerBreakdown = selectedSellerRow + ? sellerTimeSeries.sellers + .map(seller => ({ + seller, + value: Number(selectedSellerRow[seller.seriesKey] || 0) + })) + .filter(item => item.value > 0) + .sort((a, b) => b.value - a.value) + : []; + const selectedSellerBucketLabel = selectedSellerRow + ? sellerTimeSeries.isHourly + ? selectedSellerRow.date + : formatDateBucketLongLabel(selectedSellerRow.date, sellerTimeSeries.dateBucket) + : ''; const handleManualRefresh = () => { void loadDashboardMetrics(dateRange, { force: true }); @@ -580,7 +602,11 @@ const Dashboard = () => { ? 'Evolução por horário no dia selecionado.' : sellerTimeSeries.isFallback ? sellerMetricConfig.fallbackDescription - : sellerMetricConfig.trendDescription} + : sellerTimeSeries.dateBucket === 'day' + ? `${sellerMetricConfig.trendDescription} Média móvel de 7 dias.` + : sellerTimeSeries.dateBucket === 'week' + ? 'Evolução agrupada por semana. Média móvel de 4 semanas.' + : 'Evolução agrupada por mês. Média móvel de 3 meses.'}

@@ -616,7 +642,13 @@ const Dashboard = () => {
- + { + if (event?.activeLabel) setSelectedSellerBucket(String(event.activeLabel)); + }} + > {sellerTimeSeries.sellers.map(seller => ( @@ -634,7 +666,11 @@ const Dashboard = () => { axisLine={false} minTickGap={sellerTimeSeries.isHourly ? 10 : 18} interval={sellerTimeSeries.isHourly ? 2 : undefined} - tickFormatter={formatDateTick} + tickFormatter={(value) => ( + sellerTimeSeries.isHourly + ? String(value) + : formatDateBucketLabel(String(value), sellerTimeSeries.dateBucket) + )} /> { width={54} /> } + content={} cursor={{ stroke: CHART_AXIS_COLOR, strokeDasharray: '4 4' }} /> + {!sellerTimeSeries.isHourly && !sellerTimeSeries.isFallback && sellerTimeSeries.movingAverageWindow > 1 && ( + + )} {sellerTimeSeries.sellers.map(seller => { const isFocused = effectiveFocusedSellerId === seller.id; const isDimmed = Boolean(effectiveFocusedSellerId && !isFocused); @@ -688,6 +739,46 @@ const Dashboard = () => {
+ {selectedSellerRow && ( +
+
+
+

{selectedSellerBucketLabel}

+

+ Quebra por vendedor no ponto selecionado. +

+
+ +
+ {selectedSellerBreakdown.length ? ( +
+ {selectedSellerBreakdown.slice(0, 6).map(({ seller, value }) => ( + + ))} +
+ ) : ( +

Sem valores nesse ponto.

+ )} +
+ )} +
{sellerTimeSeries.sellers.map(seller => { diff --git a/src/pages/ProductDetails.tsx b/src/pages/ProductDetails.tsx index c4e89fb..8cc8812 100644 --- a/src/pages/ProductDetails.tsx +++ b/src/pages/ProductDetails.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useParams, Link, useOutletContext } from 'react-router-dom'; import { Package, DollarSign, ReceiptText, Warehouse } from 'lucide-react'; -import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import BackButton from '../components/BackButton'; import DateRangePicker from '../components/DateRangePicker'; import RefreshStatus from '../components/RefreshStatus'; @@ -9,6 +9,7 @@ import type { DateRange, ProductDetailsAnalytics } from '../types'; import { fetchProductDetailsAnalytics } from '../dataService'; import { parseProductName } from '../productParsing'; import { formatColorLabel } from '../displayFormatters'; +import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils'; const CHART_GRID_COLOR = 'var(--chart-grid)'; const CHART_AXIS_COLOR = 'var(--chart-axis)'; @@ -18,6 +19,11 @@ const VARIANT_BAR_COLOR = '#52DFA0'; type ProductChartMetric = 'quantity' | 'revenue' | 'ticket'; +type ProductMetricChartPoint = ProductDetailsAnalytics['chartData'][number] & { + selectedValue: number; + movingAverage?: number; +}; + const formatDateKey = (date: Date) => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); @@ -29,26 +35,31 @@ type CustomTooltipProps = { active?: boolean; payload?: Array<{ value: number; - payload?: ProductDetailsAnalytics['chartData'][number] & { selectedValue?: number }; + dataKey?: string; + payload?: ProductMetricChartPoint; }>; label?: string; metric: ProductChartMetric; formatCurrency: (value: number) => string; formatNumber: (value: number) => string; + isHourly: boolean; + dateBucket: DateBucket; }; -const CustomTooltip = ({ active, payload, label, metric, formatCurrency, formatNumber }: CustomTooltipProps) => { +const CustomTooltip = ({ active, payload, label, metric, formatCurrency, formatNumber, isHourly, dateBucket }: CustomTooltipProps) => { if (active && payload && payload.length) { - const point = payload[0].payload; - const value = payload[0].value; + const primaryPayload = payload.find(item => item.dataKey === 'selectedValue') || payload[0]; + const point = primaryPayload.payload; + const value = primaryPayload.value; const displayValue = metric === 'quantity' ? `${formatNumber(value)} un.` : formatCurrency(value); + const displayLabel = isHourly ? String(label || '') : formatDateBucketLongLabel(String(label || ''), dateBucket); return (
-

{label}

+

{displayLabel}

{displayValue}

{point && (
@@ -118,6 +129,7 @@ const ProductDetails = () => { const [details, setDetails] = useState(null); const [isLoading, setIsLoading] = useState(true); const [chartMetric, setChartMetric] = useState('quantity'); + const [selectedProductBucket, setSelectedProductBucket] = useState(null); useEffect(() => { let isMounted = true; @@ -171,22 +183,34 @@ const ProductDetails = () => { const { productInfo, chartData, totalSold, totalRevenue, totalOrders = 0, averageTicket = 0, variantBreakdown = [] } = details; const isRefreshing = isLoading && Boolean(details); const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end); + const isHourlyChart = isSingleDayRange && chartData.some(point => /h$|:/.test(point.date)); + const dateBucket = isHourlyChart ? 'day' : getAutoDateBucket(dateRange); + const periodDays = getRangeDayCount(dateRange); + const dailyAverageSold = totalSold / periodDays; + const projectedStockDays = dailyAverageSold > 0 ? productInfo.stock / dailyAverageSold : null; + const stockActionLabel = projectedStockDays === null + ? 'Sem venda no período' + : projectedStockDays <= 7 + ? 'Reposição crítica' + : projectedStockDays <= 21 + ? 'Planejar reposição' + : 'Estoque confortável'; const metricConfig = { quantity: { label: 'Unidades', - title: `Volume por ${isSingleDayRange ? 'Horário' : 'Data'}`, + title: `Volume por ${isHourlyChart ? 'Horário' : 'Data'}`, subtitle: 'Quantidade vendida no período selecionado.', tickFormatter: (value: number) => formatNumber(value) }, revenue: { label: 'Receita', - title: `Receita por ${isSingleDayRange ? 'Horário' : 'Data'}`, + title: `Receita por ${isHourlyChart ? 'Horário' : 'Data'}`, subtitle: 'Faturamento do produto no período selecionado.', tickFormatter: (value: number) => value >= 1000 ? `${formatNumber(value / 1000)}k` : formatCurrency(value) }, ticket: { label: 'Ticket médio', - title: `Ticket médio por ${isSingleDayRange ? 'Horário' : 'Data'}`, + title: `Ticket médio por ${isHourlyChart ? 'Horário' : 'Data'}`, subtitle: 'Receita média por pedido neste produto.', tickFormatter: (value: number) => value >= 1000 ? `${formatNumber(value / 1000)}k` : formatCurrency(value) } @@ -197,14 +221,62 @@ const ProductDetails = () => { tickFormatter: (value: number) => string; }>; const selectedMetric = metricConfig[chartMetric]; - const metricChartData = chartData.map(point => ({ - ...point, - selectedValue: chartMetric === 'quantity' - ? (point.quantitySold ?? point.value) - : chartMetric === 'revenue' - ? (point.revenue ?? 0) - : (point.averageTicket ?? 0) - })); + const metricChartData = (() => { + const bucketMap = new Map(); + + chartData.forEach(point => { + const sourceKey = isHourlyChart ? point.date : getDateBucketKey(point.date, dateBucket); + const current = bucketMap.get(sourceKey) || { + date: sourceKey, + value: 0, + quantitySold: 0, + revenue: 0, + orderCount: 0, + averageTicket: 0, + selectedValue: 0 + }; + const quantity = point.quantitySold ?? point.value ?? 0; + const revenue = point.revenue ?? 0; + const orderCount = point.orderCount ?? 0; + + current.value = (current.value || 0) + quantity; + current.quantitySold = (current.quantitySold || 0) + quantity; + current.revenue = (current.revenue || 0) + revenue; + current.orderCount = (current.orderCount || 0) + orderCount; + current.averageTicket = current.orderCount ? current.revenue / current.orderCount : 0; + bucketMap.set(sourceKey, current); + }); + + const rows = [...bucketMap.values()] + .filter(point => (point.quantitySold || point.value || point.revenue || point.orderCount)) + .sort((a, b) => a.date.localeCompare(b.date)) + .map(point => ({ + ...point, + selectedValue: chartMetric === 'quantity' + ? (point.quantitySold ?? point.value) + : chartMetric === 'revenue' + ? (point.revenue ?? 0) + : (point.averageTicket ?? 0) + })); + + const movingAverageWindow = isHourlyChart ? 0 : getMovingAverageWindow(dateBucket); + if (movingAverageWindow > 1) { + const values = rows.map(point => point.selectedValue); + rows.forEach((point, index) => { + point.movingAverage = averageRecentValues(values, index, movingAverageWindow); + }); + } + + return rows; + })(); + const selectedProductPoint = selectedProductBucket + ? metricChartData.find(point => point.date === selectedProductBucket) + : null; + const selectedProductBucketLabel = selectedProductPoint + ? isHourlyChart + ? selectedProductPoint.date + : formatDateBucketLongLabel(selectedProductPoint.date, dateBucket) + : ''; const maxVariantQuantity = Math.max(...variantBreakdown.map(variant => variant.quantitySold), 0); return ( @@ -237,6 +309,7 @@ const ProductDetails = () => {

Unidades Vendidas

{formatNumber(totalSold)}

+

{formatNumber(dailyAverageSold)} un./dia

@@ -264,6 +337,9 @@ const ProductDetails = () => {

Estoque

{formatNumber(productInfo.stock)}

+

+ {projectedStockDays === null ? stockActionLabel : `${formatNumber(projectedStockDays)} dias · ${stockActionLabel}`} +

@@ -275,7 +351,15 @@ const ProductDetails = () => {

{selectedMetric.title}

-

{selectedMetric.subtitle}

+

+ {isHourlyChart + ? selectedMetric.subtitle + : dateBucket === 'day' + ? `${selectedMetric.subtitle} Média móvel de 7 dias.` + : dateBucket === 'week' + ? 'Valores agrupados por semana com média móvel de 4 semanas.' + : 'Valores agrupados por mês com média móvel de 3 meses.'} +

{(Object.keys(metricConfig) as ProductChartMetric[]).map(metric => ( @@ -301,7 +385,13 @@ const ProductDetails = () => { ) : (
- + { + if (event?.activeLabel) setSelectedProductBucket(String(event.activeLabel)); + }} + > @@ -311,13 +401,13 @@ const ProductDetails = () => { ( + isHourlyChart ? String(value) : formatDateBucketLabel(String(value), dateBucket) + )} /> selectedMetric.tickFormatter(Number(value))} /> - } cursor={{ fill: CHART_CURSOR_COLOR }} /> + } cursor={{ fill: CHART_CURSOR_COLOR }} /> { dot={{ r: 3, strokeWidth: 2, fill: 'var(--color-dark-card)', stroke: CHART_DETAIL_BAR_COLOR }} activeDot={{ r: 5, strokeWidth: 2, fill: CHART_DETAIL_BAR_COLOR, stroke: 'var(--color-dark-card)' }} /> + {!isHourlyChart && metricChartData.some(point => point.movingAverage !== undefined) && ( + + )}
)} + {selectedProductPoint && ( +
+
+
+

{selectedProductBucketLabel}

+

Detalhe do ponto selecionado.

+
+ +
+
+
+
Unidades
+
{formatNumber(selectedProductPoint.quantitySold ?? selectedProductPoint.value ?? 0)}
+
+
+
Receita
+
{formatCurrency(selectedProductPoint.revenue ?? 0)}
+
+
+
Pedidos
+
{formatNumber(selectedProductPoint.orderCount ?? 0)}
+
+
+
Ticket
+
{formatCurrency(selectedProductPoint.averageTicket ?? 0)}
+
+
+
Cobertura
+
{projectedStockDays === null ? '-' : `${formatNumber(projectedStockDays)} dias`}
+
+
+
+ )}