diff --git a/src/components/pecas/ImportarXLSModal.tsx b/src/components/pecas/ImportarXLSModal.tsx new file mode 100644 index 0000000..45f9d99 --- /dev/null +++ b/src/components/pecas/ImportarXLSModal.tsx @@ -0,0 +1,631 @@ +import React, { useState, useRef } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { FileSpreadsheet, Upload, Download, CheckCircle2, AlertCircle, Trash2, ArrowLeft, RefreshCw } from 'lucide-react'; +import { toast } from 'sonner'; +import * as XLSX from 'xlsx'; + +export interface PecaXLSData { + of_number: string; + etapa_fase: string; + marca: string; + descricao: string; + quantidade: number; + peso_unitario: number; + peso_total: number; + tratamento_superficial: string; + material: string; + perfil_principal: string; + tem_componentes: boolean; + comprimento_ref?: number | string; +} + +interface ImportarXLSModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onImport: (pecas: any[]) => Promise; + ofDefault?: string; +} + +export function ImportarXLSModal({ + open, + onOpenChange, + onImport, + ofDefault = '', +}: ImportarXLSModalProps) { + const [file, setFile] = useState(null); + const [pecasProcessadas, setPecasProcessadas] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [step, setStep] = useState<'upload' | 'preview'>('upload'); + const fileInputRef = useRef(null); + + const resetState = () => { + setFile(null); + setPecasProcessadas([]); + setIsProcessing(false); + setIsImporting(false); + setStep('upload'); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleModalOpenChange = (newOpen: boolean) => { + if (!newOpen) { + resetState(); + } + onOpenChange(newOpen); + }; + + // Função auxiliar para converter valores para número seguro + const parseNumber = (val: any): number => { + if (val === null || val === undefined || val === '') return 0; + if (typeof val === 'number') return isNaN(val) ? 0 : val; + + // String: trata "1.413,00" ou "1,413.00" ou "1413" + let str = String(val).trim(); + // Se tiver vírgula e ponto, identifica separador decimal + if (str.includes('.') && str.includes(',')) { + if (str.indexOf('.') < str.indexOf(',')) { + // Ex: 1.413,00 (padrão BR) + str = str.replace(/\./g, '').replace(',', '.'); + } else { + // Ex: 1,413.00 (padrão US) + str = str.replace(/,/g, ''); + } + } else if (str.includes(',')) { + // Ex: 1413,50 + str = str.replace(',', '.'); + } + + const num = parseFloat(str); + return isNaN(num) ? 0 : num; + }; + + // Parser robusto do arquivo Excel/XLSX/XLS/CSV + const processExcelFile = async (selectedFile: File) => { + setIsProcessing(true); + try { + const data = await selectedFile.arrayBuffer(); + const workbook = XLSX.read(data, { type: 'array' }); + + if (!workbook.SheetNames || workbook.SheetNames.length === 0) { + toast.error('O arquivo Excel não contém nenhuma planilha.'); + setIsProcessing(false); + return; + } + + const firstSheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[firstSheetName]; + + // Converte para matriz de linhas (array de arrays) para busca flexível do cabeçalho + const rawRows: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' }); + + if (!rawRows || rawRows.length === 0) { + toast.error('A planilha selecionada está vazia.'); + setIsProcessing(false); + return; + } + + // 1. Procurar a linha de cabeçalho + let headerRowIndex = -1; + let colMap: { [key: string]: number } = {}; + + const normalizeStr = (s: any) => + String(s || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]/g, ''); + + for (let r = 0; r < Math.min(rawRows.length, 15); r++) { + const row = rawRows[r]; + if (!Array.isArray(row)) continue; + + const rowStr = row.map(normalizeStr).join(' '); + if ( + rowStr.includes('marca') || + rowStr.includes('descricao') || + rowStr.includes('perfil') || + rowStr.includes('peso') || + rowStr.includes('quant') + ) { + headerRowIndex = r; + break; + } + } + + const pecasLidas: PecaXLSData[] = []; + + if (headerRowIndex !== -1) { + // Mapeia colunas por nome + const headers = rawRows[headerRowIndex].map((h: any) => normalizeStr(h)); + headers.forEach((h: string, colIdx: number) => { + if (h.includes('of') || h.includes('trabalho') || h.includes('numof') || h.includes('numeroof')) colMap['of'] = colIdx; + else if (h.includes('fase') || h.includes('etapa')) colMap['fase'] = colIdx; + else if (h.includes('marca') || h.includes('posicao') || h.includes('item') || h.includes('pos')) colMap['marca'] = colIdx; + else if (h.includes('desc') || h.includes('nome')) colMap['descricao'] = colIdx; + else if (h.includes('comp') && (h.includes('componente') || h.includes('composto') || h.includes('con'))) colMap['componentes'] = colIdx; + else if (h.includes('quant') || h.includes('qtd')) colMap['quantidade'] = colIdx; + else if (h.includes('pesounit') || h.includes('pesodapeca') || (h.includes('unit') && h.includes('peso'))) colMap['peso_unitario'] = colIdx; + else if (h.includes('pesototal') || h.includes('totalpeso') || (h.includes('total') && h.includes('peso'))) colMap['peso_total'] = colIdx; + else if (h.includes('tratam') || h.includes('superf') || h.includes('pintura') || h.includes('acab')) colMap['tratamento'] = colIdx; + else if (h.includes('mat') || h.includes('qualidade') || h.includes('aco')) colMap['material'] = colIdx; + else if (h.includes('perfil') || h.includes('perfilprinc')) colMap['perfil_principal'] = colIdx; + else if (h.includes('compriment') || h.includes('comprmm') || h.includes('length')) colMap['comprimento'] = colIdx; + }); + + // Itera sobre as linhas de dados após o cabeçalho + for (let r = headerRowIndex + 1; r < rawRows.length; r++) { + const row = rawRows[r]; + if (!row || row.length === 0) continue; + + // Se a linha estiver totalmente vazia + if (row.every((cell: any) => cell === '' || cell === null || cell === undefined)) continue; + + const getColVal = (key: string, fallbackIdx?: number) => { + if (colMap[key] !== undefined && row[colMap[key]] !== undefined) { + return row[colMap[key]]; + } + if (fallbackIdx !== undefined && row[fallbackIdx] !== undefined) { + return row[fallbackIdx]; + } + return ''; + }; + + const rawMarca = String(getColVal('marca', 2)).trim(); + const rawDesc = String(getColVal('descricao', 3)).trim(); + const rawPerfil = String(getColVal('perfil_principal', 9) || rawDesc).trim(); + + // Ignora linhas de totalizadores ou sem marca + if (!rawMarca || rawMarca.toLowerCase().includes('total')) continue; + + let ofNumber = String(getColVal('of', 0)).trim(); + if (!ofNumber) { + ofNumber = ofDefault || 'B132'; + } + // Normaliza formato da OF se necessário (ex: "B-129" -> "B129") + ofNumber = ofNumber.replace(/^B-(\d+)/i, 'B$1'); + + let etapaFase = String(getColVal('fase', 1)).trim(); + if (!etapaFase) etapaFase = 'Fabricação'; + + const quantidade = Math.max(1, Math.round(parseNumber(getColVal('quantidade', 4)) || 1)); + let pesoUnit = parseNumber(getColVal('peso_unitario', 5)); + let pesoTot = parseNumber(getColVal('peso_total', 6)); + + if (pesoTot > 0 && pesoUnit === 0) { + pesoUnit = Math.round(pesoTot / quantidade); + } else if (pesoUnit > 0 && pesoTot === 0) { + pesoTot = Math.round(pesoUnit * quantidade); + } + + // Tratamento superficial: regra do usuário -> sempre preencher "pintura" por padrão + let tratSuperficial = String(getColVal('tratamento', 7)).trim(); + if (!tratSuperficial) { + tratSuperficial = 'pintura'; + } + + let material = String(getColVal('material', 8)).trim(); + if (!material) { + material = 'Aço A36'; + } + + const rawComp = String(getColVal('componentes', 4)).toLowerCase(); + const temComponentes = rawComp === 'sim' || rawComp === 'true' || rawComp === '1'; + const comprimentoRef = getColVal('comprimento', 10); + + pecasLidas.push({ + of_number: ofNumber, + etapa_fase: etapaFase, + marca: rawMarca, + descricao: rawDesc || rawPerfil, + quantidade, + peso_unitario: pesoUnit, + peso_total: pesoTot, + tratamento_superficial: tratSuperficial, + material, + perfil_principal: rawPerfil || rawDesc, + tem_componentes: temComponentes, + comprimento_ref: comprimentoRef || undefined, + }); + } + } else { + // Fallback por ordem posicional estrita: + // Coluna 0: OF, 1: Fase, 2: Marca, 3: Descrição, 4: Quantidade, 5: Peso Unit, 6: Peso Total, 7: Tratamento, 8: Material, 9: Perfil Principal + for (let r = 0; r < rawRows.length; r++) { + const row = rawRows[r]; + if (!row || row.length < 3) continue; + + const rawMarca = String(row[2] || row[0]).trim(); + if (!rawMarca || rawMarca.toLowerCase().includes('marca') || rawMarca.toLowerCase().includes('total')) continue; + + let ofNumber = String(row[0] || ofDefault || 'B132').trim().replace(/^B-(\d+)/i, 'B$1'); + let etapaFase = String(row[1] || 'Fabricação').trim(); + let rawDesc = String(row[3] || '').trim(); + let quantidade = Math.max(1, Math.round(parseNumber(row[4]) || 1)); + let pesoUnit = parseNumber(row[5]); + let pesoTot = parseNumber(row[6]) || (pesoUnit * quantidade); + let tratSuperficial = String(row[7] || 'pintura').trim() || 'pintura'; + let material = String(row[8] || 'Aço A36').trim(); + let perfilPrincipal = String(row[9] || rawDesc).trim(); + + pecasLidas.push({ + of_number: ofNumber, + etapa_fase: etapaFase, + marca: rawMarca, + descricao: rawDesc || perfilPrincipal, + quantidade, + peso_unitario: pesoUnit, + peso_total: pesoTot, + tratamento_superficial: tratSuperficial, + material, + perfil_principal: perfilPrincipal, + tem_componentes: false, + }); + } + } + + if (pecasLidas.length === 0) { + toast.error('Nenhuma linha de peça válida foi identificada no arquivo.'); + setIsProcessing(false); + return; + } + + setPecasProcessadas(pecasLidas); + setStep('preview'); + toast.success(`${pecasLidas.length} peça(s) identificada(s) com sucesso no arquivo!`); + } catch (err: any) { + console.error('Erro ao processar planilha:', err); + toast.error(`Falha ao ler o arquivo Excel: ${err.message || 'Formato não suportado'}`); + } finally { + setIsProcessing(false); + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const selected = e.target.files?.[0]; + if (selected) { + setFile(selected); + processExcelFile(selected); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const droppedFile = e.dataTransfer.files?.[0]; + if (droppedFile) { + setFile(droppedFile); + processExcelFile(droppedFile); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleRemovePeca = (index: number) => { + setPecasProcessadas(prev => prev.filter((_, idx) => idx !== index)); + }; + + const handleImportSubmit = async () => { + if (pecasProcessadas.length === 0) { + toast.error('Nenhuma peça para importar.'); + return; + } + + setIsImporting(true); + try { + const pecasPayload = pecasProcessadas.map(p => ({ + of_number: p.of_number, + etapa_fase: p.etapa_fase, + marca: p.marca, + descricao: p.descricao, + quantidade: p.quantidade, + peso_unitario: p.peso_unitario, + peso_total: p.peso_total, + tratamento_superficial: p.tratamento_superficial || 'pintura', + material: p.material, + perfil_principal: p.perfil_principal, + tem_componentes: p.tem_componentes, + })); + + await onImport(pecasPayload); + toast.success(`${pecasProcessadas.length} peças importadas com sucesso para a OF!`); + handleModalOpenChange(false); + } catch (err: any) { + console.error('Erro ao importar peças do Excel:', err); + toast.error(`Erro ao salvar peças: ${err.message || 'Falha de comunicação'}`); + } finally { + setIsImporting(false); + } + }; + + // Gerar e baixar modelo Excel (.xlsx) oficial + const handleDownloadModeloXLS = () => { + const headers = [ + 'OF', + 'Fase', + 'Marca', + 'Descrição', + 'Composto por Componentes', + 'Quantidade', + 'Peso Unitário (kg)', + 'Peso Total (kg)', + 'Tratamento Superficial', + 'Material', + 'Perfil Principal', + 'Comprimento Ref. (mm)' + ]; + + const exampleRows = [ + [ofDefault || 'B129', '2', '1', 'W 310x38.7', 'SIM', 1, 1413, 1413, 'pintura', 'A572-GR 50', 'W 310x38.7', 3241], + [ofDefault || 'B129', '2', '2', 'W 310x38.7', 'SIM', 1, 2549, 2549, 'pintura', 'A572-GR 50', 'W 310x38.7', 6000], + [ofDefault || 'B129', '2', '3', 'W 310x38.7', 'SIM', 1, 2379, 2379, 'pintura', 'A572-GR 50', 'W 310x38.7', 5780], + [ofDefault || 'B129', '2', '4', 'W 310x38.7', 'SIM', 1, 2287, 2287, 'pintura', 'A572-GR 50', 'W 310x38.7', 5762], + [ofDefault || 'B129', '2', '13', 'W 150x13.0', 'NÃO', 1, 2886, 2886, 'pintura', 'A36', 'W 150x13.0', ''], + [ofDefault || 'B129', '2', '15', 'L3X3X1/4', 'NÃO', 1, 545, 545, 'pintura', 'A36', 'L3X3X1/4', ''], + [ofDefault || 'B129', '2', '20', 'W 150x13.0', 'SIM', 1, 1249, 1249, 'pintura', 'A572-GR 50', 'W 150x13.0', 2841] + ]; + + const ws = XLSX.utils.aoa_to_sheet([headers, ...exampleRows]); + + // Ajusta largura das colunas + ws['!cols'] = [ + { wch: 10 }, // OF + { wch: 8 }, // Fase + { wch: 10 }, // Marca + { wch: 18 }, // Descrição + { wch: 25 }, // Composto por + { wch: 12 }, // Qtd + { wch: 18 }, // Peso Unit + { wch: 16 }, // Peso Total + { wch: 22 }, // Tratamento + { wch: 16 }, // Material + { wch: 18 }, // Perfil Principal + { wch: 22 } // Comprimento + ]; + + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Lista de Peças'); + XLSX.writeFile(wb, 'modelo_importacao_pecas.xlsx'); + toast.success('Modelo Excel (.xlsx) baixado com sucesso!'); + }; + + const totalPesoCalculado = pecasProcessadas.reduce((acc, p) => acc + (p.peso_total || 0), 0); + const totalQuantidade = pecasProcessadas.reduce((acc, p) => acc + (p.quantidade || 0), 0); + + return ( + + + + +
+ + Importar Peças via Planilha Excel (XLS / XLSX) +
+ {ofDefault && ( + + OF Alvo: {ofDefault} + + )} +
+
+ + {step === 'upload' ? ( +
+ {/* Zona de Drop / Upload */} +
fileInputRef.current?.click()} + className="border-2 border-dashed border-border hover:border-emerald-500 rounded-xl p-8 text-center cursor-pointer transition-colors bg-muted/30 hover:bg-muted/60" + > +
+
+ +
+
+

+ Clique para selecionar ou arraste sua planilha Excel aqui +

+

+ Suporta arquivos .xlsx, .xls e .csv +

+
+ +
+ +
+ + {isProcessing && ( +
+ + Processando e validando linhas da planilha... +
+ )} + + {/* Informações sobre a estrutura */} +
+
+ + Ordem e Colunas Suportadas: +
+

+ A planilha pode conter as seguintes colunas (na ordem ou por cabeçalho): +

+
+ 1. OF + 2. Fase + 3. Marca + 4. Descrição + 5. Quantidade + 6. Peso Unitário (kg) + 7. Peso Total (kg) + 8. Tratamento (pintura) + 9. Material (Aço) + 10. Perfil Principal +
+

+ 💡 Dica: O campo de Tratamento Superficial será preenchido automaticamente como "pintura" caso venha em branco. +

+
+ + {/* Ações inferiores */} +
+ + +
+
+ ) : ( + /* Step Preview */ +
+
+
+
+ Total de Peças:{' '} + {pecasProcessadas.length} ({totalQuantidade} un.) +
+
+ Peso Total:{' '} + + {totalPesoCalculado.toLocaleString('pt-BR')} kg + + + ({(totalPesoCalculado / 1000).toFixed(3)} t) + +
+
+
+ +
+
+ + {/* Tabela de Preview */} + + + + + OF + Fase + Marca + Descrição + Qtd + Peso Un. (kg) + Peso Total (kg) + Tratamento + Material + Perfil Princ. + + + + + {pecasProcessadas.map((peca, idx) => ( + + {peca.of_number} + {peca.etapa_fase} + {peca.marca} + {peca.descricao} + {peca.quantidade} + {peca.peso_unitario.toLocaleString('pt-BR')} + + {peca.peso_total.toLocaleString('pt-BR')} + + {peca.tratamento_superficial} + {peca.material} + {peca.perfil_principal} + + + + + ))} + +
+
+ + + + + +
+ )} +
+
+ ); +} diff --git a/src/components/pecas/PecaForm.tsx b/src/components/pecas/PecaForm.tsx index 486e607..64d07f8 100644 --- a/src/components/pecas/PecaForm.tsx +++ b/src/components/pecas/PecaForm.tsx @@ -6,13 +6,15 @@ import { Textarea } from '@/components/ui/textarea'; import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Card, CardContent } from '@/components/ui/card'; -import { Upload, FileText, Trash2, Download, Undo2 } from 'lucide-react'; +import { Upload, FileText, Trash2, Download, Undo2, FileSpreadsheet } from 'lucide-react'; import { toast } from 'sonner'; import { Peca } from '@/hooks/usePecas'; import { ImportarPecasModal } from './ImportarPecasModal'; +import { ImportarXLSModal } from './ImportarXLSModal'; interface PecaFormProps { ofNumbers: string[]; + ofDefault?: string; onSave: (data: any) => Promise; onUpdate: (id: string, data: any) => Promise; onImportCSV: (file: File) => Promise; @@ -30,6 +32,7 @@ interface PecaFormProps { export function PecaForm({ ofNumbers, + ofDefault, onSave, onUpdate, onImportCSV, @@ -92,6 +95,7 @@ export function PecaForm({ const fileInputRef = useRef(null); const [showImportModal, setShowImportModal] = useState(false); + const [showXLSModal, setShowXLSModal] = useState(false); const handleFileUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -361,6 +365,16 @@ export function PecaForm({ Download Modelo CSV + + + + ); } diff --git a/src/pages/CadastroPecasFiltrado.tsx b/src/pages/CadastroPecasFiltrado.tsx index ad7f85f..e72a1c5 100644 --- a/src/pages/CadastroPecasFiltrado.tsx +++ b/src/pages/CadastroPecasFiltrado.tsx @@ -240,6 +240,7 @@ export default function CadastroPecasFiltrado() {