Add inferred product classification
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m36s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m36s
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user