Add product group analysis view
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s

This commit is contained in:
Cauê Faleiros
2026-07-07 10:34:17 -03:00
parent 022ed007ff
commit 566b032fb3
4 changed files with 610 additions and 15 deletions

View File

@@ -6,6 +6,7 @@ import RefreshStatus from '../components/RefreshStatus';
import type { DateRange, ProductAnalyticsItem } from '../types';
import { exportToCSV, fetchProductAnalytics } from '../dataService';
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges';
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales';
type StockStatusFilter = 'all' | StockRisk;
@@ -13,12 +14,23 @@ type StockQuantityFilter = 'all' | 'zero' | 'positive' | 'low' | 'high';
type SalesFilter = 'all' | 'sold' | 'not_sold';
type CoverageFilter = 'all' | 'up_to_7' | 'up_to_14' | 'up_to_30' | 'over_30' | 'none';
type ProductSortOption = 'sold_desc' | 'sold_asc' | 'stock_priority' | 'revenue_desc' | 'revenue_asc' | 'stock_asc' | 'stock_desc' | 'coverage_asc' | 'coverage_desc' | 'name_asc';
type ProductViewMode = 'sku' | 'group';
type ProductRow = ProductAnalyticsItem & {
dailySales: number;
daysOfCover: number | null;
risk: StockRisk;
riskLabel: string;
baseName: string;
color: string;
size: string;
productIds: string[];
skuCount: number;
colors: string[];
sizes: string[];
topColor: string;
topSize: string;
groupKey: string;
};
const riskStyles: Record<StockRisk, { label: string; className: string; dotClass: string }> = {
@@ -176,6 +188,7 @@ const Products = () => {
const [stockQuantityFilter, setStockQuantityFilter] = useState<StockQuantityFilter>('all');
const [salesFilter, setSalesFilter] = useState<SalesFilter>('all');
const [coverageFilter, setCoverageFilter] = useState<CoverageFilter>('all');
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement>(null);
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
@@ -230,26 +243,100 @@ const Products = () => {
const productsData = useMemo<ProductRow[]>(() => {
const days = getRangeDays(dateRange);
const normalizedSearch = searchTerm.trim().toLowerCase();
const products = productAnalytics.map(product => {
const skuRows = productAnalytics.map(product => {
const dailySales = product.quantitySold / days;
const risk = classifyStockRisk(product.stock, dailySales);
const style = riskStyles[risk];
const metadata = parseProductName(product.name);
return {
...product,
dailySales,
daysOfCover: dailySales > 0 ? product.stock / dailySales : null,
risk,
riskLabel: style.label
riskLabel: style.label,
baseName: metadata.baseName,
color: metadata.color,
size: metadata.size,
productIds: [product.id],
skuCount: 1,
colors: metadata.color ? [metadata.color] : [],
sizes: metadata.size ? [metadata.size] : [],
topColor: metadata.color || '-',
topSize: metadata.size || '-',
groupKey: encodeProductGroupKey(metadata.baseName)
};
});
const groups = new Map<string, ProductRow[]>();
skuRows.forEach(product => {
const key = product.baseName.toLowerCase();
const group = groups.get(key) || [];
group.push(product);
groups.set(key, group);
});
const groupedRows = Array.from(groups.values()).map(group => {
const first = group[0];
const quantitySold = group.reduce((total, product) => total + product.quantitySold, 0);
const revenue = group.reduce((total, product) => total + product.revenue, 0);
const stock = group.reduce((total, product) => total + product.stock, 0);
const orderLineCount = group.reduce((total, product) => total + product.orderLineCount, 0);
const dailySales = quantitySold / days;
const daysOfCover = dailySales > 0 ? stock / dailySales : null;
const risk = classifyStockRisk(stock, dailySales);
const style = riskStyles[risk];
const colorTotals = new Map<string, number>();
const sizeTotals = new Map<string, number>();
group.forEach(product => {
if (product.color) {
colorTotals.set(product.color, (colorTotals.get(product.color) || 0) + product.quantitySold);
}
if (product.size) {
sizeTotals.set(product.size, (sizeTotals.get(product.size) || 0) + product.quantitySold);
}
});
const topColor = [...colorTotals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || '-';
const topSize = [...sizeTotals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || '-';
const colors = [...colorTotals.keys()].sort((a, b) => a.localeCompare(b, 'pt-BR'));
const sizes = sortProductSizes([...sizeTotals.keys()]);
return {
...first,
id: first.groupKey,
name: first.baseName,
quantitySold,
revenue,
orderLineCount,
stock,
dailySales,
daysOfCover,
risk,
riskLabel: style.label,
productIds: group.map(product => product.id),
skuCount: group.length,
colors,
sizes,
topColor,
topSize,
lastPrice: quantitySold > 0 ? revenue / quantitySold : first.lastPrice
};
});
const activeRows = viewMode === 'group' ? groupedRows : skuRows;
const filteredProducts = normalizedSearch
? products.filter(product =>
? activeRows.filter(product =>
product.name.toLowerCase().includes(normalizedSearch) ||
product.id.toLowerCase().includes(normalizedSearch)
product.id.toLowerCase().includes(normalizedSearch) ||
product.productIds.some(id => id.toLowerCase().includes(normalizedSearch)) ||
product.colors.some(color => color.toLowerCase().includes(normalizedSearch)) ||
product.sizes.some(size => size.toLowerCase().includes(normalizedSearch))
)
: products;
: activeRows;
const detailedFilteredProducts = filteredProducts.filter(product => {
const matchesStatus = stockStatusFilter === 'all' || product.risk === stockStatusFilter;
@@ -294,7 +381,7 @@ const Products = () => {
return b.quantitySold - a.quantitySold;
}
});
}, [coverageFilter, dateRange, productAnalytics, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter]);
}, [coverageFilter, dateRange, productAnalytics, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter, viewMode]);
const activeFilterCount =
(sortBy === 'sold_desc' ? 0 : 1) +
@@ -373,6 +460,29 @@ const Products = () => {
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<div className="inline-flex rounded-xl border border-dark-border bg-dark-input p-1">
{[
{ key: 'sku' as const, label: 'SKU' },
{ key: 'group' as const, label: 'Grupo' }
].map(view => (
<button
key={view.key}
type="button"
onClick={() => {
setViewMode(view.key);
setCurrentPage(1);
}}
className={`rounded-lg px-4 py-2 text-sm font-bold transition-colors cursor-pointer ${
viewMode === view.key
? 'bg-brand-primary text-brand-contrast'
: 'text-dark-muted hover:bg-dark-card hover:text-dark-text'
}`}
>
{view.label}
</button>
))}
</div>
<div className="relative w-full sm:w-72">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-5 h-5" />
<input
@@ -581,8 +691,14 @@ const Products = () => {
<button
onClick={() => {
const exportData = productsData.map(product => ({
'ID Produto': product.id,
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
'ID Produto': viewMode === 'group' ? product.productIds.join(' | ') : product.id,
'Descrição': product.name,
'SKUs': product.skuCount,
'Cores': product.colors.join(' | '),
'Tamanhos': product.sizes.join(' | '),
'Cor principal': product.topColor,
'Tamanho principal': product.topSize,
'Status': product.riskLabel,
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
'Total Vendido (un.)': product.quantitySold,
@@ -591,7 +707,7 @@ const Products = () => {
'Cobertura': product.daysOfCover === null ? '' : product.daysOfCover.toFixed(1).replace('.', ','),
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
}));
exportToCSV(exportData, `produtos_${new Date().toISOString().split('T')[0]}.csv`);
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
}}
className="flex items-center justify-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
title="Exportar para CSV"
@@ -623,8 +739,8 @@ const Products = () => {
</colgroup>
<thead className="bg-zinc-50 dark:bg-dark-header border-b border-zinc-100 dark:border-dark-border text-zinc-500 dark:text-dark-muted">
<tr>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">ID Produto</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Descrição</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">{viewMode === 'group' ? 'SKUs' : 'ID Produto'}</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">{viewMode === 'group' ? 'Grupo' : 'Descrição'}</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Status</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Total Vendido</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Estoque</th>
@@ -640,10 +756,16 @@ const Products = () => {
return (
<tr key={product.id} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{product.id}</td>
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">
{viewMode === 'group' ? `${product.skuCount} SKUs` : `#${product.id}`}
</td>
<td className="max-w-0 px-6 py-2.5">
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={product.name}>{product.name}</div>
<div className="text-[10px] text-zinc-400 dark:text-dark-muted font-medium">Preço Atual: {formatCurrency(product.lastPrice)}</div>
<div className="truncate text-[10px] text-zinc-400 dark:text-dark-muted font-medium">
{viewMode === 'group'
? `Cor principal: ${product.topColor} · Tam. principal: ${product.topSize} · ${product.colors.length} cores · ${product.sizes.length} tamanhos`
: `Preço Atual: ${formatCurrency(product.lastPrice)}`}
</div>
</td>
<td className="px-6 py-2.5">
<span className={`inline-flex items-center gap-2 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${style.className}`}>
@@ -668,11 +790,11 @@ const Products = () => {
<td className="px-6 py-2.5 text-brand-primary font-bold whitespace-nowrap">{formatCurrency(product.revenue)}</td>
<td className="px-4 py-2.5 text-right">
<Link
to={`/products/${product.id}`}
to={viewMode === 'group' ? `/products/groups/${product.groupKey}` : `/products/${product.id}`}
className="inline-flex items-center whitespace-nowrap text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity bg-brand-primary/10 px-3 py-1.5 rounded-lg cursor-pointer"
>
<TrendingUp className="w-3.5 h-3.5 mr-1.5" />
Ver Gráfico
{viewMode === 'group' ? 'Ver Grupo' : 'Ver Gráfico'}
</Link>
</td>
</tr>
@@ -696,7 +818,7 @@ const Products = () => {
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="produtos"
itemLabel={viewMode === 'group' ? 'grupos' : 'produtos'}
pageSizeLabel="itens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, productsData.length)}