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:
@@ -80,11 +80,11 @@ test('buildCutPlan estimates rolls when a family yield is configured', () => {
|
|||||||
assert.equal(plan.summary.estimatedRolls, 3);
|
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([
|
const plan = buildCutPlan([
|
||||||
product({
|
product({
|
||||||
id: 'SKU-2',
|
id: 'SKU-2',
|
||||||
name: 'BONÉ PRETO',
|
name: 'CAMISETA SEM PADRAO',
|
||||||
quantitySold: 70,
|
quantitySold: 70,
|
||||||
stock: 0
|
stock: 0
|
||||||
})
|
})
|
||||||
@@ -102,6 +102,26 @@ test('buildCutPlan applies per-product planning overrides', () => {
|
|||||||
assert.deepEqual(plan.needRows[0].issues, []);
|
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', () => {
|
test('buildOpenProductionByProductId matches open OPs by SKU and normalized product variant', () => {
|
||||||
const products = [
|
const products = [
|
||||||
product({ id: 'SKU-1' }),
|
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 });
|
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([
|
const plan = buildCutPlan([
|
||||||
product({
|
product({
|
||||||
id: 'SKU-2',
|
id: 'SKU-2',
|
||||||
name: 'BONÉ PRETO',
|
name: 'VESTUARIO SEM PADRAO',
|
||||||
quantitySold: 70,
|
quantitySold: 70,
|
||||||
stock: 0
|
stock: 0
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||||
import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts';
|
import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts';
|
||||||
|
import { classifyProductType } from '../productClassification.ts';
|
||||||
|
|
||||||
export type { CutFamilyKey, CutProductOverride };
|
export type { CutFamilyKey, CutProductOverride };
|
||||||
|
|
||||||
@@ -201,8 +202,9 @@ export const buildCutPlan = (
|
|||||||
options: CutPlanOptions = {}
|
options: CutPlanOptions = {}
|
||||||
): CutPlan => {
|
): CutPlan => {
|
||||||
const rangeDays = getRangeDays(dateRange);
|
const rangeDays = getRangeDays(dateRange);
|
||||||
|
const cuttableProducts = products.filter(product => classifyProductType(product.name) === 'finished_apparel');
|
||||||
|
|
||||||
const rows = products.map<CutPlanSkuRow>(product => {
|
const rows = cuttableProducts.map<CutPlanSkuRow>(product => {
|
||||||
const metadata = parseProductName(product.name);
|
const metadata = parseProductName(product.name);
|
||||||
const override = options.productOverrides?.[product.id];
|
const override = options.productOverrides?.[product.id];
|
||||||
const overrideRule = override?.familyKey ? FAMILY_RULES_BY_KEY.get(override.familyKey) : undefined;
|
const overrideRule = override?.familyKey ? FAMILY_RULES_BY_KEY.get(override.familyKey) : undefined;
|
||||||
|
|||||||
21
src/components/ProductTypeBadge.tsx
Normal file
21
src/components/ProductTypeBadge.tsx
Normal file
@@ -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 (
|
||||||
|
<span
|
||||||
|
className={`inline-flex w-fit items-center whitespace-nowrap rounded-full border px-2 py-0.5 text-[10px] font-bold ${config.badgeClassName} ${className}`}
|
||||||
|
title={config.description}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductTypeBadge;
|
||||||
@@ -4,12 +4,14 @@ import { Package, DollarSign, ReceiptText, Warehouse } from 'lucide-react';
|
|||||||
import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import BackButton from '../components/BackButton';
|
import BackButton from '../components/BackButton';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
|
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import type { DateRange, ProductDetailsAnalytics } from '../types';
|
import type { DateRange, ProductDetailsAnalytics } from '../types';
|
||||||
import { fetchProductDetailsAnalytics } from '../dataService';
|
import { fetchProductDetailsAnalytics } from '../dataService';
|
||||||
import { parseProductName } from '../productParsing';
|
import { parseProductName } from '../productParsing';
|
||||||
import { formatColorLabel } from '../displayFormatters';
|
import { formatColorLabel } from '../displayFormatters';
|
||||||
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
|
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_GRID_COLOR = 'var(--chart-grid)';
|
||||||
const CHART_AXIS_COLOR = 'var(--chart-axis)';
|
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 { 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 isRefreshing = isLoading && Boolean(details);
|
||||||
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
|
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
|
||||||
const isHourlyChart = isSingleDayRange && chartData.some(point => /h$|:/.test(point.date));
|
const isHourlyChart = isSingleDayRange && chartData.some(point => /h$|:/.test(point.date));
|
||||||
@@ -292,6 +296,10 @@ const ProductDetails = () => {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest">ID: #{productInfo.id}</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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import BackButton from '../components/BackButton';
|
|||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import PaginationControls from '../components/PaginationControls';
|
import PaginationControls from '../components/PaginationControls';
|
||||||
import ProductColorBadge, { ProductColorSwatch } from '../components/ProductColorBadge';
|
import ProductColorBadge, { ProductColorSwatch } from '../components/ProductColorBadge';
|
||||||
|
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import { buildSkuEditPath } from '../catalogLinks';
|
import { buildSkuEditPath } from '../catalogLinks';
|
||||||
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
||||||
@@ -12,6 +13,7 @@ import { fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
|||||||
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
|
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
|
||||||
import { formatColorLabel } from '../displayFormatters';
|
import { formatColorLabel } from '../displayFormatters';
|
||||||
import { getProductColor } from '../productColors';
|
import { getProductColor } from '../productColors';
|
||||||
|
import { classifyProductType, getDominantProductType, getProductTypeConfig, type ProductTypeKey } from '../productClassification';
|
||||||
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||||
|
|
||||||
type VariantRow = ProductAnalyticsItem & {
|
type VariantRow = ProductAnalyticsItem & {
|
||||||
@@ -23,6 +25,7 @@ type VariantRow = ProductAnalyticsItem & {
|
|||||||
availableQuantity: number;
|
availableQuantity: number;
|
||||||
suggestedReplenishment: number;
|
suggestedReplenishment: number;
|
||||||
daysOfCover: number | null;
|
daysOfCover: number | null;
|
||||||
|
productType: ProductTypeKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BreakdownRow = {
|
type BreakdownRow = {
|
||||||
@@ -240,6 +243,7 @@ const ProductGroupDetails = () => {
|
|||||||
return products
|
return products
|
||||||
.map(product => {
|
.map(product => {
|
||||||
const metadata = parseProductName(product.name);
|
const metadata = parseProductName(product.name);
|
||||||
|
const productType = classifyProductType(product.name);
|
||||||
const dailySales = product.quantitySold / rangeDays;
|
const dailySales = product.quantitySold / rangeDays;
|
||||||
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
|
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
|
||||||
const openProductionQuantity = openProductionByProductId[product.id] || 0;
|
const openProductionQuantity = openProductionByProductId[product.id] || 0;
|
||||||
@@ -256,7 +260,8 @@ const ProductGroupDetails = () => {
|
|||||||
openProductionQuantity,
|
openProductionQuantity,
|
||||||
availableQuantity,
|
availableQuantity,
|
||||||
suggestedReplenishment,
|
suggestedReplenishment,
|
||||||
daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null
|
daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null,
|
||||||
|
productType
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
|
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
|
||||||
@@ -275,6 +280,7 @@ const ProductGroupDetails = () => {
|
|||||||
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
|
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
|
||||||
const colors = new Set(groupRows.map(row => row.color).filter(Boolean));
|
const colors = new Set(groupRows.map(row => row.color).filter(Boolean));
|
||||||
const sizes = new Set(groupRows.map(row => row.size).filter(Boolean));
|
const sizes = new Set(groupRows.map(row => row.size).filter(Boolean));
|
||||||
|
const productType = getDominantProductType(groupRows);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalSold,
|
totalSold,
|
||||||
@@ -287,9 +293,11 @@ const ProductGroupDetails = () => {
|
|||||||
suggestedReplenishment,
|
suggestedReplenishment,
|
||||||
daysOfCover,
|
daysOfCover,
|
||||||
colorCount: colors.size,
|
colorCount: colors.size,
|
||||||
sizeCount: sizes.size
|
sizeCount: sizes.size,
|
||||||
|
productType
|
||||||
};
|
};
|
||||||
}, [groupRows]);
|
}, [groupRows]);
|
||||||
|
const productTypeConfig = getProductTypeConfig(totals.productType);
|
||||||
|
|
||||||
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
|
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
|
||||||
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [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
|
Grupo · {formatNumber(groupRows.length)} SKUs · {formatNumber(totals.colorCount)} cores · {formatNumber(totals.sizeCount)} tamanhos
|
||||||
</p>
|
</p>
|
||||||
<h1 className="truncate text-2xl font-bold text-zinc-900 dark:text-dark-text" title={groupName}>{groupName}</h1>
|
<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>
|
</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="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">
|
<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="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>
|
||||||
<td className="px-6 py-2.5">
|
<td className="px-6 py-2.5">
|
||||||
<ProductColorBadge label={row.color} className="max-w-[8rem]" />
|
<ProductColorBadge label={row.color} className="max-w-[8rem]" />
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ import { Download, Eye, Filter, Package, PackageCheck, Pencil, Search, TrendingD
|
|||||||
import { buildSkuEditPath } from '../catalogLinks';
|
import { buildSkuEditPath } from '../catalogLinks';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import PaginationControls from '../components/PaginationControls';
|
import PaginationControls from '../components/PaginationControls';
|
||||||
|
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import type { DateRange, ProductAnalyticsItem } from '../types';
|
import type { DateRange, ProductAnalyticsItem } from '../types';
|
||||||
import { exportToCSV, fetchProductAnalytics } from '../dataService';
|
import { exportToCSV, fetchProductAnalytics } from '../dataService';
|
||||||
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
|
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
|
||||||
import { formatColorLabel } from '../displayFormatters';
|
import { formatColorLabel } from '../displayFormatters';
|
||||||
|
import { classifyProductType, getDominantProductType, getProductTypeConfig, productTypeOptions, type ProductTypeKey } from '../productClassification';
|
||||||
|
|
||||||
type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales';
|
type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales';
|
||||||
type StockStatusFilter = 'all' | StockRisk;
|
type StockStatusFilter = 'all' | StockRisk;
|
||||||
type StockQuantityFilter = 'all' | 'zero' | 'positive' | 'low' | 'high';
|
type StockQuantityFilter = 'all' | 'zero' | 'positive' | 'low' | 'high';
|
||||||
type SalesFilter = 'all' | 'sold' | 'not_sold';
|
type SalesFilter = 'all' | 'sold' | 'not_sold';
|
||||||
type CoverageFilter = 'all' | 'up_to_7' | 'up_to_14' | 'up_to_30' | 'over_30' | 'none';
|
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 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';
|
type ProductViewMode = 'sku' | 'group';
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ type ProductRow = ProductAnalyticsItem & {
|
|||||||
topColor: string;
|
topColor: string;
|
||||||
topSize: string;
|
topSize: string;
|
||||||
groupKey: string;
|
groupKey: string;
|
||||||
|
productType: ProductTypeKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
const riskStyles: Record<StockRisk, { label: string; className: string; dotClass: string }> = {
|
const riskStyles: Record<StockRisk, { label: string; className: string; dotClass: string }> = {
|
||||||
@@ -163,6 +167,7 @@ const Products = () => {
|
|||||||
const [stockQuantityFilter, setStockQuantityFilter] = useState<StockQuantityFilter>('all');
|
const [stockQuantityFilter, setStockQuantityFilter] = useState<StockQuantityFilter>('all');
|
||||||
const [salesFilter, setSalesFilter] = useState<SalesFilter>('all');
|
const [salesFilter, setSalesFilter] = useState<SalesFilter>('all');
|
||||||
const [coverageFilter, setCoverageFilter] = useState<CoverageFilter>('all');
|
const [coverageFilter, setCoverageFilter] = useState<CoverageFilter>('all');
|
||||||
|
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
|
||||||
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
|
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
|
||||||
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
|
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
|
||||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -223,6 +228,7 @@ const Products = () => {
|
|||||||
const risk = classifyStockRisk(product.stock, dailySales);
|
const risk = classifyStockRisk(product.stock, dailySales);
|
||||||
const style = riskStyles[risk];
|
const style = riskStyles[risk];
|
||||||
const metadata = parseProductName(product.name);
|
const metadata = parseProductName(product.name);
|
||||||
|
const productType = classifyProductType(product.name);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...product,
|
...product,
|
||||||
@@ -239,7 +245,8 @@ const Products = () => {
|
|||||||
sizes: metadata.size ? [metadata.size] : [],
|
sizes: metadata.size ? [metadata.size] : [],
|
||||||
topColor: metadata.color || '-',
|
topColor: metadata.color || '-',
|
||||||
topSize: metadata.size || '-',
|
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 groupedRows = Array.from(groups.values()).map(group => {
|
||||||
const first = group[0];
|
const first = group[0];
|
||||||
|
const productType = getDominantProductType(group);
|
||||||
const quantitySold = group.reduce((total, product) => total + product.quantitySold, 0);
|
const quantitySold = group.reduce((total, product) => total + product.quantitySold, 0);
|
||||||
const revenue = group.reduce((total, product) => total + product.revenue, 0);
|
const revenue = group.reduce((total, product) => total + product.revenue, 0);
|
||||||
const stock = group.reduce((total, product) => total + product.stock, 0);
|
const stock = group.reduce((total, product) => total + product.stock, 0);
|
||||||
@@ -297,6 +305,7 @@ const Products = () => {
|
|||||||
sizes,
|
sizes,
|
||||||
topColor,
|
topColor,
|
||||||
topSize,
|
topSize,
|
||||||
|
productType,
|
||||||
lastPrice: quantitySold > 0 ? revenue / quantitySold : first.lastPrice
|
lastPrice: quantitySold > 0 ? revenue / quantitySold : first.lastPrice
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -309,7 +318,8 @@ const Products = () => {
|
|||||||
product.id.toLowerCase().includes(normalizedSearch) ||
|
product.id.toLowerCase().includes(normalizedSearch) ||
|
||||||
product.productIds.some(id => id.toLowerCase().includes(normalizedSearch)) ||
|
product.productIds.some(id => id.toLowerCase().includes(normalizedSearch)) ||
|
||||||
product.colors.some(color => color.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;
|
: activeRows;
|
||||||
|
|
||||||
@@ -325,6 +335,7 @@ const Products = () => {
|
|||||||
salesFilter === 'all' ||
|
salesFilter === 'all' ||
|
||||||
(salesFilter === 'sold' && product.quantitySold > 0) ||
|
(salesFilter === 'sold' && product.quantitySold > 0) ||
|
||||||
(salesFilter === 'not_sold' && product.quantitySold === 0);
|
(salesFilter === 'not_sold' && product.quantitySold === 0);
|
||||||
|
const matchesProductType = productTypeFilter === 'all' || product.productType === productTypeFilter;
|
||||||
const matchesCoverage =
|
const matchesCoverage =
|
||||||
coverageFilter === 'all' ||
|
coverageFilter === 'all' ||
|
||||||
(coverageFilter === 'none' && product.daysOfCover === null) ||
|
(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 === 'up_to_30' && product.daysOfCover !== null && product.daysOfCover > 14 && product.daysOfCover <= 30) ||
|
||||||
(coverageFilter === 'over_30' && product.daysOfCover !== null && 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) => {
|
return detailedFilteredProducts.sort((a, b) => {
|
||||||
@@ -356,12 +367,13 @@ const Products = () => {
|
|||||||
return b.quantitySold - a.quantitySold;
|
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 =
|
const activeFilterCount =
|
||||||
(stockStatusFilter === 'all' ? 0 : 1) +
|
(stockStatusFilter === 'all' ? 0 : 1) +
|
||||||
(stockQuantityFilter === 'all' ? 0 : 1) +
|
(stockQuantityFilter === 'all' ? 0 : 1) +
|
||||||
(salesFilter === 'all' ? 0 : 1) +
|
(salesFilter === 'all' ? 0 : 1) +
|
||||||
|
(productTypeFilter === 'all' ? 0 : 1) +
|
||||||
(coverageFilter === 'all' ? 0 : 1);
|
(coverageFilter === 'all' ? 0 : 1);
|
||||||
const hasActiveFilters = activeFilterCount > 0;
|
const hasActiveFilters = activeFilterCount > 0;
|
||||||
|
|
||||||
@@ -369,6 +381,7 @@ const Products = () => {
|
|||||||
setStockStatusFilter('all');
|
setStockStatusFilter('all');
|
||||||
setStockQuantityFilter('all');
|
setStockQuantityFilter('all');
|
||||||
setSalesFilter('all');
|
setSalesFilter('all');
|
||||||
|
setProductTypeFilter('all');
|
||||||
setCoverageFilter('all');
|
setCoverageFilter('all');
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
@@ -409,6 +422,7 @@ const Products = () => {
|
|||||||
'SKUs': product.skuCount,
|
'SKUs': product.skuCount,
|
||||||
'Cores': product.colors.join(' | '),
|
'Cores': product.colors.join(' | '),
|
||||||
'Tamanhos': product.sizes.join(' | '),
|
'Tamanhos': product.sizes.join(' | '),
|
||||||
|
'Tipo de produto': getProductTypeConfig(product.productType).label,
|
||||||
'Cor principal': product.topColor,
|
'Cor principal': product.topColor,
|
||||||
'Tamanho principal': product.topSize,
|
'Tamanho principal': product.topSize,
|
||||||
'Status': product.riskLabel,
|
'Status': product.riskLabel,
|
||||||
@@ -504,6 +518,23 @@ const Products = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
<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">
|
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
|
||||||
Status
|
Status
|
||||||
<select
|
<select
|
||||||
@@ -657,10 +688,13 @@ const Products = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="max-w-0 px-6 py-2.5">
|
<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 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">
|
<div className="mt-1 flex min-w-0 items-center gap-2">
|
||||||
{viewMode === 'group'
|
<ProductTypeBadge type={product.productType} />
|
||||||
? `Cor principal: ${formatColorLabel(product.topColor)} · Tam. principal: ${product.topSize} · ${product.colors.length} cores · ${product.sizes.length} tamanhos`
|
<span className="truncate text-[10px] font-medium text-zinc-400 dark:text-dark-muted">
|
||||||
: `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)}`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-2.5">
|
<td className="px-6 py-2.5">
|
||||||
|
|||||||
158
src/productClassification.ts
Normal file
158
src/productClassification.ts
Normal file
@@ -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<ProductTypeKey, ProductTypeConfig> = {
|
||||||
|
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 = <T extends { productType: ProductTypeKey; quantitySold: number }>(rows: T[]) => {
|
||||||
|
if (!rows.length) return 'unknown';
|
||||||
|
|
||||||
|
const totals = rows.reduce<Record<string, number>>((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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user