Add inferred product classification
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m36s

This commit is contained in:
Cauê Faleiros
2026-07-16 10:15:24 -03:00
parent 5e81a8f2c3
commit 422828f343
7 changed files with 274 additions and 16 deletions

View File

@@ -4,17 +4,20 @@ import { Download, Eye, Filter, Package, PackageCheck, Pencil, Search, TrendingD
import { buildSkuEditPath } from '../catalogLinks';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import type { DateRange, ProductAnalyticsItem } from '../types';
import { exportToCSV, fetchProductAnalytics } from '../dataService';
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { classifyProductType, getDominantProductType, getProductTypeConfig, productTypeOptions, type ProductTypeKey } from '../productClassification';
type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales';
type StockStatusFilter = 'all' | StockRisk;
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 ProductTypeFilter = 'all' | ProductTypeKey;
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';
@@ -33,6 +36,7 @@ type ProductRow = ProductAnalyticsItem & {
topColor: string;
topSize: string;
groupKey: string;
productType: ProductTypeKey;
};
const riskStyles: Record<StockRisk, { label: string; className: string; dotClass: string }> = {
@@ -163,6 +167,7 @@ const Products = () => {
const [stockQuantityFilter, setStockQuantityFilter] = useState<StockQuantityFilter>('all');
const [salesFilter, setSalesFilter] = useState<SalesFilter>('all');
const [coverageFilter, setCoverageFilter] = useState<CoverageFilter>('all');
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement>(null);
@@ -223,6 +228,7 @@ const Products = () => {
const risk = classifyStockRisk(product.stock, dailySales);
const style = riskStyles[risk];
const metadata = parseProductName(product.name);
const productType = classifyProductType(product.name);
return {
...product,
@@ -239,7 +245,8 @@ const Products = () => {
sizes: metadata.size ? [metadata.size] : [],
topColor: metadata.color || '-',
topSize: metadata.size || '-',
groupKey: encodeProductGroupKey(metadata.baseName)
groupKey: encodeProductGroupKey(metadata.baseName),
productType
};
});
@@ -254,6 +261,7 @@ const Products = () => {
const groupedRows = Array.from(groups.values()).map(group => {
const first = group[0];
const productType = getDominantProductType(group);
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);
@@ -297,6 +305,7 @@ const Products = () => {
sizes,
topColor,
topSize,
productType,
lastPrice: quantitySold > 0 ? revenue / quantitySold : first.lastPrice
};
});
@@ -309,7 +318,8 @@ const Products = () => {
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))
product.sizes.some(size => size.toLowerCase().includes(normalizedSearch)) ||
getProductTypeConfig(product.productType).label.toLowerCase().includes(normalizedSearch)
)
: activeRows;
@@ -325,6 +335,7 @@ const Products = () => {
salesFilter === 'all' ||
(salesFilter === 'sold' && product.quantitySold > 0) ||
(salesFilter === 'not_sold' && product.quantitySold === 0);
const matchesProductType = productTypeFilter === 'all' || product.productType === productTypeFilter;
const matchesCoverage =
coverageFilter === 'all' ||
(coverageFilter === 'none' && product.daysOfCover === null) ||
@@ -333,7 +344,7 @@ const Products = () => {
(coverageFilter === 'up_to_30' && product.daysOfCover !== null && product.daysOfCover > 14 && product.daysOfCover <= 30) ||
(coverageFilter === 'over_30' && product.daysOfCover !== null && product.daysOfCover > 30);
return matchesStatus && matchesStockQuantity && matchesSales && matchesCoverage;
return matchesStatus && matchesStockQuantity && matchesSales && matchesProductType && matchesCoverage;
});
return detailedFilteredProducts.sort((a, b) => {
@@ -356,12 +367,13 @@ const Products = () => {
return b.quantitySold - a.quantitySold;
}
});
}, [coverageFilter, dateRange, productAnalytics, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter, viewMode]);
}, [coverageFilter, dateRange, productAnalytics, productTypeFilter, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter, viewMode]);
const activeFilterCount =
(stockStatusFilter === 'all' ? 0 : 1) +
(stockQuantityFilter === 'all' ? 0 : 1) +
(salesFilter === 'all' ? 0 : 1) +
(productTypeFilter === 'all' ? 0 : 1) +
(coverageFilter === 'all' ? 0 : 1);
const hasActiveFilters = activeFilterCount > 0;
@@ -369,6 +381,7 @@ const Products = () => {
setStockStatusFilter('all');
setStockQuantityFilter('all');
setSalesFilter('all');
setProductTypeFilter('all');
setCoverageFilter('all');
setCurrentPage(1);
};
@@ -409,6 +422,7 @@ const Products = () => {
'SKUs': product.skuCount,
'Cores': product.colors.join(' | '),
'Tamanhos': product.sizes.join(' | '),
'Tipo de produto': getProductTypeConfig(product.productType).label,
'Cor principal': product.topColor,
'Tamanho principal': product.topSize,
'Status': product.riskLabel,
@@ -504,6 +518,23 @@ const Products = () => {
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
Tipo de produto
<select
value={productTypeFilter}
onChange={(event) => {
setProductTypeFilter(event.target.value as ProductTypeFilter);
setCurrentPage(1);
}}
className={`${filterSelectClassName} mt-1`}
>
<option value="all">Todos</option>
{productTypeOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
Status
<select
@@ -657,10 +688,13 @@ const Products = () => {
</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="truncate text-[10px] text-zinc-400 dark:text-dark-muted font-medium">
{viewMode === 'group'
? `Cor principal: ${formatColorLabel(product.topColor)} · Tam. principal: ${product.topSize} · ${product.colors.length} cores · ${product.sizes.length} tamanhos`
: `Preço Atual: ${formatCurrency(product.lastPrice)}`}
<div className="mt-1 flex min-w-0 items-center gap-2">
<ProductTypeBadge type={product.productType} />
<span className="truncate text-[10px] font-medium text-zinc-400 dark:text-dark-muted">
{viewMode === 'group'
? `Cor principal: ${formatColorLabel(product.topColor)} · Tam. principal: ${product.topSize} · ${product.colors.length} cores · ${product.sizes.length} tamanhos`
: `Preço Atual: ${formatCurrency(product.lastPrice)}`}
</span>
</div>
</td>
<td className="px-6 py-2.5">