From 635103b92423efc9d729b612146bd190593d054e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 3 Aug 2026 10:57:47 -0300 Subject: [PATCH] Refine material planning workflows --- src/pages/Cutting.tsx | 55 +++++++++++----- src/pages/ProductDetails.tsx | 32 +++++++--- src/pages/Supplies.tsx | 119 ++++++++++++++++++----------------- 3 files changed, 124 insertions(+), 82 deletions(-) diff --git a/src/pages/Cutting.tsx b/src/pages/Cutting.tsx index 02dd124..dea5353 100644 --- a/src/pages/Cutting.tsx +++ b/src/pages/Cutting.tsx @@ -18,7 +18,7 @@ type CutProductIssue = Exclude; type CorrectionIssueFilter = CutProductIssue | 'all'; type SettingsSection = 'rules' | 'corrections'; type CuttingView = 'plan' | 'families' | 'issues'; -type MaterialReadiness = { status: 'ready' | 'blocked' | 'missing'; blockers: string[] }; +type MaterialReadiness = { status: 'ready' | 'material_ready' | 'blocked' | 'missing'; blockers: string[] }; const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings'; const coverageTargetOptions = [7, 15, 30, 60]; @@ -75,6 +75,8 @@ const normalizeMaterialKey = (value: string) => value .trim() .toUpperCase(); +const isServiceComponent = (value: string) => /\b(?:SERVI[CÇ]O|FRETE|TINTURARIA|TECELAGEM|COSTURA|ESTAMPARIA|LAVANDERIA)\b/i.test(value); + const formatDateKey = (date: Date) => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); @@ -232,23 +234,47 @@ const Cutting = () => { .filter(Boolean) .forEach(key => compositionsById.set(key, composition)); }); + const stockIndexByKey = new Map(); + materialStock.forEach((stock, index) => { + [stock.produto_id, stock.nome].map(normalizeMaterialKey).filter(Boolean).forEach(key => stockIndexByKey.set(key, index)); + }); + const remainingStock = materialStock.map(stock => getPlanningStock(stock.saldo)); const readinessByProductId = new Map(); - cutPlan.rows.forEach(row => { + // Shared stock is allocated to the largest current requirement first. This + // prevents every SKU from claiming the same material balance independently. + [...cutPlan.rows].sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity || a.name.localeCompare(b.name, 'pt-BR')).forEach(row => { const composition = compositionsById.get(normalizeMaterialKey(row.id)); if (!composition?.components.length) { readinessByProductId.set(row.id, { status: 'missing', blockers: [] }); return; } - const blockers = composition.components.flatMap(component => { - const stockKeys = new Set([component.componentTinyId, component.componentSku, component.productId || '', component.componentName].map(normalizeMaterialKey).filter(Boolean)); - const match = materialStock.find(stock => stockKeys.has(normalizeMaterialKey(stock.produto_id)) || stockKeys.has(normalizeMaterialKey(stock.nome))); - const available = match ? getPlanningStock(match.saldo) : null; + const componentPlans = composition.components + .filter(component => !isServiceComponent(component.componentName)) + .map(component => { + const stockKeys = [component.componentTinyId, component.componentSku, component.productId || '', component.componentName].map(normalizeMaterialKey).filter(Boolean); + const stockIndex = stockKeys.map(key => stockIndexByKey.get(key)).find(index => index !== undefined); + const available = stockIndex === undefined ? null : remainingStock[stockIndex]; const required = row.suggestedCutQuantity * component.quantityPerUnit; + return { component, stockIndex, available, required }; + }); + if (!componentPlans.length) { + readinessByProductId.set(row.id, { status: 'missing', blockers: ['Sem material produtivo na composição'] }); + return; + } + const blockers = componentPlans.flatMap(({ component, available, required }) => { + if (!Number.isFinite(component.quantityPerUnit) || component.quantityPerUnit <= 0) return [`${component.componentName} (quantidade inválida)`]; return available === null || available < required ? [`${component.componentName}${available === null ? ' (sem estoque vinculado)' : ` (${formatNumber(available)} / ${formatNumber(required)} ${component.unit || ''})`}`] : []; }); - readinessByProductId.set(row.id, { status: blockers.length ? 'blocked' : 'ready', blockers }); + if (blockers.length) { + readinessByProductId.set(row.id, { status: 'blocked', blockers }); + return; + } + componentPlans.forEach(({ stockIndex, required }) => { + if (stockIndex !== undefined) remainingStock[stockIndex] -= required; + }); + readinessByProductId.set(row.id, { status: row.issues.length === 0 ? 'ready' : 'material_ready', blockers: [] }); }); return readinessByProductId; }, [compositions, cutPlan.rows, materialStock]); @@ -558,8 +584,9 @@ const Cutting = () => { const renderMaterialReadiness = (row: CutPlanSkuRow) => { const readiness = materialReadinessByProductId.get(row.id); if (readiness?.status === 'ready') return Pode cortar; + if (readiness?.status === 'material_ready') return Material OK · revisar dados; if (readiness?.status === 'missing') return Sem composição; - return Falta: {readiness?.blockers[0] || 'material'}; + return Falta: {readiness?.blockers[0] || 'material'}; }; return ( @@ -1262,7 +1289,7 @@ const Cutting = () => { ) : (
- +
@@ -1272,9 +1299,9 @@ const Cutting = () => { - - - + + + @@ -1335,8 +1362,8 @@ const Cutting = () => { - - + +
{row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)} {renderMaterialReadiness(row)}{renderIssueBadge(row)}{renderMaterialReadiness(row)}{renderIssueBadge(row)}
{ .replace(/\s+/g, ' ') .trim() .toUpperCase(); + const isServiceComponent = (name: string) => /\b(?:SERVI[CÇ]O|FRETE|TINTURARIA|TECELAGEM|COSTURA|ESTAMPARIA|LAVANDERIA)\b/i.test(name); + const isSuspiciousConsumption = (quantity: number, unit: string) => ( + ['kg', 'quilo', 'quilos'].includes(unit.trim().toLowerCase()) && quantity >= 0.5 + ); const materialPlan = (composition?.components || []).map(component => { + const isService = isServiceComponent(component.componentName); const candidates = new Set([ component.componentTinyId, component.componentSku, @@ -350,7 +355,7 @@ const ProductDetails = () => { const stockMatch = materialStock.find(item => ( candidates.has(normalizeMaterialKey(item.produto_id)) || candidates.has(normalizeMaterialKey(item.nome)) )); - const availableStock = stockMatch ? getPlanningStock(stockMatch.saldo) : null; + const availableStock = isService ? null : stockMatch ? getPlanningStock(stockMatch.saldo) : null; const requiredForPeriod = totalSold * component.quantityPerUnit; const productionCapacity = availableStock === null || component.quantityPerUnit <= 0 ? null @@ -358,10 +363,10 @@ const ProductDetails = () => { const periodCoverage = requiredForPeriod > 0 && availableStock !== null ? (availableStock / requiredForPeriod) * periodDays : null; - return { component, availableStock, requiredForPeriod, productionCapacity, periodCoverage }; + return { component, availableStock, requiredForPeriod, productionCapacity, periodCoverage, isService, suspicious: isSuspiciousConsumption(component.quantityPerUnit, component.unit) }; }); const blockingMaterial = materialPlan - .filter(item => item.productionCapacity !== null) + .filter(item => !item.isService && item.productionCapacity !== null) .sort((a, b) => (a.productionCapacity || 0) - (b.productionCapacity || 0))[0]; return ( @@ -477,20 +482,21 @@ const ProductDetails = () => {
) : (
- +
+ - {materialPlan.map(({ component, requiredForPeriod, availableStock, productionCapacity, periodCoverage }) => ( + {materialPlan.map(({ component, requiredForPeriod, availableStock, productionCapacity, periodCoverage, isService, suspicious }) => ( + - - + ))} diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index d7c8754..95586d4 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -1790,6 +1790,12 @@ const getNeedPendingLabel = (need: SupplyPurchaseNeed) => { return /^Cadastrar rendimento:/i.test(need.material) ? 'Rendimento' : 'Referência'; }; +type PurchaseNeedTab = 'materials' | 'services' | 'mapping'; + +const isServicePurchaseNeed = (need: SupplyPurchaseNeed) => ( + /\b(?:SERVI[CÇ]O|FRETE|TINTURARIA|TECELAGEM|COSTURA|ESTAMPARIA|LAVANDERIA)\b/i.test(need.material) +); + const sumNeedProducts = ( need: SupplyPurchaseNeed, field: 'suggestedQuantity' | 'quantitySold' | 'stockQuantity' @@ -1805,6 +1811,7 @@ const PurchaseNeedsScreen = () => { const [summary, setSummary] = useState(emptySupplySummary); const [isLoading, setIsLoading] = useState(true); const [search, setSearch] = useState(''); + const [activeTab, setActiveTab] = useState('materials'); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); @@ -1833,42 +1840,35 @@ const PurchaseNeedsScreen = () => { }; }, []); + const materialNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && !isServicePurchaseNeed(need)); + const serviceNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && isServicePurchaseNeed(need)); + const mappingNeeds = summary.purchaseNeeds.filter(need => need.missingReference); + const activeNeeds = activeTab === 'materials' ? materialNeeds : activeTab === 'services' ? serviceNeeds : mappingNeeds; const normalizedSearch = normalizeSearch(search); - const visibleNeeds = summary.purchaseNeeds.filter(need => ( + const visibleNeeds = activeNeeds.filter(need => ( !normalizedSearch || normalizeSearch(`${need.material} ${getNeedDisplayName(need)} ${need.suppliers.join(' ')} ${need.colors.join(' ')} ${(need.products || []).map(product => `${product.productId} ${product.name}`).join(' ')}`).includes(normalizedSearch) )); const totalPages = Math.ceil(visibleNeeds.length / itemsPerPage); const safeCurrentPage = Math.min(currentPage, totalPages || 1); const startIndex = (safeCurrentPage - 1) * itemsPerPage; const paginatedNeeds = visibleNeeds.slice(startIndex, startIndex + itemsPerPage); - const purchaseItemCount = summary.purchaseNeeds.filter(need => need.purchaseKg > 0).length; - const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds); - const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size; - const missingReferenceCount = summary.purchaseNeeds.filter(need => need.missingReference).length; - const mappedNeedCount = summary.purchaseNeeds.length - missingReferenceCount; - const impactedSkuCount = countImpactedSkus(summary.purchaseNeeds); - const setupMode = missingReferenceCount > 0 && missingReferenceCount >= mappedNeedCount; + const purchaseItemCount = materialNeeds.filter(need => need.purchaseKg > 0).length; + const suggestedPurchaseSummary = summarizePurchaseNeeds(materialNeeds); + const impactedSkuCount = countImpactedSkus(mappingNeeds); const pageTitle = 'Necessidade de Compra'; - const pageSubtitle = setupMode - ? 'Complete referências de consumo para liberar o cálculo real de compra por material.' - : 'Materiais abaixo do mínimo e necessidade projetada para compra.'; - const panelTitle = setupMode ? 'Pendências de cadastro' : 'Necessidade por material'; - const panelSubtitle = setupMode - ? 'Priorize os SKUs com maior impacto e cadastre material, rendimento e unidade de consumo.' - : 'Planejado - estoque aprovado - recebimentos pendentes.'; - const stats = setupMode - ? [ - { label: 'Pendências', value: `${missingReferenceCount}` }, - { label: 'SKUs impactados', value: `${impactedSkuCount || missingReferenceCount}` }, - { label: 'Impacto estimado', value: suggestedPurchaseSummary }, - { label: 'Referências prontas', value: `${mappedNeedCount}` }, - ] - : [ - { label: 'Itens a comprar', value: `${purchaseItemCount}` }, - { label: 'Compra sugerida', value: suggestedPurchaseSummary }, - { label: 'Fornecedores', value: `${pendingSupplierCount}` }, - { label: 'Sem referência', value: `${missingReferenceCount}` }, - ]; + const pageSubtitle = 'Materiais, serviços terceirizados e pendências de cadastro em filas separadas.'; + const panelTitle = activeTab === 'materials' ? 'Compra de materiais' : activeTab === 'services' ? 'Serviços terceirizados' : 'Pendências de composição'; + const panelSubtitle = activeTab === 'materials' + ? 'Demanda de produto → material → saldo disponível → quantidade para comprar.' + : activeTab === 'services' + ? 'Necessidades de beneficiamento e serviços. Não entram no total de compra de materiais.' + : 'SKUs sem consumo confiável. Corrija o cadastro antes de usar estes valores para comprar.'; + const stats = [ + { label: 'Materiais a comprar', value: `${purchaseItemCount}` }, + { label: 'Compra sugerida', value: suggestedPurchaseSummary }, + { label: 'Serviços a revisar', value: `${serviceNeeds.filter(need => need.purchaseKg > 0).length}` }, + { label: 'SKUs sem mapeamento', value: `${impactedSkuCount || mappingNeeds.length}` }, + ]; return (
@@ -1891,7 +1891,7 @@ const PurchaseNeedsScreen = () => { + ))} +
{isLoading ? (
@@ -1931,7 +1942,7 @@ const PurchaseNeedsScreen = () => { ) : visibleNeeds.length ? (
- {setupMode ? ( + {activeTab === 'mapping' ? (
Produto / insumo SKU Quantidade por unidade UnidadeValidação Necessário no período Estoque Tiny Cobertura
{component.productId ? ( @@ -502,12 +508,18 @@ const ProductDetails = () => { {component.componentSku || '—'} {formatNumber(component.quantityPerUnit)} {component.unit || '—'} + {isService ? Serviço + : suspicious ? Revisar consumo + : OK} + {formatNumber(requiredForPeriod)} {component.unit || ''}{availableStock === null ? 'Sem vínculo' : `${formatNumber(availableStock)} ${component.unit || ''}`} - {availableStock === null - ? 'Revisar link' - : `${formatNumber(productionCapacity || 0)} un. · ${periodCoverage === null ? '-' : `${formatNumber(periodCoverage)} dias`}`} + {isService ? 'Não aplicável' : availableStock === null ? 'Sem vínculo' : `${formatNumber(availableStock)} ${component.unit || ''}`} + {isService ? 'Terceirizar' + : availableStock === null + ? 'Revisar link' + : `${formatNumber(productionCapacity || 0)} un. · ${periodCoverage === null ? '-' : periodCoverage > 365 ? '> 365 dias' : `${formatNumber(periodCoverage)} dias`}`}
@@ -1997,9 +2008,9 @@ const PurchaseNeedsScreen = () => {
) : ( - +
- + @@ -2009,13 +2020,13 @@ const PurchaseNeedsScreen = () => { - - - - - - - + + + + + + + @@ -2025,14 +2036,10 @@ const PurchaseNeedsScreen = () => { @@ -2056,21 +2063,17 @@ const PurchaseNeedsScreen = () => { ) : null} - {need.products?.length ? ( -

- {need.products.slice(0, 2).map(product => product.productId).join(', ')} - {need.products.length > 2 ? ` +${need.products.length - 2}` : ''} -

- ) : null} - - + +
Demanda de produtoMaterial necessárioNecessárioEstoque atualPendenteComprarCoberturaDemanda{activeTab === 'services' ? 'Serviço' : 'Material'}{activeTab === 'services' ? 'Volume previsto' : 'Necessário'}{activeTab === 'services' ? 'Agendado' : 'Estoque atual'}{activeTab === 'services' ? 'Em aberto' : 'Pendente'}{activeTab === 'services' ? 'Contratar' : 'Comprar'}{activeTab === 'services' ? 'Ação' : 'Cobertura'}
{need.products?.length ? ( -
- {need.products.slice(0, 2).map(product => ( -
-

{product.name || product.productId}

-

SKU {product.productId} · demanda {formatNumber(product.suggestedQuantity)} un.

-
- ))} - {need.products.length > 2 &&

+{need.products.length - 2} SKUs impactados

} +
+

{need.products.length} {need.products.length === 1 ? 'SKU impactado' : 'SKUs impactados'}

+

Demanda: {formatNumber(sumNeedProducts(need, 'suggestedQuantity'))} un.

+

product.productId).join(', ')}>{need.products.slice(0, 3).map(product => product.productId).join(', ')}{need.products.length > 3 ? ` +${need.products.length - 3}` : ''}

) : Plano manual}
{formatNeedQuantity(need, need.plannedKg)}{formatNeedQuantity(need, need.stockKg)}{formatNeedQuantity(need, need.pendingKg)}{activeTab === 'services' ? '—' : formatNeedQuantity(need, need.stockKg)}{activeTab === 'services' ? '—' : formatNeedQuantity(need, need.pendingKg)} 0 ? 'text-amber-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)} { ? 'border-amber-400/30 bg-amber-400/10 text-amber-300' : 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300' }`}> - {need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]} + {activeTab === 'services' ? 'Programar serviço' : need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]}