feat(conversores): substituir conversor advance steel por modal pop-up de extracao PDF para XLSX
This commit is contained in:
@@ -0,0 +1,695 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Conversor de Lista de Peças PDF para Excel</title>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #1e3a8a;
|
||||||
|
--primary-hover: #1d4ed8;
|
||||||
|
--accent: #0284c7;
|
||||||
|
--bg: #f8fafc;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text: #0f172a;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--success: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 24px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
padding-bottom: 18px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 12px;
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #0369a1;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 36px 20px;
|
||||||
|
text-align: center;
|
||||||
|
background: #f1f5f9;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone:hover, .upload-zone.dragover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: #e0f2fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone .btn-select {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-export {
|
||||||
|
background: var(--success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-export:hover {
|
||||||
|
background: #15803d;
|
||||||
|
}
|
||||||
|
.btn-export:disabled {
|
||||||
|
background: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: 550px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-yes {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
.status-no {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-panel {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #38bdf8;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fileInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Extrator & Conversor de Lista de Peças (Corrigido)</h1>
|
||||||
|
<p style="color: var(--text-muted); font-size: 13px; margin-top: 4px;">Conversão 100% no navegador (Client-Side) com regras ajustadas</p>
|
||||||
|
</div>
|
||||||
|
<div class="badge">Autônomo / Sem Servidor</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="upload-zone" id="dropZone">
|
||||||
|
<svg width="40" height="40" fill="none" stroke="#64748b" stroke-width="2" viewBox="0 0 24 24" style="margin: 0 auto;">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||||
|
</svg>
|
||||||
|
<p>Arraste e solte o arquivo <strong>PDF da Lista de Peças Estruturada</strong> aqui</p>
|
||||||
|
<div class="btn-select">Selecionar PDF do Computador</div>
|
||||||
|
<input type="file" id="fileInput" accept="application/pdf">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-bar">
|
||||||
|
<div class="stats-card" id="statsCard">
|
||||||
|
Aguardando arquivo PDF...
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-export" id="btnExport" disabled>
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||||
|
</svg>
|
||||||
|
Baixar Planilha Excel (.xlsx)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table id="resultTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>OF</th>
|
||||||
|
<th>Fase</th>
|
||||||
|
<th>Marca (Nº Peça)</th>
|
||||||
|
<th>Descrição (Nome)</th>
|
||||||
|
<th>Composto?</th>
|
||||||
|
<th>Quantidade</th>
|
||||||
|
<th>Material Principal</th>
|
||||||
|
<th>Perfil Principal (Maior Comp.)</th>
|
||||||
|
<th>Comp. Max (mm)</th>
|
||||||
|
<th>Peso Unit. (kg)</th>
|
||||||
|
<th>Peso Total (kg)</th>
|
||||||
|
<th>Tratamento Superficial</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tableBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="12" style="text-align:center; padding: 24px; color: var(--text-muted);">
|
||||||
|
Nenhum dado extraído ainda. Carregue um PDF acima.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log-panel" id="logPanel"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||||
|
|
||||||
|
const dropZone = document.getElementById('dropZone');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const btnExport = document.getElementById('btnExport');
|
||||||
|
const tableBody = document.getElementById('tableBody');
|
||||||
|
const statsCard = document.getElementById('statsCard');
|
||||||
|
const logPanel = document.getElementById('logPanel');
|
||||||
|
|
||||||
|
let extractedData = [];
|
||||||
|
let currentFileName = 'Lista_Pecas';
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
logPanel.style.display = 'block';
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.textContent = `> ${msg}`;
|
||||||
|
logPanel.appendChild(line);
|
||||||
|
logPanel.scrollTop = logPanel.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropZone.addEventListener('click', () => fileInput.click());
|
||||||
|
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
||||||
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('dragover');
|
||||||
|
if (e.dataTransfer.files.length > 0) {
|
||||||
|
handleFile(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
if (e.target.files.length > 0) {
|
||||||
|
handleFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleFile(file) {
|
||||||
|
if (file.type !== 'application/pdf' && !file.name.endsWith('.pdf')) {
|
||||||
|
alert('Por favor, selecione um arquivo no formato PDF válido.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFileName = file.name.replace(/\.[^/.]+$/, "");
|
||||||
|
statsCard.innerHTML = `Processando: <strong>${file.name}</strong>...`;
|
||||||
|
logPanel.innerHTML = '';
|
||||||
|
log(`Carregando PDF: ${file.name}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||||
|
log(`PDF carregado com sucesso! Total de páginas: ${pdf.numPages}`);
|
||||||
|
|
||||||
|
const allItems = [];
|
||||||
|
for (let p = 1; p <= pdf.numPages; p++) {
|
||||||
|
const page = await pdf.getPage(p);
|
||||||
|
const textContent = await page.getTextContent();
|
||||||
|
textContent.items.forEach(item => {
|
||||||
|
if (item.str && item.str.trim() !== '') {
|
||||||
|
allItems.push({
|
||||||
|
text: item.str.trim(),
|
||||||
|
x: item.transform[4],
|
||||||
|
y: item.transform[5],
|
||||||
|
page: p
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Total de elementos de texto extraídos: ${allItems.length}`);
|
||||||
|
processPdfLines(allItems);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Erro ao processar o arquivo PDF: ' + err.message);
|
||||||
|
log(`ERRO: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumeric(valStr) {
|
||||||
|
if (!valStr) return 0;
|
||||||
|
let clean = valStr.toString().trim();
|
||||||
|
if (clean.includes(',') && clean.includes('.')) {
|
||||||
|
if (clean.indexOf('.') < clean.indexOf(',')) {
|
||||||
|
clean = clean.replace(/\./g, '').replace(',', '.');
|
||||||
|
} else {
|
||||||
|
clean = clean.replace(/,/g, '');
|
||||||
|
}
|
||||||
|
} else if (clean.includes(',')) {
|
||||||
|
clean = clean.replace(',', '.');
|
||||||
|
} else if (clean.includes('.')) {
|
||||||
|
const parts = clean.split('.');
|
||||||
|
if (parts.length === 2 && parts[1].length === 3) {
|
||||||
|
clean = parts[0] + parts[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const num = parseFloat(clean);
|
||||||
|
return isNaN(num) ? 0 : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processPdfLines(items) {
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.page !== b.page) return a.page - b.page;
|
||||||
|
if (Math.abs(b.y - a.y) > 3) return b.y - a.y;
|
||||||
|
return a.x - b.x;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
let currentLine = [];
|
||||||
|
let lastY = null;
|
||||||
|
let lastPage = null;
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
if (lastPage === null || item.page !== lastPage || Math.abs(item.y - lastY) > 3.5) {
|
||||||
|
if (currentLine.length > 0) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
currentLine = [item];
|
||||||
|
lastY = item.y;
|
||||||
|
lastPage = item.page;
|
||||||
|
} else {
|
||||||
|
currentLine.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (currentLine.length > 0) lines.push(currentLine);
|
||||||
|
|
||||||
|
log(`Total de linhas identificadas: ${lines.length}`);
|
||||||
|
|
||||||
|
let defaultOf = "";
|
||||||
|
for (const line of lines) {
|
||||||
|
const lineText = line.map(i => i.text).join(' ');
|
||||||
|
const matchTrabalho = lineText.match(/Trabalho:\s*([A-Za-z0-9\-]+)/i);
|
||||||
|
if (matchTrabalho) {
|
||||||
|
defaultOf = matchTrabalho[1].replace('-', '');
|
||||||
|
log(`OF / Trabalho identificado no cabeçalho: ${defaultOf}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assemblies = [];
|
||||||
|
let currentAssembly = null;
|
||||||
|
|
||||||
|
// Filtro estrito para evitar linhas duplicadas/ruídos de componentes repetidos na listagem
|
||||||
|
const processedMarks = new Set();
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const lineText = line.map(i => i.text).join(' ');
|
||||||
|
|
||||||
|
if (lineText.includes('Lista de Peças') || lineText.includes('Cliente:') || lineText.includes('Marca Quant Nome') || lineText.includes('Superfície')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstToken = line[0].text;
|
||||||
|
const markMatch = firstToken.match(/^([A-Za-z0-9]+)-(\d+)-(\d+)$/) || firstToken.match(/^([A-Za-z0-9]+)-(\d+)$/);
|
||||||
|
|
||||||
|
if (markMatch) {
|
||||||
|
const parts = firstToken.split('-');
|
||||||
|
let ofCode = "";
|
||||||
|
let phaseCode = "0";
|
||||||
|
let pieceNumber = "";
|
||||||
|
|
||||||
|
if (parts.length === 3) {
|
||||||
|
ofCode = parts[0];
|
||||||
|
phaseCode = parts[1];
|
||||||
|
pieceNumber = parts[2];
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
ofCode = parts[0];
|
||||||
|
phaseCode = "0";
|
||||||
|
pieceNumber = parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pieceNumInt = parseInt(pieceNumber, 10);
|
||||||
|
const isComponent = !isNaN(pieceNumInt) && pieceNumInt >= 1000;
|
||||||
|
|
||||||
|
if (!isComponent) {
|
||||||
|
// Evitar duplicidade caso a mesma marca principal apareça mais de uma vez agrupada
|
||||||
|
if (processedMarks.has(firstToken)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
processedMarks.add(firstToken);
|
||||||
|
|
||||||
|
let mainQuant = 1;
|
||||||
|
let mainDesc = "";
|
||||||
|
|
||||||
|
if (line.length >= 2) {
|
||||||
|
const qVal = parseInt(line[1].text, 10);
|
||||||
|
if (!isNaN(qVal)) {
|
||||||
|
mainQuant = qVal;
|
||||||
|
// CORREÇÃO 2: Garantir que a descrição seja única, pegando apenas o primeiro token descritivo limpo (ex: W 310x38.7)
|
||||||
|
let rawDescTokens = line.slice(2).map(item => item.text);
|
||||||
|
if (rawDescTokens.length > 0) {
|
||||||
|
mainDesc = rawDescTokens[0]; // Nome único/perfil inicial da peça principal
|
||||||
|
// Se o perfil tiver complemento separado por espaço (ex: W 310x38.7), agrupar se necessário ou manter limpo
|
||||||
|
if (rawDescTokens.length > 1 && (rawDescTokens[1].includes('x') || rawDescTokens[1].includes('Pl') || !isNaN(parseFloat(rawDescTokens[1])))) {
|
||||||
|
mainDesc = rawDescTokens.slice(0, 2).join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let rawDescTokens = line.slice(1).map(item => item.text);
|
||||||
|
mainDesc = rawDescTokens.length > 0 ? rawDescTokens[0] : 'ESTRUTURA';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAssembly = {
|
||||||
|
rawMark: firstToken,
|
||||||
|
of: ofCode || defaultOf,
|
||||||
|
fase: phaseCode,
|
||||||
|
marca: pieceNumber, // CORREÇÃO 1: Apenas o número da peça (última numeração)
|
||||||
|
numeroPeca: pieceNumber,
|
||||||
|
descricao: mainDesc || 'ESTRUTURA',
|
||||||
|
quantidade: mainQuant,
|
||||||
|
pesoUnitario: 0,
|
||||||
|
pesoTotal: 0,
|
||||||
|
components: []
|
||||||
|
};
|
||||||
|
assemblies.push(currentAssembly);
|
||||||
|
} else {
|
||||||
|
// Componente subordinado
|
||||||
|
if (currentAssembly) {
|
||||||
|
let compQuant = 1;
|
||||||
|
let compProfile = "";
|
||||||
|
let compMaterial = "A36";
|
||||||
|
let compLength = 0;
|
||||||
|
let compWeightTotal = 0;
|
||||||
|
|
||||||
|
let compTokens = line.map(it => it.text);
|
||||||
|
if (compTokens.length >= 2) {
|
||||||
|
const qTest = parseInt(compTokens[1], 10);
|
||||||
|
if (!isNaN(qTest)) compQuant = qTest;
|
||||||
|
}
|
||||||
|
|
||||||
|
let matIndex = -1;
|
||||||
|
for (let k = 1; k < compTokens.length; k++) {
|
||||||
|
const tok = compTokens[k].toUpperCase();
|
||||||
|
if (tok.includes('A572') || tok.includes('A36') || tok.includes('A500') || tok.includes('A106') || tok.includes('GR') || tok.includes('INOX') || tok.includes('SAE')) {
|
||||||
|
matIndex = k;
|
||||||
|
compMaterial = compTokens[k];
|
||||||
|
if (k + 1 < compTokens.length && (compTokens[k+1].toUpperCase().includes('GR') || compTokens[k+1] === '50')) {
|
||||||
|
compMaterial += ' ' + compTokens[k+1];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matIndex > 2) {
|
||||||
|
compProfile = compTokens.slice(2, matIndex).join(' ');
|
||||||
|
} else if (compTokens.length >= 3) {
|
||||||
|
compProfile = compTokens[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterMatTokens = matIndex !== -1 ? compTokens.slice(matIndex + 1) : compTokens.slice(3);
|
||||||
|
const numericValues = [];
|
||||||
|
afterMatTokens.forEach(t => {
|
||||||
|
const cleanT = t.replace(/[()]/g, '');
|
||||||
|
if (/[0-9]/.test(cleanT) && !cleanT.toUpperCase().includes('GR')) {
|
||||||
|
numericValues.push(cleanT);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (numericValues.length >= 1) {
|
||||||
|
compLength = parseNumeric(numericValues[0]);
|
||||||
|
}
|
||||||
|
// O peso total do componente geralmente é o último ou penúltimo valor numérico da linha de detalhe
|
||||||
|
if (numericValues.length >= 3) {
|
||||||
|
compWeightTotal = parseNumeric(numericValues[numericValues.length - 1]);
|
||||||
|
} else if (numericValues.length === 2) {
|
||||||
|
compWeightTotal = parseNumeric(numericValues[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAssembly.components.push({
|
||||||
|
quantidade: compQuant,
|
||||||
|
perfil: compProfile,
|
||||||
|
material: compMaterial,
|
||||||
|
comprimento: compLength,
|
||||||
|
pesoTotal: compWeightTotal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Linha de subtotal ou peso geral da peça principal
|
||||||
|
if (currentAssembly && line.length <= 3) {
|
||||||
|
const possibleTotal = parseNumeric(line[0].text);
|
||||||
|
if (possibleTotal > 10 && currentAssembly.pesoTotal === 0) {
|
||||||
|
currentAssembly.pesoTotal = possibleTotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Total de Peças Principais processadas: ${assemblies.length}`);
|
||||||
|
|
||||||
|
extractedData = assemblies.map(asm => {
|
||||||
|
const hasComponents = asm.components.length > 0;
|
||||||
|
|
||||||
|
let perfilPrincipal = asm.descricao;
|
||||||
|
let materialPrincipal = "A36";
|
||||||
|
let maxComp = 0;
|
||||||
|
let sumComponentsWeight = 0;
|
||||||
|
|
||||||
|
if (hasComponents) {
|
||||||
|
let bestComp = asm.components[0];
|
||||||
|
asm.components.forEach(c => {
|
||||||
|
if (c.comprimento > maxComp) {
|
||||||
|
maxComp = c.comprimento;
|
||||||
|
bestComp = c;
|
||||||
|
}
|
||||||
|
sumComponentsWeight += c.pesoTotal;
|
||||||
|
});
|
||||||
|
|
||||||
|
perfilPrincipal = bestComp.perfil || asm.descricao;
|
||||||
|
materialPrincipal = bestComp.material || "A36";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definir peso total final (prioriza o subtotal extraído do relatório)
|
||||||
|
let finalPesoTotal = asm.pesoTotal > 0 ? asm.pesoTotal : sumComponentsWeight;
|
||||||
|
|
||||||
|
// CORREÇÃO 3: Peso Unitário = Peso Total dividido pela quantidade da peça principal
|
||||||
|
let finalPesoUnit = asm.quantidade > 0 ? (finalPesoTotal / asm.quantidade) : finalPesoTotal;
|
||||||
|
|
||||||
|
return {
|
||||||
|
of: asm.of,
|
||||||
|
fase: asm.fase,
|
||||||
|
marca: asm.marca,
|
||||||
|
descricao: asm.descricao,
|
||||||
|
isComposed: hasComponents ? "SIM" : "NÃO",
|
||||||
|
quantidade: asm.quantidade,
|
||||||
|
material: materialPrincipal,
|
||||||
|
perfilPrincipal: perfilPrincipal,
|
||||||
|
comprimentoMax: maxComp > 0 ? maxComp : "-",
|
||||||
|
pesoUnit: finalPesoUnit > 0 ? finalPesoUnit.toFixed(2) : "-",
|
||||||
|
pesoTotal: finalPesoTotal > 0 ? finalPesoTotal.toFixed(2) : "-",
|
||||||
|
tratamentoSuperficial: "pintura"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
|
||||||
|
if (extractedData.length === 0) {
|
||||||
|
tableBody.innerHTML = `<tr><td colspan="12" style="text-align:center; padding: 20px; color: #ef4444;">Nenhuma peça identificada no PDF. Verifique o documento.</td></tr>`;
|
||||||
|
btnExport.disabled = true;
|
||||||
|
statsCard.innerHTML = `Nenhum registro encontrado.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
extractedData.forEach(row => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><strong>${row.of}</strong></td>
|
||||||
|
<td>${row.fase}</td>
|
||||||
|
<td><strong>${row.marca}</strong></td>
|
||||||
|
<td>${row.descricao}</td>
|
||||||
|
<td><span class="status-badge ${row.isComposed === 'SIM' ? 'status-yes' : 'status-no'}">${row.isComposed}</span></td>
|
||||||
|
<td style="text-align:center;">${row.quantidade}</td>
|
||||||
|
<td>${row.material}</td>
|
||||||
|
<td><strong>${row.perfilPrincipal}</strong></td>
|
||||||
|
<td style="text-align:right;">${row.comprimentoMax}</td>
|
||||||
|
<td style="text-align:right;">${row.pesoUnit}</td>
|
||||||
|
<td style="text-align:right; font-weight:600;">${row.pesoTotal}</td>
|
||||||
|
<td>${row.tratamentoSuperficial}</td>
|
||||||
|
`;
|
||||||
|
tableBody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
statsCard.innerHTML = `<strong>${extractedData.length}</strong> peças principais extraídas com sucesso!`;
|
||||||
|
btnExport.disabled = false;
|
||||||
|
log(`Tabela renderizada com ${extractedData.length} linhas.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
btnExport.addEventListener('click', () => {
|
||||||
|
if (extractedData.length === 0) return;
|
||||||
|
|
||||||
|
const excelRows = extractedData.map(item => ({
|
||||||
|
"OF": item.of,
|
||||||
|
"Fase": isNaN(Number(item.fase)) ? item.fase : Number(item.fase),
|
||||||
|
"Marca": item.marca,
|
||||||
|
"Descrição": item.descricao,
|
||||||
|
"Composto por Componentes?": item.isComposed,
|
||||||
|
"Quantidade": Number(item.quantidade),
|
||||||
|
"Peso Unitário (kg)": item.pesoUnit === "-" ? "" : Number(item.pesoUnit),
|
||||||
|
"Peso Total (kg)": item.pesoTotal === "-" ? "" : Number(item.pesoTotal),
|
||||||
|
"Tratamento Superficial": item.tratamentoSuperficial,
|
||||||
|
"Material": item.material,
|
||||||
|
"Perfil Principal": item.perfilPrincipal,
|
||||||
|
"Comprimento Ref. (mm)": item.comprimentoMax === "-" ? "" : Number(item.comprimentoMax)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
const ws = XLSX.utils.json_to_sheet(excelRows);
|
||||||
|
|
||||||
|
ws['!cols'] = [
|
||||||
|
{ wch: 12 }, { wch: 8 }, { wch: 12 }, { wch: 20 },
|
||||||
|
{ wch: 15 }, { wch: 12 }, { wch: 18 }, { wch: 16 },
|
||||||
|
{ wch: 22 }, { wch: 16 }, { wch: 22 }, { wch: 20 }
|
||||||
|
];
|
||||||
|
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "Lista_Pecas");
|
||||||
|
const outFileName = `${currentFileName}_Corrigido.xlsx`;
|
||||||
|
XLSX.writeFile(wb, outFileName);
|
||||||
|
log(`Arquivo Excel exportado: ${outFileName}`);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,16 +1,118 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { Upload, FileSpreadsheet } from 'lucide-react';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Upload, FileSpreadsheet, Download, RefreshCw, Check, AlertCircle, Terminal, FileText } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
const AdvanceSteelConverter: React.FC = () => {
|
export interface ExtractedPiece {
|
||||||
|
of: string;
|
||||||
|
fase: string;
|
||||||
|
marca: string;
|
||||||
|
descricao: string;
|
||||||
|
isComposed: 'SIM' | 'NÃO';
|
||||||
|
quantidade: number;
|
||||||
|
material: string;
|
||||||
|
perfilPrincipal: string;
|
||||||
|
comprimentoMax: number | string;
|
||||||
|
pesoUnit: number | string;
|
||||||
|
pesoTotal: number | string;
|
||||||
|
tratamentoSuperficial: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComponentItem {
|
||||||
|
quantidade: number;
|
||||||
|
perfil: string;
|
||||||
|
material: string;
|
||||||
|
comprimento: number;
|
||||||
|
pesoTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssemblyItem {
|
||||||
|
rawMark: string;
|
||||||
|
of: string;
|
||||||
|
fase: string;
|
||||||
|
marca: string;
|
||||||
|
numeroPeca: string;
|
||||||
|
descricao: string;
|
||||||
|
quantidade: number;
|
||||||
|
pesoUnitario: number;
|
||||||
|
pesoTotal: number;
|
||||||
|
components: ComponentItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PdfItem {
|
||||||
|
text: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdvanceSteelConverterProps {
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PdfTextItem {
|
||||||
|
str: string;
|
||||||
|
transform: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PdfPage {
|
||||||
|
getTextContent: () => Promise<{ items: PdfTextItem[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PdfDocument {
|
||||||
|
numPages: number;
|
||||||
|
getPage: (pageNumber: number) => Promise<PdfPage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PdfJsLib {
|
||||||
|
GlobalWorkerOptions: { workerSrc: string };
|
||||||
|
getDocument: (options: { data: ArrayBuffer }) => { promise: Promise<PdfDocument> };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carregador dinâmico do PDF.js
|
||||||
|
const loadPdfJs = async (): Promise<PdfJsLib> => {
|
||||||
|
const win = window as unknown as { pdfjsLib?: PdfJsLib };
|
||||||
|
if (win.pdfjsLib) {
|
||||||
|
return win.pdfjsLib;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
|
||||||
|
script.onload = () => {
|
||||||
|
const pdfjs = (window as unknown as { pdfjsLib?: PdfJsLib }).pdfjsLib;
|
||||||
|
if (pdfjs) {
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||||
|
resolve(pdfjs);
|
||||||
|
} else {
|
||||||
|
reject(new Error('pdfjsLib não foi encontrado após o carregamento.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
script.onerror = () => reject(new Error('Falha ao carregar a biblioteca PDF.js via CDN.'));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const AdvanceSteelConverterContent: React.FC = () => {
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [divideBy1000, setDivideBy1000] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [extractedData, setExtractedData] = useState<ExtractedPiece[]>([]);
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [statusText, setStatusText] = useState<string>('Aguardando arquivo PDF...');
|
||||||
|
const [currentFileName, setCurrentFileName] = useState<string>('Lista_Pecas');
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const addLog = (msg: string) => {
|
||||||
|
setLogs((prev) => [...prev, `> ${msg}`]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -25,9 +127,8 @@ const AdvanceSteelConverter: React.FC = () => {
|
|||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
const files = e.dataTransfer.files;
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
if (files.length > 0) {
|
handleFile(e.dataTransfer.files[0]);
|
||||||
handleFile(files[0]);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,287 +138,588 @@ const AdvanceSteelConverter: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFile = (file: File) => {
|
const parseNumeric = (valStr: string | number): number => {
|
||||||
const validTypes = [
|
if (!valStr) return 0;
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
let clean = valStr.toString().trim();
|
||||||
'application/vnd.ms-excel'
|
if (clean.includes(',') && clean.includes('.')) {
|
||||||
];
|
if (clean.indexOf('.') < clean.indexOf(',')) {
|
||||||
|
clean = clean.replace(/\./g, '').replace(',', '.');
|
||||||
if (!validTypes.includes(file.type)) {
|
} else {
|
||||||
toast.error('Por favor, selecione um arquivo Excel (.xlsx ou .xls)');
|
clean = clean.replace(/,/g, '');
|
||||||
return;
|
}
|
||||||
|
} else if (clean.includes(',')) {
|
||||||
|
clean = clean.replace(',', '.');
|
||||||
|
} else if (clean.includes('.')) {
|
||||||
|
const parts = clean.split('.');
|
||||||
|
if (parts.length === 2 && parts[1].length === 3) {
|
||||||
|
clean = parts[0] + parts[1];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const num = parseFloat(clean);
|
||||||
setSelectedFile(file);
|
return isNaN(num) ? 0 : num;
|
||||||
toast.success('Arquivo selecionado com sucesso!');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const processFile = () => {
|
const processPdfLines = (items: PdfItem[]) => {
|
||||||
if (!selectedFile) {
|
items.sort((a, b) => {
|
||||||
toast.error('Por favor, selecione um arquivo primeiro.');
|
if (a.page !== b.page) return a.page - b.page;
|
||||||
|
if (Math.abs(b.y - a.y) > 3) return b.y - a.y;
|
||||||
|
return a.x - b.x;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines: PdfItem[][] = [];
|
||||||
|
let currentLine: PdfItem[] = [];
|
||||||
|
let lastY: number | null = null;
|
||||||
|
let lastPage: number | null = null;
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (lastPage === null || item.page !== lastPage || Math.abs(item.y - (lastY ?? item.y)) > 3.5) {
|
||||||
|
if (currentLine.length > 0) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
currentLine = [item];
|
||||||
|
lastY = item.y;
|
||||||
|
lastPage = item.page;
|
||||||
|
} else {
|
||||||
|
currentLine.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (currentLine.length > 0) lines.push(currentLine);
|
||||||
|
|
||||||
|
addLog(`Total de linhas identificadas: ${lines.length}`);
|
||||||
|
|
||||||
|
let defaultOf = '';
|
||||||
|
for (const line of lines) {
|
||||||
|
const lineText = line.map((i) => i.text).join(' ');
|
||||||
|
const matchTrabalho = lineText.match(/Trabalho:\s*([A-Za-z0-9-]+)/i);
|
||||||
|
if (matchTrabalho) {
|
||||||
|
defaultOf = matchTrabalho[1].replace(/-/g, '');
|
||||||
|
addLog(`OF / Trabalho identificado no cabeçalho: ${defaultOf}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assemblies: AssemblyItem[] = [];
|
||||||
|
let currentAssembly: AssemblyItem | null = null;
|
||||||
|
const processedMarks = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const lineText = line.map((it) => it.text).join(' ');
|
||||||
|
|
||||||
|
if (
|
||||||
|
lineText.includes('Lista de Peças') ||
|
||||||
|
lineText.includes('Cliente:') ||
|
||||||
|
lineText.includes('Marca Quant Nome') ||
|
||||||
|
lineText.includes('Superfície')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstToken = line[0].text;
|
||||||
|
const markMatch = firstToken.match(/^([A-Za-z0-9]+)-(\d+)-(\d+)$/) || firstToken.match(/^([A-Za-z0-9]+)-(\d+)$/);
|
||||||
|
|
||||||
|
if (markMatch) {
|
||||||
|
const parts = firstToken.split('-');
|
||||||
|
let ofCode = '';
|
||||||
|
let phaseCode = '0';
|
||||||
|
let pieceNumber = '';
|
||||||
|
|
||||||
|
if (parts.length === 3) {
|
||||||
|
ofCode = parts[0];
|
||||||
|
phaseCode = parts[1];
|
||||||
|
pieceNumber = parts[2];
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
ofCode = parts[0];
|
||||||
|
phaseCode = '0';
|
||||||
|
pieceNumber = parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pieceNumInt = parseInt(pieceNumber, 10);
|
||||||
|
const isComponent = !isNaN(pieceNumInt) && pieceNumInt >= 1000;
|
||||||
|
|
||||||
|
if (!isComponent) {
|
||||||
|
if (processedMarks.has(firstToken)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
processedMarks.add(firstToken);
|
||||||
|
|
||||||
|
let mainQuant = 1;
|
||||||
|
let mainDesc = '';
|
||||||
|
|
||||||
|
if (line.length >= 2) {
|
||||||
|
const qVal = parseInt(line[1].text, 10);
|
||||||
|
if (!isNaN(qVal)) {
|
||||||
|
mainQuant = qVal;
|
||||||
|
const rawDescTokens = line.slice(2).map((item) => item.text);
|
||||||
|
if (rawDescTokens.length > 0) {
|
||||||
|
mainDesc = rawDescTokens[0];
|
||||||
|
if (
|
||||||
|
rawDescTokens.length > 1 &&
|
||||||
|
(rawDescTokens[1].includes('x') || rawDescTokens[1].includes('Pl') || !isNaN(parseFloat(rawDescTokens[1])))
|
||||||
|
) {
|
||||||
|
mainDesc = rawDescTokens.slice(0, 2).join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const rawDescTokens = line.slice(1).map((item) => item.text);
|
||||||
|
mainDesc = rawDescTokens.length > 0 ? rawDescTokens[0] : 'ESTRUTURA';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAssembly = {
|
||||||
|
rawMark: firstToken,
|
||||||
|
of: ofCode || defaultOf,
|
||||||
|
fase: phaseCode,
|
||||||
|
marca: pieceNumber,
|
||||||
|
numeroPeca: pieceNumber,
|
||||||
|
descricao: mainDesc || 'ESTRUTURA',
|
||||||
|
quantidade: mainQuant,
|
||||||
|
pesoUnitario: 0,
|
||||||
|
pesoTotal: 0,
|
||||||
|
components: []
|
||||||
|
};
|
||||||
|
assemblies.push(currentAssembly);
|
||||||
|
} else {
|
||||||
|
if (currentAssembly) {
|
||||||
|
let compQuant = 1;
|
||||||
|
let compProfile = '';
|
||||||
|
let compMaterial = 'A36';
|
||||||
|
let compLength = 0;
|
||||||
|
let compWeightTotal = 0;
|
||||||
|
|
||||||
|
const compTokens = line.map((it) => it.text);
|
||||||
|
if (compTokens.length >= 2) {
|
||||||
|
const qTest = parseInt(compTokens[1], 10);
|
||||||
|
if (!isNaN(qTest)) compQuant = qTest;
|
||||||
|
}
|
||||||
|
|
||||||
|
let matIndex = -1;
|
||||||
|
for (let k = 1; k < compTokens.length; k++) {
|
||||||
|
const tok = compTokens[k].toUpperCase();
|
||||||
|
if (
|
||||||
|
tok.includes('A572') ||
|
||||||
|
tok.includes('A36') ||
|
||||||
|
tok.includes('A500') ||
|
||||||
|
tok.includes('A106') ||
|
||||||
|
tok.includes('GR') ||
|
||||||
|
tok.includes('INOX') ||
|
||||||
|
tok.includes('SAE')
|
||||||
|
) {
|
||||||
|
matIndex = k;
|
||||||
|
compMaterial = compTokens[k];
|
||||||
|
if (k + 1 < compTokens.length && (compTokens[k + 1].toUpperCase().includes('GR') || compTokens[k + 1] === '50')) {
|
||||||
|
compMaterial += ' ' + compTokens[k + 1];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matIndex > 2) {
|
||||||
|
compProfile = compTokens.slice(2, matIndex).join(' ');
|
||||||
|
} else if (compTokens.length >= 3) {
|
||||||
|
compProfile = compTokens[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterMatTokens = matIndex !== -1 ? compTokens.slice(matIndex + 1) : compTokens.slice(3);
|
||||||
|
const numericValues: string[] = [];
|
||||||
|
afterMatTokens.forEach((t) => {
|
||||||
|
const cleanT = t.replace(/[()]/g, '');
|
||||||
|
if (/[0-9]/.test(cleanT) && !cleanT.toUpperCase().includes('GR')) {
|
||||||
|
numericValues.push(cleanT);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (numericValues.length >= 1) {
|
||||||
|
compLength = parseNumeric(numericValues[0]);
|
||||||
|
}
|
||||||
|
if (numericValues.length >= 3) {
|
||||||
|
compWeightTotal = parseNumeric(numericValues[numericValues.length - 1]);
|
||||||
|
} else if (numericValues.length === 2) {
|
||||||
|
compWeightTotal = parseNumeric(numericValues[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAssembly.components.push({
|
||||||
|
quantidade: compQuant,
|
||||||
|
perfil: compProfile,
|
||||||
|
material: compMaterial,
|
||||||
|
comprimento: compLength,
|
||||||
|
pesoTotal: compWeightTotal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentAssembly && line.length <= 3) {
|
||||||
|
const possibleTotal = parseNumeric(line[0].text);
|
||||||
|
if (possibleTotal > 10 && currentAssembly.pesoTotal === 0) {
|
||||||
|
currentAssembly.pesoTotal = possibleTotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(`Total de Peças Principais processadas: ${assemblies.length}`);
|
||||||
|
|
||||||
|
const finalPieces: ExtractedPiece[] = assemblies.map((asm) => {
|
||||||
|
const hasComponents = asm.components.length > 0;
|
||||||
|
|
||||||
|
let perfilPrincipal = asm.descricao;
|
||||||
|
let materialPrincipal = 'A36';
|
||||||
|
let maxComp = 0;
|
||||||
|
let sumComponentsWeight = 0;
|
||||||
|
|
||||||
|
if (hasComponents) {
|
||||||
|
let bestComp = asm.components[0];
|
||||||
|
asm.components.forEach((c) => {
|
||||||
|
if (c.comprimento > maxComp) {
|
||||||
|
maxComp = c.comprimento;
|
||||||
|
bestComp = c;
|
||||||
|
}
|
||||||
|
sumComponentsWeight += c.pesoTotal;
|
||||||
|
});
|
||||||
|
|
||||||
|
perfilPrincipal = bestComp.perfil || asm.descricao;
|
||||||
|
materialPrincipal = bestComp.material || 'A36';
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalPesoTotal = asm.pesoTotal > 0 ? asm.pesoTotal : sumComponentsWeight;
|
||||||
|
const finalPesoUnit = asm.quantidade > 0 ? finalPesoTotal / asm.quantidade : finalPesoTotal;
|
||||||
|
|
||||||
|
return {
|
||||||
|
of: asm.of,
|
||||||
|
fase: asm.fase,
|
||||||
|
marca: asm.marca,
|
||||||
|
descricao: asm.descricao,
|
||||||
|
isComposed: hasComponents ? 'SIM' : 'NÃO',
|
||||||
|
quantidade: asm.quantidade,
|
||||||
|
material: materialPrincipal,
|
||||||
|
perfilPrincipal: perfilPrincipal,
|
||||||
|
comprimentoMax: maxComp > 0 ? maxComp : '-',
|
||||||
|
pesoUnit: finalPesoUnit > 0 ? finalPesoUnit.toFixed(2) : '-',
|
||||||
|
pesoTotal: finalPesoTotal > 0 ? finalPesoTotal.toFixed(2) : '-',
|
||||||
|
tratamentoSuperficial: 'pintura'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setExtractedData(finalPieces);
|
||||||
|
setStatusText(`${finalPieces.length} peças principais extraídas com sucesso!`);
|
||||||
|
addLog(`Tabela renderizada com ${finalPieces.length} registros.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFile = async (file: File) => {
|
||||||
|
if (file.type !== 'application/pdf' && !file.name.endsWith('.pdf')) {
|
||||||
|
toast.error('Por favor, selecione um arquivo no formato PDF válido.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = new FileReader();
|
setSelectedFile(file);
|
||||||
reader.onload = (e) => {
|
const baseName = file.name.replace(/\.[^/.]+$/, '');
|
||||||
try {
|
setCurrentFileName(baseName);
|
||||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
setStatusText(`Processando: ${file.name}...`);
|
||||||
const workbook = XLSX.read(data, { type: 'array' });
|
setLogs([]);
|
||||||
const firstSheetName = workbook.SheetNames[0];
|
setIsProcessing(true);
|
||||||
const worksheet = workbook.Sheets[firstSheetName];
|
|
||||||
const json = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
|
|
||||||
|
|
||||||
const newSheetData: any[][] = [];
|
addLog(`Carregando PDF: ${file.name}`);
|
||||||
newSheetData.push(['Marca', 'Qtde', 'Descrição', 'Mat.', 'Comp.', 'Larg.', 'P.Un.', 'P.Tot.']);
|
|
||||||
|
|
||||||
let fileNamePrefix: string | null = null;
|
|
||||||
const processedMarks = new Set<number>();
|
|
||||||
|
|
||||||
for (let i = 0; i < json.length; i++) {
|
try {
|
||||||
const row = json[i];
|
const pdfjs = await loadPdfJs();
|
||||||
if (!row || row.length === 0) continue;
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
|
||||||
|
addLog(`PDF carregado com sucesso! Total de páginas: ${pdf.numPages}`);
|
||||||
|
|
||||||
const marcaCompleta = String(row[0] || '');
|
const allItems: PdfItem[] = [];
|
||||||
|
for (let p = 1; p <= pdf.numPages; p++) {
|
||||||
// Primeiro tenta o formato com fase: B118-4-2
|
const page = await pdf.getPage(p);
|
||||||
let mainMarkMatch = marcaCompleta.match(/^(B\d+)-(\d+)-(\d+)$/);
|
const textContent = await page.getTextContent();
|
||||||
let prefixo: string;
|
textContent.items.forEach((item) => {
|
||||||
let numeroMarca: number;
|
if (item.str && item.str.trim() !== '') {
|
||||||
|
allItems.push({
|
||||||
if (mainMarkMatch) {
|
text: item.str.trim(),
|
||||||
// Formato com fase: B118-4-2
|
x: item.transform[4],
|
||||||
const ofNumber = mainMarkMatch[1]; // B118
|
y: item.transform[5],
|
||||||
const faseNumber = mainMarkMatch[2]; // 4
|
page: p
|
||||||
const marcaNumber = mainMarkMatch[3]; // 2
|
});
|
||||||
prefixo = `${ofNumber}-`; // B118-
|
|
||||||
numeroMarca = parseInt(marcaNumber, 10); // 2
|
|
||||||
} else {
|
|
||||||
// Tenta o formato sem fase: B118-2
|
|
||||||
mainMarkMatch = marcaCompleta.match(/^(B\d+-)(\d+)$/);
|
|
||||||
if (mainMarkMatch) {
|
|
||||||
prefixo = mainMarkMatch[1]; // B118-
|
|
||||||
numeroMarca = parseInt(mainMarkMatch[2], 10); // 2
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
if (mainMarkMatch) {
|
|
||||||
|
|
||||||
if (numeroMarca >= 999 || processedMarks.has(numeroMarca)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
processedMarks.add(numeroMarca);
|
|
||||||
|
|
||||||
if (!fileNamePrefix) {
|
|
||||||
fileNamePrefix = prefixo;
|
|
||||||
}
|
|
||||||
|
|
||||||
const qtde = row[5];
|
|
||||||
const descricao = row[6];
|
|
||||||
const larg = row[14];
|
|
||||||
|
|
||||||
let mat = '';
|
|
||||||
let compRaw = '';
|
|
||||||
let maxComp = 0;
|
|
||||||
|
|
||||||
// Busca o maior comprimento entre todas as sub-linhas da marca
|
|
||||||
for (let j = i + 1; j < json.length; j++) {
|
|
||||||
const subRow = json[j];
|
|
||||||
if (!subRow || !subRow[0]) continue;
|
|
||||||
|
|
||||||
const subMarkCompleta = String(subRow[0]);
|
|
||||||
|
|
||||||
// Verifica formato com fase: B118-4-2
|
|
||||||
let subMarkMatch = subMarkCompleta.match(/^(B\d+)-(\d+)-(\d+)$/);
|
|
||||||
let subNumeroMarca: number | null = null;
|
|
||||||
|
|
||||||
if (subMarkMatch) {
|
|
||||||
// Formato com fase
|
|
||||||
subNumeroMarca = parseInt(subMarkMatch[3], 10);
|
|
||||||
} else {
|
|
||||||
// Verifica formato sem fase: B118-2
|
|
||||||
subMarkMatch = subMarkCompleta.match(/^(B\d+-)(\d+)$/);
|
|
||||||
if (subMarkMatch) {
|
|
||||||
subNumeroMarca = parseInt(subMarkMatch[2], 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se encontrou uma nova marca principal, para a busca
|
|
||||||
if (subMarkMatch && subNumeroMarca !== null && subNumeroMarca < 999 && !processedMarks.has(subNumeroMarca)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se é uma sub-linha da marca atual, verifica o comprimento
|
|
||||||
if (subMarkMatch) {
|
|
||||||
const currentComp = Number(String(subRow[13] || 0).replace(',', '.'));
|
|
||||||
if (!isNaN(currentComp) && currentComp > maxComp) {
|
|
||||||
maxComp = currentComp;
|
|
||||||
mat = subRow[12];
|
|
||||||
compRaw = subRow[13];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let pTotSum = 0;
|
|
||||||
for (let j = i + 1; j < json.length; j++) {
|
|
||||||
const subRow = json[j];
|
|
||||||
if (!subRow || !subRow[0]) continue;
|
|
||||||
|
|
||||||
const subMarkCompleta = String(subRow[0]);
|
|
||||||
|
|
||||||
// Verifica formato com fase: B118-4-2
|
|
||||||
let subMarkMatch = subMarkCompleta.match(/^(B\d+)-(\d+)-(\d+)$/);
|
|
||||||
let subNumeroMarca: number | null = null;
|
|
||||||
|
|
||||||
if (subMarkMatch) {
|
|
||||||
// Formato com fase
|
|
||||||
subNumeroMarca = parseInt(subMarkMatch[3], 10);
|
|
||||||
} else {
|
|
||||||
// Verifica formato sem fase: B118-2
|
|
||||||
subMarkMatch = subMarkCompleta.match(/^(B\d+-)(\d+)$/);
|
|
||||||
if (subMarkMatch) {
|
|
||||||
subNumeroMarca = parseInt(subMarkMatch[2], 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subMarkMatch && subNumeroMarca !== null && subNumeroMarca < 999 && !processedMarks.has(subNumeroMarca)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subMarkMatch) {
|
|
||||||
const pTotSubItemRaw = Number(String(subRow[16] || 0).replace(',', '.'));
|
|
||||||
const pTotSubItemValue = divideBy1000 ? pTotSubItemRaw / 1000 : pTotSubItemRaw;
|
|
||||||
if (!isNaN(pTotSubItemValue)) {
|
|
||||||
pTotSum += pTotSubItemValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const qtdeValue = Number(String(qtde || 0).replace(',', '.'));
|
|
||||||
const compValue = Number(String(compRaw || 0).replace(',', '.'));
|
|
||||||
|
|
||||||
let pUnCalculated = 0;
|
|
||||||
if (qtdeValue !== 0) {
|
|
||||||
pUnCalculated = pTotSum / qtdeValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const compRounded = Math.round(compValue);
|
|
||||||
const pUnRounded = Math.round(pUnCalculated);
|
|
||||||
const pTotRounded = Math.round(pTotSum);
|
|
||||||
|
|
||||||
newSheetData.push([
|
|
||||||
numeroMarca,
|
|
||||||
qtde,
|
|
||||||
descricao,
|
|
||||||
mat,
|
|
||||||
isNaN(compRounded) ? '' : compRounded,
|
|
||||||
larg,
|
|
||||||
isNaN(pUnRounded) ? '' : pUnRounded,
|
|
||||||
isNaN(pTotRounded) ? '' : pTotRounded
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newSheetData.length <= 1) {
|
|
||||||
throw new Error("Nenhuma linha válida foi encontrada para conversão.");
|
|
||||||
}
|
|
||||||
if (!fileNamePrefix) {
|
|
||||||
throw new Error("Não foi possível determinar o prefixo para o nome do arquivo.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const newWorksheet = XLSX.utils.aoa_to_sheet(newSheetData);
|
|
||||||
const newWorkbook = XLSX.utils.book_new();
|
|
||||||
XLSX.utils.book_append_sheet(newWorkbook, newWorksheet, 'Lista de Peças');
|
|
||||||
|
|
||||||
const newFileName = `${fileNamePrefix}Lista de Peças.xlsx`;
|
|
||||||
XLSX.writeFile(newWorkbook, newFileName);
|
|
||||||
|
|
||||||
toast.success('Arquivo convertido e baixado com sucesso!');
|
|
||||||
setSelectedFile(null);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro no processamento:', error);
|
|
||||||
toast.error(`Erro ao processar o arquivo: ${(error as Error).message}`);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
reader.onerror = () => {
|
addLog(`Total de elementos de texto extraídos: ${allItems.length}`);
|
||||||
toast.error('Não foi possível ler o arquivo.');
|
processPdfLines(allItems);
|
||||||
};
|
toast.success(`PDF processado com sucesso! ${allItems.length} elementos analisados.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
const errMsg = err instanceof Error ? err.message : 'Erro desconhecido';
|
||||||
|
toast.error(`Erro ao processar o arquivo PDF: ${errMsg}`);
|
||||||
|
addLog(`ERRO: ${errMsg}`);
|
||||||
|
setStatusText('Falha no processamento.');
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
reader.readAsArrayBuffer(selectedFile);
|
const exportToExcel = () => {
|
||||||
|
if (extractedData.length === 0) return;
|
||||||
|
|
||||||
|
const excelRows = extractedData.map((item) => ({
|
||||||
|
OF: item.of,
|
||||||
|
Fase: isNaN(Number(item.fase)) ? item.fase : Number(item.fase),
|
||||||
|
Marca: item.marca,
|
||||||
|
Descrição: item.descricao,
|
||||||
|
'Composto por Componentes?': item.isComposed,
|
||||||
|
Quantidade: Number(item.quantidade),
|
||||||
|
'Peso Unitário (kg)': item.pesoUnit === '-' ? '' : Number(item.pesoUnit),
|
||||||
|
'Peso Total (kg)': item.pesoTotal === '-' ? '' : Number(item.pesoTotal),
|
||||||
|
'Tratamento Superficial': item.tratamentoSuperficial,
|
||||||
|
Material: item.material,
|
||||||
|
'Perfil Principal': item.perfilPrincipal,
|
||||||
|
'Comprimento Ref. (mm)': item.comprimentoMax === '-' ? '' : Number(item.comprimentoMax)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
const ws = XLSX.utils.json_to_sheet(excelRows);
|
||||||
|
|
||||||
|
ws['!cols'] = [
|
||||||
|
{ wch: 12 },
|
||||||
|
{ wch: 8 },
|
||||||
|
{ wch: 12 },
|
||||||
|
{ wch: 20 },
|
||||||
|
{ wch: 15 },
|
||||||
|
{ wch: 12 },
|
||||||
|
{ wch: 18 },
|
||||||
|
{ wch: 16 },
|
||||||
|
{ wch: 22 },
|
||||||
|
{ wch: 16 },
|
||||||
|
{ wch: 22 },
|
||||||
|
{ wch: 20 }
|
||||||
|
];
|
||||||
|
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Lista_Pecas');
|
||||||
|
const outFileName = `${currentFileName}_Corrigido.xlsx`;
|
||||||
|
XLSX.writeFile(wb, outFileName);
|
||||||
|
addLog(`Arquivo Excel exportado: ${outFileName}`);
|
||||||
|
toast.success(`Planilha Excel "${outFileName}" gerada com sucesso!`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-2xl mx-auto">
|
<div className="space-y-6">
|
||||||
<CardHeader>
|
{/* Upload Zone */}
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div
|
||||||
<FileSpreadsheet className="h-5 w-5" />
|
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200 ${
|
||||||
Conversor Advance Steel
|
isDragging
|
||||||
</CardTitle>
|
? 'border-sky-500 bg-sky-500/10 scale-[1.01]'
|
||||||
<p className="text-sm text-muted-foreground">
|
: 'border-slate-700 bg-slate-800/40 hover:border-sky-500/50 hover:bg-slate-800/80'
|
||||||
Converta a "Lista de Peças - Estruturada" para o formato final.
|
}`}
|
||||||
</p>
|
onDragOver={handleDragOver}
|
||||||
</CardHeader>
|
onDragLeave={handleDragLeave}
|
||||||
<CardContent className="space-y-4">
|
onDrop={handleDrop}
|
||||||
<div
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
>
|
||||||
isDragging
|
<input
|
||||||
? 'border-primary bg-primary/10'
|
ref={fileInputRef}
|
||||||
: 'border-muted-foreground/25 hover:border-primary hover:bg-accent'
|
type="file"
|
||||||
}`}
|
accept="application/pdf,.pdf"
|
||||||
onDragOver={handleDragOver}
|
onChange={handleFileInput}
|
||||||
onDragLeave={handleDragLeave}
|
className="hidden"
|
||||||
onDrop={handleDrop}
|
/>
|
||||||
onClick={() => document.getElementById('advance-file-input')?.click()}
|
<Upload className="mx-auto h-12 w-12 text-slate-400 mb-3 animate-bounce-subtle" />
|
||||||
>
|
{selectedFile ? (
|
||||||
<input
|
<div>
|
||||||
id="advance-file-input"
|
<p className="text-sm font-semibold text-sky-400 mb-1">Arquivo selecionado:</p>
|
||||||
type="file"
|
<p className="text-base text-white font-mono">{selectedFile.name}</p>
|
||||||
className="hidden"
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
accept=".xlsx,.xls"
|
({(selectedFile.size / 1024).toFixed(1)} KB) — Clique para trocar
|
||||||
onChange={handleFileInput}
|
</p>
|
||||||
/>
|
</div>
|
||||||
<Upload className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
) : (
|
||||||
{selectedFile ? (
|
<div>
|
||||||
<div>
|
<p className="text-sm text-slate-300 font-medium mb-1">
|
||||||
<p className="text-sm font-medium text-foreground mb-2">
|
Arraste e solte o arquivo <strong className="text-sky-400 font-semibold">PDF da Lista de Peças Estruturada</strong> aqui
|
||||||
Arquivo selecionado:
|
</p>
|
||||||
</p>
|
<p className="text-xs text-slate-400 mb-4">Suporta relatórios originais do Advance Steel em PDF</p>
|
||||||
<p className="text-sm text-muted-foreground">{selectedFile.name}</p>
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
) : (
|
variant="secondary"
|
||||||
<div>
|
size="sm"
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
className="bg-sky-600 hover:bg-sky-500 text-white font-medium shadow-md pointer-events-none"
|
||||||
<span className="font-semibold text-primary">Clique para carregar</span> ou arraste e solte a planilha
|
>
|
||||||
</p>
|
Selecionar PDF do Computador
|
||||||
<p className="text-xs text-muted-foreground">
|
</Button>
|
||||||
Formatos suportados: .xlsx, .xls
|
</div>
|
||||||
</p>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions & Status Bar */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 bg-slate-800/60 p-4 rounded-lg border border-slate-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="outline" className="bg-slate-900/80 text-sky-400 border-sky-500/30 px-3 py-1 text-xs font-mono">
|
||||||
|
{statusText}
|
||||||
|
</Badge>
|
||||||
|
{isProcessing && (
|
||||||
|
<div className="flex items-center text-xs text-amber-400 gap-1.5 animate-pulse">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
<span>Processando PDF...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="divide-by-1000"
|
|
||||||
checked={divideBy1000}
|
|
||||||
onCheckedChange={(checked) => setDivideBy1000(checked === true)}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="divide-by-1000"
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
Dividir peso por 1000?
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={processFile}
|
onClick={exportToExcel}
|
||||||
disabled={!selectedFile}
|
disabled={extractedData.length === 0 || isProcessing}
|
||||||
className="w-full"
|
className="bg-emerald-600 hover:bg-emerald-500 text-white font-semibold shadow-lg transition-all"
|
||||||
>
|
>
|
||||||
Converter e Baixar
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Baixar Planilha Excel (.xlsx)
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Preview */}
|
||||||
|
<Card className="bg-slate-900/80 border-slate-800 shadow-xl overflow-hidden">
|
||||||
|
<CardHeader className="py-3.5 px-4 bg-slate-800/50 border-b border-slate-800 flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-semibold text-slate-200 flex items-center gap-2">
|
||||||
|
<FileSpreadsheet className="h-4 w-4 text-emerald-400" />
|
||||||
|
Peças Extraídas ({extractedData.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<ScrollArea className="h-[420px] w-full">
|
||||||
|
<table className="w-full text-xs text-left border-collapse">
|
||||||
|
<thead className="bg-slate-950 text-slate-300 font-mono sticky top-0 z-10 shadow-sm">
|
||||||
|
<tr>
|
||||||
|
<th className="p-2.5 border-b border-slate-800">OF</th>
|
||||||
|
<th className="p-2.5 border-b border-slate-800">Fase</th>
|
||||||
|
<th className="p-2.5 border-b border-slate-800">Marca</th>
|
||||||
|
<th className="p-2.5 border-b border-slate-800">Descrição</th>
|
||||||
|
<th className="p-2.5 border-b border-slate-800">Composto?</th>
|
||||||
|
<th className="p-2.5 border-b border-slate-800 text-center">Qtd</th>
|
||||||
|
<th className="p-2.5 border-b border-slate-800">Material</th>
|
||||||
|
<th className="p-2.5 border-b border-slate-800">Perfil Principal</th>
|
||||||
|
<th className="p-2.5 border-b border-slate-800 text-right">Comp. Max (mm)</th>
|
||||||
|
<th className="p-2.5 border-b border-slate-800 text-right">Peso Unit. (kg)</th>
|
||||||
|
<th className="p-2.5 border-b border-slate-800 text-right">Peso Total (kg)</th>
|
||||||
|
<th className="p-2.5 border-b border-slate-800">Tratamento</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60 font-sans">
|
||||||
|
{extractedData.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={12} className="py-12 text-center text-slate-500 font-medium">
|
||||||
|
Nenhum dado extraído ainda. Carregue um PDF de Lista de Peças acima.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
extractedData.map((row, idx) => (
|
||||||
|
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
|
||||||
|
<td className="p-2.5 font-mono font-bold text-sky-400">{row.of}</td>
|
||||||
|
<td className="p-2.5 text-slate-300">{row.fase}</td>
|
||||||
|
<td className="p-2.5 font-mono font-semibold text-white">{row.marca}</td>
|
||||||
|
<td className="p-2.5 text-slate-200">{row.descricao}</td>
|
||||||
|
<td className="p-2.5">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
row.isComposed === 'SIM'
|
||||||
|
? 'bg-emerald-950/60 text-emerald-400 border-emerald-800/60 text-[10px]'
|
||||||
|
: 'bg-amber-950/60 text-amber-400 border-amber-800/60 text-[10px]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.isComposed}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-2.5 text-center font-bold text-white">{row.quantidade}</td>
|
||||||
|
<td className="p-2.5 text-slate-300">{row.material}</td>
|
||||||
|
<td className="p-2.5 font-medium text-slate-200">{row.perfilPrincipal}</td>
|
||||||
|
<td className="p-2.5 text-right font-mono text-slate-300">{row.comprimentoMax}</td>
|
||||||
|
<td className="p-2.5 text-right font-mono text-slate-300">{row.pesoUnit}</td>
|
||||||
|
<td className="p-2.5 text-right font-mono font-bold text-emerald-400">{row.pesoTotal}</td>
|
||||||
|
<td className="p-2.5 text-slate-400 capitalize">{row.tratamentoSuperficial}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Terminal Log Panel */}
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<Card className="bg-slate-950 border-slate-800 font-mono text-xs overflow-hidden">
|
||||||
|
<CardHeader className="py-2 px-3 bg-slate-900/90 border-b border-slate-800 flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-xs font-semibold text-slate-400 flex items-center gap-1.5">
|
||||||
|
<Terminal className="h-3.5 w-3.5 text-sky-400" />
|
||||||
|
Log de Processamento
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<ScrollArea className="h-28 w-full">
|
||||||
|
<div className="space-y-1 text-sky-400/90 leading-relaxed">
|
||||||
|
{logs.map((logLine, idx) => (
|
||||||
|
<div key={idx}>{logLine}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdvanceSteelConverterModal: React.FC<AdvanceSteelConverterProps> = ({ open, onOpenChange }) => {
|
||||||
|
if (open === undefined || onOpenChange === undefined) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-800 text-white max-w-5xl max-h-[92vh] overflow-y-auto">
|
||||||
|
<DialogHeader className="border-b border-slate-800 pb-3">
|
||||||
|
<div className="flex items-center justify-between pr-4">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-sky-400" />
|
||||||
|
Conversor Advance Steel (PDF para Excel)
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400 text-xs mt-1">
|
||||||
|
Conversão 100% no navegador (Client-Side) de relatórios estruturados do Advance Steel em PDF para Excel (.xlsx)
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-sky-500/10 text-sky-400 border border-sky-500/20 text-xs">
|
||||||
|
Autônomo / Sem Servidor
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<AdvanceSteelConverterContent />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AdvanceSteelConverter: React.FC<AdvanceSteelConverterProps> = ({ open, onOpenChange }) => {
|
||||||
|
if (open !== undefined && onOpenChange !== undefined) {
|
||||||
|
return <AdvanceSteelConverterModal open={open} onOpenChange={onOpenChange} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-slate-900 border-slate-800 text-white max-w-5xl mx-auto shadow-2xl">
|
||||||
|
<CardHeader className="border-b border-slate-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-sky-400" />
|
||||||
|
Conversor Advance Steel (PDF para Excel)
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Conversão 100% no navegador (Client-Side) de relatórios estruturados em PDF para Excel (.xlsx)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-sky-500/10 text-sky-400 border border-sky-500/20 text-xs">
|
||||||
|
Autônomo / Sem Servidor
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<AdvanceSteelConverterContent />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -174,22 +174,7 @@ const ConversoresDados = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAdvanceSteelConverter) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleGoBack}
|
|
||||||
className="bg-slate-600 border-slate-500 text-white hover:bg-slate-500"
|
|
||||||
>
|
|
||||||
← Voltar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<AdvanceSteelConverter />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -373,6 +358,12 @@ const ConversoresDados = () => {
|
|||||||
open={showGenericConverter}
|
open={showGenericConverter}
|
||||||
onOpenChange={setShowGenericConverter}
|
onOpenChange={setShowGenericConverter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Modal de Conversão Advance Steel (PDF para Excel) */}
|
||||||
|
<AdvanceSteelConverter
|
||||||
|
open={showAdvanceSteelConverter}
|
||||||
|
onOpenChange={setShowAdvanceSteelConverter}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user