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,12 +4,14 @@ import { Package, DollarSign, ReceiptText, Warehouse } from 'lucide-react';
import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import type { DateRange, ProductDetailsAnalytics } from '../types';
import { fetchProductDetailsAnalytics } from '../dataService';
import { parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
import { classifyProductType, getProductTypeConfig } from '../productClassification';
const CHART_GRID_COLOR = 'var(--chart-grid)';
const CHART_AXIS_COLOR = 'var(--chart-axis)';
@@ -181,6 +183,8 @@ const ProductDetails = () => {
}
const { productInfo, chartData, totalSold, totalRevenue, totalOrders = 0, averageTicket = 0, variantBreakdown = [] } = details;
const productType = classifyProductType(productInfo.name);
const productTypeConfig = getProductTypeConfig(productType);
const isRefreshing = isLoading && Boolean(details);
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
const isHourlyChart = isSingleDayRange && chartData.some(point => /h$|:/.test(point.date));
@@ -292,6 +296,10 @@ const ProductDetails = () => {
<div>
<div className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest">ID: #{productInfo.id}</div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{productInfo.name}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2">
<ProductTypeBadge type={productType} />
<span className="text-xs font-semibold text-dark-muted">{productTypeConfig.description}</span>
</div>
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import ProductColorBadge, { ProductColorSwatch } from '../components/ProductColorBadge';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import { buildSkuEditPath } from '../catalogLinks';
import { buildOpenProductionByProductId } from '../analytics/cutting';
@@ -12,6 +13,7 @@ import { fetchProductAnalytics, fetchProductionOrders } from '../dataService';
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { getProductColor } from '../productColors';
import { classifyProductType, getDominantProductType, getProductTypeConfig, type ProductTypeKey } from '../productClassification';
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
type VariantRow = ProductAnalyticsItem & {
@@ -23,6 +25,7 @@ type VariantRow = ProductAnalyticsItem & {
availableQuantity: number;
suggestedReplenishment: number;
daysOfCover: number | null;
productType: ProductTypeKey;
};
type BreakdownRow = {
@@ -240,6 +243,7 @@ const ProductGroupDetails = () => {
return products
.map(product => {
const metadata = parseProductName(product.name);
const productType = classifyProductType(product.name);
const dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
const openProductionQuantity = openProductionByProductId[product.id] || 0;
@@ -256,7 +260,8 @@ const ProductGroupDetails = () => {
openProductionQuantity,
availableQuantity,
suggestedReplenishment,
daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null
daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null,
productType
};
})
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
@@ -275,6 +280,7 @@ const ProductGroupDetails = () => {
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
const colors = new Set(groupRows.map(row => row.color).filter(Boolean));
const sizes = new Set(groupRows.map(row => row.size).filter(Boolean));
const productType = getDominantProductType(groupRows);
return {
totalSold,
@@ -287,9 +293,11 @@ const ProductGroupDetails = () => {
suggestedReplenishment,
daysOfCover,
colorCount: colors.size,
sizeCount: sizes.size
sizeCount: sizes.size,
productType
};
}, [groupRows]);
const productTypeConfig = getProductTypeConfig(totals.productType);
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [groupRows]);
@@ -335,6 +343,10 @@ const ProductGroupDetails = () => {
Grupo · {formatNumber(groupRows.length)} SKUs · {formatNumber(totals.colorCount)} cores · {formatNumber(totals.sizeCount)} tamanhos
</p>
<h1 className="truncate text-2xl font-bold text-zinc-900 dark:text-dark-text" title={groupName}>{groupName}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2">
<ProductTypeBadge type={totals.productType} />
<span className="text-xs font-semibold text-dark-muted">{productTypeConfig.description}</span>
</div>
</div>
</div>
</div>
@@ -522,7 +534,10 @@ const ProductGroupDetails = () => {
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{row.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={row.name}>{row.name}</div>
<div className="text-[10px] font-medium text-zinc-400 dark:text-dark-muted">Preço Atual: {formatCurrency(row.lastPrice)}</div>
<div className="mt-1 flex min-w-0 items-center gap-2">
<ProductTypeBadge type={row.productType} />
<span className="truncate text-[10px] font-medium text-zinc-400 dark:text-dark-muted">Preço Atual: {formatCurrency(row.lastPrice)}</span>
</div>
</td>
<td className="px-6 py-2.5">
<ProductColorBadge label={row.color} className="max-w-[8rem]" />

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