Improve chart readability and drilldowns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s
This commit is contained in:
@@ -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)' }}
|
||||
>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-dark-muted">{formatDateLabel(String(label || ''))}</p>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-dark-muted">
|
||||
{isHourly ? String(label || '') : formatDateBucketLongLabel(String(label || ''), dateBucket)}
|
||||
</p>
|
||||
<div className="space-y-2 text-sm" style={{ color: 'var(--chart-tooltip-text)' }}>
|
||||
{rows.length ? rows.map(({ seller, value }) => (
|
||||
<div key={`seller-tooltip-${seller.id}`} className="flex items-center justify-between gap-4">
|
||||
@@ -310,6 +302,7 @@ const Dashboard = () => {
|
||||
const [isMetricsLoading, setIsMetricsLoading] = useState(true);
|
||||
const [focusedSellerId, setFocusedSellerId] = useState<string | null>(null);
|
||||
const [sellerMetric, setSellerMetric] = useState<SellerMetricKey>('revenue');
|
||||
const [selectedSellerBucket, setSelectedSellerBucket] = useState<string | null>(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<string, Omit<SellerTimeSeriesSeller, 'seriesKey' | 'total'>>();
|
||||
@@ -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.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -616,7 +642,13 @@ const Dashboard = () => {
|
||||
<div>
|
||||
<div className="h-96 min-w-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={sellerTimeSeries.chartData} margin={{ top: 14, right: 24, left: 4, bottom: 12 }}>
|
||||
<AreaChart
|
||||
data={sellerTimeSeries.chartData}
|
||||
margin={{ top: 14, right: 24, left: 4, bottom: 12 }}
|
||||
onClick={(event) => {
|
||||
if (event?.activeLabel) setSelectedSellerBucket(String(event.activeLabel));
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
{sellerTimeSeries.sellers.map(seller => (
|
||||
<linearGradient key={`seller-gradient-${seller.seriesKey}`} id={`seller-gradient-${seller.seriesKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||
@@ -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)
|
||||
)}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={CHART_AXIS_COLOR}
|
||||
@@ -645,9 +681,24 @@ const Dashboard = () => {
|
||||
width={54}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<SellerMetricTooltip focusedSellerId={effectiveFocusedSellerId} sellers={sellerTimeSeries.sellers} metricConfig={sellerMetricConfig} />}
|
||||
content={<SellerMetricTooltip focusedSellerId={effectiveFocusedSellerId} sellers={sellerTimeSeries.sellers} metricConfig={sellerMetricConfig} isHourly={sellerTimeSeries.isHourly} dateBucket={sellerTimeSeries.dateBucket} />}
|
||||
cursor={{ stroke: CHART_AXIS_COLOR, strokeDasharray: '4 4' }}
|
||||
/>
|
||||
{!sellerTimeSeries.isHourly && !sellerTimeSeries.isFallback && sellerTimeSeries.movingAverageWindow > 1 && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="movingAverage"
|
||||
name="Média móvel"
|
||||
stroke="var(--chart-label)"
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray="6 5"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
isAnimationActive
|
||||
animationBegin={160}
|
||||
animationDuration={700}
|
||||
/>
|
||||
)}
|
||||
{sellerTimeSeries.sellers.map(seller => {
|
||||
const isFocused = effectiveFocusedSellerId === seller.id;
|
||||
const isDimmed = Boolean(effectiveFocusedSellerId && !isFocused);
|
||||
@@ -688,6 +739,46 @@ const Dashboard = () => {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{selectedSellerRow && (
|
||||
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input/45 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-dark-text">{selectedSellerBucketLabel}</h4>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
Quebra por vendedor no ponto selecionado.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
{selectedSellerBreakdown.length ? (
|
||||
<div className="mt-4 grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{selectedSellerBreakdown.slice(0, 6).map(({ seller, value }) => (
|
||||
<button
|
||||
key={`seller-drill-${seller.id}`}
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: seller.fill }} />
|
||||
<span className="truncate text-xs font-bold text-dark-text">{seller.name}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs font-bold text-dark-muted">{sellerMetricConfig.formatTick(value)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-sm font-semibold text-dark-muted">Sem valores nesse ponto.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative mt-5 border-t border-dark-border pt-3">
|
||||
<div className="flex h-8 items-center gap-5 overflow-x-auto overflow-y-hidden whitespace-nowrap pr-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{sellerTimeSeries.sellers.map(seller => {
|
||||
|
||||
Reference in New Issue
Block a user