feat(conversores): substituir conversor advance steel por modal pop-up de extracao PDF para XLSX
This commit is contained in:
@@ -1,16 +1,118 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Upload, FileSpreadsheet } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
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 * 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 [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) => {
|
||||
e.preventDefault();
|
||||
@@ -25,9 +127,8 @@ const AdvanceSteelConverter: React.FC = () => {
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -37,287 +138,588 @@ const AdvanceSteelConverter: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
const validTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel'
|
||||
];
|
||||
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error('Por favor, selecione um arquivo Excel (.xlsx ou .xls)');
|
||||
return;
|
||||
const parseNumeric = (valStr: string | number): number => {
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
toast.success('Arquivo selecionado com sucesso!');
|
||||
const num = parseFloat(clean);
|
||||
return isNaN(num) ? 0 : num;
|
||||
};
|
||||
|
||||
const processFile = () => {
|
||||
if (!selectedFile) {
|
||||
toast.error('Por favor, selecione um arquivo primeiro.');
|
||||
const processPdfLines = (items: PdfItem[]) => {
|
||||
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: 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;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
const json = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
|
||||
setSelectedFile(file);
|
||||
const baseName = file.name.replace(/\.[^/.]+$/, '');
|
||||
setCurrentFileName(baseName);
|
||||
setStatusText(`Processando: ${file.name}...`);
|
||||
setLogs([]);
|
||||
setIsProcessing(true);
|
||||
|
||||
const newSheetData: any[][] = [];
|
||||
newSheetData.push(['Marca', 'Qtde', 'Descrição', 'Mat.', 'Comp.', 'Larg.', 'P.Un.', 'P.Tot.']);
|
||||
|
||||
let fileNamePrefix: string | null = null;
|
||||
const processedMarks = new Set<number>();
|
||||
addLog(`Carregando PDF: ${file.name}`);
|
||||
|
||||
for (let i = 0; i < json.length; i++) {
|
||||
const row = json[i];
|
||||
if (!row || row.length === 0) continue;
|
||||
try {
|
||||
const pdfjs = await loadPdfJs();
|
||||
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] || '');
|
||||
|
||||
// Primeiro tenta o formato com fase: B118-4-2
|
||||
let mainMarkMatch = marcaCompleta.match(/^(B\d+)-(\d+)-(\d+)$/);
|
||||
let prefixo: string;
|
||||
let numeroMarca: number;
|
||||
|
||||
if (mainMarkMatch) {
|
||||
// Formato com fase: B118-4-2
|
||||
const ofNumber = mainMarkMatch[1]; // B118
|
||||
const faseNumber = mainMarkMatch[2]; // 4
|
||||
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
|
||||
}
|
||||
const allItems: PdfItem[] = [];
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
toast.error('Não foi possível ler o arquivo.');
|
||||
};
|
||||
addLog(`Total de elementos de texto extraídos: ${allItems.length}`);
|
||||
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 (
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-5 w-5" />
|
||||
Conversor Advance Steel
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Converta a "Lista de Peças - Estruturada" para o formato final.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-muted-foreground/25 hover:border-primary hover:bg-accent'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('advance-file-input')?.click()}
|
||||
>
|
||||
<input
|
||||
id="advance-file-input"
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
<Upload className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
||||
{selectedFile ? (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-2">
|
||||
Arquivo selecionado:
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{selectedFile.name}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
<span className="font-semibold text-primary">Clique para carregar</span> ou arraste e solte a planilha
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Formatos suportados: .xlsx, .xls
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
{/* Upload Zone */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200 ${
|
||||
isDragging
|
||||
? 'border-sky-500 bg-sky-500/10 scale-[1.01]'
|
||||
: 'border-slate-700 bg-slate-800/40 hover:border-sky-500/50 hover:bg-slate-800/80'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
/>
|
||||
<Upload className="mx-auto h-12 w-12 text-slate-400 mb-3 animate-bounce-subtle" />
|
||||
{selectedFile ? (
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-sky-400 mb-1">Arquivo selecionado:</p>
|
||||
<p className="text-base text-white font-mono">{selectedFile.name}</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
({(selectedFile.size / 1024).toFixed(1)} KB) — Clique para trocar
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm text-slate-300 font-medium mb-1">
|
||||
Arraste e solte o arquivo <strong className="text-sky-400 font-semibold">PDF da Lista de Peças Estruturada</strong> aqui
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mb-4">Suporta relatórios originais do Advance Steel em PDF</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="bg-sky-600 hover:bg-sky-500 text-white font-medium shadow-md pointer-events-none"
|
||||
>
|
||||
Selecionar PDF do Computador
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</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 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
|
||||
onClick={processFile}
|
||||
disabled={!selectedFile}
|
||||
className="w-full"
|
||||
onClick={exportToExcel}
|
||||
disabled={extractedData.length === 0 || isProcessing}
|
||||
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>
|
||||
</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>
|
||||
</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 (
|
||||
<div className="space-y-6">
|
||||
@@ -373,6 +358,12 @@ const ConversoresDados = () => {
|
||||
open={showGenericConverter}
|
||||
onOpenChange={setShowGenericConverter}
|
||||
/>
|
||||
|
||||
{/* Modal de Conversão Advance Steel (PDF para Excel) */}
|
||||
<AdvanceSteelConverter
|
||||
open={showAdvanceSteelConverter}
|
||||
onOpenChange={setShowAdvanceSteelConverter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user