Add product group analysis view
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
This commit is contained in:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user