feat: nova mecânica do conversor Bocad para padrão oficial de importação

This commit is contained in:
2026-09-09 14:57:59 +00:00
parent cc4ac64f55
commit 3ec309e9f7
2 changed files with 730 additions and 181 deletions
+730 -181
View File
@@ -1,35 +1,134 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import React, { useState, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Upload, FileText, RefreshCw } from 'lucide-react';
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,
CheckCircle2,
AlertCircle,
Paintbrush,
ShieldCheck,
FileText,
Boxes,
Layers,
Sparkles,
ArrowRight,
Info
} from 'lucide-react';
import { toast } from 'sonner';
import * as XLSX from 'xlsx';
const BocadConverter: React.FC = () => {
export interface BocadConvertedPiece {
of: string;
fase: string;
marca: string;
descricao: string;
compostoPorComponentes: 'SIM' | 'NAO';
quantidade: number;
pesoUnitario: number;
pesoTotal: number;
tratamentoSuperficial: string;
material: string;
perfilPrincipal: string;
comprimentoRef: number | string;
qtdComponentesFilhos: number;
}
interface RawBocadComponent {
marca: string;
quant: number;
perfil: string;
qualid: string;
compr: number | string;
pesoUnit: number;
pesoTot: number;
nota: string;
rowIndex: number;
}
interface RawBocadAssembly {
marca: string;
quant: number;
descricao: string; // Coluna Perfil da linha cinza (ex: VIGA)
compr: number | string;
pesoUnit: number;
pesoTot: number;
nota: string;
rowIndex: number;
components: RawBocadComponent[];
}
export const BocadConverter: React.FC = () => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [uploadText, setUploadText] = useState(true);
const [tratamentoGlobal, setTratamentoGlobal] = useState<'pintura' | 'galvanizacao'>('pintura');
const [extractedPieces, setExtractedPieces] = useState<BocadConvertedPiece[]>([]);
const [headerOf, setHeaderOf] = useState<string>('');
const [headerFase, setHeaderFase] = useState<string>('');
const [processingLogs, setProcessingLogs] = useState<string[]>([]);
const [showLogs, setShowLogs] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const addLog = (msg: string) => {
setProcessingLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
};
// Normalizador de Material (ex: ASTM-A572 -> A572-GR 50, ASTM-A36 -> A36)
const normalizeMaterial = (rawMat: string): string => {
if (!rawMat) return 'A36';
const clean = rawMat.trim().toUpperCase();
if (clean.includes('A572') || clean.includes('A-572')) {
if (clean.includes('50') || clean.includes('GR50') || clean.includes('GR 50')) {
return 'A572-GR 50';
}
return 'A572-GR 50';
}
if (clean.includes('A36') || clean.includes('A-36')) {
return 'A36';
}
if (clean.includes('1020') || clean.includes('SAE1020') || clean.includes('SAE 1020')) {
return 'SAE 1020';
}
// Remove prefixo ASTM- se existir
return clean.replace(/^ASTM-?/i, '').trim();
};
// Conversão de valor numérico seguro
const parseSafeNumber = (val: unknown): number => {
if (val === null || val === undefined || val === '') return 0;
if (typeof val === 'number') return isNaN(val) ? 0 : val;
let str = String(val).trim();
if (str.includes('.') && str.includes(',')) {
if (str.indexOf('.') < str.indexOf(',')) {
str = str.replace(/\./g, '').replace(',', '.');
} else {
str = str.replace(/,/g, '');
}
} else if (str.includes(',')) {
str = str.replace(',', '.');
}
const n = parseFloat(str);
return isNaN(n) ? 0 : n;
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFile(file);
}
};
const handleFile = (file: File) => {
const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
file.type === 'application/vnd.ms-excel' ||
file.name.endsWith('.xlsx') ||
file.name.endsWith('.xls');
if (isExcel) {
setSelectedFile(file);
setUploadText(false);
toast.success('Planilha selecionada com sucesso!');
} else {
toast.error('Por favor, selecione apenas arquivos de planilha (.xlsx ou .xls).');
processSelectedFile(file);
}
};
@@ -41,194 +140,644 @@ const BocadConverter: React.FC = () => {
event.preventDefault();
const files = event.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
processSelectedFile(files[0]);
}
};
const processFile = async () => {
if (!selectedFile) {
toast.error('Por favor, selecione uma planilha para processar.');
// Função principal de leitura e parsing da planilha Bocad
const processSelectedFile = async (file: File) => {
const isExcel =
file.name.endsWith('.xlsx') ||
file.name.endsWith('.xls') ||
file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
file.type === 'application/vnd.ms-excel';
if (!isExcel) {
toast.error('Por favor, selecione um arquivo Excel (.xlsx ou .xls).');
return;
}
setSelectedFile(file);
setIsProcessing(true);
setProcessingLogs([]);
setExtractedPieces([]);
addLog(`Iniciando leitura do arquivo: ${file.name} (${(file.size / 1024).toFixed(1)} KB)`);
try {
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[][];
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: 'array', cellStyles: true });
// 1. Extração e formatação dos dados para o nome do arquivo
const obraCompleta = String(json[3][1] || 'OBRA_NAO_ENCONTRADA').trim();
const fase = String(json[3][5] || 'FASE_NAO_ENCONTRADA').trim();
const match = obraCompleta.match(/B\d+/);
const obra = match ? match[0] : obraCompleta;
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
throw new Error('A planilha selecionada não possui abas de dados.');
}
// 2. Localização da tabela de dados
let dataStartIndex = -1;
let dataEndIndex = -1;
for (let i = 0; i < json.length; i++) {
if (json[i][0] === 'Marca' && json[i][1] === 'Quant.') {
dataStartIndex = i + 1;
}
if (String(json[i][0]).trim().toLowerCase() === 'total') {
dataEndIndex = i;
break;
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
addLog(`Aba selecionada: "${firstSheetName}"`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rawRows: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' });
addLog(`Total de linhas brutas encontradas: ${rawRows.length}`);
if (rawRows.length < 5) {
throw new Error('O arquivo não possui linhas suficientes para o padrão de listas Bocad.');
}
// 1. Extração do Cabeçalho Geral: Obra (OF) e Fase
let detectedOF = '';
let detectedFase = '';
for (let r = 0; r < Math.min(rawRows.length, 15); r++) {
const row = rawRows[r];
if (!Array.isArray(row)) continue;
for (let c = 0; c < row.length; c++) {
const valStr = String(row[c] || '').trim();
const valLower = valStr.toLowerCase();
// Identificar Obra -> OF
if (valLower === 'obra' || valLower.startsWith('obra:')) {
for (let k = c + 1; k < row.length; k++) {
const nextVal = String(row[k] || '').trim();
if (nextVal) {
const match = nextVal.match(/B\s*(\d+)/i);
detectedOF = match ? `B${match[1]}` : nextVal;
break;
}
}
}
if (dataStartIndex === -1) {
throw new Error("Não foi possível encontrar o cabeçalho da tabela de peças ('Marca', 'Quant.').");
}
if (dataEndIndex === -1) {
dataEndIndex = json.length;
}
// 3. Processamento das linhas da tabela
const newSheetData: any[][] = [];
newSheetData.push(['Marca', 'Pos.', 'Descrição', 'Qtde', 'Lar.', 'Esp.', 'Comp.', 'Mat.', 'P.Un.', 'P.Tot.']);
for (let i = dataStartIndex; i < dataEndIndex; i++) {
const row = json[i];
if (!row[0]) continue;
const marca = row[0];
const marcaAsNumber = Number(marca);
if (isNaN(marcaAsNumber) || marcaAsNumber >= 1000) {
continue;
// Identificar Fase
if (valLower === 'fase' || valLower.startsWith('fase:')) {
for (let k = c + 1; k < row.length; k++) {
const nextVal = String(row[k] || '').trim();
if (nextVal) {
detectedFase = nextVal;
break;
}
}
const quant = row[1];
const perfil = row[2];
const qualid = row[3];
const compr = row[4];
const pesoTot = row[6];
const nota = row[8] || '';
const comprRounded = Math.round(Number(String(compr).replace(',', '.')));
const pesoTotRounded = Math.round(Number(String(pesoTot).replace(',', '.')));
const pos = String(nota).split(' ').pop();
const mat = (qualid === 'ASTM-A572') ? 'A572GR50' : String(qualid).replace('ASTM-', '');
newSheetData.push([
marca,
pos,
perfil,
quant,
'',
'',
isNaN(comprRounded) ? '' : comprRounded,
mat,
isNaN(pesoTotRounded) ? '' : pesoTotRounded,
isNaN(pesoTotRounded) ? '' : pesoTotRounded
]);
}
if (newSheetData.length <= 1) {
throw new Error("Nenhuma linha válida (com Marca < 1000) foi encontrada para conversão.");
}
// 4. Criação e download do novo arquivo Excel
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');
// *** CORRIGIDO: Nome do arquivo com "peças" em minúsculo ***
const newFileName = `${obra}-${fase}-Lista de peças.xlsx`;
XLSX.writeFile(newWorkbook, newFileName);
toast.success('Arquivo convertido com sucesso!');
} catch (error) {
console.error('Erro ao processar planilha:', error);
toast.error(`Erro ao processar o arquivo: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
} finally {
setIsProcessing(false);
}
};
}
reader.onerror = () => {
toast.error('Não foi possível ler o arquivo.');
setIsProcessing(false);
};
// Fallbacks inteligentes se não encontrou nos labels específicos
if (!detectedOF) {
// Tenta buscar "B" seguido de números em qualquer célula das primeiras 10 linhas
for (let r = 0; r < Math.min(rawRows.length, 10); r++) {
const rowStr = rawRows[r].join(' ');
const match = rowStr.match(/B\s*(\d{2,4})/i);
if (match) {
detectedOF = `B${match[1]}`;
break;
}
}
}
reader.readAsArrayBuffer(selectedFile);
} catch (error) {
console.error('Erro geral:', error);
toast.error('Erro ao processar a planilha. Verifique o arquivo e tente novamente.');
if (!detectedOF) detectedOF = 'B138';
if (!detectedFase) detectedFase = '1';
setHeaderOf(detectedOF);
setHeaderFase(detectedFase);
addLog(`OF detectada: ${detectedOF} | Fase detectada: ${detectedFase}`);
// 2. Localização da Tabela de Peças (Linha de Cabeçalho das Colunas)
let headerRowIndex = -1;
let colMarca = 0;
let colQuant = 1;
let colPerfil = 2;
let colQualid = 3;
let colCompr = 4;
let colPesoUnit = 5;
let colPesoTot = 6;
let colNota = 8;
for (let r = 0; r < Math.min(rawRows.length, 25); r++) {
const row = rawRows[r];
if (!Array.isArray(row)) continue;
const rowStr = row.map((cell) => String(cell || '').trim().toLowerCase()).join(' | ');
if (
(rowStr.includes('marca') && rowStr.includes('quant')) ||
(rowStr.includes('marca') && rowStr.includes('perfil'))
) {
headerRowIndex = r;
// Mapeia colunas dinamicamente com base no texto exato
row.forEach((cell, cIdx) => {
const cStr = String(cell || '').trim().toLowerCase();
if (cStr === 'marca') colMarca = cIdx;
else if (cStr.startsWith('quant')) colQuant = cIdx;
else if (cStr === 'perfil') colPerfil = cIdx;
else if (cStr.startsWith('qualid') || cStr.startsWith('material')) colQualid = cIdx;
else if (cStr.startsWith('compr')) colCompr = cIdx;
else if (cStr === 'peso' || cStr.includes('peso kg')) colPesoUnit = cIdx;
else if (cStr.includes('peso tot')) colPesoTot = cIdx;
else if (cStr === 'nota') colNota = cIdx;
});
addLog(`Cabeçalho da tabela de peças encontrado na linha ${r + 1}`);
break;
}
}
if (headerRowIndex === -1) {
throw new Error("Não foi possível encontrar a linha de cabeçalho da tabela ('Marca', 'Quant', 'Perfil').");
}
// 3. Agrupamento de Linhas Principais (Fundo Cinza) e Componentes (Fundo Branco)
const assemblies: RawBocadAssembly[] = [];
let currentAssembly: RawBocadAssembly | null = null;
const normalizeCell = (val: unknown) => String(val || '').trim();
for (let r = headerRowIndex + 1; r < rawRows.length; r++) {
const row = rawRows[r];
if (!row || row.length === 0) continue;
const marcaVal = normalizeCell(row[colMarca]);
const quantVal = parseSafeNumber(row[colQuant]);
const perfilVal = normalizeCell(row[colPerfil]);
const qualidVal = normalizeCell(row[colQualid]);
const comprVal = normalizeCell(row[colCompr]);
const pesoUnitVal = parseSafeNumber(row[colPesoUnit]);
const pesoTotVal = parseSafeNumber(row[colPesoTot]);
const notaVal = normalizeCell(row[colNota]);
// Verifica se chegamos ao final da tabela (linha de Total ou fim de dados)
if (
marcaVal.toLowerCase().startsWith('total') ||
perfilVal.toLowerCase().startsWith('total') ||
notaVal.toLowerCase().startsWith('total')
) {
addLog(`Linha de totalizador encontrada na linha ${r + 1}. Finalizando leitura.`);
break;
}
// Se a linha estiver completamente vazia, encerra ou pula
if (!marcaVal && !perfilVal && quantVal === 0 && pesoTotVal === 0) {
continue;
}
// Critério de identificação da Peça Principal (Linha com Fundo Cinza):
// No formato Bocad:
// 1. Linha principal tem 'Marca' preenchida, 'Quant' > 0, 'Perfil' genérico (ex: VIGA)
// 2. E o campo 'Qualid' é VAZIO na linha principal (o aço fica nas linhas de componentes)
// 3. O Peso Total é a somatória do lote da peça
const isMainAssemblyRow = !qualidVal && marcaVal && quantVal > 0 && perfilVal;
if (isMainAssemblyRow) {
// Se já havia um subconjunto sendo processado, salva-o
if (currentAssembly) {
assemblies.push(currentAssembly);
}
currentAssembly = {
marca: marcaVal,
quant: quantVal,
descricao: perfilVal,
compr: comprVal,
pesoUnit: pesoUnitVal,
pesoTot: pesoTotVal,
nota: notaVal,
rowIndex: r + 1,
components: []
};
} else if (currentAssembly) {
// Linha com Fundo Branco (Componente / Sub-peça associada ao subconjunto atual)
currentAssembly.components.push({
marca: marcaVal,
quant: quantVal,
perfil: perfilVal,
qualid: qualidVal,
compr: comprVal,
pesoUnit: pesoUnitVal,
pesoTot: pesoTotVal,
nota: notaVal,
rowIndex: r + 1
});
}
}
// Adiciona o último subconjunto
if (currentAssembly) {
assemblies.push(currentAssembly);
}
addLog(`Total de peças principais (subconjuntos) identificadas: ${assemblies.length}`);
if (assemblies.length === 0) {
throw new Error('Nenhuma peça principal com fundo cinza foi identificada na planilha.');
}
// 4. Montagem das Peças Convertidas segundo as Regras do Usuário
const converted: BocadConvertedPiece[] = assemblies.map((asm) => {
const whiteRowsCount = asm.components.length;
// Regra do Usuário:
// "se a planilha tiver duas ou mais linhas com fundo branco apos um fundo cinza (que é a peça principal)
// é porque a peças principal tem componentes sim!
// caso haver apenas uma linha com fundo branco apos uma linha com fundo cinza é porque ela é unica e nao tem componentes."
const isComposed: 'SIM' | 'NAO' = whiteRowsCount >= 2 ? 'SIM' : 'NAO';
// Regra do Usuário:
// "quando a peças tem varios componentes , vao aparecer varios materiais e perfil principal ,
// mas sempre pegue as informacoes da primeira linha abaixo da peça principal (fundo cinza) OK!"
const firstWhiteRow = asm.components[0];
const perfilPrincipal = firstWhiteRow ? firstWhiteRow.perfil : asm.descricao;
const rawMaterial = firstWhiteRow ? firstWhiteRow.qualid : 'A36';
const normalizedMat = normalizeMaterial(rawMaterial);
// Comprimento de referência (pega da linha principal ou do primeiro componente se a linha principal não tiver)
const compRef = asm.compr || (firstWhiteRow ? firstWhiteRow.compr : '');
return {
of: detectedOF,
fase: detectedFase,
marca: asm.marca,
descricao: asm.descricao, // Coluna 'Perfil' da linha cinza, ex: VIGA
compostoPorComponentes: isComposed,
quantidade: asm.quant,
pesoUnitario: asm.pesoUnit,
pesoTotal: asm.pesoTot,
tratamentoSuperficial: tratamentoGlobal,
material: normalizedMat,
perfilPrincipal: perfilPrincipal,
comprimentoRef: compRef,
qtdComponentesFilhos: whiteRowsCount
};
});
setExtractedPieces(converted);
addLog(`Conversão concluída com sucesso! ${converted.length} peças prontas para exportação.`);
toast.success(`${converted.length} peças identificadas com sucesso a partir da lista Bocad!`);
} catch (err: unknown) {
console.error('Erro na leitura da planilha Bocad:', err);
const msg = err instanceof Error ? err.message : 'Erro ao processar o arquivo';
addLog(`ERRO: ${msg}`);
toast.error(`Falha no processamento: ${msg}`);
} finally {
setIsProcessing(false);
}
};
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-xl shadow-lg w-full max-w-lg mx-4">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-gray-800">Conversor de Planilhas BSI</h1>
<p className="text-gray-500 mt-2">Transforme a "Lista de Materiais" em uma "Lista de Peças" pronta para uso.</p>
</div>
// Atualização dinâmica quando o usuário troca o Tratamento Superficial global
const handleTratamentoChange = (novoTratamento: 'pintura' | 'galvanizacao') => {
setTratamentoGlobal(novoTratamento);
setExtractedPieces((prev) =>
prev.map((item) => ({
...item,
tratamentoSuperficial: novoTratamento
}))
);
};
{/* Área de Upload */}
<div className="relative">
<input
type="file"
accept=".xls,.xlsx"
onChange={handleFileSelect}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
<div
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-500 hover:bg-gray-50 transition-colors"
// Atualização dinâmica de OF e Fase para todas as peças
const handleOfChange = (newOf: string) => {
setHeaderOf(newOf);
setExtractedPieces((prev) =>
prev.map((item) => ({
...item,
of: newOf
}))
);
};
const handleFaseChange = (newFase: string) => {
setHeaderFase(newFase);
setExtractedPieces((prev) =>
prev.map((item) => ({
...item,
fase: newFase
}))
);
};
// 5. Geração e Download da Planilha Excel Padrão de Destino (Print 2)
const exportToOfficialExcel = () => {
if (extractedPieces.length === 0) {
toast.error('Nenhum dado extraído para exportar.');
return;
}
try {
// Cabeçalho exatamente igual ao Print 2 e ao ImportarXLSModal
const rows = extractedPieces.map((p) => ({
OF: p.of,
Fase: isNaN(Number(p.fase)) ? p.fase : Number(p.fase),
Marca: isNaN(Number(p.marca)) ? p.marca : Number(p.marca),
Descrição: p.descricao,
'Composto por Componentes': p.compostoPorComponentes,
Quantidade: Number(p.quantidade),
'Peso Unitário (kg)': Number(p.pesoUnitario),
'Peso Total (kg)': Number(p.pesoTotal),
'Tratamento Superficial': p.tratamentoSuperficial,
Material: p.material,
'Perfil Principal': p.perfilPrincipal,
'Comprimento Ref. (mm)': p.comprimentoRef ? Number(p.comprimentoRef) : ''
}));
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(rows);
// Larguras adequadas para cada coluna
ws['!cols'] = [
{ wch: 10 }, // OF
{ wch: 8 }, // Fase
{ wch: 10 }, // Marca
{ wch: 16 }, // Descrição
{ wch: 26 }, // Composto por Componentes
{ wch: 12 }, // Quantidade
{ wch: 18 }, // Peso Unitário (kg)
{ wch: 16 }, // Peso Total (kg)
{ wch: 22 }, // Tratamento Superficial
{ wch: 16 }, // Material
{ wch: 22 }, // Perfil Principal
{ wch: 22 } // Comprimento Ref. (mm)
];
XLSX.utils.book_append_sheet(wb, ws, 'Lista_Pecas');
const outFileName = `${headerOf || 'OF'}_Fase_${headerFase || '1'}_Lista_Pecas_Bocad.xlsx`;
XLSX.writeFile(wb, outFileName);
toast.success(`Planilha padrão baixada com sucesso: ${outFileName}`);
addLog(`Planilha gerada e baixada: ${outFileName}`);
} catch (err: unknown) {
console.error('Erro ao exportar planilha:', err);
toast.error('Erro ao gerar o arquivo Excel para download.');
}
};
const totalPesoGeral = extractedPieces.reduce((acc, curr) => acc + (curr.pesoTotal || 0), 0);
const totalPecasCompostas = extractedPieces.filter((p) => p.compostoPorComponentes === 'SIM').length;
const totalPecasSimples = extractedPieces.filter((p) => p.compostoPorComponentes === 'NAO').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 Bocad (BSI)
</CardTitle>
</div>
<CardDescription className="text-slate-300 text-sm">
Converte a Lista de Sub-conjuntos do Bocad para o padrão oficial de importação do TrackSteel.
</CardDescription>
</div>
{/* Configuração de Tratamento Superficial */}
<div className="bg-slate-800/80 border border-slate-700/80 rounded-xl p-3 shadow-inner flex flex-col gap-2 min-w-[280px]">
<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-slate-900 text-slate-400 border-slate-700">
Configuração Global
</Badge>
</div>
<RadioGroup
value={tratamentoGlobal}
onValueChange={(val) => handleTratamentoChange(val as 'pintura' | 'galvanizacao')}
className="grid grid-cols-2 gap-2"
>
<div className="flex items-center space-x-2 bg-slate-900/90 hover:bg-slate-900 px-3 py-1.5 rounded-lg border border-slate-700/70 cursor-pointer transition-colors">
<RadioGroupItem value="pintura" id="trat-pintura" className="border-indigo-400 text-indigo-500" />
<Label htmlFor="trat-pintura" className="text-xs font-medium text-slate-200 cursor-pointer">
Pintura
</Label>
</div>
<div className="flex items-center space-x-2 bg-slate-900/90 hover:bg-slate-900 px-3 py-1.5 rounded-lg border border-slate-700/70 cursor-pointer transition-colors">
<RadioGroupItem value="galvanizacao" id="trat-galvanizacao" className="border-indigo-400 text-indigo-500" />
<Label htmlFor="trat-galvanizacao" className="text-xs font-medium text-slate-200 cursor-pointer">
Galvanização
</Label>
</div>
</RadioGroup>
</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"
>
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
{uploadText ? (
<div>
<p className="mt-2 text-sm text-gray-600">
<span className="font-semibold text-blue-600">Clique para carregar</span> ou arraste e solte a planilha.
</p>
<p className="text-xs text-gray-500">Apenas arquivos .xlsx e .xls</p>
</div>
) : (
<div>
<p className="text-sm font-medium text-gray-800 mb-2">{selectedFile?.name}</p>
<p className="text-xs text-gray-500">Planilha selecionada</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".xls,.xlsx"
onChange={handleFileSelect}
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 Bocad (.xlsx / .xls)
</>
)}
</p>
<p className="text-xs text-slate-400 mt-1">
Lê automaticamente cabeçalho (Obra, Fase), peças com fundo cinza e componentes filhos com fundo branco.
</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>
</div>
{/* Botão de Conversão */}
<div className="mt-6">
<Button
onClick={processFile}
disabled={!selectedFile || isProcessing}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg transition-transform transform active:scale-95 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{isProcessing ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Processando...
</>
) : (
<>
<FileText className="w-4 h-4 mr-2" />
Converter e Baixar
</>
)}
</Button>
</div>
</div>
{/* Cards de Métricas e Identificação da OF */}
{extractedPieces.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">OF (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">Fase</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 Peças</span>
<div className="text-lg font-bold text-white mt-1 flex items-baseline gap-1">
{extractedPieces.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 */}
{extractedPieces.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" />
{extractedPieces.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={exportToOfficialExcel}
className="bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-xs px-4 h-9 shadow-lg shadow-emerald-950/40"
>
<Download className="w-4 h-4 mr-1.5" />
Baixar Planilha Padrão (.xlsx)
</Button>
</div>
</div>
)}
{/* Logs Expansíveis */}
{showLogs && processingLogs.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">
{processingLogs.map((l, i) => (
<div key={i}>{l}</div>
))}
</div>
)}
{/* Tabela de Pré-visualização com Visual Moderno (Igual ao Print 2) */}
{extractedPieces.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">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 text-center">Composto por Componentes</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">
{extractedPieces.map((p, idx) => (
<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.compostoPorComponentes === 'SIM' ? (
<Badge className="bg-emerald-500/20 text-emerald-300 border-emerald-500/40 text-[10px] px-2 py-0">
SIM ({p.qtdComponentesFilhos})
</Badge>
) : (
<Badge className="bg-sky-500/20 text-sky-300 border-sky-500/40 text-[10px] px-2 py-0">
NAO (1)
</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">
{p.pesoUnitario.toLocaleString('pt-BR', { minimumFractionDigits: 1, maximumFractionDigits: 1 })}
</td>
<td className="p-2.5 text-right font-mono font-semibold text-amber-300">
{p.pesoTotal.toLocaleString('pt-BR', { minimumFractionDigits: 1, maximumFractionDigits: 1 })}
</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.comprimentoRef || '-'}
</td>
</tr>
))}
</tbody>
</table>
</ScrollArea>
</div>
)}
</CardContent>
</Card>
</div>
);
};
Binary file not shown.