748 lines
30 KiB
TypeScript
748 lines
30 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 } from 'recharts';
|
|
import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
|
|
import DateRangePicker from '../components/DateRangePicker';
|
|
import type { DashboardAnalytics, OrderData, DateRange } from '../types';
|
|
import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard';
|
|
import { fetchDashboardAnalytics } from '../dataService';
|
|
|
|
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 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 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;
|
|
[key: string]: string | number;
|
|
};
|
|
|
|
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 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;
|
|
};
|
|
|
|
const SellerMetricTooltip = ({ active, payload, label, focusedSellerId, sellers, metricConfig }: 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">{formatDateLabel(String(label || ''))}</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 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 } = useMemo(() => {
|
|
if (serverMetrics) return applyDashboardColors(serverMetrics);
|
|
return buildDashboardMetrics(ordersData, dateRange);
|
|
}, [dateRange, ordersData, serverMetrics]);
|
|
|
|
const sellerColorMap = useMemo(() => {
|
|
const colorMap = new Map<string, string>();
|
|
|
|
[...revenueBySeller, ...ordersBySeller].forEach((seller) => {
|
|
const key = seller.id || seller.name;
|
|
if (!colorMap.has(key)) {
|
|
colorMap.set(key, SELLER_COLORS[colorMap.size % SELLER_COLORS.length]);
|
|
}
|
|
});
|
|
|
|
return colorMap;
|
|
}, [ordersBySeller, revenueBySeller]);
|
|
|
|
const sellerMetricConfig = sellerMetricOptions.find(option => option.key === sellerMetric) || sellerMetricOptions[0];
|
|
|
|
const sellerTimeSeries = useMemo(() => {
|
|
if (!sellerRevenueByDate.length && revenueBySeller.length) {
|
|
const startDate = formatDateKey(dateRange.start);
|
|
const endDate = formatDateKey(dateRange.end);
|
|
const chartDates = startDate === endDate ? [endDate] : [startDate, endDate];
|
|
const sellersById = new Map<string, Omit<SellerTimeSeriesSeller, 'seriesKey' | 'total'>>();
|
|
|
|
revenueBySeller.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
|
|
});
|
|
});
|
|
|
|
ordersBySeller.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 };
|
|
}
|
|
|
|
const sellersById = new Map<string, Omit<SellerTimeSeriesSeller, 'seriesKey' | 'total'>>();
|
|
const valuesByDate = new Map<string, Map<string, { revenue: number; orders: number }>>();
|
|
|
|
sellerRevenueByDate.forEach(point => {
|
|
const id = point.id || point.name;
|
|
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(point.date)) {
|
|
valuesByDate.set(point.date, new Map());
|
|
}
|
|
const dateValues = valuesByDate.get(point.date);
|
|
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 chartData: SellerTimeSeriesChartData[] = [...valuesByDate.entries()]
|
|
.sort(([dateA], [dateB]) => dateA.localeCompare(dateB))
|
|
.map(([date, values]) => {
|
|
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;
|
|
});
|
|
|
|
return { sellers, chartData, isFallback: false };
|
|
}, [dateRange.end, dateRange.start, ordersBySeller, revenueBySeller, sellerColorMap, sellerMetric, sellerRevenueByDate]);
|
|
|
|
const focusedSeller = focusedSellerId ? sellerTimeSeries.sellers.find(seller => seller.id === focusedSellerId) : null;
|
|
const effectiveFocusedSellerId = focusedSeller?.id || null;
|
|
|
|
const handleManualRefresh = () => {
|
|
void loadDashboardMetrics(dateRange, { force: true });
|
|
};
|
|
const shouldShowSkeleton = isMetricsLoading && !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>
|
|
|
|
{shouldShowSkeleton ? (
|
|
<DashboardSkeleton />
|
|
) : (
|
|
<>
|
|
|
|
<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.isFallback
|
|
? sellerMetricConfig.fallbackDescription
|
|
: sellerMetricConfig.trendDescription}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<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 }}>
|
|
<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={18}
|
|
tickFormatter={formatDateTick}
|
|
/>
|
|
<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} />}
|
|
cursor={{ stroke: CHART_AXIS_COLOR, strokeDasharray: '4 4' }}
|
|
/>
|
|
{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>
|
|
|
|
<div className="mt-5 flex flex-wrap gap-2 border-t border-dark-border pt-4">
|
|
{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={`flex max-w-full cursor-pointer items-center gap-2 rounded-full border px-3 py-2 text-left transition-colors ${
|
|
isFocused
|
|
? 'border-dark-text bg-dark-input text-dark-text'
|
|
: 'border-dark-border bg-dark-input/40 text-dark-muted hover:bg-dark-input hover:text-dark-text'
|
|
}`}
|
|
style={{ opacity: isDimmed ? 0.48 : 1 }}
|
|
title={seller.name}
|
|
>
|
|
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: seller.fill }} />
|
|
<span className="max-w-[220px] truncate text-sm font-bold">{seller.name}</span>
|
|
<span className="shrink-0 text-xs font-bold opacity-75">{sellerMetricConfig.formatTick(seller.total)}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</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>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|