import { useEffect, useMemo, useState } from 'react'; import { Link, useOutletContext, useSearchParams } from 'react-router-dom'; import { AlertTriangle, ArrowLeft, ClipboardList, Download, Eye, Layers3, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; import ProductColorBadge from '../components/ProductColorBadge'; import RefreshStatus from '../components/RefreshStatus'; import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting'; import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchStock, saveCuttingSettings } from '../dataService'; import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, StockData } from '../types'; import { getPlanningStock } from '../planningStock'; type CutFilter = 'need' | 'all' | 'issues' | 'covered'; type ExecutionFilter = 'all' | 'ready' | 'blocked' | 'data'; type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc'; type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; type CutProductIssue = Exclude; type CorrectionIssueFilter = CutProductIssue | 'all'; type SettingsSection = 'rules' | 'corrections'; type CuttingView = 'plan' | 'families' | 'issues'; type MaterialReadiness = { status: 'ready' | 'material_ready' | 'blocked' | 'missing'; blockers: string[] }; 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' }, ...CUT_FAMILY_RULES.map(rule => ({ value: rule.key, label: `${rule.materialLabel} · ${rule.label}` })), { value: 'OUTROS', label: 'Sem regra' } ]; const filterOptions: Array<{ value: CutFilter; label: string }> = [ { value: 'need', label: 'Com necessidade' }, { value: 'all', label: 'Todos' }, { value: 'issues', label: 'Dados pendentes' }, { value: 'covered', label: 'Sem necessidade' } ]; const issueLabels: Record = { missing_family_rule: 'Sem família', missing_color: 'Sem cor', missing_size: 'Sem tamanho', missing_yield_rule: 'Sem rendimento' }; const issueHelp: Record = { missing_family_rule: 'Produto não caiu em BLCS, BLOS, BLMC ou BLPM.', missing_color: 'Nome do produto não tem uma cor clara para montar matriz de corte.', missing_size: 'Nome do produto não tem tamanho claro para montar matriz de corte.', missing_yield_rule: 'Família reconhecida, mas ainda falta cadastrar unidades por rolo.' }; const correctionFilterOptions: Array<{ value: CorrectionIssueFilter; label: string }> = [ { value: 'all', label: 'Todos os dados pendentes' }, { value: 'missing_family_rule', label: issueLabels.missing_family_rule }, { value: 'missing_color', label: issueLabels.missing_color }, { value: 'missing_size', label: issueLabels.missing_size } ]; const familyStyles: Record = { BLCS: 'border-fuchsia-400/30 bg-fuchsia-400/10 text-fuchsia-300', BLOS: 'border-sky-400/30 bg-sky-400/10 text-sky-300', BLMC: 'border-amber-400/30 bg-amber-400/10 text-amber-300', BLPM: 'border-purple-400/30 bg-purple-400/10 text-purple-300', OUTROS: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-300' }; const formatNumber = (value: number, maximumFractionDigits = 0) => ( new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value) ); const normalizeMaterialKey = (value: string) => value .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/\s+/g, ' ') .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'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; const formatDays = (value: number | null) => { if (value === null) return '-'; if (value > 999) return '999+ dias'; return `${formatNumber(value, value < 10 ? 1 : 0)} dias`; }; const allProductionOrdersRange = { start: new Date(2000, 0, 1), 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 hasCuttingSettings = (settings: CuttingSettings) => ( Object.keys(settings.familyYields).length > 0 || Object.keys(settings.productOverrides).length > 0 ); const CuttingSkeleton = () => (
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item =>
)}
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item =>
)}
))}
); const Cutting = () => { const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void }>(); const [searchParams] = useSearchParams(); const [products, setProducts] = useState([]); const [productionOrders, setProductionOrders] = useState([]); const [compositions, setCompositions] = useState([]); const [materialStock, setMaterialStock] = useState([]); const [isLoading, setIsLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [targetCoverageDays, setTargetCoverageDays] = useState(30); const [familyFilter, setFamilyFilter] = useState('all'); const [cutFilter, setCutFilter] = useState('need'); const [executionFilter, setExecutionFilter] = useState('all'); const [sortBy, setSortBy] = useState('need_desc'); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [settingsSection, setSettingsSection] = useState('rules'); const [cuttingSettings, setCuttingSettings] = useState(loadCuttingSettings); const [saveStatus, setSaveStatus] = useState('idle'); const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false); const [correctionIssueFilter, setCorrectionIssueFilter] = useState('all'); const [correctionPage, setCorrectionPage] = useState(1); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); const [isGeneratingOrders, setIsGeneratingOrders] = useState(false); const [isOrderPreviewOpen, setIsOrderPreviewOpen] = useState(false); const [previewPage, setPreviewPage] = useState(1); const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10); const [editingCutRow, setEditingCutRow] = useState(null); const [quickCutEdit, setQuickCutEdit] = useState({}); const [isQuickCutEditSaving, setIsQuickCutEditSaving] = useState(false); const [activeCuttingView, setActiveCuttingView] = useState('plan'); const [generationMessage, setGenerationMessage] = useState(''); const cutConfigSection = searchParams.get('config'); const targetCorrectionSku = (searchParams.get('sku') || '').trim(); useEffect(() => { let isMounted = true; const loadSettings = async () => { const settings = await fetchCuttingSettings(); if (!isMounted) return; if (hasCuttingSettings(settings)) { setCuttingSettings(settings); localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); setHasUnsavedSettings(false); } else { const localSettings = loadCuttingSettings(); setCuttingSettings(localSettings); setHasUnsavedSettings(hasCuttingSettings(localSettings)); } setSaveStatus('idle'); }; void loadSettings(); return () => { isMounted = false; }; }, []); useEffect(() => { let isMounted = true; const loadProducts = async () => { setIsLoading(true); const [productData, productionOrderData, compositionData, stockData] = await Promise.all([ fetchProductAnalytics(dateRange), fetchProductionOrders(allProductionOrdersRange), fetchProductCompositions(), fetchStock() ]); if (isMounted) { setProducts(productData); setProductionOrders(productionOrderData.orders); setCompositions(compositionData); setMaterialStock(stockData); setIsLoading(false); } }; void loadProducts(); return () => { isMounted = false; }; }, [dateRange]); const openProductionByProductId = useMemo( () => buildOpenProductionByProductId(products, productionOrders), [products, productionOrders] ); const cutPlan = useMemo( () => buildCutPlan(products, dateRange, targetCoverageDays, openProductionByProductId, cuttingSettings), [cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays] ); const materialReadinessByProductId = useMemo(() => { const compositionsById = new Map(); compositions.forEach(composition => { [composition.finishedTinyProductId, composition.finishedProductSku] .map(normalizeMaterialKey) .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(); // 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) { // Products without an imported composition remain eligible for cutting. // They are excluded from material-demand calculations instead of // blocking the rest of the production queue. readinessByProductId.set(row.id, { status: 'ready', blockers: [] }); return; } 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: 'ready', blockers: [] }); 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 || ''})`}`] : []; }); 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: 'ready', blockers: [] }); }); return readinessByProductId; }, [compositions, cutPlan.rows, materialStock]); const issueSummaries = useMemo(() => { const counts = new Map(); cutPlan.needRows.forEach(row => { row.issues.forEach(issue => counts.set(issue, (counts.get(issue) || 0) + 1)); }); return Array.from(counts.entries()).map(([issue, count]) => ({ issue, count })); }, [cutPlan.needRows]); const filteredRows = useMemo(() => { const normalizedSearch = searchTerm.trim().toLowerCase(); const searchedRows = normalizedSearch ? cutPlan.rows.filter(row => ( row.name.toLowerCase().includes(normalizedSearch) || row.id.toLowerCase().includes(normalizedSearch) || row.baseName.toLowerCase().includes(normalizedSearch) || row.color.toLowerCase().includes(normalizedSearch) )) : cutPlan.rows; const familyRows = familyFilter === 'all' ? searchedRows : searchedRows.filter(row => row.family.key === familyFilter); const statusRows = familyRows.filter(row => { if (cutFilter === 'all') return true; if (cutFilter === 'need') return row.suggestedCutQuantity > 0; if (cutFilter === 'issues') return row.issues.length > 0; return row.suggestedCutQuantity === 0; }); const executionRows = statusRows.filter(row => { if (executionFilter === 'all') return true; const status = materialReadinessByProductId.get(row.id)?.status; if (executionFilter === 'ready') return row.suggestedCutQuantity > 0 && status === 'ready'; if (executionFilter === 'blocked') return row.suggestedCutQuantity > 0 && (status === 'blocked' || status === 'missing'); return row.suggestedCutQuantity > 0 && status === 'material_ready'; }); return [...executionRows].sort((a, b) => { switch (sortBy) { case 'need_asc': return a.suggestedCutQuantity - b.suggestedCutQuantity; case 'demand_desc': return b.projectedDemand - a.projectedDemand; case 'stock_asc': return a.stock - b.stock; case 'sold_desc': return b.quantitySold - a.quantitySold; case 'name_asc': return a.name.localeCompare(b.name, 'pt-BR'); case 'need_desc': default: return b.suggestedCutQuantity - a.suggestedCutQuantity; } }); }, [cutFilter, cutPlan.rows, executionFilter, familyFilter, materialReadinessByProductId, searchTerm, sortBy]); const rowsAvailableForOrder = useMemo(() => ( filteredRows.filter(row => row.suggestedCutQuantity > 0 && materialReadinessByProductId.get(row.id)?.status === 'ready') ), [filteredRows, materialReadinessByProductId]); const cutExecutionSummary = useMemo(() => { const rowsWithNeed = cutPlan.needRows.filter(row => row.suggestedCutQuantity > 0); const readyRows = rowsWithNeed.filter(row => materialReadinessByProductId.get(row.id)?.status === 'ready'); const blockedByMaterial = rowsWithNeed.filter(row => { const status = materialReadinessByProductId.get(row.id)?.status; return status === 'blocked' || status === 'missing'; }); const blockedByData = rowsWithNeed.filter(row => materialReadinessByProductId.get(row.id)?.status === 'material_ready'); return { rawUnits: rowsWithNeed.reduce((total, row) => total + row.suggestedCutQuantity, 0), readyUnits: readyRows.reduce((total, row) => total + row.suggestedCutQuantity, 0), readyRows: readyRows.length, blockedRows: blockedByMaterial.length + blockedByData.length, blockedByMaterial: blockedByMaterial.length, blockedByData: blockedByData.length, }; }, [cutPlan.needRows, materialReadinessByProductId]); const orderPreviewTotalUnits = useMemo(() => ( rowsAvailableForOrder.reduce((total, row) => total + row.suggestedCutQuantity, 0) ), [rowsAvailableForOrder]); const previewRowsWithIssues = useMemo(() => ( rowsAvailableForOrder.filter(row => row.issues.length).length ), [rowsAvailableForOrder]); const previewTotalPages = Math.ceil(rowsAvailableForOrder.length / previewItemsPerPage); const safePreviewPage = Math.min(previewPage, previewTotalPages || 1); const previewStartIndex = (safePreviewPage - 1) * previewItemsPerPage; const paginatedPreviewRows = rowsAvailableForOrder.slice(previewStartIndex, previewStartIndex + previewItemsPerPage); const totalPages = Math.ceil(filteredRows.length / itemsPerPage); const safeCurrentPage = Math.min(currentPage, totalPages || 1); const startIndex = (safeCurrentPage - 1) * itemsPerPage; const paginatedRows = filteredRows.slice(startIndex, startIndex + itemsPerPage); const isRefreshing = isLoading && products.length > 0; const correctionRows = useMemo(() => { const rows = cutPlan.rows.filter(row => ( row.issues.some(issue => issue !== 'missing_yield_rule') && (correctionIssueFilter === 'all' || row.issues.includes(correctionIssueFilter)) )); const sortedRows = [...rows].sort((a, b) => { if (b.suggestedCutQuantity !== a.suggestedCutQuantity) { return b.suggestedCutQuantity - a.suggestedCutQuantity; } return a.name.localeCompare(b.name, 'pt-BR'); }); if (!targetCorrectionSku) return sortedRows; const targetRow = cutPlan.rows.find(row => row.id.toLowerCase() === targetCorrectionSku.toLowerCase()); if (!targetRow) return sortedRows; return [targetRow, ...sortedRows.filter(row => row.id !== targetRow.id)]; }, [correctionIssueFilter, cutPlan.rows, targetCorrectionSku]); const correctionItemsPerPage = 12; const correctionTotalPages = Math.ceil(correctionRows.length / correctionItemsPerPage); const safeCorrectionPage = Math.min(correctionPage, correctionTotalPages || 1); const correctionStartIndex = (safeCorrectionPage - 1) * correctionItemsPerPage; const paginatedCorrectionRows = correctionRows.slice(correctionStartIndex, correctionStartIndex + correctionItemsPerPage); const configuredYieldCount = CUT_FAMILY_RULES.filter(rule => cuttingSettings.familyYields[rule.key]).length; const productOverrideCount = Object.keys(cuttingSettings.productOverrides).length; useEffect(() => { if (cutConfigSection !== 'corrections' || !targetCorrectionSku) return; const correctionIndex = correctionRows.findIndex(row => row.id.toLowerCase() === targetCorrectionSku.toLowerCase()); queueMicrotask(() => { setIsSettingsOpen(true); setSettingsSection('corrections'); setCorrectionIssueFilter('all'); if (correctionIndex >= 0) { setCorrectionPage(Math.floor(correctionIndex / correctionItemsPerPage) + 1); } }); }, [correctionItemsPerPage, correctionRows, cutConfigSection, targetCorrectionSku]); useEffect(() => { if (!isSettingsOpen && !isOrderPreviewOpen && !editingCutRow) return undefined; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { if (isOrderPreviewOpen) { setIsOrderPreviewOpen(false); } else if (editingCutRow) { setEditingCutRow(null); } else { setIsSettingsOpen(false); } } }; const previousOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; document.addEventListener('keydown', closeOnEscape); return () => { document.body.style.overflow = previousOverflow; document.removeEventListener('keydown', closeOnEscape); }; }, [editingCutRow, isOrderPreviewOpen, isSettingsOpen]); 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 }; }); setHasUnsavedSettings(true); setSaveStatus('idle'); }; 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 }; }); setHasUnsavedSettings(true); setSaveStatus('idle'); }; const clearProductOverride = (productId: string) => { setCuttingSettings(current => { const productOverrides = { ...current.productOverrides }; delete productOverrides[productId]; return { ...current, productOverrides }; }); setHasUnsavedSettings(true); setSaveStatus('idle'); }; const persistSettings = async (settings = cuttingSettings) => { setSaveStatus('saving'); try { const savedSettings = await saveCuttingSettings(settings); setCuttingSettings(savedSettings); localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(savedSettings)); setHasUnsavedSettings(false); setSaveStatus('saved'); } catch (error) { console.error('Save cutting settings failed', error); setSaveStatus('error'); } }; const clearSettings = () => { const emptySettings = { familyYields: {}, productOverrides: {} }; setCuttingSettings(emptySettings); setHasUnsavedSettings(true); setSaveStatus('idle'); }; const openSettingsSection = (section: SettingsSection) => { setSettingsSection(section); setIsSettingsOpen(true); }; const openQuickCutEdit = (row: CutPlanSkuRow) => { const override = cuttingSettings.productOverrides[row.id] || {}; setEditingCutRow(row); setQuickCutEdit({ familyKey: override.familyKey || '', color: override.color || '', size: override.size || '' }); }; const saveQuickCutEdit = async () => { if (!editingCutRow) return; const normalizedOverride: CutProductOverride = { familyKey: quickCutEdit.familyKey || '', color: quickCutEdit.color || '', size: quickCutEdit.size || '' }; const productOverrides = { ...cuttingSettings.productOverrides }; if (!normalizedOverride.familyKey && !normalizedOverride.color && !normalizedOverride.size) { delete productOverrides[editingCutRow.id]; } else { productOverrides[editingCutRow.id] = normalizedOverride; } const nextSettings = { ...cuttingSettings, productOverrides }; setIsQuickCutEditSaving(true); try { const savedSettings = await saveCuttingSettings(nextSettings); setCuttingSettings(savedSettings); localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(savedSettings)); setHasUnsavedSettings(false); setSaveStatus('saved'); setEditingCutRow(null); } catch (error) { console.error('Save quick cut edit failed', error); setSaveStatus('error'); } finally { setIsQuickCutEditSaving(false); } }; const openBulkCorrectionForRow = (row: CutPlanSkuRow) => { const correctionIndex = correctionRows.findIndex(item => item.id === row.id); setEditingCutRow(null); setSettingsSection('corrections'); setCorrectionIssueFilter('all'); if (correctionIndex >= 0) setCorrectionPage(Math.floor(correctionIndex / correctionItemsPerPage) + 1); setIsSettingsOpen(true); }; const exportRows = () => { exportToCSV(filteredRows.map(row => ({ 'ID Produto': row.id, 'Descrição': row.name, 'Família': row.family.label, 'Material': row.family.materialLabel, 'Cor': row.color, 'Tamanho': row.size, 'Vendido no período': row.quantitySold, 'Média diária': row.dailySales.toFixed(2).replace('.', ','), 'Demanda projetada': row.projectedDemand.toFixed(2).replace('.', ','), 'Estoque': row.stock, 'OP aberta': row.openProductionQuantity, 'Disponível': row.availableQuantity, 'Necessidade corte': row.suggestedCutQuantity, 'Rendimento un/rolo': row.family.unitsPerRoll || '', 'Rolos estimados': row.estimatedRolls || '', 'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','), 'Pronto para cortar': materialReadinessByProductId.get(row.id)?.status === 'ready' ? 'Sim' : materialReadinessByProductId.get(row.id)?.status === 'blocked' ? 'Não - falta material' : 'Sem composição', 'Materiais bloqueando': materialReadinessByProductId.get(row.id)?.blockers.join(' | ') || '', 'Dados pendentes': row.issues.map(issue => issueLabels[issue]).join(' | ') })), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`); }; const openProductionOrderPreview = () => { if (!rowsAvailableForOrder.length) { setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.'); return; } setGenerationMessage(''); setPreviewPage(1); setIsOrderPreviewOpen(true); }; const generateProductionOrders = async () => { if (!rowsAvailableForOrder.length) { setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.'); return; } setIsGeneratingOrders(true); setGenerationMessage(''); const issueDate = formatDateKey(new Date()); const orderReference = `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`; try { const result = await createProductionOrders(rowsAvailableForOrder.map(row => ({ status: 'open', orderReference, issueDate, productSku: row.id, productDescription: row.name, quantity: row.suggestedCutQuantity, unit: 'UN', integrationStatus: 'Local', markers: [ { label: row.family.materialLabel, color: '#38bdf8' }, ...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []), { label: 'Material confirmado', color: '#52DFA0' }, ...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : []) ], metadata: { source: 'cut_plan', familyKey: row.family.key, familyLabel: row.family.label, materialLabel: row.family.materialLabel, color: row.color, size: row.size, targetCoverageDays, projectedDemand: row.projectedDemand, stock: row.stock, openProductionQuantity: row.openProductionQuantity, availableQuantity: row.availableQuantity, issues: row.issues } }))); const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true }); setProductionOrders(refreshedOrders.orders); setGenerationMessage(`${result.created.length} OPs criadas${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`); setIsOrderPreviewOpen(false); } catch (error) { setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.'); } finally { setIsGeneratingOrders(false); } }; const renderIssueBadge = (row: CutPlanSkuRow) => { if (!row.issues.length) { return OK; } const issueSummary = row.issues.map(issue => issueLabels[issue]).join(', '); return ( {row.issues.length === 1 ? issueLabels[row.issues[0]] : `${row.issues.length} dados`} ); }; const renderCutStatus = (row: CutPlanSkuRow) => { const readiness = materialReadinessByProductId.get(row.id); if (readiness?.status === 'ready') { return ; } if (readiness?.status === 'material_ready') { return issueLabels[issue]).join(', ')}>Dados pendentes; } if (readiness?.status === 'missing') { return Sem composição; } return Falta: {readiness?.blockers[0] || 'material'}; }; return (
Suprimentos

Plano de Corte

Necessidade por família, cor e tamanho calculada com vendas, estoque e cobertura alvo.

{ setDateRange(range); setCurrentPage(1); setCorrectionPage(1); }} />
{generationMessage && (
{generationMessage}
)} {isOrderPreviewOpen && (
{ if (!isGeneratingOrders) setIsOrderPreviewOpen(false); }} >
event.stopPropagation()} >

Prévia das OPs

Serão criadas ordens locais para as necessidades do filtro atual.

{[ ['OPs a criar', formatNumber(rowsAvailableForOrder.length), 'text-dark-text'], ['Unidades para cortar', `${formatNumber(orderPreviewTotalUnits)} un.`, 'text-brand-primary'], ['Cobertura alvo', `${formatNumber(targetCoverageDays)} dias`, 'text-dark-text'], ['Revisar dados', formatNumber(previewRowsWithIssues), previewRowsWithIssues ? 'text-amber-300' : 'text-dark-text'] ].map(([label, value, tone]) =>

{label}

{value}

)}
{paginatedPreviewRows.map(row => ( ))}
Produto Cortar agora Demanda Disponível Revisar
{row.name}
#{row.id} {row.family.materialLabel} {row.size || '-'}
{formatNumber(row.suggestedCutQuantity)} un. {formatNumber(row.projectedDemand, 1)} un.
{formatNumber(row.availableQuantity)} un.
{row.openProductionQuantity ?
{formatNumber(row.openProductionQuantity)} em OP
: null}
{renderIssueBadge(row)}
{ setPreviewItemsPerPage(pageSize); setPreviewPage(1); }} className="border-t border-dark-border px-4 py-3" />

OPs abertas já existentes para o mesmo plano e SKU serão ignoradas.

)} {editingCutRow && (
!isQuickCutEditSaving && setEditingCutRow(null)}>
event.stopPropagation()}>

Editar dados de corte

{editingCutRow.name}

#{editingCutRow.id}

{editingCutRow.family.materialLabel} {editingCutRow.size || 'Sem tamanho'}

Altere apenas quando a leitura automática do SKU estiver incorreta. Salvar atualiza este SKU imediatamente.

)} {isSettingsOpen && (
setIsSettingsOpen(false)} >
event.stopPropagation()} >

Configuração de Corte

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

{saveStatus === 'saved' && (

Regras salvas no banco de dados.

)} {saveStatus === 'error' && (

Não foi possível salvar as regras. Tente novamente.

)} {hasUnsavedSettings && saveStatus !== 'saving' && (

Existem alterações ainda não salvas.

)}
{settingsSection === 'rules' && (
{CUT_FAMILY_RULES.map(rule => ( ))}
)} {settingsSection === 'corrections' && (

Correções de produto

Use quando o nome do SKU não deixa claro família, cor ou tamanho para montar o corte.

{formatNumber(correctionRows.length)} produtos
{correctionRows.length ? ( <>
ID Produto Família Cor Tamanho Ações
{paginatedCorrectionRows.map(row => { const override = cuttingSettings.productOverrides[row.id] || {}; const isTargetRow = targetCorrectionSku.toLowerCase() === row.id.toLowerCase(); return (
#{row.id}
{row.name}
{row.issues.filter(issue => issue !== 'missing_yield_rule').map(issue => ( {issueLabels[issue]} ))}
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" />
); })}
setCorrectionPage(1)} className="border-t border-dark-border px-4 py-3" /> ) : (

Nenhuma correção de produto pendente.

Os dados restantes, se existirem, são de rendimento por família.

)}
)}
)}
{[ { id: 'all', label: 'Necessidade', value: `${formatNumber(cutExecutionSummary.rawUnits)} un.`, detail: `${formatNumber(cutPlan.summary.skuCount)} SKUs`, tone: 'text-dark-text' }, { id: 'ready', label: 'Pronto para corte', value: `${formatNumber(cutExecutionSummary.readyUnits)} un.`, detail: `${formatNumber(cutExecutionSummary.readyRows)} SKUs liberados`, tone: 'text-emerald-300' }, { id: 'blocked', label: 'Bloqueados', value: formatNumber(cutExecutionSummary.blockedRows), detail: `${formatNumber(cutExecutionSummary.blockedByMaterial)} material · ${formatNumber(cutExecutionSummary.blockedByData)} dados`, tone: cutExecutionSummary.blockedRows ? 'text-amber-300' : 'text-dark-text' }, { id: 'families', label: 'Famílias', value: formatNumber(cutPlan.summary.familiesWithNeed), detail: cutPlan.summary.estimatedRolls === null ? 'Rendimento a configurar' : `${formatNumber(cutPlan.summary.estimatedRolls)} rolos estimados`, tone: 'text-dark-text' } ].map(metric => ( ))}
{cutExecutionSummary.rawUnits > 0 && cutExecutionSummary.readyUnits === 0 && (

Há demanda, mas nenhum SKU está liberado para corte.

Corrija materiais bloqueados e rendimentos antes de gerar ordens de produção.

)} {activeCuttingView === 'issues' && (

Dados pendentes do plano

Dados que bloqueiam o cálculo completo: família, cor, tamanho ou rendimento.

{formatNumber(cutPlan.summary.rowsWithIssues)} SKUs
{issueSummaries.length ? (
{issueSummaries.map(summary => (
{issueLabels[summary.issue]} {formatNumber(summary.count)}

{issueHelp[summary.issue]}

))}
) : (

Nenhuma pendência no plano atual.

Os SKUs com necessidade já têm os dados mínimos para o cálculo.

)}
)} {activeCuttingView === 'families' && (

Corte por família

Volume agrupado para orientar compra e corte.

{cutPlan.familySummaries.length ? (
{cutPlan.familySummaries.map(summary => (
{summary.family.materialLabel}

{summary.family.label}

{formatNumber(summary.skuCount)} SKUs

{formatNumber(summary.suggestedCutQuantity)} un.

{formatNumber(summary.colorCount)} cores · {summary.estimatedRolls === null ? 'sem rendimento' : `${formatNumber(summary.estimatedRolls)} rolos`}

))}
) : (

Nenhuma família com necessidade no período.

Ajuste o período ou a cobertura alvo para revisar outro cenário.

)}
)} {activeCuttingView === 'plan' && ( <>
{ setSearchTerm(event.target.value); setCurrentPage(1); }} className="w-full rounded-xl border border-dark-border bg-dark-input py-2.5 pl-10 pr-4 text-dark-text transition-colors hover:border-brand-primary focus:border-brand-primary focus:outline-none" />
{isLoading && products.length === 0 ? ( ) : (
{paginatedRows.map(row => { const readiness = materialReadinessByProductId.get(row.id); const needsAttention = readiness?.status !== 'ready'; return ( )})}
Produto Cortar agora Demanda Disponível Status Ações
{row.name}
#{row.id} {row.family.materialLabel} {row.size || '-'}
Média: {formatNumber(row.dailySales, 2)} un./dia · Cobertura: {formatDays(row.daysOfCover)}
{formatNumber(row.suggestedCutQuantity)} un.
{formatNumber(row.projectedDemand, 1)} un.
{formatNumber(row.availableQuantity)} un.
{row.openProductionQuantity ?
{formatNumber(row.openProductionQuantity)} em OP
: null}
{renderCutStatus(row)}
{!filteredRows.length && (

Nenhum item encontrado.

Ajuste a busca, família, status ou período selecionado.

)} { setItemsPerPage(pageSize); setCurrentPage(1); }} />
)} )}
); }; export default Cutting;