Refine seller dashboard chart
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s

This commit is contained in:
Cauê Faleiros
2026-06-26 14:53:49 -03:00
parent 59fffa2018
commit 901752ebc5
6 changed files with 513 additions and 184 deletions

View File

@@ -469,7 +469,7 @@ const buildRfmClients = (baseClients) => {
const getDashboardAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range);
const [totalsResult, salesResult, revenueResult, sellerRevenueResult, sellerOrdersResult] = await Promise.all([
const [totalsResult, salesResult, revenueResult, sellerRevenueResult, sellerOrdersResult, sellerRevenueByDateResult] = await Promise.all([
pool.query(`
SELECT
COALESCE(SUM(quantidade * valor_unitario), 0) as total_revenue,
@@ -543,6 +543,39 @@ const getDashboardAnalytics = async (range = {}) => {
GROUP BY seller_key
ORDER BY value DESC
LIMIT 10;
`, params),
pool.query(`
WITH seller_orders AS (
SELECT
COALESCE(${SELLER_ID_SQL}, 'name:' || ${SELLER_NAME_SQL}) as seller_key,
COALESCE(${SELLER_NAME_SQL}, ${SELLER_ID_SQL}, 'Sem vendedor') as seller_name,
data_pedido_date,
quantidade,
valor_unitario,
pedido_id,
data_pedido,
valor_pedido
FROM orders
${whereClause}
AND (${SELLER_ID_SQL} IS NOT NULL OR ${SELLER_NAME_SQL} IS NOT NULL)
),
top_sellers AS (
SELECT seller_key
FROM seller_orders
GROUP BY seller_key
ORDER BY COALESCE(SUM(quantidade * valor_unitario), 0) DESC
LIMIT 8
)
SELECT
seller_orders.seller_key as id,
MAX(seller_orders.seller_name) as name,
seller_orders.data_pedido_date::text as date,
COALESCE(SUM(seller_orders.quantidade * seller_orders.valor_unitario), 0) as value,
COUNT(DISTINCT COALESCE(NULLIF(seller_orders.pedido_id, ''), seller_orders.data_pedido || '_' || seller_orders.valor_pedido::text))::int as orders
FROM seller_orders
INNER JOIN top_sellers ON top_sellers.seller_key = seller_orders.seller_key
GROUP BY seller_orders.seller_key, seller_orders.data_pedido_date
ORDER BY seller_orders.data_pedido_date ASC, value DESC;
`, params)
]);
@@ -578,6 +611,13 @@ const getDashboardAnalytics = async (range = {}) => {
name: row.name,
id: row.id,
value: toNumber(row.value)
})),
sellerRevenueByDate: sellerRevenueByDateResult.rows.map(row => ({
name: row.name,
id: row.id,
date: row.date,
value: toNumber(row.value),
orders: toNumber(row.orders)
}))
};
};

View File

@@ -153,6 +153,12 @@ test('getDashboardAnalytics includes seller revenue and order metrics', async ()
};
}
if (sql.includes('data_pedido_date::text as date')) {
return {
rows: [{ id: '977226210', name: 'KEDMA DA SILVA', date: '2026-06-05', value: 400, orders: 2 }]
};
}
if (sql.includes('seller_orders') && sql.includes('SUM(quantidade * valor_unitario)')) {
return {
rows: [{ id: '977226210', name: 'KEDMA DA SILVA', value: 750 }]
@@ -181,7 +187,7 @@ test('getDashboardAnalytics includes seller revenue and order metrics', async ()
const sellerRevenueQuery = calls.find(call => call.sql.includes('seller_orders') && call.sql.includes('SUM(quantidade * valor_unitario)'));
const sellerOrdersQuery = calls.find(call => call.sql.includes('seller_orders') && call.sql.includes('COUNT(DISTINCT'));
assert.equal(calls.length, 5);
assert.equal(calls.length, 6);
calls.forEach(call => {
assert.deepEqual(call.params, ['2026-06-01', '2026-06-24']);
});
@@ -194,6 +200,9 @@ test('getDashboardAnalytics includes seller revenue and order metrics', async ()
assert.deepEqual(dashboard.ordersBySeller, [
{ id: '977226210', name: 'KEDMA DA SILVA', value: 3 }
]);
assert.deepEqual(dashboard.sellerRevenueByDate, [
{ id: '977226210', name: 'KEDMA DA SILVA', date: '2026-06-05', value: 400, orders: 2 }
]);
} finally {
pool.query = originalQuery;
}

View File

@@ -1,5 +1,5 @@
import type { DashboardAnalytics, DateRange, OrderData } from '../types';
import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue } from './orders';
import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue, parseOrderDate } from './orders';
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
const COLORS = [
@@ -27,6 +27,11 @@ export interface ChartProductMetric {
fill: string;
}
export interface SellerDateRevenueMetric extends ChartProductMetric {
date: string;
orders: number;
}
export interface DashboardMetrics {
totalRevenue: number;
totalOrders: number;
@@ -35,6 +40,7 @@ export interface DashboardMetrics {
revenueByProduct: ChartProductMetric[];
revenueBySeller: ChartProductMetric[];
ordersBySeller: ChartProductMetric[];
sellerRevenueByDate: SellerDateRevenueMetric[];
}
export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetrics => {
@@ -42,7 +48,8 @@ export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetr
...metrics.salesByProduct.map(product => product.name),
...metrics.revenueByProduct.map(product => product.name),
...(metrics.revenueBySeller || []).map(seller => seller.name),
...(metrics.ordersBySeller || []).map(seller => seller.name)
...(metrics.ordersBySeller || []).map(seller => seller.name),
...(metrics.sellerRevenueByDate || []).map(seller => seller.name)
])).sort();
const productColors = displayProducts.reduce<Record<string, string>>((colors, name) => {
colors[name] = getProductColor(name);
@@ -70,6 +77,12 @@ export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetr
...seller,
name: formatDisplayName(removeTrailingSellerId(seller.name)),
fill: productColors[seller.name]
})),
sellerRevenueByDate: (metrics.sellerRevenueByDate || []).map(seller => ({
...seller,
name: formatDisplayName(removeTrailingSellerId(seller.name)),
orders: seller.orders || 0,
fill: productColors[seller.name]
}))
};
};
@@ -82,6 +95,8 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa
const productRevenueMap: Record<string, number> = {};
const productNameIdMap: Record<string, string> = {};
const sellerRevenueMap: Record<string, number> = {};
const sellerRevenueByDateMap: Record<string, Record<string, number>> = {};
const sellerOrderKeysByDateMap: Record<string, Record<string, Set<string>>> = {};
const sellerOrderKeysMap: Record<string, Set<string>> = {};
const sellerIdMap: Record<string, string> = {};
@@ -99,8 +114,18 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa
productRevenueMap[productName] = (productRevenueMap[productName] || 0) + itemRevenue;
if (sellerName) {
const parsedOrderDate = parseOrderDate(order.Data_Pedido);
const dateKey = Number.isNaN(parsedOrderDate.getTime())
? order.Data_Pedido
: parsedOrderDate.toISOString().slice(0, 10);
sellerIdMap[sellerName] = sellerId;
sellerRevenueMap[sellerName] = (sellerRevenueMap[sellerName] || 0) + itemRevenue;
if (!sellerRevenueByDateMap[sellerName]) sellerRevenueByDateMap[sellerName] = {};
sellerRevenueByDateMap[sellerName][dateKey] = (sellerRevenueByDateMap[sellerName][dateKey] || 0) + itemRevenue;
if (!sellerOrderKeysByDateMap[sellerName]) sellerOrderKeysByDateMap[sellerName] = {};
if (!sellerOrderKeysByDateMap[sellerName][dateKey]) sellerOrderKeysByDateMap[sellerName][dateKey] = new Set();
sellerOrderKeysByDateMap[sellerName][dateKey].add(orderKey);
if (!sellerOrderKeysMap[sellerName]) sellerOrderKeysMap[sellerName] = new Set();
sellerOrderKeysMap[sellerName].add(orderKey);
}
@@ -144,6 +169,19 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa
fill: getProductColor(name)
}));
const sellerRevenueByDate = topSellerRevenueNames.flatMap(name => (
Object.keys(sellerRevenueByDateMap[name] || {})
.sort()
.map(date => ({
name,
id: sellerIdMap[name],
date,
value: sellerRevenueByDateMap[name][date],
orders: sellerOrderKeysByDateMap[name]?.[date]?.size || 0,
fill: getProductColor(name)
}))
));
return {
totalRevenue: revenue,
totalOrders: totalItems,
@@ -151,6 +189,7 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa
salesByProduct,
revenueByProduct,
revenueBySeller,
ordersBySeller
ordersBySeller,
sellerRevenueByDate
};
};

View File

@@ -176,7 +176,12 @@ export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: Ca
...options,
shouldCache: (data) => {
const metrics = data as DashboardAnalytics | null;
return Boolean(metrics && Array.isArray(metrics.revenueBySeller) && Array.isArray(metrics.ordersBySeller));
return Boolean(
metrics &&
Array.isArray(metrics.revenueBySeller) &&
Array.isArray(metrics.ordersBySeller) &&
Array.isArray(metrics.sellerRevenueByDate)
);
}
});
};

View File

@@ -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>

View File

@@ -78,6 +78,13 @@ export interface DashboardAnalytics {
id: string;
value: number;
}>;
sellerRevenueByDate?: Array<{
name: string;
id: string;
date: string;
value: number;
orders?: number;
}>;
}
export interface ProductAnalyticsItem {