Refine seller dashboard chart
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useOutletContext, useNavigate } from 'react-router-dom';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LabelList } from 'recharts';
|
||||
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';
|
||||
@@ -11,9 +11,36 @@ const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
};
|
||||
|
||||
const truncateLabel = (value: string, maxLength = 22) => {
|
||||
if (value.length <= maxLength) return value;
|
||||
return `${value.slice(0, maxLength - 1)}...`;
|
||||
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 = [
|
||||
@@ -35,7 +62,73 @@ 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)';
|
||||
const CHART_LABEL_COLOR = 'var(--chart-label)';
|
||||
|
||||
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)`
|
||||
@@ -68,33 +161,17 @@ const DashboardSkeleton = () => (
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{[0, 1].map(item => (
|
||||
<div key={`dashboard-seller-chart-skeleton-${item}`} className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||
<div className="skeleton h-5 w-48" />
|
||||
<div className="mt-8 space-y-4">
|
||||
{[0, 1, 2, 3, 4, 5].map(row => (
|
||||
<div key={`dashboard-bar-skeleton-${item}-${row}`} className="flex items-center gap-4">
|
||||
<div className="skeleton h-3 w-28 shrink-0" />
|
||||
<div className="skeleton h-7" style={{ width: `${88 - row * 10}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||
<div className="skeleton h-5 w-44" />
|
||||
<div className="mt-5 space-y-4">
|
||||
{[0, 1, 2, 3, 4].map(row => (
|
||||
<div key={`dashboard-table-skeleton-${row}`} className="grid grid-cols-[1fr_120px_80px_120px] gap-4">
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
</div>
|
||||
))}
|
||||
<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>
|
||||
|
||||
@@ -119,6 +196,8 @@ type ChartTooltipPayload = {
|
||||
value: number;
|
||||
name?: string;
|
||||
color?: string;
|
||||
stroke?: string;
|
||||
dataKey?: string;
|
||||
payload?: {
|
||||
fill?: string;
|
||||
};
|
||||
@@ -134,7 +213,7 @@ type CustomTooltipProps = {
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, isCurrency, valueLabel }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const color = payload[0].payload?.fill || payload[0].color || 'var(--chart-detail-bar)';
|
||||
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:');
|
||||
@@ -151,6 +230,57 @@ const CustomTooltip = ({ active, payload, label, isCurrency, valueLabel }: Custo
|
||||
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<{
|
||||
@@ -162,6 +292,8 @@ const Dashboard = () => {
|
||||
}>();
|
||||
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);
|
||||
@@ -186,7 +318,7 @@ const Dashboard = () => {
|
||||
return () => clearInterval(intervalId);
|
||||
}, [dateRange, loadDashboardMetrics, refreshInterval]);
|
||||
|
||||
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct, revenueBySeller, ordersBySeller } = useMemo(() => {
|
||||
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct, revenueBySeller, ordersBySeller, sellerRevenueByDate } = useMemo(() => {
|
||||
if (serverMetrics) return applyDashboardColors(serverMetrics);
|
||||
return buildDashboardMetrics(ordersData, dateRange);
|
||||
}, [dateRange, ordersData, serverMetrics]);
|
||||
@@ -204,55 +336,124 @@ const Dashboard = () => {
|
||||
return colorMap;
|
||||
}, [ordersBySeller, revenueBySeller]);
|
||||
|
||||
const revenueBySellerChartData = useMemo(() => {
|
||||
return revenueBySeller.map((seller) => ({
|
||||
...seller,
|
||||
fill: sellerColorMap.get(seller.id || seller.name) || seller.fill
|
||||
}));
|
||||
}, [revenueBySeller, sellerColorMap]);
|
||||
const sellerMetricConfig = sellerMetricOptions.find(option => option.key === sellerMetric) || sellerMetricOptions[0];
|
||||
|
||||
const sellerPerformanceRows = useMemo(() => {
|
||||
const rowsBySeller = new Map<string, { id: string; name: string; revenue: number; orders: number; fill: string }>();
|
||||
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 key = seller.id || seller.name;
|
||||
rowsBySeller.set(seller.id || seller.name, {
|
||||
id: key,
|
||||
name: seller.name,
|
||||
revenue: seller.value,
|
||||
orders: 0,
|
||||
fill: sellerColorMap.get(key) || seller.fill
|
||||
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)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ordersBySeller.forEach((seller) => {
|
||||
const key = seller.id || seller.name;
|
||||
const existing = rowsBySeller.get(key);
|
||||
rowsBySeller.set(key, {
|
||||
id: key,
|
||||
name: existing?.name || seller.name,
|
||||
revenue: existing?.revenue || 0,
|
||||
orders: seller.value,
|
||||
fill: existing?.fill || sellerColorMap.get(key) || seller.fill
|
||||
});
|
||||
});
|
||||
|
||||
return [...rowsBySeller.values()]
|
||||
.sort((a, b) => b.revenue - a.revenue)
|
||||
.slice(0, 10);
|
||||
}, [ordersBySeller, revenueBySeller, sellerColorMap]);
|
||||
|
||||
const ticketBySellerChartData = useMemo(() => {
|
||||
return sellerPerformanceRows
|
||||
.filter((seller) => seller.orders > 0)
|
||||
.map((seller) => ({
|
||||
id: seller.id,
|
||||
name: seller.name,
|
||||
value: seller.revenue / seller.orders,
|
||||
fill: seller.fill
|
||||
const sellers: SellerTimeSeriesSeller[] = [...sellersById.values()]
|
||||
.map(seller => ({
|
||||
...seller,
|
||||
total: getSellerMetricValue(sellerMetric, seller.revenue, seller.orders),
|
||||
seriesKey: ''
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}, [sellerPerformanceRows]);
|
||||
.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 });
|
||||
@@ -318,123 +519,151 @@ const Dashboard = () => {
|
||||
</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">Receita por Vendedor</h3>
|
||||
<div className="h-80 w-full flex items-center justify-center">
|
||||
{revenueBySellerChartData.length ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={revenueBySellerChartData} layout="vertical" margin={{ top: 5, right: 88, left: 8, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} horizontal={false} />
|
||||
<XAxis type="number" stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} tickFormatter={(value) => `${Number(value) / 1000}k`} />
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
width={150}
|
||||
stroke={CHART_AXIS_COLOR}
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => truncateLabel(String(value))}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip isCurrency valueLabel="Receita:" />} cursor={{ fill: CHART_CURSOR_COLOR }} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} isAnimationActive={false}>
|
||||
{revenueBySellerChartData.map((entry) => (
|
||||
<Cell
|
||||
key={`seller-revenue-${entry.id}`}
|
||||
fill={entry.fill}
|
||||
fillOpacity={BAR_FILL_OPACITY}
|
||||
stroke={entry.fill}
|
||||
strokeOpacity={BAR_STROKE_OPACITY}
|
||||
strokeWidth={1.25}
|
||||
style={getBarGlowStyle(entry.fill)}
|
||||
/>
|
||||
))}
|
||||
<LabelList dataKey="value" position="right" fill={CHART_LABEL_COLOR} fontSize={11} fontWeight={700} formatter={(value) => formatCurrency(Number(value) || 0)} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-dark-muted">Nenhuma venda com vendedor no período.</p>
|
||||
<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>
|
||||
|
||||
<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">Ticket Médio por Vendedor</h3>
|
||||
<div className="h-80 w-full flex items-center justify-center">
|
||||
{ticketBySellerChartData.length ? (
|
||||
{sellerTimeSeries.sellers.length ? (
|
||||
<div>
|
||||
<div className="h-96 min-w-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={ticketBySellerChartData} layout="vertical" margin={{ top: 5, right: 88, left: 8, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} horizontal={false} />
|
||||
<XAxis type="number" stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} tickFormatter={(value) => `${Number(value) / 1000}k`} />
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
width={150}
|
||||
<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}
|
||||
tickFormatter={(value) => truncateLabel(String(value))}
|
||||
minTickGap={18}
|
||||
tickFormatter={formatDateTick}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip isCurrency valueLabel="Ticket médio:" />} cursor={{ fill: CHART_CURSOR_COLOR }} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} isAnimationActive={false}>
|
||||
{ticketBySellerChartData.map((entry) => (
|
||||
<Cell
|
||||
key={`seller-ticket-${entry.id}`}
|
||||
fill={entry.fill}
|
||||
fillOpacity={BAR_FILL_OPACITY}
|
||||
stroke={entry.fill}
|
||||
strokeOpacity={BAR_STROKE_OPACITY}
|
||||
strokeWidth={1.25}
|
||||
style={getBarGlowStyle(entry.fill)}
|
||||
<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={false}
|
||||
/>
|
||||
))}
|
||||
<LabelList dataKey="value" position="right" fill={CHART_LABEL_COLOR} fontSize={11} fontWeight={700} formatter={(value) => formatCurrency(Number(value) || 0)} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
);
|
||||
})}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-dark-muted">Nenhum ticket médio por vendedor no período.</p>
|
||||
)}
|
||||
</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>
|
||||
) : (
|
||||
<div className="flex h-80 items-center justify-center">
|
||||
<p className="text-sm font-semibold text-dark-muted">{sellerMetricConfig.emptyText}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sellerPerformanceRows.length > 0 && (
|
||||
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||
<h3 className="text-lg font-bold mb-4 text-dark-text">Resumo por Vendedor</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-dark-border text-xs uppercase tracking-wide text-dark-muted">
|
||||
<th className="pb-3 font-bold">Vendedor</th>
|
||||
<th className="pb-3 text-right font-bold">Receita</th>
|
||||
<th className="pb-3 text-right font-bold">Pedidos</th>
|
||||
<th className="pb-3 text-right font-bold">Ticket médio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sellerPerformanceRows.map((seller) => (
|
||||
<tr key={`seller-summary-${seller.id}`} className="border-b border-dark-border/70 last:border-0">
|
||||
<td className="max-w-[320px] py-3 font-semibold text-dark-text">
|
||||
<div 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="block truncate" title={seller.name}>{seller.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 text-right font-bold text-dark-text">{formatCurrency(seller.revenue)}</td>
|
||||
<td className="py-3 text-right font-semibold text-dark-muted">{seller.orders}</td>
|
||||
<td className="py-3 text-right font-semibold text-dark-muted">{seller.orders ? formatCurrency(seller.revenue / seller.orders) : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user