Files
TSCUT/src/main.js

1232 lines
45 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// --- LOGTO AUTH GUARD ---
import LogtoClient from '@logto/browser';
// Escondemos o app imediatamente
const style = document.createElement('style');
style.innerHTML = 'body { display: none !important; }';
document.head.appendChild(style);
const logtoClient = new LogtoClient({
endpoint: import.meta.env.VITE_LOGTO_ENDPOINT,
appId: import.meta.env.VITE_LOGTO_APP_ID,
resource: 'https://default.logto.app/api',
scopes: ['openid', 'offline_access', 'profile', 'email', 'organizations'],
});
async function protectPage() {
const isCallback = window.location.pathname.includes('callback');
if (isCallback) {
try {
await logtoClient.handleSignInCallback(window.location.href);
window.location.assign('/');
} catch (error) {
console.error('Falha no callback do Logto:', error);
}
return;
}
const isAuthenticated = await logtoClient.isAuthenticated();
if (!isAuthenticated) {
await logtoClient.signIn(window.location.origin);
return;
}
// Se chegou aqui, está logado. Mostramos o app.
style.remove();
console.log('TSCUT: Acesso autorizado.');
}
// Execução imediata
protectPage();
// -------------------------
import * as XLSX from 'xlsx';
import Papa from 'papaparse';
import './style.css';
let availableBars = [];
let editingBarId = null;
let demandPieces = [];
let lastResults = null;
let editingPieceId = null;
let selectedProcess = 'plasma'; // Default
let kerfSize = 3; // Default for plasma
const PROCESS_KERFS = {
'plasma': 3,
'saw': 2,
'oxy': 5
};
function updateProcess() {
const radios = document.getElementsByName('cuttingProcess');
for (const radio of radios) {
if (radio.checked) {
selectedProcess = radio.value;
kerfSize = PROCESS_KERFS[selectedProcess];
break;
}
}
console.log(`Process updated: ${selectedProcess}, Kerf: ${kerfSize}mm`);
}
const colors = ['#3498db', '#2ecc71', '#9b59b6', '#f39c12', '#1abc9c', '#e74c3c', '#34495e', '#16a085', '#d68910', '#2980b9'];
const PROCESS_NAMES = {
'plasma': 'Plasma',
'saw': 'Serra/Disco',
'oxy': 'Oxicorte'
};
// ===== BARRAS =====
function addBar() {
const desc = document.getElementById('barDesc').value.trim();
const qty = parseInt(document.getElementById('barQty').value);
const length = parseInt(document.getElementById('barLength').value);
const weight = parseFloat(document.getElementById('barWeight').value);
if (!desc || !qty || !length || !weight) {
showToast('Preencha todos os campos da barra', 'warning');
return;
}
if (editingBarId) {
const index = availableBars.findIndex(b => b.id === editingBarId);
if (index !== -1) {
availableBars[index] = { ...availableBars[index], desc, qty, length, weight, remainingQty: qty };
}
cancelBarEdit(); // Reset form and state
} else {
availableBars.push({ id: Date.now(), desc, qty, length, weight, remainingQty: qty });
document.getElementById('barDesc').value = '';
document.getElementById('barQty').value = '1';
document.getElementById('barLength').value = '6000';
document.getElementById('barWeight').value = '45';
}
renderBars();
}
function removeBar(id) {
if (editingBarId === id) cancelBarEdit();
availableBars = availableBars.filter(b => b.id !== id);
renderBars();
}
function editBar(id) {
const bar = availableBars.find(b => b.id === id);
if (!bar) return;
document.getElementById('barDesc').value = bar.desc;
document.getElementById('barQty').value = bar.qty;
document.getElementById('barLength').value = bar.length;
document.getElementById('barWeight').value = bar.weight;
editingBarId = id;
const btnAdd = document.getElementById('btnAddBar');
btnAdd.textContent = '💾 Salvar Alteração';
btnAdd.classList.remove('btn-primary');
btnAdd.classList.add('btn-success');
document.getElementById('btnCancelBarEdit').style.display = 'block';
}
function cancelBarEdit() {
editingBarId = null;
document.getElementById('barDesc').value = '';
document.getElementById('barQty').value = '1';
document.getElementById('barLength').value = '6000';
document.getElementById('barWeight').value = '45';
const btnAdd = document.getElementById('btnAddBar');
btnAdd.textContent = ' Adicionar Barra';
btnAdd.classList.remove('btn-success');
btnAdd.classList.add('btn-primary');
document.getElementById('btnCancelBarEdit').style.display = 'none';
}
function renderBars() {
const container = document.getElementById('barsList');
if (availableBars.length === 0) {
container.innerHTML = '<div class="no-data">Nenhuma barra adicionada</div>';
return;
}
container.innerHTML = availableBars.map(b => `
<div class="item-card dynamic-flex-row">
<div class="dynamic-item-info">
<strong>${b.desc}</strong> <span class="dynamic-item-subinfo">${b.qty}x ${b.length}mm | ${b.weight}kg</span>
</div>
<div class="dynamic-item-actions">
<button onclick="editBar(${b.id})" class="dynamic-action-btn edit" title="Editar">✎</button>
<button onclick="removeBar(${b.id})" class="dynamic-action-btn remove" title="Remover">✕</button>
</div>
</div>
`).join('');
}
// ===== PEÇAS =====
function addPiece() {
const tag = document.getElementById('pieceTag').value.trim();
const length = parseInt(document.getElementById('pieceLength').value);
const qty = parseInt(document.getElementById('pieceQty').value);
if (!tag || !length || !qty) {
showToast('Preencha TAG, comprimento e quantidade', 'warning');
return;
}
if (editingPieceId) {
const index = demandPieces.findIndex(p => p.id === editingPieceId);
if (index !== -1) {
demandPieces[index] = { ...demandPieces[index], tag, length, qty };
}
cancelEdit(); // Reset form and state
} else {
demandPieces.push({ id: Date.now(), tag, length, qty });
document.getElementById('pieceTag').value = '';
document.getElementById('pieceLength').value = '';
document.getElementById('pieceQty').value = '1';
}
renderPieces();
}
function removePiece(id) {
if (editingPieceId === id) cancelEdit();
demandPieces = demandPieces.filter(p => p.id !== id);
renderPieces();
}
function editPiece(id) {
const piece = demandPieces.find(p => p.id === id);
if (!piece) return;
document.getElementById('pieceTag').value = piece.tag;
document.getElementById('pieceLength').value = piece.length;
document.getElementById('pieceQty').value = piece.qty;
editingPieceId = id;
const btnAdd = document.getElementById('btnAddPiece');
btnAdd.textContent = '💾 Salvar Alteração';
btnAdd.classList.remove('btn-primary');
btnAdd.classList.add('btn-success');
document.getElementById('btnCancelEdit').style.display = 'block';
}
function cancelEdit() {
editingPieceId = null;
document.getElementById('pieceTag').value = '';
document.getElementById('pieceLength').value = '';
document.getElementById('pieceQty').value = '1';
const btnAdd = document.getElementById('btnAddPiece');
btnAdd.textContent = ' Adicionar Peça';
btnAdd.classList.remove('btn-success');
btnAdd.classList.add('btn-primary');
document.getElementById('btnCancelEdit').style.display = 'none';
}
function renderPieces() {
const container = document.getElementById('piecesList');
if (demandPieces.length === 0) {
container.innerHTML = '<div class="no-data">Nenhuma peça adicionada</div>';
renderBalloons([]);
return;
}
container.innerHTML = demandPieces.map(p => `
<div class="item-card dynamic-flex-row">
<div class="dynamic-item-info">
<strong>${p.tag}</strong> <span class="dynamic-item-subinfo">${p.qty}x ${p.length}mm</span>
</div>
<div class="dynamic-item-actions">
<button onclick="editPiece(${p.id})" class="dynamic-action-btn edit" title="Editar">✎</button>
<button onclick="removePiece(${p.id})" class="dynamic-action-btn remove" title="Remover">✕</button>
</div>
</div>
`).join('');
renderBalloons(demandPieces);
}
function renderBalloons(pieces) {
const container = document.getElementById('piecesBalloons');
if (pieces.length === 0) {
container.innerHTML = '<div class="no-data full-width">Nenhuma peça adicionada</div>';
return;
}
container.innerHTML = pieces.map(p => `
<div class="piece-balloon">
<span class="piece-balloon-tag">${p.tag}</span>
<div class="piece-balloon-info">
<span>${p.length}mm × ${p.qty}</span>
</div>
</div>
`).join('');
}
// ===== IMPORTAÇÃO DE ARQUIVO =====
function importFile() {
const fileInput = document.getElementById('fileImport');
const file = fileInput.files[0];
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
if (ext === 'csv') {
Papa.parse(file, {
header: false,
complete: (results) => processImportedData(results.data),
error: (error) => showFeedback('Erro ao ler arquivo: ' + error.message, 'error')
});
} else if (['xlsx', 'xls'].includes(ext)) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });
processImportedData(rows);
} catch (error) {
showFeedback('Erro ao ler arquivo Excel: ' + error.message, 'error');
}
};
reader.readAsArrayBuffer(file);
} else {
showFeedback('Formato não suportado. Use CSV, XLSX ou XLS', 'error');
}
fileInput.value = '';
}
function processImportedData(rows) {
if (!rows || rows.length === 0) {
showFeedback('Arquivo vazio', 'error');
return;
}
// Detectar cabeçalho
let startRow = 0;
if (isHeaderRow(rows[0])) {
startRow = 1;
}
// Processar dados
let importedCount = 0;
let errors = [];
const duplicates = [];
const newPieces = [];
for (let i = startRow; i < rows.length; i++) {
const row = rows[i];
if (!row || row.length < 3) continue;
const tag = String(row[0]).trim();
const qtyStr = String(row[1]).trim();
const lengthStr = String(row[2]).trim();
if (!tag || !qtyStr || !lengthStr) continue;
const qty = parseInt(qtyStr);
const length = parseInt(lengthStr);
if (isNaN(qty) || isNaN(length) || qty <= 0 || length <= 0) {
errors.push(`Linha ${i + 1}: dados inválidos`);
continue;
}
if (demandPieces.some(p => p.tag === tag)) {
duplicates.push(tag);
}
newPieces.push({ id: Date.now() + i, tag, length, qty });
importedCount++;
}
if (newPieces.length === 0) {
showToast('Nenhuma peça válida encontrada no arquivo', 'error');
return;
}
const processImport = () => {
demandPieces.push(...newPieces);
renderPieces();
showToast(`${newPieces.length} peça(s) importada(s)`, 'success');
};
if (duplicates.length > 0) {
showConfirmModal(
"Peças Duplicadas",
`Foram encontradas ${duplicates.length} peças com TAGs que já existem na lista (ex: ${duplicates.slice(0, 3).join(', ')}...).\n\nDeseja importá-las mesmo assim?`,
processImport
);
} else {
processImport();
}
if (errors.length > 0) {
console.warn('Erros na importação:', errors);
}
}
function isHeaderRow(row) {
if (!row || row.length < 3) return false;
const col1 = String(row[0]).toLowerCase().trim();
const col2 = String(row[1]).toLowerCase().trim();
const col3 = String(row[2]).toLowerCase().trim();
// Palavras-chave comuns em cabeçalhos
const headerKeywords = ['tag', 'id', 'identificacao', 'nome', 'qtd', 'quantidade', 'comp', 'comprimento', 'mm', 'length'];
return headerKeywords.some(kw =>
col1.includes(kw) || col2.includes(kw) || col3.includes(kw)
);
}
// ===== UI HELPERS =====
function showToast(message, type = 'info', title = '') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
let icon = '';
if (type === 'success') icon = '✅';
if (type === 'error') icon = '❌';
if (type === 'warning') icon = '⚠️';
if (!title) {
if (type === 'success') title = 'Sucesso';
if (type === 'error') title = 'Erro';
if (type === 'warning') title = 'Atenção';
if (type === 'info') title = 'Informação';
}
toast.innerHTML = `
<div class="toast-icon">${icon}</div>
<div class="toast-content">
<div class="toast-title">${title}</div>
<div class="toast-message">${message}</div>
</div>
<button class="toast-close" onclick="this.parentElement.remove()">✕</button>
`;
container.appendChild(toast);
// Trigger animation
requestAnimationFrame(() => {
toast.classList.add('show');
});
// Auto remove
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 5000);
}
let currentConfirmCallback = null;
function showConfirmModal(title, message, onConfirm) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalMessage').innerHTML = message.replace(/\n/g, '<br>');
const modal = document.getElementById('confirmModal');
modal.classList.add('show');
currentConfirmCallback = onConfirm;
}
function closeModal() {
document.getElementById('confirmModal').classList.remove('show');
currentConfirmCallback = null;
}
// Setup modal confirm button listener once
document.getElementById('modalConfirmBtn').addEventListener('click', () => {
if (currentConfirmCallback) currentConfirmCallback();
closeModal();
});
function showFeedback(message, type) {
// Deprecated in favor of showToast, but keeping for compatibility if needed
// or redirecting to showToast
showToast(message, type);
}
// ===== ALGORITMO FFD AVANÇADO =====
function optimizeCutting() {
if (availableBars.length === 0 || demandPieces.length === 0) {
showToast('Adicione barras e peças', 'warning');
return;
}
// Expandir peças com ID único global
const expandedPieces = [];
let uniqueIdCounter = 0;
demandPieces.forEach(p => {
for (let i = 0; i < p.qty; i++) {
expandedPieces.push({ ...p, uniqueId: ++uniqueIdCounter });
}
});
// FFD em cada barra disponível
const usedBars = [];
const unusedPieces = [...expandedPieces];
// Ordenar barras por comprimento (maior para menor) para tentar usar as maiores primeiro?
// O código original não ordenava, seguia a ordem de inserção. Manteremos assim por enquanto.
for (let barType of availableBars) {
for (let barCopy = 0; barCopy < barType.qty; barCopy++) {
if (unusedPieces.length === 0) break;
const bar = {
barType: barType.desc,
length: barType.length,
weight: barType.weight,
pieces: [],
remaining: barType.length,
isSimulated: barType.isSimulated || false
};
// Tentar encaixar peças (FFD)
// Ordenar peças restantes por tamanho decrescente
const sorted = [...unusedPieces].sort((a, b) => b.length - a.length);
const toRemoveIds = [];
for (let piece of sorted) {
// Check if piece fits considering kerf loss for this cut
// We assume each piece consumes its length + kerf
// Exception: The very last piece in a bar might not strictly need a kerf if it's the end,
// but usually in cutting processes, you cut the piece out, so kerf is consumed.
// User requirement: "consumo adicional de cada corte... sera de mais 2mm para cada corte"
const requiredSpace = piece.length + kerfSize;
// However, we need to be careful. If remaining is EXACTLY piece.length, can we cut it?
// If we cut it, we lose the kerf. So we need remaining >= piece.length + kerf?
// Or does the kerf come from the "waste"?
// Usually: Bar 6000. Piece 1000. Kerf 3.
// Cut 1: Consumes 1003. Remaining 4997.
// So yes, we treat piece length as (length + kerf).
if (bar.remaining >= requiredSpace) {
bar.pieces.push(piece);
bar.remaining -= requiredSpace;
toRemoveIds.push(piece.uniqueId);
} else if (bar.remaining >= piece.length && bar.remaining < requiredSpace) {
// Edge case: Fits exactly or with less than kerf remaining?
// If I have 1000mm remaining and need 1000mm piece.
// If I cut, I destroy 3mm. So I need 1003mm to get a 1000mm piece?
// Yes, usually. Unless it's the raw end of the bar, but we can't assume that.
// Let's stick to the rule: consumption = length + kerf.
// If bar.remaining < length + kerf, we can't cut it.
}
}
// Remover peças colocadas usando ID único
for (let uid of toRemoveIds) {
const idx = unusedPieces.findIndex(p => p.uniqueId === uid);
if (idx !== -1) unusedPieces.splice(idx, 1);
}
if (bar.pieces.length > 0) {
usedBars.push(bar);
}
}
}
return { usedBars, unusedPieces, availableBars };
}
function checkStockSufficiency() {
const totalDemandLength = demandPieces.reduce((sum, p) => sum + (p.length * p.qty), 0);
const totalStockLength = availableBars.reduce((sum, b) => sum + (b.length * b.qty), 0);
return totalStockLength >= totalDemandLength;
}
// ===== CÁLCULO =====
function calculateOptimization() {
if (availableBars.length === 0 || demandPieces.length === 0) {
showToast('Adicione barras e peças para calcular', 'warning');
return;
}
let currentAvailableBars = JSON.parse(JSON.stringify(availableBars));
if (!checkStockSufficiency()) {
showConfirmModal(
"Estoque Insuficiente",
"⚠️ O estoque atual de barras NÃO é suficiente para atender toda a demanda.\n\nDeseja continuar simulando a quantidade necessária de barras?",
() => {
runOptimizationWithSimulation(currentAvailableBars, true);
}
);
return;
}
runOptimizationWithSimulation(currentAvailableBars, false);
}
function runOptimizationWithSimulation(currentAvailableBars, simulateMode) {
if (simulateMode) {
// Find the longest bar to use as standard for simulation
const standardBar = availableBars.reduce((prev, current) => (prev.length > current.length) ? prev : current);
// Add a virtually infinite amount of the standard bar
// We add enough to cover the deficit + buffer
const totalDemandLength = demandPieces.reduce((sum, p) => sum + (p.length * p.qty), 0);
const totalStockLength = availableBars.reduce((sum, b) => sum + (b.length * b.qty), 0);
const deficit = totalDemandLength - totalStockLength;
const extraBarsNeeded = Math.ceil(deficit / standardBar.length) + 10; // +10 buffer
currentAvailableBars.push({
...standardBar,
qty: extraBarsNeeded,
remainingQty: extraBarsNeeded,
desc: standardBar.desc + " (Simulado)",
isSimulated: true
});
}
// Temporarily swap availableBars with the simulated list for the optimize function
const originalBars = availableBars;
availableBars = currentAvailableBars;
const result = optimizeCutting();
// Restore original bars
availableBars = originalBars;
if (!result) return;
const { usedBars, unusedPieces } = result;
// Calcular totais
const totalPieces = demandPieces.reduce((s, p) => s + p.qty, 0);
const totalLength = demandPieces.reduce((s, p) => s + (p.length * p.qty), 0);
const totalWaste = usedBars.reduce((s, b) => s + b.remaining, 0);
const totalBarLength = usedBars.reduce((s, b) => s + b.length, 0);
const efficiency = totalBarLength > 0 ? ((1 - totalWaste / totalBarLength) * 100).toFixed(2) : 0;
const totalWeight = usedBars.reduce((s, b) => s + b.weight, 0);
const unusedPiecesCount = unusedPieces.reduce((s, p) => s + 1, 0);
// Calculate total kerf loss
const totalKerfLoss = usedBars.reduce((sum, bar) => sum + (bar.pieces.length * kerfSize), 0);
// Generate Scraps List
const scraps = usedBars.map(b => b.remaining).filter(r => r > 0).sort((a, b) => b - a);
const scrapsListHtml = scraps.length > 0
? `<div class="scraps-list">
${Object.entries(scraps.reduce((acc, val) => { acc[val] = (acc[val] || 0) + 1; return acc; }, {}))
.map(([size, count]) => `<div>${count}x ${size}mm</div>`).join('')}
</div>`
: '<div class="no-scraps">Sem sobras!</div>';
// Resumo Detalhado
const simulationWarning = simulateMode
? `<div class="result-card warning simulation-warning">
<div class="result-label danger">Aviso de Estoque</div>
<div class="result-value small-danger">Uso de barras Adicionais (sem estoque)</div>
</div>`
: '';
document.getElementById('summaryResults').innerHTML = `
<div class="result-card success grid-full-col">
<div class="result-label">Eficiência Global</div>
<div class="result-value">${efficiency}%</div>
<div class="result-subtext">Aproveitamento do Material</div>
</div>
<div class="result-card">
<div class="result-label">Barras Usadas</div>
<div class="result-value">${usedBars.length}</div>
<div class="result-subtext">Total: ${totalWeight.toFixed(1)}kg</div>
</div>
<div class="result-card ${unusedPiecesCount > 0 ? 'warning' : 'success'}">
<div class="result-label">Peças Faltando</div>
<div class="result-value">${unusedPiecesCount}</div>
<div class="result-subtext">De ${totalPieces} totais</div>
</div>
<div class="result-card warning grid-full-col">
<div class="result-label">Resumo de Sobras (Retalhos)</div>
<div class="result-value medium-size">Total: ${totalWaste}mm</div>
${scrapsListHtml}
</div>
<div class="result-card">
<div class="result-label">Peso Sucata</div>
<div class="result-value">${(totalWeight * (1 - (efficiency / 100))).toFixed(1)}kg</div>
</div>
<div class="result-card process-info grid-full-col">
<div class="result-label primary">Processo de Corte</div>
<div class="result-value medium-small">${PROCESS_NAMES[selectedProcess]}</div>
<div class="kerf-info">Consumo adicional de material: <strong>${totalKerfLoss} mm</strong></div>
</div>
${simulationWarning}
`;
// Barras - Mostrar todas as barras individualmente
document.getElementById('barsContainer').innerHTML = usedBars.map((bar, idx) => {
const used = bar.pieces.reduce((s, p) => s + p.length, 0);
const eff = ((used / bar.length) * 100).toFixed(2);
return renderBar(bar, idx + 1, used, eff);
}).join('');
lastResults = { usedBars, unusedPieces, totalWeight, totalLength, totalPieces };
if (simulateMode) {
showToast("O cálculo foi realizado em MODO SIMULAÇÃO.", "warning", "Atenção");
}
}
function renderBar(bar, barNum, used, efficiency) {
const scale = 6200 / bar.length;
const noStockLabel = bar.isSimulated ? '<span class="no-stock-badge">SEM ESTOQUE</span>' : '';
// Reconstruindo o SVG corretamente
let svgContent = `<rect x="0" y="150" width="${bar.length * scale}" height="100" fill="#ecf0f1" stroke="#34495e" stroke-width="2"/>`;
let position = 0;
bar.pieces.forEach((piece, idx) => {
const scaledWidth = piece.length * scale;
const colorIdx = idx % colors.length;
svgContent += `
<rect x="${position}" y="150" width="${scaledWidth}" height="100" fill="${colors[colorIdx]}" stroke="#34495e" stroke-width="2"/>
<line x1="${position + scaledWidth}" y1="150" x2="${position + scaledWidth}" y2="250" stroke="#000" stroke-width="2"/>
<text x="${position + scaledWidth / 2}" y="130" class="bar-svg-piece-tag">${piece.tag}</text>
<text x="${position + scaledWidth / 2}" y="340" class="bar-svg-piece-length">${piece.length}</text>`;
position += scaledWidth;
});
const waste = bar.remaining;
if (waste > 0) {
const wasteWidth = waste * scale;
svgContent += `<rect x="${position}" y="150" width="${wasteWidth}" height="100" fill="#e74c3c" stroke="#c0392b" stroke-width="2" stroke-dasharray="10,10"/>
<text x="${position + wasteWidth / 2}" y="130" class="bar-svg-waste-tag">sobra</text>
<text x="${position + wasteWidth / 2}" y="340" class="bar-svg-waste-length">${waste}</text>`;
}
const svg = `<svg viewBox="0 0 6200 400" height="100">${svgContent}</svg>`;
const pieceDetails = groupPieces(bar.pieces);
const table = `
<table class="bar-table">
<tr><th>TAG</th><th>mm</th><th>Qtd</th></tr>
${pieceDetails.map(g => `<tr><td>${g.tag}</td><td>${g.length}</td><td>${g.count}</td></tr>`).join('')}
<tr class="row-waste"><td><strong>SOBRA</strong></td><td>${waste}</td><td>mm</td></tr>
</table>
`;
const effBar = `<div class="efficiency-bar"><div class="efficiency-fill" style="width: ${efficiency}%;">${efficiency}%</div></div>`;
const repetitionText = bar.count > 1 ? '<span class="repetition-text"> × ' + bar.count + '</span>' : '';
return `
<div class="bar-card">
<div class="bar-header">
<div><strong>BARRA ${barNum}</strong>${repetitionText}${noStockLabel}<br><span class="bar-subtitle">${bar.barType}</span></div>
<div class="bar-stats">
<div>Usado: <strong>${used}mm</strong></div>
<div>Peso: <strong>${bar.weight}kg</strong></div>
</div>
</div>
${svg}
${table}
${effBar}
</div>
`;
}
function groupPieces(pieces) {
const groups = {};
pieces.forEach(p => {
if (!groups[p.tag]) groups[p.tag] = { length: p.length, count: 0 };
groups[p.tag].count++;
});
return Object.entries(groups).map(([tag, data]) => ({ tag, ...data }));
}
function clearAll() {
availableBars = [];
demandPieces = [];
lastResults = null;
renderBars();
renderPieces();
document.getElementById('summaryResults').innerHTML = '<div class="no-data grid-full-col">Calcule a otimização para ver os resultados</div>';
document.getElementById('barsContainer').innerHTML = '<div class="no-data">Resultados aparecerão após o cálculo</div>';
}
function generateReportHTML() {
if (!lastResults) return null;
const { usedBars, totalWeight } = lastResults;
const dateStr = new Date().toLocaleDateString('pt-BR');
const timeStr = new Date().toLocaleTimeString('pt-BR');
const totalPieces = demandPieces.reduce((s, p) => s + p.qty, 0);
// Paginação simples: 3 barras por página
const barsPerPage = 3;
const pages = [];
for (let i = 0; i < usedBars.length; i += barsPerPage) {
pages.push({
items: usedBars.slice(i, i + barsPerPage).map((bar, idx) => ({ type: 'bar', data: bar, index: i + idx + 1 })),
type: 'bars'
});
}
// Adicionar resumo na última página ou nova página
pages.push({
items: [{ type: 'summary', data: lastResults }],
type: 'summary'
});
const totalPages = pages.length;
const jobTitle = document.getElementById('jobTitle').value.trim() || 'Relatório de Corte';
let htmlContent = pages.map((page, pageIdx) => {
const pageNum = pageIdx + 1;
let pageBody = page.items.map(item => {
if (item.type === 'bar') return renderReportBar(item.data, item.index);
if (item.type === 'summary') return renderReportSummary(item.data);
return '';
}).join('');
return `
<div class="page">
<header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h1>📊 ${jobTitle}</h1>
<p>Gerado em: ${dateStr} às ${timeStr}</p>
</div>
<div style="text-align: right; font-size: 10pt;">
Página <strong>${pageNum}</strong> de <strong>${totalPages}</strong>
</div>
</div>
</header>
<div class="content">
${pageBody}
</div>
<footer>
Otimizador de Corte | Total de Peças: ${totalPieces} | Aproveitamento Global: ${((1 - (lastResults.usedBars.reduce((s, b) => s + b.remaining, 0) / lastResults.usedBars.reduce((s, b) => s + b.length, 0))) * 100).toFixed(2)}%
</footer>
</div>`;
}).join('');
return `
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Relatório de Corte - ${dateStr}</title>
<style>
:root { --primary: #1a6b8f; --success: #27ae60; --warning: #e67e22; --danger: #e74c3c; --gray: #bdc3c7; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #555; font-family: 'Segoe UI', Arial, sans-serif; }
.page {
background: white;
width: 210mm;
height: 297mm;
margin: 20px auto;
padding: 10mm;
position: relative;
box-shadow: 0 0 10px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
}
@media print {
@page { size: A4; margin: 0; }
body { background: white; margin: 0; padding: 0; }
.page {
margin: 0;
box-shadow: none;
page-break-after: always;
width: 100%;
height: 100vh;
border: none;
}
}
header {
border-bottom: 2px solid var(--primary);
padding-bottom: 10px;
margin-bottom: 20px;
color: var(--primary);
}
header h1 { font-size: 16pt; margin-bottom: 5px; }
header p { font-size: 9pt; color: #666; }
footer {
position: absolute;
bottom: 10mm;
left: 10mm;
right: 10mm;
border-top: 1px solid var(--gray);
padding-top: 10px;
text-align: center;
font-size: 8pt;
color: #888;
}
.content { flex: 1; }
/* Estilos dos Cards */
.bar-section {
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
margin-bottom: 15px;
page-break-inside: avoid;
}
.bar-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 10pt;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
}
.bar-stats { font-size: 9pt; color: #666; }
svg { width: 100%; height: 80px; background: #f9f9f9; border: 1px solid #eee; margin-bottom: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 8pt; }
th { background: var(--primary); color: white; text-align: left; padding: 4px; }
td { border-bottom: 1px solid #eee; padding: 3px 4px; }
tr:nth-child(even) { background: #f9f9f9; }
.summary-box {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 5px;
border: 1px solid #ddd;
}
.summary-item { text-align: center; }
.summary-label { font-size: 8pt; color: #666; text-transform: uppercase; }
.summary-value { font-size: 14pt; font-weight: bold; color: var(--primary); }
</style>
</head>
<body>
${htmlContent}
</body>
</html>`;
}
function renderReportBar(bar, index) {
const used = bar.pieces.reduce((s, p) => s + p.length, 0);
const efficiency = ((used / bar.length) * 100).toFixed(2);
const waste = bar.remaining;
const groups = groupPieces(bar.pieces);
const scale = 6200 / bar.length;
let svgContent = `<rect x="0" y="150" width="${bar.length * scale}" height="100" fill="#ecf0f1" stroke="#bdc3c7" />`;
let pos = 0;
bar.pieces.forEach((p, i) => {
const w = p.length * scale;
svgContent += `<rect x="${pos}" y="150" width="${w}" height="100" fill="${colors[i % colors.length]}" stroke="#34495e" />`;
svgContent += `<text x="${pos + w / 2}" y="130" font-size="120" fill="#333" text-anchor="middle" font-weight="bold">${p.tag}</text>`;
pos += w;
});
if (waste > 0) {
svgContent += `<rect x="${pos}" y="150" width="${waste * scale}" height="100" fill="#e74c3c" stroke="#c0392b" stroke-dasharray="10,10" opacity="0.5" />`;
svgContent += `<text x="${pos + (waste * scale) / 2}" y="130" font-size="100" fill="#c0392b" text-anchor="middle">sobra</text>`;
}
const repetition = bar.count > 1 ? `<span style="background:var(--warning); color:white; padding:1px 4px; border-radius:3px; font-size:8pt;">${bar.count} cópias idênticas</span>` : '';
return `
<div class="bar-section">
<div class="bar-header">
<div><strong>BARRA ${index}</strong> - ${bar.barType} ${repetition}</div>
<div class="bar-stats">Eficiência: <strong>${efficiency}%</strong> | Sobra: <span style="color:var(--danger)">${waste}mm</span></div>
</div>
<svg viewBox="0 0 6200 400">${svgContent}</svg>
<table>
<thead><tr><th>Peça</th><th>Comp. (mm)</th><th>Qtd no Corte</th></tr></thead>
<tbody>
${groups.map(g => `<tr><td>${g.tag}</td><td>${g.length}</td><td>${g.count}</td></tr>`).join('')}
${waste > 0 ? `<tr style="color:var(--danger); font-style:italic;"><td>SOBRA</td><td>${waste}</td><td>-</td></tr>` : ''}
</tbody>
</table>
</div>`;
}
function renderReportSummary(data) {
const totalWaste = data.usedBars.reduce((s, b) => s + b.remaining, 0);
const totalLength = data.usedBars.reduce((s, b) => s + b.length, 0);
const efficiency = ((1 - totalWaste / totalLength) * 100).toFixed(2);
const totalKerfLoss = data.usedBars.reduce((sum, bar) => sum + (bar.pieces.length * kerfSize), 0);
return `
<div style="margin-top: 30px; border-top: 2px solid #eee; padding-top: 20px;">
<h3>📌 Resumo Final</h3>
<div class="summary-box">
<div class="summary-item">
<div class="summary-label">Total de Barras</div>
<div class="summary-value">${data.usedBars.length}</div>
</div>
<div class="summary-item">
<div class="summary-label">Peso Total</div>
<div class="summary-value">${data.totalWeight.toFixed(1)} kg</div>
</div>
<div class="summary-item">
<div class="summary-label">Sobra Total</div>
<div class="summary-value" style="color:var(--danger)">${totalWaste} mm</div>
</div>
<div class="summary-item">
<div class="summary-label">Eficiência Global</div>
<div class="summary-value" style="color:var(--success)">${efficiency}%</div>
</div>
<div class="summary-item" style="grid-column: span 4; margin-top: 10px; border-top: 1px solid #eee; padding-top: 10px;">
<div class="summary-label">Processo: <strong style="color:var(--primary)">${PROCESS_NAMES[selectedProcess]}</strong></div>
<div style="font-size: 9pt; color: #666;">Perda estimada por corte (Kerf): <strong>${totalKerfLoss} mm</strong></div>
</div>
</div>
</div>`;
}
function exportHTML() {
const html = generateReportHTML();
if (!html) {
showToast('Calcule a otimização primeiro', 'warning');
return;
}
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'relatorio-corte-' + new Date().toISOString().split('T')[0] + '.html';
a.click();
}
function printReport() {
const html = generateReportHTML();
if (!html) {
showToast('Calcule a otimização primeiro', 'warning');
return;
}
const win = window.open('', '_blank');
win.document.write(html);
win.document.close();
setTimeout(() => {
win.print();
}, 500);
}
// Expose functions to window for HTML onclick events
window.addBar = addBar;
window.removeBar = removeBar;
window.addPiece = addPiece;
window.removePiece = removePiece;
window.editPiece = editPiece;
window.cancelEdit = cancelEdit;
window.importFile = importFile;
window.calculateOptimization = calculateOptimization;
window.clearAll = clearAll;
window.exportHTML = exportHTML;
// ===== GERENCIAMENTO DE TRABALHO =====
// ===== GERENCIAMENTO DE TRABALHO =====
function saveJob() {
const title = document.getElementById('jobTitle').value.trim() || 'Trabalho Sem Titulo';
// Formato CSV Customizado: TYPE, P1, P2, P3, P4
let csvContent = "TYPE,PARAM1,PARAM2,PARAM3,PARAM4\n";
// 1. Metadata
csvContent += `JOB,${title},,,\n`;
csvContent += `METADATA,PROCESS,${selectedProcess},,\n`;
// 2. Barras
availableBars.forEach(b => {
csvContent += `BAR,${b.desc},${b.qty},${b.length},${b.weight}\n`;
});
// 3. Peças
demandPieces.forEach(p => {
csvContent += `PIECE,${p.tag},${p.length},${p.qty},\n`;
});
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", `${title}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function loadJob() {
const fileInput = document.getElementById('jobImport');
const file = fileInput.files[0];
if (!file) return;
Papa.parse(file, {
header: true,
skipEmptyLines: true,
complete: function (results) {
try {
let jobTitleFound = "";
const newBars = [];
const newPieces = [];
const duplicateBars = [];
results.data.forEach(row => {
const type = row.TYPE;
if (type === 'JOB') {
jobTitleFound = row.PARAM1;
} else if (type === 'METADATA' && row.PARAM1 === 'PROCESS') {
selectedProcess = row.PARAM2;
// Update UI
const radios = document.getElementsByName('cuttingProcess');
for (const radio of radios) {
if (radio.value === selectedProcess) {
radio.checked = true;
}
}
updateProcess();
} else if (type === 'BAR') {
const desc = row.PARAM1;
if (availableBars.some(b => b.desc === desc)) {
duplicateBars.push(desc);
}
newBars.push({
id: Date.now() + Math.random(),
desc: desc,
qty: parseInt(row.PARAM2),
length: parseInt(row.PARAM3),
weight: parseFloat(row.PARAM4),
remainingQty: parseInt(row.PARAM2)
});
} else if (type === 'PIECE') {
newPieces.push({
id: Date.now() + Math.random(),
tag: row.PARAM1,
length: parseInt(row.PARAM2),
qty: parseInt(row.PARAM3)
});
}
});
const finishLoad = () => {
// Limpar estado atual
clearAll();
if (jobTitleFound) document.getElementById('jobTitle').value = jobTitleFound;
newBars.forEach(b => availableBars.push(b));
newPieces.forEach(p => demandPieces.push(p));
renderBars();
renderPieces();
showToast('Trabalho carregado com sucesso!', 'success');
};
// If the list is not empty, warn user that current data will be lost
if (availableBars.length > 0 || demandPieces.length > 0) {
showConfirmModal(
"Substituir Trabalho Atual?",
"Carregar um novo trabalho irá limpar todas as barras e peças atuais.\n\nDeseja continuar?",
finishLoad
);
} else {
finishLoad();
}
} catch (e) {
showToast('Erro ao carregar arquivo: ' + e.message, 'error');
}
}
});
fileInput.value = '';
}
// Expose functions to window for HTML onclick events
window.addBar = addBar;
window.removeBar = removeBar;
window.editBar = editBar;
window.cancelBarEdit = cancelBarEdit;
window.addPiece = addPiece;
window.removePiece = removePiece;
window.importFile = importFile;
window.calculateOptimization = calculateOptimization;
window.clearAll = clearAll;
window.exportHTML = exportHTML;
window.printReport = printReport;
window.saveJob = saveJob;
window.loadJob = loadJob;
window.closeModal = closeModal;
window.updateProcess = updateProcess;
// THEME HANDLING
function toggleTheme() {
const body = document.body;
body.classList.toggle('dark-theme');
const isDark = body.classList.contains('dark-theme');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
updateThemeIcon(isDark);
}
function updateThemeIcon(isDark) {
const btn = document.getElementById('themeToggle');
if (btn) {
btn.textContent = isDark ? '☀️' : '🌙';
btn.title = isDark ? 'Mudar para Modo Claro' : 'Mudar para Modo Escuro';
}
}
function initTheme() {
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
document.body.classList.add('dark-theme');
updateThemeIcon(true);
} else {
document.body.classList.remove('dark-theme');
updateThemeIcon(false);
}
}
window.toggleTheme = toggleTheme;
initTheme();
renderBars();
renderPieces();
updateProcess(); // Initialize kerf size based on default checked radio