diff --git a/src/analytics/cutting.test.ts b/src/analytics/cutting.test.ts index e92f60f..fa6e9d6 100644 --- a/src/analytics/cutting.test.ts +++ b/src/analytics/cutting.test.ts @@ -80,11 +80,11 @@ test('buildCutPlan estimates rolls when a family yield is configured', () => { assert.equal(plan.summary.estimatedRolls, 3); }); -test('buildCutPlan applies per-product planning overrides', () => { +test('buildCutPlan applies per-product planning overrides to finished apparel', () => { const plan = buildCutPlan([ product({ id: 'SKU-2', - name: 'BONÉ PRETO', + name: 'CAMISETA SEM PADRAO', quantitySold: 70, stock: 0 }) @@ -102,6 +102,26 @@ test('buildCutPlan applies per-product planning overrides', () => { assert.deepEqual(plan.needRows[0].issues, []); }); +test('buildCutPlan excludes non-apparel products from cut planning', () => { + const plan = buildCutPlan([ + product({ + id: 'SKU-2', + name: 'BONÉ PRETO', + quantitySold: 70, + stock: 0 + }), + product({ + id: 'SKU-3', + name: 'TRANSPARENTE PP MILHEIRO - 25x35', + quantitySold: 70, + stock: 0 + }) + ], range, 7); + + assert.equal(plan.rows.length, 0); + assert.equal(plan.summary.skuCount, 0); +}); + test('buildOpenProductionByProductId matches open OPs by SKU and normalized product variant', () => { const products = [ product({ id: 'SKU-1' }), @@ -116,11 +136,11 @@ test('buildOpenProductionByProductId matches open OPs by SKU and normalized prod assert.deepEqual(totals, { 'SKU-1': 10, 'SKU-2': 20 }); }); -test('buildCutPlan marks products that cannot be planned cleanly for cutting', () => { +test('buildCutPlan marks apparel products that cannot be planned cleanly for cutting', () => { const plan = buildCutPlan([ product({ id: 'SKU-2', - name: 'BONÉ PRETO', + name: 'VESTUARIO SEM PADRAO', quantitySold: 70, stock: 0 }) diff --git a/src/analytics/cutting.ts b/src/analytics/cutting.ts index 2d48236..af49925 100644 --- a/src/analytics/cutting.ts +++ b/src/analytics/cutting.ts @@ -1,5 +1,6 @@ import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts'; +import { classifyProductType } from '../productClassification.ts'; export type { CutFamilyKey, CutProductOverride }; @@ -201,8 +202,9 @@ export const buildCutPlan = ( options: CutPlanOptions = {} ): CutPlan => { const rangeDays = getRangeDays(dateRange); + const cuttableProducts = products.filter(product => classifyProductType(product.name) === 'finished_apparel'); - const rows = products.map(product => { + const rows = cuttableProducts.map(product => { const metadata = parseProductName(product.name); const override = options.productOverrides?.[product.id]; const overrideRule = override?.familyKey ? FAMILY_RULES_BY_KEY.get(override.familyKey) : undefined; diff --git a/src/components/ProductTypeBadge.tsx b/src/components/ProductTypeBadge.tsx new file mode 100644 index 0000000..7a3c0bd --- /dev/null +++ b/src/components/ProductTypeBadge.tsx @@ -0,0 +1,21 @@ +import { getProductTypeConfig, type ProductTypeKey } from '../productClassification'; + +type ProductTypeBadgeProps = { + type: ProductTypeKey; + className?: string; +}; + +const ProductTypeBadge = ({ type, className = '' }: ProductTypeBadgeProps) => { + const config = getProductTypeConfig(type); + + return ( + + {config.label} + + ); +}; + +export default ProductTypeBadge; diff --git a/src/pages/ProductDetails.tsx b/src/pages/ProductDetails.tsx index 8cc8812..b79221b 100644 --- a/src/pages/ProductDetails.tsx +++ b/src/pages/ProductDetails.tsx @@ -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 = () => {
ID: #{productInfo.id}

{productInfo.name}

+
+ + {productTypeConfig.description} +
diff --git a/src/pages/ProductGroupDetails.tsx b/src/pages/ProductGroupDetails.tsx index b01169a..e72c6b5 100644 --- a/src/pages/ProductGroupDetails.tsx +++ b/src/pages/ProductGroupDetails.tsx @@ -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

{groupName}

+
+ + {productTypeConfig.description} +
@@ -522,7 +534,10 @@ const ProductGroupDetails = () => { #{row.id}
{row.name}
-
Preço Atual: {formatCurrency(row.lastPrice)}
+
+ + Preço Atual: {formatCurrency(row.lastPrice)} +
diff --git a/src/pages/Products.tsx b/src/pages/Products.tsx index 0bb6da6..5b3760e 100644 --- a/src/pages/Products.tsx +++ b/src/pages/Products.tsx @@ -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 = { @@ -163,6 +167,7 @@ const Products = () => { const [stockQuantityFilter, setStockQuantityFilter] = useState('all'); const [salesFilter, setSalesFilter] = useState('all'); const [coverageFilter, setCoverageFilter] = useState('all'); + const [productTypeFilter, setProductTypeFilter] = useState('all'); const [viewMode, setViewMode] = useState('sku'); const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); const filterMenuRef = useRef(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 = () => {
+ +