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.
+
+
+
setSelectedSellerBucket(null)}
+ className="h-8 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-muted transition-colors hover:text-dark-text"
+ >
+ Limpar
+
+
+ {selectedSellerBreakdown.length ? (
+
+ {selectedSellerBreakdown.slice(0, 6).map(({ seller, value }) => (
+ setFocusedSellerId(current => current === seller.id ? null : seller.id)}
+ className="flex min-w-0 cursor-pointer items-center justify-between gap-3 rounded-lg border border-dark-border bg-dark-card px-3 py-2 text-left transition-colors hover:border-brand-primary"
+ >
+
+
+ {seller.name}
+
+ {sellerMetricConfig.formatTick(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.
+
+
setSelectedProductBucket(null)}
+ className="h-8 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-muted transition-colors hover:text-dark-text"
+ >
+ Limpar
+
+
+
+
+
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`}
+
+
+
+ )}