From 422828f343bc9dceac86654efd0d316368d0c512 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Cau=C3=AA=20Faleiros?=
Date: Thu, 16 Jul 2026 10:15:24 -0300
Subject: [PATCH] Add inferred product classification
---
src/analytics/cutting.test.ts | 28 ++++-
src/analytics/cutting.ts | 4 +-
src/components/ProductTypeBadge.tsx | 21 ++++
src/pages/ProductDetails.tsx | 8 ++
src/pages/ProductGroupDetails.tsx | 21 +++-
src/pages/Products.tsx | 50 +++++++--
src/productClassification.ts | 158 ++++++++++++++++++++++++++++
7 files changed, 274 insertions(+), 16 deletions(-)
create mode 100644 src/components/ProductTypeBadge.tsx
create mode 100644 src/productClassification.ts
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 = () => {
+
+
|
{product.name}
-
- {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)}`}
+
+
+
+ {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)}`}
+
|
diff --git a/src/productClassification.ts b/src/productClassification.ts
new file mode 100644
index 0000000..de0f149
--- /dev/null
+++ b/src/productClassification.ts
@@ -0,0 +1,158 @@
+import { normalizeProductText } from './productParsing.ts';
+
+export type ProductTypeKey =
+ | 'finished_apparel'
+ | 'finished_accessory'
+ | 'raw_material'
+ | 'packaging'
+ | 'dtf_input'
+ | 'dtf_service'
+ | 'kit_bundle'
+ | 'service'
+ | 'machine_part'
+ | 'equipment'
+ | 'ignore_from_planning'
+ | 'unknown';
+
+export type ProductPlanningMode = 'cutting' | 'unit_replenishment' | 'material' | 'service' | 'ignore' | 'review';
+
+export type ProductTypeConfig = {
+ key: ProductTypeKey;
+ label: string;
+ description: string;
+ planningMode: ProductPlanningMode;
+ badgeClassName: string;
+};
+
+export const productTypeConfigs: Record = {
+ finished_apparel: {
+ key: 'finished_apparel',
+ label: 'Vestuário',
+ description: 'Produto acabado que pode entrar em corte/reposição por SKU.',
+ planningMode: 'cutting',
+ badgeClassName: 'border-sky-400/30 bg-sky-400/10 text-sky-300'
+ },
+ finished_accessory: {
+ key: 'finished_accessory',
+ label: 'Acessório',
+ description: 'Produto acabado sem regra de malha principal.',
+ planningMode: 'unit_replenishment',
+ badgeClassName: 'border-cyan-400/30 bg-cyan-400/10 text-cyan-300'
+ },
+ raw_material: {
+ key: 'raw_material',
+ label: 'Matéria-prima',
+ description: 'Entrada de produção, como malha, ribana, tecido, fio ou resíduo.',
+ planningMode: 'material',
+ badgeClassName: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
+ },
+ packaging: {
+ key: 'packaging',
+ label: 'Embalagem',
+ description: 'Insumo de embalagem controlado por unidade/milheiro.',
+ planningMode: 'unit_replenishment',
+ badgeClassName: 'border-amber-400/30 bg-amber-400/10 text-amber-300'
+ },
+ dtf_input: {
+ key: 'dtf_input',
+ label: 'Insumo DTF',
+ description: 'Insumo para DTF, como tinta, filme, poliamida ou fluido.',
+ planningMode: 'material',
+ badgeClassName: 'border-fuchsia-400/30 bg-fuchsia-400/10 text-fuchsia-300'
+ },
+ dtf_service: {
+ key: 'dtf_service',
+ label: 'Serviço DTF',
+ description: 'Impressão, estampa ou personalização vendida como serviço/produto customizado.',
+ planningMode: 'service',
+ badgeClassName: 'border-purple-400/30 bg-purple-400/10 text-purple-300'
+ },
+ kit_bundle: {
+ key: 'kit_bundle',
+ label: 'Kit',
+ description: 'Bundle marketplace composto por outros SKUs.',
+ planningMode: 'review',
+ badgeClassName: 'border-indigo-400/30 bg-indigo-400/10 text-indigo-300'
+ },
+ service: {
+ key: 'service',
+ label: 'Serviço',
+ description: 'Frete, transporte, tecelagem, tinturaria ou serviço técnico.',
+ planningMode: 'service',
+ badgeClassName: 'border-zinc-400/30 bg-zinc-400/10 text-zinc-300'
+ },
+ machine_part: {
+ key: 'machine_part',
+ label: 'Peça máquina',
+ description: 'Peça, limpeza ou manutenção de máquina.',
+ planningMode: 'unit_replenishment',
+ badgeClassName: 'border-orange-400/30 bg-orange-400/10 text-orange-300'
+ },
+ equipment: {
+ key: 'equipment',
+ label: 'Equipamento',
+ description: 'Máquina ou equipamento permanente.',
+ planningMode: 'ignore',
+ badgeClassName: 'border-slate-400/30 bg-slate-400/10 text-slate-300'
+ },
+ ignore_from_planning: {
+ key: 'ignore_from_planning',
+ label: 'Ignorar',
+ description: 'Item que não deve dirigir corte, compra ou reposição.',
+ planningMode: 'ignore',
+ badgeClassName: 'border-dark-border bg-dark-input text-dark-muted'
+ },
+ unknown: {
+ key: 'unknown',
+ label: 'Revisar',
+ description: 'Tipo não identificado automaticamente.',
+ planningMode: 'review',
+ badgeClassName: 'border-red-400/30 bg-red-400/10 text-red-300'
+ }
+};
+
+const has = (value: string, pattern: RegExp) => pattern.test(value);
+
+export const classifyProductType = (name: string): ProductTypeKey => {
+ const normalizedName = normalizeProductText(name)
+ .normalize('NFD')
+ .replace(/\p{Diacritic}/gu, '')
+ .toUpperCase();
+
+ if (!normalizedName) return 'unknown';
+
+ if (has(normalizedName, /\bKIT\b/)) return 'kit_bundle';
+ if (has(normalizedName, /TRANSPARENTE PP|SACO DE SEGURANCA|MILHEIRO|SACOLA|EMBALAGEM/)) return 'packaging';
+ if (has(normalizedName, /PRENSA|MAQUINA|OVERLOCK/)) return 'equipment';
+ if (has(normalizedName, /FRETE|SERVICO|TRANSPORTE|MOTOTAXI|TECELAGEM|TINTURARIA/)) return 'service';
+ if (has(normalizedName, /DUMPER|CABO FLAT|WIPPER|PRIMER|FLUIDO DE LIMPEZA|MISTURADOR|CABECA I3200|PECA DE MAQUINA/)) return 'machine_part';
+ if (has(normalizedName, /TINTA DTF|FILME DTF|POLIAMIDA.*DTF|DTF ROLO|PO PARA DTF/)) return 'dtf_input';
+ if (has(normalizedName, /IMPRESSAO DTF|CORRECAO IMPRESSAO|ESTAMPA|PERSONALIZACAO/)) return 'dtf_service';
+ if (has(normalizedName, /MALHA|RIBANA|FIO|TECIDO|RESIDUO|RETALHO|PIMA|ALGODAO/)) {
+ if (!has(normalizedName, /CAMISETA|MOLETOM|REGATA|BONE|CHINELO|OVERSIZE|OVER SIZE/)) {
+ return 'raw_material';
+ }
+ }
+ if (has(normalizedName, /BONE|TRUCKER|\bCAP\b|CHINELO/)) return 'finished_accessory';
+ if (has(normalizedName, /CAMISETA|MOLETOM|CANGURU|REGATA|INFANTIL|OVER SIZE|OVERSIZE|PROMOCIONAL|VESTUARIO/)) return 'finished_apparel';
+ if (has(normalizedName, /ETIQUETA|TAG|FITA|LINHA PARA COSTURA|COSTURA/)) return 'raw_material';
+
+ return 'unknown';
+};
+
+export const getProductTypeConfig = (type: ProductTypeKey) => productTypeConfigs[type] || productTypeConfigs.unknown;
+
+export const productTypeOptions = Object.values(productTypeConfigs)
+ .filter(config => config.key !== 'ignore_from_planning')
+ .map(config => ({ value: config.key, label: config.label }));
+
+export const getDominantProductType = (rows: T[]) => {
+ if (!rows.length) return 'unknown';
+
+ const totals = rows.reduce>((acc, row) => {
+ acc[row.productType] = (acc[row.productType] || 0) + Math.max(row.quantitySold, 1);
+ return acc;
+ }, {});
+
+ return (Object.entries(totals).sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown') as ProductTypeKey;
+};
|