diff --git a/src/analytics/cutting.test.ts b/src/analytics/cutting.test.ts index 165ff9d..e92f60f 100644 --- a/src/analytics/cutting.test.ts +++ b/src/analytics/cutting.test.ts @@ -70,6 +70,38 @@ test('buildCutPlan subtracts open production quantity from suggested cut need', assert.equal(row.suggestedCutQuantity, 20); }); +test('buildCutPlan estimates rolls when a family yield is configured', () => { + const plan = buildCutPlan([product({})], range, 14, {}, { familyYields: { BLCS: 50 } }); + const [row] = plan.needRows; + + assert.equal(row.suggestedCutQuantity, 120); + assert.equal(row.estimatedRolls, 3); + assert.deepEqual(row.issues, []); + assert.equal(plan.summary.estimatedRolls, 3); +}); + +test('buildCutPlan applies per-product planning overrides', () => { + const plan = buildCutPlan([ + product({ + id: 'SKU-2', + name: 'BONÉ PRETO', + quantitySold: 70, + stock: 0 + }) + ], range, 7, {}, { + familyYields: { BLCS: 35 }, + productOverrides: { + 'SKU-2': { familyKey: 'BLCS', color: 'Preto', size: 'M' } + } + }); + + assert.equal(plan.needRows[0].family.key, 'BLCS'); + assert.equal(plan.needRows[0].color, 'Preto'); + assert.equal(plan.needRows[0].size, 'M'); + assert.equal(plan.needRows[0].estimatedRolls, 2); + assert.deepEqual(plan.needRows[0].issues, []); +}); + test('buildOpenProductionByProductId matches open OPs by SKU and normalized product variant', () => { const products = [ product({ id: 'SKU-1' }), diff --git a/src/analytics/cutting.ts b/src/analytics/cutting.ts index f8f8064..24fb097 100644 --- a/src/analytics/cutting.ts +++ b/src/analytics/cutting.ts @@ -12,6 +12,17 @@ export interface CutFamilyRule { unitsPerRoll: number | null; } +export interface CutProductOverride { + familyKey?: CutFamilyKey | ''; + color?: string; + size?: string; +} + +export interface CutPlanOptions { + familyYields?: Partial>; + productOverrides?: Record; +} + export interface CutPlanSkuRow extends ProductAnalyticsItem { family: CutFamilyRule; baseName: string; @@ -99,6 +110,11 @@ export const OUTROS_RULE: CutFamilyRule = { unitsPerRoll: null }; +const FAMILY_RULES_BY_KEY = new Map([ + ...CUT_FAMILY_RULES.map(rule => [rule.key, rule] as const), + [OUTROS_RULE.key, OUTROS_RULE] +]); + export const getRangeDays = (range: DateRange) => { const start = new Date(range.start); const end = new Date(range.end); @@ -121,6 +137,14 @@ export const classifyCutFamily = (baseName: string): CutFamilyRule => { )) || OUTROS_RULE; }; +const ruleWithConfiguredYield = (rule: CutFamilyRule, familyYields?: Partial>): CutFamilyRule => { + const configuredYield = familyYields?.[rule.key]; + return { + ...rule, + unitsPerRoll: configuredYield && configuredYield > 0 ? configuredYield : rule.unitsPerRoll + }; +}; + const normalizeMatchText = (value: string) => normalizeProductText(value) .normalize('NFD') .replace(/\p{Diacritic}/gu, '') @@ -178,13 +202,18 @@ export const buildCutPlan = ( products: ProductAnalyticsItem[], dateRange: DateRange, targetCoverageDays: number, - openProductionByProductId: Record = {} + openProductionByProductId: Record = {}, + options: CutPlanOptions = {} ): CutPlan => { const rangeDays = getRangeDays(dateRange); const rows = products.map(product => { const metadata = parseProductName(product.name); - const family = classifyCutFamily(metadata.baseName); + const override = options.productOverrides?.[product.id]; + const overrideRule = override?.familyKey ? FAMILY_RULES_BY_KEY.get(override.familyKey) : undefined; + const family = ruleWithConfiguredYield(overrideRule || classifyCutFamily(metadata.baseName), options.familyYields); + const color = normalizeProductText(override?.color || metadata.color); + const size = normalizeProductText(override?.size || metadata.size).toUpperCase(); const dailySales = product.quantitySold / rangeDays; const projectedDemand = dailySales * targetCoverageDays; const openProductionQuantity = openProductionByProductId[product.id] || 0; @@ -197,16 +226,16 @@ export const buildCutPlan = ( const issues: CutIssue[] = []; if (family.key === 'OUTROS') issues.push('missing_family_rule'); - if (!metadata.color) issues.push('missing_color'); - if (!metadata.size) issues.push('missing_size'); + if (!color) issues.push('missing_color'); + if (!size) issues.push('missing_size'); if (suggestedCutQuantity > 0 && family.key !== 'OUTROS' && !family.unitsPerRoll) issues.push('missing_yield_rule'); return { ...product, family, baseName: metadata.baseName, - color: metadata.color, - size: metadata.size, + color, + size, dailySales, projectedDemand, openProductionQuantity, diff --git a/src/pages/Cutting.tsx b/src/pages/Cutting.tsx index a51a197..7163db0 100644 --- a/src/pages/Cutting.tsx +++ b/src/pages/Cutting.tsx @@ -1,16 +1,21 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; -import { AlertTriangle, ClipboardList, Download, Layers3, Package, Palette, Ruler, Scissors, Search } from 'lucide-react'; +import { AlertTriangle, ClipboardList, Download, Layers3, Package, Palette, RotateCcw, Ruler, Scissors, Search, Settings2 } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; import RefreshStatus from '../components/RefreshStatus'; -import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow } from '../analytics/cutting'; +import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting'; import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService'; import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; type CutFilter = 'need' | 'all' | 'issues' | 'covered'; type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc'; +type CuttingSettings = { + familyYields: Partial>; + productOverrides: Record; +}; +const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings'; const coverageTargetOptions = [7, 15, 30, 60]; const familyOptions: Array<{ value: CutFamilyKey | 'all'; label: string }> = [ { value: 'all', label: 'Todas famílias' }, @@ -62,6 +67,20 @@ const allProductionOrdersRange = { end: new Date(2100, 11, 31) }; +const loadCuttingSettings = (): CuttingSettings => { + try { + const rawSettings = localStorage.getItem(SETTINGS_STORAGE_KEY); + if (!rawSettings) return { familyYields: {}, productOverrides: {} }; + const parsed = JSON.parse(rawSettings) as Partial; + return { + familyYields: parsed.familyYields || {}, + productOverrides: parsed.productOverrides || {} + }; + } catch { + return { familyYields: {}, productOverrides: {} }; + } +}; + const CuttingSkeleton = () => (
@@ -92,9 +111,15 @@ const Cutting = () => { const [familyFilter, setFamilyFilter] = useState('all'); const [cutFilter, setCutFilter] = useState('need'); const [sortBy, setSortBy] = useState('need_desc'); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [cuttingSettings, setCuttingSettings] = useState(loadCuttingSettings); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); + useEffect(() => { + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(cuttingSettings)); + }, [cuttingSettings]); + useEffect(() => { let isMounted = true; @@ -124,8 +149,8 @@ const Cutting = () => { ); const cutPlan = useMemo( - () => buildCutPlan(products, dateRange, targetCoverageDays, openProductionByProductId), - [dateRange, openProductionByProductId, products, targetCoverageDays] + () => buildCutPlan(products, dateRange, targetCoverageDays, openProductionByProductId, cuttingSettings), + [cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays] ); const issueSummaries = useMemo(() => { @@ -177,6 +202,51 @@ const Cutting = () => { const startIndex = (safeCurrentPage - 1) * itemsPerPage; const paginatedRows = filteredRows.slice(startIndex, startIndex + itemsPerPage); const isRefreshing = isLoading && products.length > 0; + const correctionRows = cutPlan.rows.filter(row => ( + row.issues.some(issue => issue !== 'missing_yield_rule') + )).slice(0, 12); + const configuredYieldCount = CUT_FAMILY_RULES.filter(rule => cuttingSettings.familyYields[rule.key]).length; + const productOverrideCount = Object.keys(cuttingSettings.productOverrides).length; + + const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => { + const parsedValue = Number(value); + setCuttingSettings(current => { + const familyYields = { ...current.familyYields }; + if (Number.isFinite(parsedValue) && parsedValue > 0) { + familyYields[familyKey] = parsedValue; + } else { + delete familyYields[familyKey]; + } + return { ...current, familyYields }; + }); + }; + + const updateProductOverride = (productId: string, patch: CutProductOverride) => { + setCuttingSettings(current => { + const currentOverride = current.productOverrides[productId] || {}; + const nextOverride = { ...currentOverride, ...patch }; + const normalizedOverride: CutProductOverride = { + familyKey: nextOverride.familyKey || '', + color: nextOverride.color || '', + size: nextOverride.size || '' + }; + const productOverrides = { ...current.productOverrides }; + if (!normalizedOverride.familyKey && !normalizedOverride.color && !normalizedOverride.size) { + delete productOverrides[productId]; + } else { + productOverrides[productId] = normalizedOverride; + } + return { ...current, productOverrides }; + }); + }; + + const clearProductOverride = (productId: string) => { + setCuttingSettings(current => { + const productOverrides = { ...current.productOverrides }; + delete productOverrides[productId]; + return { ...current, productOverrides }; + }); + }; const exportRows = () => { exportToCSV(filteredRows.map(row => ({ @@ -241,11 +311,125 @@ const Cutting = () => { Exportar + +
+ {isSettingsOpen && ( +
+
+
+

Regras de Corte

+

+ {configuredYieldCount} rendimentos configurados · {productOverrideCount} correções de produto +

+
+ +
+ +
+ {CUT_FAMILY_RULES.map(rule => ( + + ))} +
+ + {!!correctionRows.length && ( +
+
+ ID + Produto + Família + Cor + Tamanho + Ações +
+
+ {correctionRows.map(row => { + const override = cuttingSettings.productOverrides[row.id] || {}; + return ( +
+ #{row.id} +
+
{row.name}
+
+ {row.issues.map(issue => issueLabels[issue]).join(', ')} +
+
+ + updateProductOverride(row.id, { color: event.target.value })} + placeholder={row.color || 'Cor'} + className="h-9 rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary" + /> + updateProductOverride(row.id, { size: event.target.value })} + placeholder={row.size || 'Tam.'} + className="h-9 rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary" + /> +
+ +
+
+ ); + })} +
+
+ )} +
+ )} +