Files
graphs/src/pages/Cutting.tsx
2026-08-11 14:08:50 -03:00

1476 lines
79 KiB
TypeScript

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<CutIssue, 'missing_yield_rule'>;
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<CutIssue, string> = {
missing_family_rule: 'Sem família',
missing_color: 'Sem cor',
missing_size: 'Sem tamanho',
missing_yield_rule: 'Sem rendimento'
};
const issueHelp: Record<CutIssue, string> = {
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<CutFamilyKey, string> = {
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<CuttingSettings>;
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 = () => (
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm" aria-label="Carregando plano de corte">
<div className="border-b border-dark-border p-4">
<div className="grid grid-cols-[120px_1.4fr_130px_110px_110px_120px_130px_130px_110px] gap-6">
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => <div key={item} className="skeleton h-3" />)}
</div>
</div>
<div className="divide-y divide-dark-border">
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
<div key={row} className="grid grid-cols-[120px_1.4fr_130px_110px_110px_120px_130px_130px_110px] gap-6 px-6 py-4">
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => <div key={item} className="skeleton h-4" />)}
</div>
))}
</div>
</div>
);
const Cutting = () => {
const { dateRange, setDateRange } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void
}>();
const [searchParams] = useSearchParams();
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
const [compositions, setCompositions] = useState<ProductComposition[]>([]);
const [materialStock, setMaterialStock] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
const [familyFilter, setFamilyFilter] = useState<CutFamilyKey | 'all'>('all');
const [cutFilter, setCutFilter] = useState<CutFilter>('need');
const [executionFilter, setExecutionFilter] = useState<ExecutionFilter>('all');
const [sortBy, setSortBy] = useState<CutSort>('need_desc');
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules');
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
const [correctionIssueFilter, setCorrectionIssueFilter] = useState<CorrectionIssueFilter>('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<CutPlanSkuRow | null>(null);
const [quickCutEdit, setQuickCutEdit] = useState<CutProductOverride>({});
const [isQuickCutEditSaving, setIsQuickCutEditSaving] = useState(false);
const [activeCuttingView, setActiveCuttingView] = useState<CuttingView>('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<string, ProductComposition>();
compositions.forEach(composition => {
[composition.finishedTinyProductId, composition.finishedProductSku]
.map(normalizeMaterialKey)
.filter(Boolean)
.forEach(key => compositionsById.set(key, composition));
});
const stockIndexByKey = new Map<string, number>();
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<string, MaterialReadiness>();
// 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<CutIssue, number>();
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 <span className="text-xs font-bold text-emerald-300">OK</span>;
}
const issueSummary = row.issues.map(issue => issueLabels[issue]).join(', ');
return (
<span
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-xs font-bold text-amber-300"
title={issueSummary}
>
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{row.issues.length === 1 ? issueLabels[row.issues[0]] : `${row.issues.length} dados`}
</span>
</span>
);
};
const renderCutStatus = (row: CutPlanSkuRow) => {
const readiness = materialReadinessByProductId.get(row.id);
if (readiness?.status === 'ready') {
return <span className="inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400 shadow-[0_0_0_4px_rgba(52,211,153,0.10)]" title="Pronto para cortar" aria-label="Pronto para cortar" />;
}
if (readiness?.status === 'material_ready') {
return <span className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-xs font-bold text-amber-300" title={row.issues.map(issue => issueLabels[issue]).join(', ')}><AlertTriangle className="h-3.5 w-3.5 shrink-0" /><span className="truncate">Dados pendentes</span></span>;
}
if (readiness?.status === 'missing') {
return <span className="inline-flex rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-xs font-bold text-amber-300">Sem composição</span>;
}
return <span className="inline-flex max-w-full rounded-full border border-red-400/30 bg-red-400/10 px-2.5 py-1 text-xs font-bold text-red-300" title={readiness?.blockers.join(' · ')}><span className="truncate">Falta: {readiness?.blockers[0] || 'material'}</span></span>;
};
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
<div>
<Link to="/supplies" className="mb-3 inline-flex items-center gap-2 text-sm font-bold text-zinc-500 transition-colors hover:text-zinc-900 dark:text-dark-muted dark:hover:text-dark-text">
<ArrowLeft className="h-4 w-4" />
Suprimentos
</Link>
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Plano de Corte</h1>
<p className="font-medium text-zinc-500 dark:text-dark-muted">
Necessidade por família, cor e tamanho calculada com vendas, estoque e cobertura alvo.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<DateRangePicker
dateRange={dateRange}
onChange={(range) => {
setDateRange(range);
setCurrentPage(1);
setCorrectionPage(1);
}}
/>
<button
type="button"
onClick={openProductionOrderPreview}
disabled={isGeneratingOrders || !rowsAvailableForOrder.length}
className="flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2.5 text-sm font-medium text-brand-primary shadow-sm transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
title="Revisar ordens locais antes de gerar"
>
<ClipboardList size={16} />
<span className="hidden sm:inline">{isGeneratingOrders ? 'Gerando' : 'Prévia OPs'}</span>
</button>
<button
type="button"
onClick={exportRows}
className="flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-medium text-dark-text shadow-sm transition-colors hover:border-brand-primary cursor-pointer"
title="Exportar para CSV"
>
<Download size={16} className="text-brand-primary" />
<span className="hidden sm:inline">Exportar</span>
</button>
<button
type="button"
onClick={() => openSettingsSection('rules')}
className="flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-medium text-dark-text shadow-sm transition-colors hover:border-brand-primary cursor-pointer"
title="Configurar rendimentos por família"
>
<Settings2 size={16} className="text-brand-primary" />
<span className="hidden sm:inline">Regras</span>
</button>
</div>
</div>
{generationMessage && (
<div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">
{generationMessage}
</div>
)}
<RefreshStatus isRefreshing={isRefreshing} />
{isOrderPreviewOpen && (
<div
className="fixed inset-0 z-50 flex h-dvh items-center justify-center overflow-hidden bg-zinc-950/80 p-4 backdrop-blur-md dark:bg-black/75"
role="dialog"
aria-modal="true"
aria-label="Prévia de ordens de produção"
onClick={() => {
if (!isGeneratingOrders) setIsOrderPreviewOpen(false);
}}
>
<div
className="flex max-h-[90dvh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex flex-col gap-4 border-b border-dark-border p-5 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-lg font-bold text-dark-text">Prévia das OPs</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">
Serão criadas ordens locais para as necessidades do filtro atual.
</p>
</div>
<button
type="button"
onClick={() => setIsOrderPreviewOpen(false)}
disabled={isGeneratingOrders}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
title="Fechar"
aria-label="Fechar prévia"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-2 divide-x divide-y divide-dark-border border-b border-dark-border md:grid-cols-4 md:divide-y-0">
{[
['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]) => <div key={label} className="p-4"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{label}</p><p className={`mt-1 text-xl font-bold ${tone}`}>{value}</p></div>)}
</div>
<div className="overflow-auto">
<table className="w-full min-w-[900px] table-fixed text-left text-sm">
<colgroup>
<col className="w-[44%]" />
<col className="w-[16%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[12%]" />
</colgroup>
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Produto</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Cortar agora</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Demanda</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Disponível</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Revisar</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{paginatedPreviewRows.map(row => (
<tr key={row.id} className="hover:bg-dark-input/50">
<td className="max-w-0 px-4 py-3">
<div className="truncate font-bold text-dark-text" title={row.name}>{row.name}</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<span className="font-mono text-[10px] font-medium text-dark-muted">#{row.id}</span>
<span className={`inline-flex whitespace-nowrap rounded-full border px-2 py-0.5 text-[10px] font-bold ${familyStyles[row.family.key]}`}>{row.family.materialLabel}</span>
<ProductColorBadge label={row.color} className="max-w-[110px] px-2" />
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2 py-0.5 text-[10px] font-bold text-emerald-300">
<Ruler className="h-3 w-3" />
{row.size || '-'}
</span>
</div>
</td>
<td className="px-4 py-3 font-bold whitespace-nowrap text-brand-primary">{formatNumber(row.suggestedCutQuantity)} un.</td>
<td className="px-4 py-3 font-bold whitespace-nowrap text-dark-text">{formatNumber(row.projectedDemand, 1)} un.</td>
<td className="px-4 py-3 whitespace-nowrap"><div className="font-bold text-dark-text">{formatNumber(row.availableQuantity)} un.</div>{row.openProductionQuantity ? <div className="mt-1 text-[10px] font-semibold text-dark-muted">{formatNumber(row.openProductionQuantity)} em OP</div> : null}</td>
<td className="px-4 py-3">{renderIssueBadge(row)}</td>
</tr>
))}
</tbody>
</table>
</div>
<PaginationControls
totalItems={rowsAvailableForOrder.length}
currentPage={safePreviewPage}
totalPages={previewTotalPages}
pageSize={previewItemsPerPage}
pageSizeOptions={[10, 20, 50]}
itemLabel="OPs"
pageSizeLabel="por página"
startIndex={previewStartIndex}
endIndex={Math.min(previewStartIndex + previewItemsPerPage, rowsAvailableForOrder.length)}
onPageChange={setPreviewPage}
onPageSizeChange={(pageSize) => { setPreviewItemsPerPage(pageSize); setPreviewPage(1); }}
className="border-t border-dark-border px-4 py-3"
/>
<div className="flex flex-col gap-3 border-t border-dark-border p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-semibold text-dark-muted">
OPs abertas já existentes para o mesmo plano e SKU serão ignoradas.
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => setIsOrderPreviewOpen(false)}
disabled={isGeneratingOrders}
className="inline-flex items-center justify-center rounded-xl border border-dark-border bg-dark-input px-4 py-2 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
Cancelar
</button>
<button
type="button"
onClick={() => void generateProductionOrders()}
disabled={isGeneratingOrders}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2 text-sm font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<ClipboardList className="h-4 w-4" />
{isGeneratingOrders ? 'Gerando OPs' : 'Confirmar geração'}
</button>
</div>
</div>
</div>
</div>
)}
{editingCutRow && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="Editar dados de corte" onClick={() => !isQuickCutEditSaving && setEditingCutRow(null)}>
<div className="w-full max-w-md rounded-2xl border border-dark-border bg-dark-card shadow-2xl" onClick={(event) => event.stopPropagation()}>
<div className="flex items-start justify-between gap-4 border-b border-dark-border p-5">
<div className="min-w-0">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Editar dados de corte</p>
<h2 className="mt-1 truncate text-base font-bold text-dark-text" title={editingCutRow.name}>{editingCutRow.name}</h2>
<p className="mt-1 font-mono text-xs font-semibold text-brand-primary">#{editingCutRow.id}</p>
</div>
<button type="button" onClick={() => setEditingCutRow(null)} disabled={isQuickCutEditSaving} title="Fechar" aria-label="Fechar" className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-4 p-5">
<div className="flex flex-wrap gap-2 text-xs font-bold text-dark-muted">
<span className={`rounded-full border px-2.5 py-1 ${familyStyles[editingCutRow.family.key]}`}>{editingCutRow.family.materialLabel}</span>
<ProductColorBadge label={editingCutRow.color} className="px-2.5 py-1 text-xs" />
<span className="rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-1 text-emerald-300">{editingCutRow.size || 'Sem tamanho'}</span>
</div>
<p className="text-xs font-semibold leading-relaxed text-dark-muted">Altere apenas quando a leitura automática do SKU estiver incorreta. Salvar atualiza este SKU imediatamente.</p>
<label className="block text-xs font-bold text-dark-muted">Família<select value={quickCutEdit.familyKey || ''} onChange={(event) => setQuickCutEdit(current => ({ ...current, familyKey: event.target.value as CutFamilyKey | '' }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary cursor-pointer"><option value="">Automática ({editingCutRow.family.materialLabel})</option>{familyOptions.filter(option => option.value !== 'all').map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="text-xs font-bold text-dark-muted">Cor<input value={quickCutEdit.color || ''} onChange={(event) => setQuickCutEdit(current => ({ ...current, color: event.target.value }))} placeholder={editingCutRow.color || 'Informar cor'} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary" /></label>
<label className="text-xs font-bold text-dark-muted">Tamanho<input value={quickCutEdit.size || ''} onChange={(event) => setQuickCutEdit(current => ({ ...current, size: event.target.value }))} placeholder={editingCutRow.size || 'Informar tamanho'} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary" /></label>
</div>
<button type="button" onClick={() => setQuickCutEdit({})} className="inline-flex items-center gap-2 text-xs font-bold text-dark-muted transition-colors hover:text-dark-text cursor-pointer"><RotateCcw className="h-3.5 w-3.5" /> Usar leitura automática</button>
</div>
<div className="flex flex-col gap-2 border-t border-dark-border p-4 sm:flex-row sm:items-center sm:justify-between">
<button type="button" onClick={() => openBulkCorrectionForRow(editingCutRow)} disabled={isQuickCutEditSaving} className="text-left text-xs font-bold text-brand-primary transition-colors hover:text-brand-primary/80 disabled:opacity-50 cursor-pointer">Abrir correções em lote</button>
<div className="flex gap-2 sm:justify-end"><button type="button" onClick={() => setEditingCutRow(null)} disabled={isQuickCutEditSaving} className="h-10 rounded-xl border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary disabled:opacity-50 cursor-pointer">Cancelar</button><button type="button" onClick={() => void saveQuickCutEdit()} disabled={isQuickCutEditSaving} className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand-primary px-3 text-xs font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><SaveIcon className="h-4 w-4" />{isQuickCutEditSaving ? 'Salvando' : 'Salvar'}</button></div>
</div>
</div>
</div>
)}
{isSettingsOpen && (
<div
className="fixed inset-0 z-50 flex h-dvh justify-end overflow-hidden bg-zinc-950/80 backdrop-blur-md dark:bg-black/75"
role="dialog"
aria-modal="true"
aria-label="Regras de corte"
onClick={() => setIsSettingsOpen(false)}
>
<div
className="h-dvh w-full max-w-6xl space-y-4 overflow-y-auto border-l border-dark-border bg-dark-card p-5 shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-sm font-bold text-dark-text">Configuração de Corte</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{configuredYieldCount} rendimentos configurados · {productOverrideCount} correções de produto
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={clearSettings}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer"
>
<RotateCcw className="h-4 w-4" />
Limpar tudo
</button>
<button
type="button"
onClick={() => void persistSettings()}
disabled={saveStatus === 'saving' || !hasUnsavedSettings}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-3 py-2 text-xs font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<SaveIcon className="h-4 w-4" />
{saveStatus === 'saving' ? 'Salvando' : 'Salvar'}
</button>
<button
type="button"
onClick={() => setIsSettingsOpen(false)}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer"
title="Fechar"
aria-label="Fechar regras"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{saveStatus === 'saved' && (
<p className="text-xs font-semibold text-emerald-300">Regras salvas no banco de dados.</p>
)}
{saveStatus === 'error' && (
<p className="text-xs font-semibold text-red-300">Não foi possível salvar as regras. Tente novamente.</p>
)}
{hasUnsavedSettings && saveStatus !== 'saving' && (
<p className="text-xs font-semibold text-amber-300">Existem alterações ainda não salvas.</p>
)}
<div className="inline-flex rounded-xl border border-dark-border bg-dark-input p-1">
<button
type="button"
onClick={() => setSettingsSection('rules')}
aria-pressed={settingsSection === 'rules'}
className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${settingsSection === 'rules' ? 'bg-brand-primary/15 text-brand-primary' : 'text-dark-muted hover:text-dark-text'}`}
>
Rendimentos
</button>
<button
type="button"
onClick={() => setSettingsSection('corrections')}
aria-pressed={settingsSection === 'corrections'}
className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${settingsSection === 'corrections' ? 'bg-brand-primary/15 text-brand-primary' : 'text-dark-muted hover:text-dark-text'}`}
>
Correções
</button>
</div>
{settingsSection === 'rules' && (
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
{CUT_FAMILY_RULES.map(rule => (
<label key={rule.key} className="rounded-xl border border-dark-border bg-dark-input p-3">
<span className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs font-bold text-dark-text">{rule.materialLabel}</span>
<span className="text-[11px] font-semibold text-dark-muted">{rule.label}</span>
</span>
<input
type="number"
min="1"
step="1"
value={cuttingSettings.familyYields[rule.key] || ''}
onChange={(event) => updateFamilyYield(rule.key, event.target.value)}
placeholder="un./rolo"
className="h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary"
/>
</label>
))}
</div>
)}
{settingsSection === 'corrections' && (
<div className="rounded-xl border border-dark-border">
<div className="flex flex-col gap-3 border-b border-dark-border bg-dark-header p-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Correções de produto</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">
Use quando o nome do SKU não deixa claro família, cor ou tamanho para montar o corte.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<span className="text-xs font-bold text-dark-muted">
{formatNumber(correctionRows.length)} produtos
</span>
<select
value={correctionIssueFilter}
onChange={(event) => {
setCorrectionIssueFilter(event.target.value as CorrectionIssueFilter);
setCorrectionPage(1);
}}
className="h-9 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-text outline-none focus:border-brand-primary cursor-pointer"
>
{correctionFilterOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
{correctionRows.length ? (
<>
<div className="overflow-x-auto">
<div className="min-w-[760px]">
<div className="grid grid-cols-[120px_1.4fr_150px_150px_120px_80px] gap-3 border-b border-dark-border px-4 py-3 text-[10px] font-bold uppercase tracking-wider text-dark-muted">
<span>ID</span>
<span>Produto</span>
<span>Família</span>
<span>Cor</span>
<span>Tamanho</span>
<span className="text-right">Ações</span>
</div>
<div className="divide-y divide-dark-border">
{paginatedCorrectionRows.map(row => {
const override = cuttingSettings.productOverrides[row.id] || {};
const isTargetRow = targetCorrectionSku.toLowerCase() === row.id.toLowerCase();
return (
<div
key={row.id}
className={`grid grid-cols-[120px_1.4fr_150px_150px_120px_80px] items-center gap-3 px-4 py-3 ${isTargetRow ? 'bg-brand-primary/10 ring-1 ring-inset ring-brand-primary/35' : ''}`}
>
<span className="font-mono text-[11px] text-dark-muted">#{row.id}</span>
<div className="min-w-0">
<div className="truncate text-xs font-bold text-dark-text" title={row.name}>{row.name}</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{row.issues.filter(issue => issue !== 'missing_yield_rule').map(issue => (
<span key={issue} className="rounded-full border border-amber-400/25 bg-amber-400/10 px-2 py-0.5 text-[10px] font-bold text-amber-300">
{issueLabels[issue]}
</span>
))}
</div>
</div>
<select
value={override.familyKey || ''}
onChange={(event) => updateProductOverride(row.id, { familyKey: event.target.value as CutFamilyKey | '' })}
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 cursor-pointer"
>
<option value="">Auto</option>
{familyOptions.filter(option => option.value !== 'all').map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<input
type="text"
value={override.color || ''}
onChange={(event) => 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"
/>
<input
type="text"
value={override.size || ''}
onChange={(event) => 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"
/>
<div className="flex justify-end">
<button
type="button"
onClick={() => clearProductOverride(row.id)}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
title="Limpar correção"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
</div>
</div>
<PaginationControls
totalItems={correctionRows.length}
currentPage={safeCorrectionPage}
totalPages={correctionTotalPages}
pageSize={correctionItemsPerPage}
pageSizeOptions={[12]}
itemLabel="produtos"
pageSizeLabel="por página"
startIndex={correctionStartIndex}
endIndex={Math.min(correctionStartIndex + correctionItemsPerPage, correctionRows.length)}
onPageChange={setCorrectionPage}
onPageSizeChange={() => setCorrectionPage(1)}
className="border-t border-dark-border px-4 py-3"
/>
</>
) : (
<div className="px-4 py-8 text-center">
<p className="text-sm font-bold text-dark-text">Nenhuma correção de produto pendente.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Os dados restantes, se existirem, são de rendimento por família.</p>
</div>
)}
</div>
)}
</div>
</div>
)}
<div className="rounded-2xl border border-dark-border bg-dark-card p-1.5 shadow-sm">
<div className="flex flex-wrap gap-1.5">
<button type="button" onClick={() => setActiveCuttingView('plan')} aria-pressed={activeCuttingView === 'plan'} className={`inline-flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-bold transition-colors cursor-pointer ${activeCuttingView === 'plan' ? 'bg-brand-primary/15 text-brand-primary' : 'text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}>
<Scissors className="h-4 w-4" /> Necessidades <span className="text-xs opacity-80">{formatNumber(cutPlan.summary.skuCount)}</span>
</button>
<button type="button" onClick={() => setActiveCuttingView('families')} aria-pressed={activeCuttingView === 'families'} className={`inline-flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-bold transition-colors cursor-pointer ${activeCuttingView === 'families' ? 'bg-brand-primary/15 text-brand-primary' : 'text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}>
<Layers3 className="h-4 w-4" /> Famílias <span className="text-xs opacity-80">{formatNumber(cutPlan.familySummaries.length)}</span>
</button>
<button type="button" onClick={() => setActiveCuttingView('issues')} aria-pressed={activeCuttingView === 'issues'} className={`inline-flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-bold transition-colors cursor-pointer ${activeCuttingView === 'issues' ? 'bg-amber-400/10 text-amber-300' : 'text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}>
<AlertTriangle className="h-4 w-4" /> Pendências <span className="text-xs opacity-80">{formatNumber(cutPlan.summary.rowsWithIssues)}</span>
</button>
</div>
</div>
<div className="grid grid-cols-2 divide-x divide-y divide-dark-border overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm lg:grid-cols-4 lg:divide-y-0">
{[
{ 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 => (
<button
key={metric.id}
type="button"
onClick={() => {
if (metric.id === 'families') {
setActiveCuttingView('families');
return;
}
setActiveCuttingView('plan');
setExecutionFilter(metric.id as ExecutionFilter);
setCutFilter('need');
setCurrentPage(1);
}}
className={`p-4 text-left transition-colors hover:bg-dark-input/60 cursor-pointer sm:px-5 ${metric.id === 'families' ? activeCuttingView === 'families' ? 'bg-brand-primary/[0.07]' : '' : activeCuttingView === 'plan' && executionFilter === metric.id ? 'bg-brand-primary/[0.07]' : ''}`}
>
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{metric.label}</p>
<p className={`mt-1 text-xl font-bold ${metric.tone}`}>{metric.value}</p>
<p className="mt-1 truncate text-xs font-semibold text-dark-muted">{metric.detail}</p>
</button>
))}
</div>
{cutExecutionSummary.rawUnits > 0 && cutExecutionSummary.readyUnits === 0 && (
<div className="flex flex-col gap-3 rounded-2xl border border-amber-400/30 bg-amber-400/10 p-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-bold text-amber-300">Há demanda, mas nenhum SKU está liberado para corte.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Corrija materiais bloqueados e rendimentos antes de gerar ordens de produção.</p>
</div>
<button type="button" onClick={() => setActiveCuttingView('issues')} className="h-9 rounded-lg border border-amber-400/30 bg-dark-card px-3 text-xs font-bold text-amber-300 hover:border-amber-400 cursor-pointer">Ver pendências</button>
</div>
)}
{activeCuttingView === 'issues' && (
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-sm font-bold text-dark-text">Dados pendentes do plano</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">Dados que bloqueiam o cálculo completo: família, cor, tamanho ou rendimento.</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-xs font-bold text-amber-300">
{formatNumber(cutPlan.summary.rowsWithIssues)} SKUs
</span>
<button
type="button"
onClick={() => {
setCorrectionIssueFilter('all');
setCorrectionPage(1);
openSettingsSection('corrections');
}}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
>
<Settings2 className="h-4 w-4 text-brand-primary" />
Corrigir
</button>
<button
type="button"
onClick={() => {
setCutFilter('issues');
setCurrentPage(1);
setActiveCuttingView('plan');
}}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
>
<Search className="h-4 w-4 text-brand-primary" />
Ver SKUs
</button>
</div>
</div>
{issueSummaries.length ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
{issueSummaries.map(summary => (
<div
key={summary.issue}
className="rounded-xl border border-dark-border bg-dark-input/60 p-3"
>
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-bold text-dark-text">{issueLabels[summary.issue]}</span>
<span className="text-sm font-bold text-amber-300">{formatNumber(summary.count)}</span>
</div>
<p className="mt-1 line-clamp-2 text-[11px] font-medium leading-relaxed text-dark-muted">{issueHelp[summary.issue]}</p>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-dark-border bg-dark-input/60 px-4 py-8 text-center">
<p className="text-sm font-bold text-dark-text">Nenhuma pendência no plano atual.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Os SKUs com necessidade já têm os dados mínimos para o cálculo.</p>
</div>
)}
</div>
)}
{activeCuttingView === 'families' && (
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-bold text-dark-text">Corte por família</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">Volume agrupado para orientar compra e corte.</p>
</div>
<Scissors className="h-5 w-5 text-dark-muted" />
</div>
{cutPlan.familySummaries.length ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 2xl:grid-cols-5">
{cutPlan.familySummaries.map(summary => (
<div
key={summary.family.key}
className="rounded-xl border border-dark-border bg-dark-input/50 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${familyStyles[summary.family.key]}`}>
{summary.family.materialLabel}
</span>
<h3 className="mt-2 truncate text-sm font-bold text-dark-text">{summary.family.label}</h3>
</div>
<span className="text-[11px] font-bold text-dark-muted">{formatNumber(summary.skuCount)} SKUs</span>
</div>
<p className="mt-3 text-2xl font-bold text-dark-text">{formatNumber(summary.suggestedCutQuantity)} un.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{formatNumber(summary.colorCount)} cores · {summary.estimatedRolls === null ? 'sem rendimento' : `${formatNumber(summary.estimatedRolls)} rolos`}
</p>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-dark-border bg-dark-input/60 px-4 py-8 text-center">
<p className="text-sm font-bold text-dark-text">Nenhuma família com necessidade no período.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Ajuste o período ou a cobertura alvo para revisar outro cenário.</p>
</div>
)}
</div>
)}
{activeCuttingView === 'plan' && (
<>
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[1fr_170px_170px_170px_190px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
<input
type="text"
placeholder="Buscar por nome, ID, grupo ou cor..."
value={searchTerm}
onChange={(event) => {
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"
/>
</div>
<select
value={targetCoverageDays}
onChange={(event) => {
setTargetCoverageDays(Number(event.target.value));
setCurrentPage(1);
setCorrectionPage(1);
}}
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
aria-label="Dias de cobertura alvo"
>
{coverageTargetOptions.map(days => <option key={days} value={days}>Cobrir {days} dias</option>)}
</select>
<select
value={familyFilter}
onChange={(event) => {
setFamilyFilter(event.target.value as CutFamilyKey | 'all');
setCurrentPage(1);
}}
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
>
{familyOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<select
value={cutFilter}
onChange={(event) => {
setCutFilter(event.target.value as CutFilter);
setCurrentPage(1);
}}
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
>
{filterOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<select
value={sortBy}
onChange={(event) => {
setSortBy(event.target.value as CutSort);
setCurrentPage(1);
}}
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
>
<option value="need_desc">Maior necessidade</option>
<option value="need_asc">Menor necessidade</option>
<option value="demand_desc">Maior demanda projetada</option>
<option value="stock_asc">Menor estoque</option>
<option value="sold_desc">Mais vendidos</option>
<option value="name_asc">Nome A-Z</option>
</select>
</div>
{isLoading && products.length === 0 ? (
<CuttingSkeleton />
) : (
<div className={`overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] table-fixed text-left text-sm">
<colgroup>
<col className="w-[36%]" />
<col className="w-[16%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[12%]" />
<col className="w-[8%]" />
</colgroup>
<thead className="sticky top-0 z-10 border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted">
<tr>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Produto</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Cortar agora</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Demanda</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Disponível</th>
<th className="px-4 py-4 text-center text-[10px] font-bold uppercase tracking-wider">Status</th>
<th className="px-4 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
{paginatedRows.map(row => {
const readiness = materialReadinessByProductId.get(row.id);
const needsAttention = readiness?.status !== 'ready';
return (
<tr key={row.id} className={`transition-colors hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 ${needsAttention ? 'border-l-2 border-amber-400/70' : ''}`}>
<td className="max-w-0 px-4 py-2.5">
<Link to={`/products/${row.id}`} className="block truncate font-semibold text-zinc-900 transition-colors hover:text-brand-primary dark:text-dark-text" title={`Ver ${row.name}`}>{row.name}</Link>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<span className="font-mono text-[10px] font-medium text-zinc-400 dark:text-dark-muted">#{row.id}</span>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-bold ${familyStyles[row.family.key]}`}>{row.family.materialLabel}</span>
<ProductColorBadge label={row.color} className="max-w-[92px] px-2 py-0.5 text-[10px]" />
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2 py-0.5 text-[10px] font-bold text-emerald-300"><Ruler className="h-3 w-3" />{row.size || '-'}</span>
</div>
<div className="mt-1 text-[10px] font-medium text-zinc-400 dark:text-dark-muted">Média: {formatNumber(row.dailySales, 2)} un./dia · Cobertura: {formatDays(row.daysOfCover)}</div>
</td>
<td className="px-4 py-2.5 whitespace-nowrap">
<div className="text-lg font-bold text-brand-primary">{formatNumber(row.suggestedCutQuantity)} un.</div>
</td>
<td className="px-4 py-2.5 whitespace-nowrap font-bold text-dark-text">{formatNumber(row.projectedDemand, 1)} un.</td>
<td className="px-4 py-2.5 whitespace-nowrap"><div className="font-bold text-dark-text">{formatNumber(row.availableQuantity)} un.</div>{row.openProductionQuantity ? <div className="mt-1 text-[10px] font-semibold text-dark-muted">{formatNumber(row.openProductionQuantity)} em OP</div> : null}</td>
<td className="px-4 py-2.5 text-center align-middle">{renderCutStatus(row)}</td>
<td className="px-4 py-2.5 text-right">
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => openQuickCutEdit(row)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border cursor-pointer"
title={`Editar dados de corte do SKU ${row.id}`}
aria-label={`Editar dados de corte do SKU ${row.id}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<Link
to={`/products/${row.id}`}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80"
title={`Ver SKU ${row.id}`}
aria-label={`Ver SKU ${row.id}`}
>
<Eye className="h-3.5 w-3.5" />
</Link>
</div>
</td>
</tr>
)})}
</tbody>
</table>
</div>
{!filteredRows.length && (
<div className="px-6 py-12 text-center">
<Scissors className="mx-auto h-10 w-10 text-dark-muted" />
<p className="mt-4 text-sm font-bold text-dark-text">Nenhum item encontrado.</p>
<p className="mt-1 text-sm text-dark-muted">Ajuste a busca, família, status ou período selecionado.</p>
</div>
)}
<PaginationControls
totalItems={filteredRows.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="SKUs"
pageSizeLabel="itens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, filteredRows.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1);
}}
/>
</div>
)}
</>
)}
</div>
);
};
export default Cutting;