Files
tracksteel_app/src/components/conversores/AdvanceSteelConverter.tsx
T

1220 lines
48 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import {
Upload,
FileSpreadsheet,
Download,
RefreshCw,
Terminal,
FileText,
Code2,
AlertTriangle,
Boxes,
Paintbrush,
CheckCircle2,
Info
} from 'lucide-react';
import { toast } from 'sonner';
import * as XLSX from 'xlsx';
import { useAppLabels } from '@/hooks/useAppLabels';
import { usePecas } from '@/hooks/usePecas';
import { supabase } from '@/integrations/supabase/client';
import { analisarConflitosImportacao, consolidarPecasEComponentes } from '@/utils/importDiffService';
import { ResultadoAnaliseImportacao } from '@/types/importDiff';
import { ImportarPecasDiffModal } from '@/components/pecas/ImportarPecasDiffModal';
export interface ComponentItem {
marca: string;
quantidade: number;
perfil: string;
material: string;
comprimento: number;
pesoUnitario?: number;
pesoTotal: number;
}
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;
components?: ComponentItem[];
}
interface AssemblyItem {
rawMark: string;
of: string;
fase: string;
marca: string;
numeroPeca: string;
descricao: string;
quantidade: number;
material: string;
comprimento: number;
pesoUnitario: number;
pesoTotal: number;
subtotalPeso: number;
isComposed: 'SIM' | 'NÃO';
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 robusto do PDF.js
const ensurePdfJs = async (): Promise<PdfJsLib> => {
const win = window as unknown as { pdfjsLib?: PdfJsLib };
if (win.pdfjsLib) {
win.pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
return win.pdfjsLib;
}
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.id = 'pdfjs-script-cdn';
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 carregar a CDN.'));
}
};
script.onerror = () => reject(new Error('Falha ao carregar script PDF.js via CDN.'));
document.head.appendChild(script);
});
};
const AdvanceSteelConverterContent: React.FC = () => {
const { labels, isComponente } = useAppLabels();
const { importPecas } = usePecas();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isDirectImporting, setIsDirectImporting] = useState(false);
const [extractedData, setExtractedData] = useState<ExtractedPiece[]>([]);
const [headerOf, setHeaderOf] = useState<string>('');
const [headerFase, setHeaderFase] = useState<string>('1');
const [tratamentoGlobal, setTratamentoGlobal] = useState<'pintura' | 'galvanizacao'>('pintura');
const [showLogs, setShowLogs] = useState(false);
const [logs, setLogs] = useState<string[]>(['> Aguardando seleção do PDF...']);
const [statusText, setStatusText] = useState<string>('Aguardando arquivo PDF...');
const [currentFileName, setCurrentFileName] = useState<string>('Lista_Pecas');
const [useIframeMode, setUseIframeMode] = useState<boolean>(false);
const [analiseDiff, setAnaliseDiff] = useState<ResultadoAnaliseImportacao | null>(null);
const [componentesMapDiff, setComponentesMapDiff] = useState<Map<string, any[]>>(new Map());
const [showDiffModal, setShowDiffModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const logEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
ensurePdfJs().catch((err) => console.warn('Preload PDF.js notice:', err));
}, []);
const addLog = (msg: string) => {
setLogs((prev) => [...prev, `> ${msg}`]);
setTimeout(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 50);
};
const buscarTratamentoOF = async (ofCode: string): Promise<'pintura' | 'galvanizacao'> => {
if (!ofCode) return 'pintura';
try {
const cleanOf = ofCode.trim();
const { data } = await supabase
.from('ordens_fabricacao')
.select('num_of, tratamento_final')
.or(`num_of.eq.${cleanOf},num_of.ilike.%${cleanOf}%`)
.limit(1);
if (data && data.length > 0 && data[0].tratamento_final) {
const trat = data[0].tratamento_final.toLowerCase();
if (trat.includes('galv')) {
return 'galvanizacao';
}
return 'pintura';
}
} catch (err) {
console.warn('Aviso ao buscar tratamento da OF:', err);
}
return 'pintura';
};
const handleTratamentoChange = (novoTratamento: 'pintura' | 'galvanizacao') => {
setTratamentoGlobal(novoTratamento);
setExtractedData((prev) =>
prev.map((item) => ({
...item,
tratamentoSuperficial: novoTratamento
}))
);
};
const handleOfChange = async (newOf: string) => {
setHeaderOf(newOf);
const trat = await buscarTratamentoOF(newOf);
setTratamentoGlobal(trat);
setExtractedData((prev) =>
prev.map((item) => ({
...item,
of: newOf,
tratamentoSuperficial: trat
}))
);
};
const handleFaseChange = (newFase: string) => {
setHeaderFase(newFase);
setExtractedData((prev) =>
prev.map((item) => ({
...item,
fase: newFase
}))
);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleFile(e.dataTransfer.files[0]);
}
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
};
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];
}
}
const num = parseFloat(clean);
return isNaN(num) ? 0 : num;
};
const processPdfLines = async (items: PdfItem[]) => {
// 1. Agrupamento robusto de linhas por Clustering de Coordenada Y em cada página
const pagesMap = new Map<number, PdfItem[]>();
items.forEach((it) => {
if (!pagesMap.has(it.page)) pagesMap.set(it.page, []);
pagesMap.get(it.page)!.push(it);
});
const lines: PdfItem[][] = [];
const Y_TOLERANCE = 6.0; // Tolerância vertical em pixels para agrupar na mesma linha visual
const pageNumbers = Array.from(pagesMap.keys()).sort((a, b) => a - b);
for (const pNum of pageNumbers) {
const pageItems = pagesMap.get(pNum)!;
interface LineCluster {
ySum: number;
count: number;
avgY: number;
items: PdfItem[];
}
const clusters: LineCluster[] = [];
// Ordena por Y decrescente (do topo da página para o rodapé)
pageItems.sort((a, b) => b.y - a.y);
for (const item of pageItems) {
let bestCluster: LineCluster | null = null;
let minDiff = Infinity;
for (const cluster of clusters) {
const diff = Math.abs(item.y - cluster.avgY);
if (diff <= Y_TOLERANCE && diff < minDiff) {
minDiff = diff;
bestCluster = cluster;
}
}
if (bestCluster) {
bestCluster.items.push(item);
bestCluster.ySum += item.y;
bestCluster.count += 1;
bestCluster.avgY = bestCluster.ySum / bestCluster.count;
} else {
clusters.push({
ySum: item.y,
count: 1,
avgY: item.y,
items: [item]
});
}
}
// Ordena as linhas do topo para o rodapé da página
clusters.sort((a, b) => b.avgY - a.avgY);
// Dentro de cada linha, ordena da esquerda para a direita (X crescente)
for (const cluster of clusters) {
cluster.items.sort((a, b) => a.x - b.x);
lines.push(cluster.items);
}
}
addLog(`Total de linhas identificadas no PDF: ${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) ||
lineText.match(/Projeto:\s*([A-Za-z0-9-]+)/i);
if (matchTrabalho) {
defaultOf = matchTrabalho[1].replace(/-/g, '');
addLog(`OF / Trabalho identificado no cabeçalho: ${defaultOf}`);
setHeaderOf(defaultOf);
break;
}
}
const KNOWN_MATERIALS = [
'A572-GR 50', 'A572-GR50', 'A572-GR.50', 'A572 GR50', 'A572GR50', 'A572GR5', 'A572',
'A36', 'ASTM A36', 'ASTM A572', '10.9', '8.8', 'SAE 1020', 'SAE 1045', 'INOX', '304', '316'
];
const normalizeLineText = (lineStr: string): string => {
let s = lineStr.trim();
s = s.replace(/([A-Za-z0-9]+)\s*-\s*(\d+)\s*-\s*(\d+)/g, '$1-$2-$3');
s = s.replace(/([A-Za-z0-9]+)\s*-\s*(\d+)/g, '$1-$2');
s = s.replace(/A572\s*-\s*GR\s*50/gi, 'A572-GR 50');
s = s.replace(/A572\s*-\s*GR/gi, 'A572-GR');
return s;
};
const assemblies: AssemblyItem[] = [];
let currentAssembly: AssemblyItem | null = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
let rawLineText = line.map((it) => it.text).join(' ').trim();
const lineText = normalizeLineText(rawLineText);
// Ignorar cabeçalhos e metadados conhecidos
if (
lineText.includes('LISTA DE PEÇAS') ||
lineText.includes('LISTA ESTRUTURADA') ||
lineText.includes('Lista de Peças') ||
lineText.includes('DetailDwgExtract') ||
lineText.includes('Cliente:') ||
lineText.includes('Desenhado') ||
lineText.includes('Superfície') ||
lineText.includes('Trabalho:') ||
lineText.includes('Kg/piece') ||
lineText.includes('( mm)') ||
lineText.toLowerCase().includes('peso total:') ||
(lineText.toLowerCase().includes('marca') && lineText.toLowerCase().includes('nome'))
) {
continue;
}
// 1. Linha Mestre de Peça Principal (ex: "B134-13-1 90 W 150x13.0" ou "B134-13-2 72 W 150x13.0")
// A primeira linha de cada registro contém a Marca, a Quantidade Total da Peça e o Nome/Perfil
const masterMatch = lineText.match(/^([A-Za-z0-9]+(?:-\d+)+)(?:\s+(\d+))?\s+([A-Za-z].*)$/);
if (masterMatch) {
const fullMark = masterMatch[1];
const quantFromLine = masterMatch[2] ? parseInt(masterMatch[2], 10) : 1;
const descCandidate = masterMatch[3].trim();
const parts = fullMark.split('-');
const pieceNumber = parts[parts.length - 1];
const pieceNumInt = parseInt(pieceNumber, 10);
// Se o número da peça for menor que 1000, é uma peça principal mestre
if (pieceNumInt < 1000) {
const hasMaterial = KNOWN_MATERIALS.some((m) => descCandidate.includes(m));
// A linha mestre não possui indicação de material na descrição nem pesos
if (!hasMaterial) {
const ofCode = parts.length >= 2 ? parts[0] : defaultOf;
const phaseCode = parts.length >= 3 ? parts[1] : (parts.length === 2 ? parts[0] : '1');
if (phaseCode && phaseCode !== '0' && isNaN(Number(phaseCode)) === false) {
setHeaderFase(phaseCode);
}
currentAssembly = {
rawMark: fullMark,
of: ofCode || defaultOf,
fase: phaseCode || '1',
marca: pieceNumber,
numeroPeca: pieceNumber,
descricao: descCandidate || 'ESTRUTURA',
quantidade: quantFromLine > 0 ? quantFromLine : 1,
material: '',
comprimento: 0,
pesoUnitario: 0,
pesoTotal: 0,
subtotalPeso: 0,
isComposed: 'NÃO',
components: []
};
assemblies.push(currentAssembly);
continue;
}
}
}
// 2. Linha com Subtotal da Marca (ex: "1.973,10 101,691" ou "408,70 20,434" ou "81 3,07")
const tokens = lineText.split(/\s+/);
const allTokensNumeric = tokens.length > 0 && tokens.every((tok) => /^[0-9.,]+$/.test(tok));
if (allTokensNumeric && tokens.length <= 3) {
if (currentAssembly && (!currentAssembly.subtotalPeso || currentAssembly.subtotalPeso === 0)) {
const subP = parseNumeric(tokens[0]);
if (subP > 0) {
currentAssembly.subtotalPeso = subP;
}
}
continue;
}
// 3. Linha de Detalhe de Monopeça ou Componente Subordinado
let itemMark = '';
let itemQuant = 1;
let remaining = lineText;
const mMark = remaining.match(/^([A-Za-z0-9]+(?:-\d+)+|-)\s+(\d+)\s+/);
if (mMark) {
itemMark = mMark[1];
itemQuant = parseInt(mMark[2], 10);
remaining = remaining.substring(mMark[0].length).trim();
} else if (remaining.startsWith('-')) {
itemMark = '-';
remaining = remaining.substring(1).trim();
const mQ = remaining.match(/^(\d+)\s+/);
if (mQ) {
itemQuant = parseInt(mQ[1], 10);
remaining = remaining.substring(mQ[0].length).trim();
}
}
// Localizar Material dentro de remaining
let detectedMaterial = '';
let matIdx = -1;
let matLen = 0;
for (const mat of KNOWN_MATERIALS) {
const idx = remaining.indexOf(mat);
if (idx !== -1 && idx > matIdx) {
detectedMaterial = mat;
matIdx = idx;
matLen = mat.length;
}
}
let itemDesc = '';
let afterMat = '';
if (matIdx !== -1) {
itemDesc = remaining.substring(0, matIdx).trim();
afterMat = remaining.substring(matIdx + matLen).trim();
} else {
const parts = remaining.split(/\s+/);
const firstNumIdx = parts.findIndex((p) => /^[0-9.,]+$/.test(p));
if (firstNumIdx > 0) {
itemDesc = parts.slice(0, firstNumIdx).join(' ');
afterMat = parts.slice(firstNumIdx).join(' ');
} else {
itemDesc = remaining;
}
}
const numTokens = afterMat.split(/\s+/).filter((tok) => /^[0-9.,]+$/.test(tok));
const nums = numTokens.map(parseNumeric);
let itemComp = 0;
let itemPUn = 0;
let itemPesoTot = 0;
if (nums.length >= 1) {
itemComp = nums[0]; // O comprimento é sempre o primeiro valor numérico após o material
}
if (nums.length === 3) {
itemPUn = nums[1];
itemPesoTot = nums[2];
} else if (nums.length === 4) {
itemPUn = nums[1];
itemPesoTot = nums[2];
} else if (nums.length === 5) {
itemPUn = nums[1];
itemPesoTot = nums[2];
} else if (nums.length >= 6) {
itemPUn = nums[2];
itemPesoTot = nums[3];
}
if (currentAssembly) {
// Extrai o número final da marca do item (ex: de "B134-13-1" -> 1, de "B134-13-1000" -> 1000, de "B133-13-1015" -> 1015, de "1000" -> 1000)
const lastNumberMatch = itemMark.match(/(?:^|-)(\d+)$/);
const itemNum = lastNumberMatch ? parseInt(lastNumberMatch[1], 10) : (parseInt(itemMark, 10) || 0);
// Um item é SUBCOMPONENTE apenas se sua numeração de marca for >= faixaComponenteMin (1000) OU se for explicitamente '-'
const isSubComp = itemMark === '-' || itemNum >= (labels.faixaComponenteMin || 1000);
if (isSubComp) {
currentAssembly.isComposed = 'SIM';
let cleanCompMark = itemMark;
const subMatch = itemMark.match(/-?(\d{4,})$/) || itemMark.match(/(\d{4,})/);
if (subMatch) {
cleanCompMark = subMatch[1];
} else if (itemMark === '-' || !/^\d{4}$/.test(itemMark)) {
cleanCompMark = String((labels.faixaComponenteMin || 1000) + currentAssembly.components.length);
}
currentAssembly.components.push({
marca: cleanCompMark,
quantidade: itemQuant,
perfil: itemDesc || currentAssembly.descricao,
material: detectedMaterial || 'A36',
comprimento: itemComp,
pesoUnitario: itemPUn,
pesoTotal: itemPesoTot
});
} else {
// Monopeça (peça simples sem componentes subordinados)
// Preenche os dados técnicos da peça SEM sobrescrever a quantidade mestre
if (!currentAssembly.material && detectedMaterial) {
currentAssembly.material = detectedMaterial;
}
if (itemComp > 0 && (!currentAssembly.comprimento || currentAssembly.comprimento === 0)) {
currentAssembly.comprimento = itemComp;
}
if (itemPUn > 0 && (!currentAssembly.pesoUnitario || currentAssembly.pesoUnitario === 0)) {
currentAssembly.pesoUnitario = itemPUn;
}
if (itemPesoTot > 0 && (!currentAssembly.pesoTotal || currentAssembly.pesoTotal === 0)) {
currentAssembly.pesoTotal = itemPesoTot;
}
}
}
}
addLog(`Total de Peças Principais extraídas: ${assemblies.length}`);
const targetOf = defaultOf || headerOf || (assemblies[0] ? assemblies[0].of : '');
const detectedTratamento = await buscarTratamentoOF(targetOf);
setTratamentoGlobal(detectedTratamento);
addLog(`Tratamento superficial obtido automaticamente da OF ${targetOf}: ${detectedTratamento === 'galvanizacao' ? 'Galvanizado' : 'Pintura'}`);
const finalPieces: ExtractedPiece[] = assemblies.map((asm) => {
const isComposed = asm.isComposed === 'SIM' && asm.components.length > 0;
let perfilPrincipal = asm.descricao;
let materialPrincipal = asm.material || 'A36';
let maxComp = asm.comprimento || 0;
let totalPeso = asm.subtotalPeso > 0 ? asm.subtotalPeso : asm.pesoTotal;
if (isComposed) {
let sumComponentsWeight = 0;
let sumUnitComponentsWeight = 0;
let bestComp = asm.components[0];
maxComp = 0;
asm.components.forEach((c) => {
if (c.comprimento > maxComp) {
maxComp = c.comprimento;
bestComp = c;
}
sumComponentsWeight += c.pesoTotal;
if (c.pesoUnitario && c.pesoUnitario > 0) {
sumUnitComponentsWeight += c.pesoUnitario * c.quantidade;
}
});
if (bestComp) {
perfilPrincipal = bestComp.perfil;
materialPrincipal = bestComp.material;
}
if (sumComponentsWeight > 0) {
totalPeso = sumComponentsWeight;
}
// Se a quantidade do assembly principal veio 1 mas a soma unitária x total dá a razão
if (asm.quantidade === 1 && sumUnitComponentsWeight > 0 && totalPeso > 0) {
const ratio = Math.round(totalPeso / sumUnitComponentsWeight);
if (ratio > 1) {
asm.quantidade = ratio;
}
}
} else {
// Monopeça: se totalPeso não veio, calcula
if (!totalPeso || totalPeso === 0) {
totalPeso = (asm.pesoUnitario || 0) * asm.quantidade;
}
// Fallback de segurança para monopeça caso a quantidade mestre não tenha sido lida e veio 1
if (asm.quantidade === 1 && asm.pesoUnitario > 0 && totalPeso > asm.pesoUnitario) {
const ratio = Math.round(totalPeso / asm.pesoUnitario);
if (ratio > 1) {
asm.quantidade = ratio;
}
}
}
// Regra de Ouro: Peso Unitário = Peso Total dividido pela Quantidade da peça principal
const pesoUnit = asm.quantidade > 0 ? totalPeso / asm.quantidade : (asm.pesoUnitario || totalPeso);
return {
of: asm.of,
fase: asm.fase,
marca: asm.marca,
descricao: asm.descricao,
isComposed: isComposed ? 'SIM' : 'NÃO',
quantidade: asm.quantidade,
material: materialPrincipal,
perfilPrincipal: perfilPrincipal,
comprimentoMax: maxComp > 0 ? maxComp : '-',
pesoUnit: pesoUnit > 0 ? pesoUnit.toFixed(2) : '-',
pesoTotal: totalPeso > 0 ? totalPeso.toFixed(2) : '-',
tratamentoSuperficial: detectedTratamento,
components: isComposed ? asm.components : []
};
});
setExtractedData(finalPieces);
if (finalPieces.length === 0) {
setStatusText('Nenhuma peça principal identificada.');
addLog('AVISO: Nenhuma peça bateu com a máscara (ex: B132-1 ou B132-4-1).');
toast.warning('PDF lido, mas nenhuma marca de peça foi reconhecida.');
} else {
setStatusText(`${finalPieces.length} peças principais extraídas com sucesso!`);
addLog(`Tabela renderizada com ${finalPieces.length} registros.`);
toast.success(`${finalPieces.length} peças extraídas do PDF!`);
}
};
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;
}
setSelectedFile(file);
const baseName = file.name.replace(/\.[^/.]+$/, '');
setCurrentFileName(baseName);
setStatusText(`Processando: ${file.name}...`);
setLogs([`> Carregando arquivo: ${file.name}`]);
setIsProcessing(true);
try {
const pdfjs = await ensurePdfJs();
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
addLog(`PDF aberto! Total de páginas: ${pdf.numPages}`);
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
});
}
});
}
addLog(`Total de elementos de texto extraídos: ${allItems.length}`);
await processPdfLines(allItems);
} catch (err) {
console.error(err);
const errMsg = err instanceof Error ? err.message : 'Erro desconhecido';
toast.error(`Erro ao processar PDF: ${errMsg}`);
addLog(`ERRO CRÍTICO: ${errMsg}`);
setStatusText('Falha no processamento.');
} finally {
setIsProcessing(false);
}
};
const exportToExcel = () => {
if (extractedData.length === 0) return;
// Aba 1: Lista consolidada de Peças
const excelRowsPecas = 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 || tratamentoGlobal,
Material: item.material,
'Perfil Principal': item.perfilPrincipal,
'Comprimento Ref. (mm)': item.comprimentoMax === '-' ? '' : Number(item.comprimentoMax)
}));
// Aba 2: Lista detalhada de Componentes
const excelRowsComponentes: any[] = [];
extractedData.forEach((item) => {
if (item.components && item.components.length > 0) {
item.components.forEach((comp) => {
const qtdPorPeca = Number(comp.quantidade) || 1;
const qtdTotal = qtdPorPeca * Number(item.quantidade);
excelRowsComponentes.push({
OF: item.of,
Fase: isNaN(Number(item.fase)) ? item.fase : Number(item.fase),
'Peça Principal': item.marca,
'Marca Componente': comp.marca || '-',
'Descrição Componente': comp.perfil || item.descricao,
Material: comp.material || item.material,
'Comprimento (mm)': comp.comprimento || '',
'Qtd / Peça': qtdPorPeca,
'Qtd Total no Lote': qtdTotal,
'Peso Unitário (kg)': comp.pesoUnitario || '',
'Peso Total (kg)': comp.pesoTotal || ''
});
});
}
});
// Aba 3: Formato Plano Oficial para Importação TrackSteel
const excelRowsImportacao: any[] = [];
extractedData.forEach((item) => {
if (item.components && item.components.length > 0) {
item.components.forEach((comp) => {
excelRowsImportacao.push({
of_number: item.of,
etapa_fase: item.fase,
marca: item.marca,
descricao: item.descricao,
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material,
perfil_principal: item.perfilPrincipal,
tem_componentes: true,
marca_componente: comp.marca || '',
descricao_componente: comp.perfil || item.descricao,
perfil_componente: comp.perfil || item.descricao,
peso_unitario_componente: comp.pesoUnitario || 0,
quantidade_por_peca: Number(comp.quantidade) || 1
});
});
} else {
excelRowsImportacao.push({
of_number: item.of,
etapa_fase: item.fase,
marca: item.marca,
descricao: item.descricao,
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material,
perfil_principal: item.perfilPrincipal,
tem_componentes: false,
marca_componente: '',
descricao_componente: '',
perfil_componente: '',
peso_unitario_componente: 0,
quantidade_por_peca: 0
});
}
});
const wb = XLSX.utils.book_new();
const wsPecas = XLSX.utils.json_to_sheet(excelRowsPecas);
wsPecas['!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, wsPecas, 'Lista_Pecas');
if (excelRowsComponentes.length > 0) {
const wsComp = XLSX.utils.json_to_sheet(excelRowsComponentes);
wsComp['!cols'] = [
{ wch: 12 }, { wch: 8 }, { wch: 16 }, { wch: 18 }, { wch: 22 },
{ wch: 16 }, { wch: 18 }, { wch: 12 }, { wch: 16 }, { wch: 18 }, { wch: 16 }
];
XLSX.utils.book_append_sheet(wb, wsComp, 'Lista_Componentes');
}
const wsImport = XLSX.utils.json_to_sheet(excelRowsImportacao);
XLSX.utils.book_append_sheet(wb, wsImport, 'Importacao_TrackSteel');
const outFileName = `${headerOf || currentFileName}_Fase_${headerFase || '1'}_Lista_Pecas_AdvanceSteel.xlsx`;
XLSX.writeFile(wb, outFileName);
addLog(`Planilha Excel baixada com ${excelRowsPecas.length} peças e ${excelRowsComponentes.length} componentes: ${outFileName}`);
toast.success(`Planilha gerada com sucesso: ${outFileName}`);
};
const handleDirectImport = async () => {
if (extractedData.length === 0) return;
setIsDirectImporting(true);
try {
const importacaoRows: any[] = [];
extractedData.forEach((item) => {
if (item.components && item.components.length > 0) {
item.components.forEach((comp) => {
importacaoRows.push({
of_number: item.of,
etapa_fase: item.fase,
marca: item.marca,
descricao: item.descricao,
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material,
perfil_principal: item.perfilPrincipal,
tem_componentes: true,
marca_componente: comp.marca || '',
descricao_componente: comp.perfil || item.descricao,
perfil_componente: comp.perfil || item.descricao,
peso_unitario_componente: comp.pesoUnitario || 0,
quantidade_por_peca: Number(comp.quantidade) || 1
});
});
} else {
importacaoRows.push({
of_number: item.of,
etapa_fase: item.fase,
marca: item.marca,
descricao: item.descricao,
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material,
perfil_principal: item.perfilPrincipal,
tem_componentes: false,
marca_componente: '',
descricao_componente: '',
perfil_componente: '',
peso_unitario_componente: 0,
quantidade_por_peca: 0
});
}
});
toast.info('Verificando existência e status de produção das peças...');
const analise = await analisarConflitosImportacao(importacaoRows);
const { componentesMap } = consolidarPecasEComponentes(importacaoRows);
if (analise.totalComDivergencia > 0 || analise.totalIdenticas > 0 || analise.totalComApontamento > 0) {
setAnaliseDiff(analise);
setComponentesMapDiff(componentesMap);
setShowDiffModal(true);
setIsDirectImporting(false);
return;
}
await importPecas(importacaoRows);
toast.success(`${extractedData.length} peças e seus componentes foram importados com sucesso!`);
addLog(`✅ Importação concluída: ${extractedData.length} peças gravadas no banco.`);
} catch (err: any) {
console.error('Erro na importação direta:', err);
toast.error(`Falha ao importar: ${err?.message || 'Erro no banco'}`);
} finally {
setIsDirectImporting(false);
}
};
const totalPesoGeral = extractedData.reduce((acc, curr) => {
const val = typeof curr.pesoTotal === 'number' ? curr.pesoTotal : parseFloat(String(curr.pesoTotal)) || 0;
return acc + val;
}, 0);
const totalPecasCompostas = extractedData.filter((p) => p.isComposed === 'SIM').length;
const totalPecasSimples = extractedData.filter((p) => p.isComposed === 'NÃO').length;
return (
<div className="space-y-6">
{/* Banner Principal de Conversão */}
<Card className="bg-slate-900/90 border-slate-800 shadow-xl overflow-hidden">
<CardHeader className="bg-gradient-to-r from-slate-900 via-slate-800 to-indigo-950/40 border-b border-slate-800 pb-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<div className="p-2 bg-indigo-500/10 border border-indigo-500/30 rounded-lg text-indigo-400">
<Boxes className="w-5 h-5" />
</div>
<CardTitle className="text-xl font-bold text-white tracking-tight">
Conversor de Listas Advance Steel
</CardTitle>
</div>
<CardDescription className="text-slate-300 text-sm">
Converte a Lista Estruturada de Peças do Advance Steel para o padrão oficial de importação do TrackSteel.
</CardDescription>
</div>
{/* Indicador Automático de Tratamento Superficial da OF */}
<div className="bg-slate-800/80 border border-slate-700/80 rounded-xl p-3 shadow-inner flex flex-col gap-1 min-w-[240px]">
<div className="flex items-center justify-between text-xs font-semibold text-slate-300">
<span className="flex items-center gap-1.5 text-indigo-300">
<Paintbrush className="w-3.5 h-3.5 text-indigo-400" />
Tratamento Superficial:
</span>
<Badge variant="outline" className="text-[10px] bg-indigo-500/10 text-indigo-300 border-indigo-500/30">
Automático da OF
</Badge>
</div>
<div className="flex items-center gap-2 pt-1 text-sm font-semibold text-white">
<span className={`w-2.5 h-2.5 rounded-full ${tratamentoGlobal === 'galvanizacao' ? 'bg-amber-400' : 'bg-blue-400'}`}></span>
{tratamentoGlobal === 'galvanizacao' ? 'Galvanizado (OF)' : 'Pintura (OF)'}
</div>
</div>
</div>
</CardHeader>
<CardContent className="p-6 space-y-6">
{/* Área de Upload / Arrastar Arquivo */}
<div
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className="group border-2 border-dashed border-slate-700 hover:border-indigo-500/80 bg-slate-950/40 hover:bg-slate-900/50 rounded-xl p-8 text-center cursor-pointer transition-all duration-200 flex flex-col items-center justify-center space-y-3"
>
<input
ref={fileInputRef}
type="file"
accept="application/pdf,.pdf"
onChange={handleFileInput}
className="hidden"
/>
<div className="p-3.5 bg-indigo-500/10 border border-indigo-500/20 group-hover:border-indigo-500/40 rounded-full text-indigo-400 group-hover:scale-105 transition-transform duration-200">
<Upload className="h-7 w-7" />
</div>
<div>
<p className="text-sm font-semibold text-slate-200">
{selectedFile ? (
<span className="text-indigo-400">{selectedFile.name}</span>
) : (
<>
<span className="text-indigo-400 font-bold hover:underline">Clique para selecionar</span> ou arraste a lista Advance Steel (.pdf)
</>
)}
</p>
<p className="text-xs text-slate-400 mt-1">
Lê automaticamente cabeçalho (Trabalho/OF, Fase), marcas principais, subcomponentes e subtotais.
</p>
</div>
{selectedFile && (
<Badge variant="outline" className="bg-slate-800 text-slate-300 border-slate-700 mt-2 font-mono text-[11px]">
{(selectedFile.size / 1024).toFixed(1)} KB carregado
</Badge>
)}
</div>
{/* Cards de Métricas e Identificação da OF */}
{extractedData.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
<div className="bg-slate-800/60 border border-slate-700/70 p-3 rounded-lg flex flex-col justify-between">
<span className="text-[11px] font-medium text-slate-400">{labels.ofLabel} (Obra)</span>
<Input
value={headerOf}
onChange={(e) => handleOfChange(e.target.value)}
className="h-7 text-xs font-bold font-mono bg-slate-900 border-slate-700 text-indigo-300 mt-1"
/>
</div>
<div className="bg-slate-800/60 border border-slate-700/70 p-3 rounded-lg flex flex-col justify-between">
<span className="text-[11px] font-medium text-slate-400">{labels.faseLabel}</span>
<Input
value={headerFase}
onChange={(e) => handleFaseChange(e.target.value)}
className="h-7 text-xs font-bold font-mono bg-slate-900 border-slate-700 text-indigo-300 mt-1"
/>
</div>
<div className="bg-slate-800/60 border border-slate-700/70 p-3 rounded-lg">
<span className="text-[11px] font-medium text-slate-400">Total de {labels.pecaPlural}</span>
<div className="text-lg font-bold text-white mt-1 flex items-baseline gap-1">
{extractedData.length} <span className="text-xs font-normal text-slate-400">itens</span>
</div>
</div>
<div className="bg-slate-800/60 border border-slate-700/70 p-3 rounded-lg">
<span className="text-[11px] font-medium text-slate-400">Compostas / Simples</span>
<div className="text-xs font-bold mt-1.5 flex items-center gap-1.5">
<Badge className="bg-emerald-500/15 text-emerald-300 border-emerald-500/30 text-[10px] px-1.5 py-0">
SIM: {totalPecasCompostas}
</Badge>
<Badge className="bg-sky-500/15 text-sky-300 border-sky-500/30 text-[10px] px-1.5 py-0">
NÃO: {totalPecasSimples}
</Badge>
</div>
</div>
<div className="col-span-2 sm:col-span-1 bg-slate-800/60 border border-slate-700/70 p-3 rounded-lg">
<span className="text-[11px] font-medium text-slate-400">Peso Total</span>
<div className="text-lg font-bold text-amber-400 mt-1">
{totalPesoGeral.toLocaleString('pt-BR', { minimumFractionDigits: 1, maximumFractionDigits: 1 })}{' '}
<span className="text-xs font-normal text-slate-400">kg</span>
</div>
</div>
</div>
)}
{/* Barra de Ações */}
{extractedData.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 bg-slate-800/40 p-3 rounded-lg border border-slate-700/60">
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-400 border-emerald-500/30 text-xs px-2.5 py-1">
<CheckCircle2 className="w-3.5 h-3.5 mr-1 inline" />
{extractedData.length} Peças Prontas para Importação
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => setShowLogs(!showLogs)}
className="text-xs text-slate-400 hover:text-slate-200 h-7"
>
<Info className="w-3.5 h-3.5 mr-1" />
{showLogs ? 'Ocultar Logs' : 'Ver Logs'}
</Button>
</div>
<div className="flex items-center gap-2">
<Button
onClick={exportToExcel}
variant="outline"
className="border-emerald-600/60 text-emerald-400 hover:bg-emerald-600/10 font-semibold text-xs px-3.5 h-9"
>
<Download className="w-4 h-4 mr-1.5" />
Baixar Planilha Excel
</Button>
<Button
onClick={handleDirectImport}
disabled={isDirectImporting}
className="bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs px-4 h-9 shadow-lg shadow-indigo-950/40"
>
{isDirectImporting ? (
<>
<RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />
Gravando no Banco...
</>
) : (
<>
<Upload className="w-4 h-4 mr-1.5" />
Importar no Sistema (Peças + Componentes)
</>
)}
</Button>
</div>
</div>
)}
{/* Logs Expansíveis */}
{showLogs && logs.length > 0 && (
<div className="bg-slate-950 border border-slate-800 rounded-lg p-3 font-mono text-[11px] text-slate-400 space-y-1 max-h-40 overflow-y-auto">
{logs.map((l, i) => (
<div key={i}>{l}</div>
))}
</div>
)}
{/* Tabela de Pré-visualização com Visual Moderno (Igual ao Bocad) */}
{extractedData.length > 0 && (
<div className="border border-slate-800 rounded-xl overflow-hidden bg-slate-950/60">
<div className="px-4 py-3 bg-slate-800/60 border-b border-slate-800 flex items-center justify-between">
<span className="text-xs font-semibold text-slate-200 flex items-center gap-2">
<FileSpreadsheet className="w-4 h-4 text-emerald-400" />
Prévia da Planilha de Destino (12 Colunas Oficiais)
</span>
<span className="text-[11px] text-slate-400">
Pronto para uso direto no botão "Importar Peças"
</span>
</div>
<ScrollArea className="h-[420px] w-full">
<table className="w-full text-xs text-left border-collapse">
<thead className="bg-slate-900 text-slate-300 font-mono text-[11px] sticky top-0 z-10 shadow-sm">
<tr>
<th className="p-2.5 border-b border-slate-800">{labels.ofLabel}</th>
<th className="p-2.5 border-b border-slate-800">{labels.faseLabel}</th>
<th className="p-2.5 border-b border-slate-800">{labels.pecaLabel}</th>
<th className="p-2.5 border-b border-slate-800">Descrição</th>
<th className="p-2.5 border-b border-slate-800 text-center">Com {labels.componentePlural}</th>
<th className="p-2.5 border-b border-slate-800 text-center">Quantidade</th>
<th className="p-2.5 border-b border-slate-800 text-right">Peso Unitário (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 Superficial</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">Comprimento Ref. (mm)</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 font-sans">
{extractedData.map((p, idx) => {
const pUnit = typeof p.pesoUnit === 'number' ? p.pesoUnit : parseFloat(String(p.pesoUnit)) || 0;
const pTot = typeof p.pesoTotal === 'number' ? p.pesoTotal : parseFloat(String(p.pesoTotal)) || 0;
return (
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
<td className="p-2.5 font-mono text-indigo-300 font-medium">{p.of}</td>
<td className="p-2.5 font-mono text-slate-300">{p.fase}</td>
<td className="p-2.5 font-mono font-bold text-white">{p.marca}</td>
<td className="p-2.5 text-slate-200 font-medium">{p.descricao}</td>
<td className="p-2.5 text-center">
{p.isComposed === 'SIM' ? (
<Badge className="bg-emerald-500/20 text-emerald-300 border-emerald-500/40 text-[10px] px-2 py-0">
SIM
</Badge>
) : (
<Badge className="bg-sky-500/20 text-sky-300 border-sky-500/40 text-[10px] px-2 py-0">
NÃO
</Badge>
)}
</td>
<td className="p-2.5 text-center font-bold text-slate-200">{p.quantidade}</td>
<td className="p-2.5 text-right font-mono text-slate-300">
{pUnit > 0 ? pUnit.toLocaleString('pt-BR', { minimumFractionDigits: 1, maximumFractionDigits: 2 }) : '-'}
</td>
<td className="p-2.5 text-right font-mono font-semibold text-amber-300">
{pTot > 0 ? pTot.toLocaleString('pt-BR', { minimumFractionDigits: 1, maximumFractionDigits: 2 }) : '-'}
</td>
<td className="p-2.5 text-slate-300">
<Badge variant="outline" className="text-[10px] bg-slate-900 border-slate-700 text-slate-300">
{p.tratamentoSuperficial}
</Badge>
</td>
<td className="p-2.5 font-medium text-emerald-400">{p.material}</td>
<td className="p-2.5 font-mono text-slate-200">{p.perfilPrincipal}</td>
<td className="p-2.5 text-right font-mono text-slate-400">
{p.comprimentoMax || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</ScrollArea>
</div>
)}
</CardContent>
</Card>
{/* Modal de Detecção de Conflitos e Conciliação */}
<ImportarPecasDiffModal
open={showDiffModal}
onOpenChange={setShowDiffModal}
analise={analiseDiff}
componentesMap={componentesMapDiff}
ofDefault={headerOf || ''}
onConcluido={() => {
setShowDiffModal(false);
addLog(`✅ Importação conciliada concluída com sucesso.`);
}}
/>
</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">
<AdvanceSteelConverterContent />
</DialogContent>
</Dialog>
);
};
export const AdvanceSteelConverter: React.FC<AdvanceSteelConverterProps> = ({ open, onOpenChange }) => {
if (open !== undefined && onOpenChange !== undefined) {
return <AdvanceSteelConverterModal open={open} onOpenChange={onOpenChange} />;
}
return <AdvanceSteelConverterContent />;
};
export default AdvanceSteelConverter;