914 lines
38 KiB
TypeScript
914 lines
38 KiB
TypeScript
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, 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);
|
|
};
|
|
|
|
const formatCompactCurrency = (value: number) => {
|
|
const absValue = Math.abs(value);
|
|
const formatNumber = (nextValue: number) => Number.isInteger(nextValue)
|
|
? String(nextValue)
|
|
: nextValue.toLocaleString('pt-BR', { maximumFractionDigits: 1 });
|
|
|
|
if (absValue >= 1_000_000) return `${formatNumber(value / 1_000_000)}M`;
|
|
if (absValue >= 1_000) return `${formatNumber(value / 1_000)}k`;
|
|
return formatNumber(value);
|
|
};
|
|
|
|
const formatHourKey = (hour: number) => `${String(hour).padStart(2, '0')}h`;
|
|
|
|
const formatDateKey = (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 SELLER_COLORS = [
|
|
'#25C2FF',
|
|
'#52DFA0',
|
|
'#A06BFF',
|
|
'#FF8A63',
|
|
'#FFC247',
|
|
'#FF7A9B',
|
|
'#18D6B5',
|
|
'#FFA24A',
|
|
'#82B8FF',
|
|
'#B8E84D'
|
|
];
|
|
|
|
const BAR_FILL_OPACITY = 0.5;
|
|
const BAR_STROKE_OPACITY = 0.85;
|
|
const PIE_FILL_OPACITY = 0.68;
|
|
const CHART_GRID_COLOR = 'var(--chart-grid)';
|
|
const CHART_AXIS_COLOR = 'var(--chart-axis)';
|
|
const CHART_CURSOR_COLOR = 'var(--chart-cursor)';
|
|
|
|
type SellerMetricKey = 'revenue' | 'ticket' | 'orders';
|
|
|
|
type SellerTimeSeriesSeller = {
|
|
id: string;
|
|
seriesKey: string;
|
|
name: string;
|
|
fill: string;
|
|
total: number;
|
|
revenue: number;
|
|
orders: number;
|
|
};
|
|
|
|
type SellerTimeSeriesChartData = {
|
|
date: string;
|
|
movingAverage?: number;
|
|
[key: string]: string | number | undefined;
|
|
};
|
|
|
|
type SellerMetricConfig = {
|
|
key: SellerMetricKey;
|
|
label: string;
|
|
title: string;
|
|
fallbackDescription: string;
|
|
trendDescription: string;
|
|
emptyText: string;
|
|
formatValue: (value: number) => string;
|
|
formatTick: (value: number) => string;
|
|
};
|
|
|
|
const sellerMetricOptions: SellerMetricConfig[] = [
|
|
{
|
|
key: 'revenue',
|
|
label: 'Receita',
|
|
title: 'Receita por Vendedor',
|
|
fallbackDescription: 'Total por vendedor no período selecionado.',
|
|
trendDescription: 'Evolução por data no período selecionado.',
|
|
emptyText: 'Nenhuma receita com vendedor no período.',
|
|
formatValue: formatCurrency,
|
|
formatTick: formatCompactCurrency
|
|
},
|
|
{
|
|
key: 'ticket',
|
|
label: 'Ticket médio',
|
|
title: 'Ticket Médio por Vendedor',
|
|
fallbackDescription: 'Ticket médio por vendedor no período selecionado.',
|
|
trendDescription: 'Ticket médio por data no período selecionado.',
|
|
emptyText: 'Nenhum ticket médio com vendedor no período.',
|
|
formatValue: formatCurrency,
|
|
formatTick: formatCompactCurrency
|
|
},
|
|
{
|
|
key: 'orders',
|
|
label: 'Pedidos',
|
|
title: 'Pedidos por Vendedor',
|
|
fallbackDescription: 'Pedidos por vendedor no período selecionado.',
|
|
trendDescription: 'Pedidos por data no período selecionado.',
|
|
emptyText: 'Nenhum pedido com vendedor no período.',
|
|
formatValue: (value) => new Intl.NumberFormat('pt-BR').format(value),
|
|
formatTick: (value) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(value)
|
|
}
|
|
];
|
|
|
|
const getSellerMetricValue = (metric: SellerMetricKey, revenue: number, orders: number) => {
|
|
if (metric === 'orders') return orders;
|
|
if (metric === 'ticket') return orders ? revenue / orders : 0;
|
|
return revenue;
|
|
};
|
|
|
|
const normalizeSellerGraphName = (name: string) => (
|
|
name
|
|
.normalize('NFD')
|
|
.replace(/\p{Diacritic}/gu, '')
|
|
.trim()
|
|
.toLowerCase()
|
|
);
|
|
|
|
const shouldHideSellerFromGraph = (name: string) => {
|
|
const normalizedName = normalizeSellerGraphName(name);
|
|
return normalizedName === 'pos vendas';
|
|
};
|
|
|
|
const getBarGlowStyle = (color: string) => ({
|
|
filter: `drop-shadow(0 0 3px ${color}40)`
|
|
});
|
|
|
|
const getPieGlowStyle = (color: string) => ({
|
|
filter: `drop-shadow(0 0 8px ${color}80)`,
|
|
cursor: 'pointer'
|
|
});
|
|
|
|
const kpiIconStyle = (color: string) => ({
|
|
backgroundColor: `${color}14`,
|
|
borderColor: `${color}26`,
|
|
color
|
|
});
|
|
|
|
const DashboardSkeleton = () => (
|
|
<div className="space-y-6" aria-label="Carregando dashboard">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
{[0, 1, 2].map(item => (
|
|
<div key={`dashboard-kpi-skeleton-${item}`} className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
|
<div className="flex justify-between gap-6">
|
|
<div className="w-full">
|
|
<div className="skeleton h-4 w-32" />
|
|
<div className="skeleton mt-3 h-8 w-44" />
|
|
</div>
|
|
<div className="skeleton h-12 w-12 shrink-0 rounded-xl" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
<div className="skeleton h-5 w-48" />
|
|
</div>
|
|
<div className="mt-8">
|
|
<div className="skeleton h-80 w-full" />
|
|
<div className="mt-5 flex flex-wrap gap-2">
|
|
{[0, 1, 2, 3, 4, 5].map(row => (
|
|
<div key={`dashboard-seller-legend-skeleton-${row}`} className="skeleton h-9 rounded-full" style={{ width: `${150 - row * 7}px` }} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{[0, 1].map(item => (
|
|
<div key={`dashboard-product-chart-skeleton-${item}`} className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
|
<div className="skeleton h-5 w-44" />
|
|
<div className="mt-8 h-80">
|
|
<div className="flex h-full items-end justify-center gap-3">
|
|
{[0, 1, 2, 3, 4, 5, 6, 7].map(bar => (
|
|
<div key={`dashboard-column-skeleton-${item}-${bar}`} className="skeleton w-10" style={{ height: `${35 + ((bar * 17) % 55)}%` }} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
type ChartTooltipPayload = {
|
|
value: number;
|
|
name?: string;
|
|
color?: string;
|
|
stroke?: string;
|
|
dataKey?: string;
|
|
payload?: {
|
|
fill?: string;
|
|
};
|
|
};
|
|
|
|
type CustomTooltipProps = {
|
|
active?: boolean;
|
|
payload?: ChartTooltipPayload[];
|
|
label?: string;
|
|
isCurrency?: boolean;
|
|
valueLabel?: string;
|
|
};
|
|
|
|
const CustomTooltip = ({ active, payload, label, isCurrency, valueLabel }: CustomTooltipProps) => {
|
|
if (active && payload && payload.length) {
|
|
const color = payload[0].payload?.fill || payload[0].stroke || payload[0].color || 'var(--chart-detail-bar)';
|
|
const displayLabel = label || payload[0].name;
|
|
const value = isCurrency ? formatCurrency(payload[0].value) : payload[0].value;
|
|
const displayValueLabel = valueLabel || (isCurrency ? 'Receita:' : 'Vendas:');
|
|
return (
|
|
<div
|
|
className="rounded-xl border p-3 shadow-lg"
|
|
style={{ backgroundColor: 'var(--chart-tooltip-bg)', borderColor: 'var(--chart-tooltip-border)' }}
|
|
>
|
|
<p className="font-bold mb-1" style={{ color }}>{displayLabel}</p>
|
|
<p className="m-0" style={{ color: 'var(--chart-tooltip-text)' }}>{displayValueLabel} {value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
type SellerMetricTooltipProps = {
|
|
active?: boolean;
|
|
payload?: ChartTooltipPayload[];
|
|
label?: string;
|
|
focusedSellerId: string | null;
|
|
sellers: SellerTimeSeriesSeller[];
|
|
metricConfig: SellerMetricConfig;
|
|
isHourly: boolean;
|
|
dateBucket: DateBucket;
|
|
};
|
|
|
|
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]));
|
|
const rows = payload
|
|
.map(item => {
|
|
const seller = sellersByKey.get(String(item.dataKey || ''));
|
|
return seller ? { seller, value: Number(item.value || 0) } : null;
|
|
})
|
|
.filter((item): item is { seller: SellerTimeSeriesSeller; value: number } => Boolean(item && item.value > 0))
|
|
.sort((a, b) => {
|
|
if (focusedSellerId) {
|
|
if (a.seller.id === focusedSellerId) return -1;
|
|
if (b.seller.id === focusedSellerId) return 1;
|
|
}
|
|
return b.value - a.value;
|
|
})
|
|
.slice(0, focusedSellerId ? 4 : 6);
|
|
|
|
return (
|
|
<div
|
|
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">
|
|
{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">
|
|
<span className="flex min-w-0 items-center gap-2 font-semibold">
|
|
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: seller.fill }} />
|
|
<span className="truncate">{seller.name}</span>
|
|
</span>
|
|
<span className="shrink-0 font-bold">{metricConfig.formatValue(value)}</span>
|
|
</div>
|
|
)) : (
|
|
<p className="m-0 text-dark-muted">Sem receita nesse dia.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Dashboard = () => {
|
|
const navigate = useNavigate();
|
|
const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval } = useOutletContext<{
|
|
dateRange: DateRange,
|
|
setDateRange: (range: DateRange) => void,
|
|
ordersData: OrderData[],
|
|
refreshInterval: number,
|
|
setRefreshInterval: (interval: number) => void,
|
|
}>();
|
|
const [serverMetrics, setServerMetrics] = useState<DashboardAnalytics | null>(null);
|
|
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 [showSellerTrend, setShowSellerTrend] = useState(false);
|
|
|
|
const loadDashboardMetrics = useCallback(async (range: DateRange, options?: { force?: boolean }) => {
|
|
setIsMetricsLoading(true);
|
|
const metrics = await fetchDashboardAnalytics(range, options);
|
|
setServerMetrics(metrics);
|
|
setIsMetricsLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// Dashboard metrics are synchronized with the selected server-side date range.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
void loadDashboardMetrics(dateRange);
|
|
}, [dateRange, loadDashboardMetrics]);
|
|
|
|
useEffect(() => {
|
|
if (refreshInterval === 0) return;
|
|
|
|
const intervalId = setInterval(() => {
|
|
void loadDashboardMetrics(dateRange, { force: true });
|
|
}, refreshInterval);
|
|
|
|
return () => clearInterval(intervalId);
|
|
}, [dateRange, loadDashboardMetrics, refreshInterval]);
|
|
|
|
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct, revenueBySeller, ordersBySeller, sellerRevenueByDate, sellerRevenueByHour } = useMemo(() => {
|
|
if (serverMetrics) return applyDashboardColors(serverMetrics);
|
|
return buildDashboardMetrics(ordersData, dateRange);
|
|
}, [dateRange, ordersData, serverMetrics]);
|
|
|
|
const chartRevenueBySeller = useMemo(
|
|
() => revenueBySeller.filter(seller => !shouldHideSellerFromGraph(seller.name)),
|
|
[revenueBySeller]
|
|
);
|
|
const chartOrdersBySeller = useMemo(
|
|
() => ordersBySeller.filter(seller => !shouldHideSellerFromGraph(seller.name)),
|
|
[ordersBySeller]
|
|
);
|
|
const chartSellerRevenueByDate = useMemo(
|
|
() => sellerRevenueByDate.filter(seller => !shouldHideSellerFromGraph(seller.name)),
|
|
[sellerRevenueByDate]
|
|
);
|
|
const chartSellerRevenueByHour = useMemo(
|
|
() => sellerRevenueByHour.filter(seller => !shouldHideSellerFromGraph(seller.name)),
|
|
[sellerRevenueByHour]
|
|
);
|
|
|
|
const sellerColorMap = useMemo(() => {
|
|
const colorMap = new Map<string, string>();
|
|
|
|
[...chartRevenueBySeller, ...chartOrdersBySeller].forEach((seller) => {
|
|
const key = seller.id || seller.name;
|
|
if (!colorMap.has(key)) {
|
|
colorMap.set(key, SELLER_COLORS[colorMap.size % SELLER_COLORS.length]);
|
|
}
|
|
});
|
|
|
|
return colorMap;
|
|
}, [chartOrdersBySeller, chartRevenueBySeller]);
|
|
|
|
const sellerMetricConfig = sellerMetricOptions.find(option => option.key === sellerMetric) || sellerMetricOptions[0];
|
|
|
|
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,
|
|
date: formatHourKey(point.hour)
|
|
}))
|
|
: chartSellerRevenueByDate;
|
|
|
|
if (!activeTrendPoints.length && chartRevenueBySeller.length) {
|
|
const startDate = formatDateKey(dateRange.start);
|
|
const endDate = formatDateKey(dateRange.end);
|
|
const chartDates = isSingleDayRange && isHourly
|
|
? Array.from({ length: 24 }, (_, hour) => formatHourKey(hour))
|
|
: startDate === endDate ? [endDate] : [startDate, endDate];
|
|
const sellersById = new Map<string, Omit<SellerTimeSeriesSeller, 'seriesKey' | 'total'>>();
|
|
|
|
chartRevenueBySeller.forEach(seller => {
|
|
const id = seller.id || seller.name;
|
|
sellersById.set(id, {
|
|
id,
|
|
name: seller.name,
|
|
fill: sellerColorMap.get(id) || seller.fill,
|
|
revenue: seller.value,
|
|
orders: 0
|
|
});
|
|
});
|
|
|
|
chartOrdersBySeller.forEach(seller => {
|
|
const id = seller.id || seller.name;
|
|
const existing = sellersById.get(id);
|
|
sellersById.set(id, {
|
|
id,
|
|
name: existing?.name || seller.name,
|
|
fill: existing?.fill || sellerColorMap.get(id) || seller.fill,
|
|
revenue: existing?.revenue || 0,
|
|
orders: seller.value
|
|
});
|
|
});
|
|
|
|
const sellers: SellerTimeSeriesSeller[] = [...sellersById.values()]
|
|
.map(seller => ({
|
|
...seller,
|
|
total: getSellerMetricValue(sellerMetric, seller.revenue, seller.orders),
|
|
seriesKey: ''
|
|
}))
|
|
.filter(seller => seller.total > 0)
|
|
.sort((a, b) => b.total - a.total)
|
|
.slice(0, 8)
|
|
.map((seller, index) => {
|
|
const id = seller.id || seller.name;
|
|
return {
|
|
...seller,
|
|
id,
|
|
seriesKey: `seller_${index}`
|
|
};
|
|
});
|
|
|
|
const chartData: SellerTimeSeriesChartData[] = chartDates.map(date => {
|
|
const row: SellerTimeSeriesChartData = { date };
|
|
sellers.forEach(seller => {
|
|
row[seller.seriesKey] = seller.total;
|
|
});
|
|
return row;
|
|
});
|
|
|
|
return { sellers, chartData, isFallback: true, isHourly, dateBucket, movingAverageWindow: 0 };
|
|
}
|
|
|
|
const sellersById = new Map<string, Omit<SellerTimeSeriesSeller, 'seriesKey' | 'total'>>();
|
|
const valuesByDate = new Map<string, Map<string, { revenue: number; orders: number }>>();
|
|
|
|
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,
|
|
name: point.name,
|
|
fill: sellerColorMap.get(id) || point.fill,
|
|
revenue: (existing?.revenue || 0) + point.value,
|
|
orders: (existing?.orders || 0) + (point.orders || 0)
|
|
});
|
|
|
|
if (!valuesByDate.has(bucketKey)) {
|
|
valuesByDate.set(bucketKey, new Map());
|
|
}
|
|
const dateValues = valuesByDate.get(bucketKey);
|
|
if (dateValues) {
|
|
const existingValue = dateValues.get(id);
|
|
dateValues.set(id, {
|
|
revenue: (existingValue?.revenue || 0) + point.value,
|
|
orders: (existingValue?.orders || 0) + (point.orders || 0)
|
|
});
|
|
}
|
|
});
|
|
|
|
const sellers: SellerTimeSeriesSeller[] = [...sellersById.values()]
|
|
.map(seller => ({
|
|
...seller,
|
|
total: getSellerMetricValue(sellerMetric, seller.revenue, seller.orders),
|
|
seriesKey: ''
|
|
}))
|
|
.filter(seller => seller.total > 0)
|
|
.sort((a, b) => b.total - a.total)
|
|
.slice(0, 8)
|
|
.map((seller, index) => ({
|
|
...seller,
|
|
seriesKey: `seller_${index}`
|
|
}));
|
|
|
|
const chartKeys = isHourly
|
|
? Array.from({ length: 24 }, (_, hour) => formatHourKey(hour))
|
|
: [...valuesByDate.keys()].sort((dateA, dateB) => dateA.localeCompare(dateB));
|
|
|
|
const chartData: SellerTimeSeriesChartData[] = chartKeys
|
|
.map(date => {
|
|
const values = valuesByDate.get(date);
|
|
const row: SellerTimeSeriesChartData = { date };
|
|
sellers.forEach(seller => {
|
|
const value = values?.get(seller.id);
|
|
row[seller.seriesKey] = value ? getSellerMetricValue(sellerMetric, value.revenue, value.orders) : 0;
|
|
});
|
|
return row;
|
|
});
|
|
|
|
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 });
|
|
};
|
|
const shouldShowSkeleton = isMetricsLoading && !serverMetrics;
|
|
const isRefreshing = isMetricsLoading && Boolean(serverMetrics);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold mb-2 text-dark-text">Visão Geral</h1>
|
|
<p className="text-dark-muted font-medium">Resumo de vendas e performance dos produtos.</p>
|
|
</div>
|
|
<DateRangePicker
|
|
dateRange={dateRange}
|
|
onChange={setDateRange}
|
|
refreshInterval={refreshInterval}
|
|
setRefreshInterval={setRefreshInterval}
|
|
onManualRefresh={handleManualRefresh}
|
|
/>
|
|
</div>
|
|
|
|
<RefreshStatus isRefreshing={isRefreshing} />
|
|
|
|
{shouldShowSkeleton ? (
|
|
<DashboardSkeleton />
|
|
) : (
|
|
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<p className="text-dark-muted text-sm font-medium mb-1">Receita Total</p>
|
|
<h3 className="text-3xl font-bold text-dark-text">{formatCurrency(totalRevenue)}</h3>
|
|
</div>
|
|
<div className="rounded-xl border p-3" style={kpiIconStyle('#52DFA0')}>
|
|
<DollarSign className="w-6 h-6" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<p className="text-dark-muted text-sm font-medium mb-1">Total de Produtos Vendidos</p>
|
|
<h3 className="text-3xl font-bold text-dark-text">{totalOrders}</h3>
|
|
</div>
|
|
<div className="rounded-xl border p-3" style={kpiIconStyle('#82B8FF')}>
|
|
<ShoppingCart className="w-6 h-6" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<p className="text-dark-muted text-sm font-medium mb-1">Ticket Médio (Por Item)</p>
|
|
<h3 className="text-3xl font-bold text-dark-text">{formatCurrency(averageOrderValue)}</h3>
|
|
</div>
|
|
<div className="rounded-xl border p-3" style={kpiIconStyle('#B992FF')}>
|
|
<TrendingUp className="w-6 h-6" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm flex flex-col">
|
|
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-bold text-dark-text">{sellerMetricConfig.title}</h3>
|
|
<p className="mt-1 text-sm font-medium text-dark-muted">
|
|
{focusedSeller
|
|
? `Foco em ${focusedSeller.name}`
|
|
: sellerTimeSeries.isHourly
|
|
? 'Evolução por horário no dia selecionado.'
|
|
: sellerTimeSeries.isFallback
|
|
? sellerMetricConfig.fallbackDescription
|
|
: sellerTimeSeries.dateBucket === 'day'
|
|
? sellerMetricConfig.trendDescription
|
|
: sellerTimeSeries.dateBucket === 'week'
|
|
? 'Evolução agrupada por semana no período selecionado.'
|
|
: 'Evolução agrupada por mês no período selecionado.'}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{!sellerTimeSeries.isHourly && !sellerTimeSeries.isFallback && sellerTimeSeries.movingAverageWindow > 1 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSellerTrend(current => !current)}
|
|
aria-pressed={showSellerTrend}
|
|
className={`h-9 cursor-pointer rounded-lg border px-3 text-xs font-bold transition-colors ${
|
|
showSellerTrend
|
|
? 'border-brand-primary bg-brand-primary/10 text-brand-primary'
|
|
: 'border-dark-border bg-dark-input text-dark-muted hover:text-dark-text'
|
|
}`}
|
|
title="Mostrar ou esconder média móvel"
|
|
>
|
|
Tendência
|
|
</button>
|
|
)}
|
|
<div className="inline-flex rounded-xl border border-dark-border bg-dark-input p-1">
|
|
{sellerMetricOptions.map(option => (
|
|
<button
|
|
key={option.key}
|
|
type="button"
|
|
onClick={() => setSellerMetric(option.key)}
|
|
className={`h-8 cursor-pointer rounded-lg px-3 text-xs font-bold transition-colors ${
|
|
sellerMetric === option.key
|
|
? 'bg-dark-card text-dark-text shadow-sm'
|
|
: 'text-dark-muted hover:text-dark-text'
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{focusedSeller && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setFocusedSellerId(null)}
|
|
className="h-9 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-muted transition-colors hover:text-dark-text"
|
|
>
|
|
Ver todos
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{sellerTimeSeries.sellers.length ? (
|
|
<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 }}
|
|
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">
|
|
<stop offset="5%" stopColor={seller.fill} stopOpacity={effectiveFocusedSellerId && effectiveFocusedSellerId !== seller.id ? 0.08 : 0.42} />
|
|
<stop offset="95%" stopColor={seller.fill} stopOpacity={effectiveFocusedSellerId && effectiveFocusedSellerId !== seller.id ? 0.01 : 0.05} />
|
|
</linearGradient>
|
|
))}
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
|
|
<XAxis
|
|
dataKey="date"
|
|
stroke={CHART_AXIS_COLOR}
|
|
fontSize={11}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
minTickGap={sellerTimeSeries.isHourly ? 10 : 18}
|
|
interval={sellerTimeSeries.isHourly ? 2 : undefined}
|
|
tickFormatter={(value) => (
|
|
sellerTimeSeries.isHourly
|
|
? String(value)
|
|
: formatDateBucketLabel(String(value), sellerTimeSeries.dateBucket)
|
|
)}
|
|
/>
|
|
<YAxis
|
|
stroke={CHART_AXIS_COLOR}
|
|
fontSize={11}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(value) => sellerMetricConfig.formatTick(Number(value))}
|
|
width={54}
|
|
/>
|
|
<Tooltip
|
|
content={<SellerMetricTooltip focusedSellerId={effectiveFocusedSellerId} sellers={sellerTimeSeries.sellers} metricConfig={sellerMetricConfig} isHourly={sellerTimeSeries.isHourly} dateBucket={sellerTimeSeries.dateBucket} />}
|
|
cursor={{ stroke: CHART_AXIS_COLOR, strokeDasharray: '4 4' }}
|
|
/>
|
|
{showSellerTrend && !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);
|
|
return (
|
|
<Area
|
|
key={seller.id}
|
|
type="monotone"
|
|
dataKey={seller.seriesKey}
|
|
name={seller.name}
|
|
stroke={seller.fill}
|
|
strokeWidth={isFocused ? 3 : 2}
|
|
strokeOpacity={isDimmed ? 0.2 : 0.95}
|
|
fill={`url(#seller-gradient-${seller.seriesKey})`}
|
|
fillOpacity={isDimmed ? 0.25 : 1}
|
|
dot={sellerTimeSeries.isFallback ? {
|
|
r: 3,
|
|
strokeWidth: 2,
|
|
fill: seller.fill,
|
|
stroke: 'var(--color-dark-card)'
|
|
} : false}
|
|
activeDot={{
|
|
r: isFocused ? 5.5 : 4,
|
|
strokeWidth: 2,
|
|
fill: seller.fill,
|
|
stroke: 'var(--color-dark-card)',
|
|
onClick: () => setFocusedSellerId(current => current === seller.id ? null : seller.id)
|
|
}}
|
|
onClick={() => setFocusedSellerId(current => current === seller.id ? null : seller.id)}
|
|
style={{ cursor: 'pointer' }}
|
|
isAnimationActive
|
|
animationBegin={120}
|
|
animationDuration={700}
|
|
animationEasing="ease-out"
|
|
/>
|
|
);
|
|
})}
|
|
</AreaChart>
|
|
</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 => {
|
|
const isFocused = effectiveFocusedSellerId === seller.id;
|
|
const isDimmed = Boolean(effectiveFocusedSellerId && !isFocused);
|
|
|
|
return (
|
|
<button
|
|
key={`seller-legend-${seller.id}`}
|
|
type="button"
|
|
onClick={() => setFocusedSellerId(current => current === seller.id ? null : seller.id)}
|
|
className={`group inline-flex h-7 shrink-0 cursor-pointer items-center gap-2 rounded-md px-1.5 text-left transition-colors ${
|
|
isFocused
|
|
? 'bg-dark-input/70 text-dark-text'
|
|
: 'text-dark-muted hover:text-dark-text'
|
|
}`}
|
|
style={{ opacity: isDimmed ? 0.38 : 1 }}
|
|
title={seller.name}
|
|
>
|
|
<span className="h-1.5 w-5 shrink-0 rounded-full" style={{ backgroundColor: seller.fill }} />
|
|
<span className="max-w-[190px] truncate text-xs font-bold">{seller.name}</span>
|
|
<span className="shrink-0 text-[11px] font-bold text-dark-muted group-hover:text-dark-text">
|
|
{sellerMetricConfig.formatTick(seller.total)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="pointer-events-none absolute bottom-0 right-0 top-3 w-10 bg-gradient-to-l from-dark-card to-transparent" />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-80 items-center justify-center">
|
|
<p className="text-sm font-semibold text-dark-muted">{sellerMetricConfig.emptyText}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm flex flex-col">
|
|
<h3 className="text-lg font-bold mb-6 text-dark-text">Produtos Mais Vendidos</h3>
|
|
<div className="h-80 w-full flex items-center justify-center">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={salesByProduct} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
|
|
<XAxis
|
|
dataKey="name" stroke={CHART_AXIS_COLOR} fontSize={10} tickLine={false} axisLine={false}
|
|
tick={false}
|
|
/>
|
|
<YAxis stroke={CHART_AXIS_COLOR} fontSize={12} tickLine={false} axisLine={false} />
|
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
|
|
<Bar dataKey="value" radius={[4, 4, 0, 0]} onClick={(data) => { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}>
|
|
{salesByProduct.map((entry) => (
|
|
<Cell
|
|
key={`cell-${entry.name}`}
|
|
fill={entry.fill}
|
|
fillOpacity={BAR_FILL_OPACITY}
|
|
stroke={entry.fill}
|
|
strokeOpacity={BAR_STROKE_OPACITY}
|
|
strokeWidth={1.25}
|
|
style={{ ...getBarGlowStyle(entry.fill), cursor: 'pointer' }}
|
|
/>
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
|
{salesByProduct.map((entry) => (
|
|
<div key={`bar-legend-${entry.name}`} className="flex items-center text-[10px] cursor-pointer hover:opacity-80 transition-opacity" onClick={() => navigate(`/products/${entry.id}`)}>
|
|
<span className="w-2.5 h-2.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: entry.fill }}></span>
|
|
<span className="text-dark-muted truncate font-semibold" title={entry.name}>{entry.name}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm flex flex-col">
|
|
<h3 className="text-lg font-bold mb-6 text-dark-text">Receita por Produto</h3>
|
|
<div className="h-80 w-full flex items-center justify-center">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie data={revenueByProduct} cx="50%" cy="50%" innerRadius={80} outerRadius={110} paddingAngle={5} dataKey="value" onClick={(data) => { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}>
|
|
{revenueByProduct.map((entry) => (
|
|
<Cell
|
|
key={`cell-${entry.name}`}
|
|
fill={entry.fill}
|
|
fillOpacity={PIE_FILL_OPACITY}
|
|
stroke={entry.fill}
|
|
strokeOpacity={BAR_STROKE_OPACITY}
|
|
strokeWidth={1.5}
|
|
style={getPieGlowStyle(entry.fill)}
|
|
/>
|
|
))}
|
|
</Pie>
|
|
<Tooltip content={<CustomTooltip isCurrency={true} />} cursor={{ fill: CHART_CURSOR_COLOR }} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
|
{revenueByProduct.map((entry) => (
|
|
<div key={`pie-legend-${entry.name}`} className="flex items-center text-[10px] cursor-pointer hover:opacity-80 transition-opacity" onClick={() => navigate(`/products/${entry.id}`)}>
|
|
<span className="w-2.5 h-2.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: entry.fill }}></span>
|
|
<span className="text-dark-muted truncate font-semibold">{entry.name}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|