From beda3cc7a9b763a5215b7ecb32f5bab5a7255081 Mon Sep 17 00:00:00 2001 From: Marcos Reifonas Date: Sun, 20 Sep 2026 14:52:31 +0000 Subject: [PATCH] feat: persistencia de modelo IFC na nuvem e filtro 3D por fase --- src/components/viewer3d/ModelViewer3D.tsx | 46 ++- src/lib/ifc/ifcLoaderService.ts | 90 +++++- src/lib/ifc/ifcStorageService.ts | 267 +++++++++++++++++ src/pages/Visualizador3D.tsx | 338 ++++++++++++++++++---- 4 files changed, 678 insertions(+), 63 deletions(-) create mode 100644 src/lib/ifc/ifcStorageService.ts diff --git a/src/components/viewer3d/ModelViewer3D.tsx b/src/components/viewer3d/ModelViewer3D.tsx index 0c1a1d0..a471921 100644 --- a/src/components/viewer3d/ModelViewer3D.tsx +++ b/src/components/viewer3d/ModelViewer3D.tsx @@ -252,10 +252,11 @@ export const ModelViewer3D: React.FC = ({ const cleanAssMark = String(mesh.userData.cleanAssemblyMark || cleanMark).trim().toUpperCase(); const section = String(mesh.userData.section || '').trim().toUpperCase(); - if (colorMode === 'production' && productionData && productionData.size > 0) { - let prod: ProductionPieceStatus | undefined = undefined; + // 1. Determinação da fase e vínculo de produção + let prod: ProductionPieceStatus | undefined = undefined; - // 1. Prioridade: Busca pela Assembly Mark do conjunto estrutural (ex: B135-3-7 -> "B135-3-7", "3-7", "7") + if (colorMode === 'production' && productionData && productionData.size > 0) { + // Prioridade 1: Busca pela Assembly Mark do conjunto estrutural if (assMark && assMark !== 'INDEFINIDO') { prod = productionData.get(assMark) || productionData.get(cleanAssMark) || @@ -263,7 +264,7 @@ export const ModelViewer3D: React.FC = ({ (selectedOF ? productionData.get(`${selectedOF}-${cleanAssMark}`.toUpperCase()) : undefined); } - // 2. Busca pela marca própria da peça (ex: B135-3-22 -> "B135-3-22", "3-22", "22") + // Prioridade 2: Busca pela marca própria da peça if (!prod && pmark && pmark !== 'INDEFINIDO') { prod = productionData.get(pmark) || productionData.get(cleanMark) || @@ -271,7 +272,7 @@ export const ModelViewer3D: React.FC = ({ (selectedOF ? productionData.get(`${selectedOF}-${cleanMark}`.toUpperCase()) : undefined); } - // 3. Fallback inteligente para perfis / elementos sem marca (ex: barras redondas 'RD19', tubos 'Box150X150X9.') + // Prioridade 3: Fallback para perfis / bitolas if (!prod && section && section !== '-' && section !== 'INDEFINIDO') { prod = productionData.get(`PERFIL:${section}`) || productionData.get(`DESC:${section}`); @@ -288,7 +289,38 @@ export const ModelViewer3D: React.FC = ({ } } } + } + // 2. Extração da fase deste elemento (Banco > Propriedade IFC > Regex na Marca) + let elementPhase = ''; + if (prod && prod.fase) { + elementPhase = String(prod.fase).trim(); + } + if (!elementPhase && mesh.userData.phase) { + elementPhase = String(mesh.userData.phase).trim(); + } + if (!elementPhase) { + const raw = assMark || pmark; + const match = raw.match(/^(?:[A-Za-z0-9]+-)?(\d+)-/i); + if (match) { + elementPhase = match[1]; + } + } + + // 3. Controle Estrito de Visibilidade por Fase + const isVisibleByPhase = + !selectedPhase || + selectedPhase === 'all' || + elementPhase === selectedPhase || + String(elementPhase).toLowerCase() === String(selectedPhase).toLowerCase(); + + mesh.visible = isVisibleByPhase; + + // Se o elemento não estiver visível na fase atual, pula estilização detalhada + if (!isVisibleByPhase) return; + + // 4. Aplicação de Cores / Materiais + if (colorMode === 'production' && productionData && productionData.size > 0) { if (prod) { matchedCount++; if (!sampleMatch && prod.pointedQtd > 0) { @@ -344,6 +376,10 @@ export const ModelViewer3D: React.FC = ({ } }); + if (selectedPhase && selectedPhase !== 'all') { + console.log(`[Viewer3D] Filtrando modelo 3D pela Fase: "${selectedPhase}"`); + } + if (totalMeshCount > 0 && productionData && productionData.size > 0) { console.log( `%c[IFC Sync] Amarração de Peças: ${matchedCount}/${totalMeshCount} malhas vinculadas | ${pointedCount} apontadas na produção | ${totalMeshCount - pointedCount} pendentes em cinza claro`, diff --git a/src/lib/ifc/ifcLoaderService.ts b/src/lib/ifc/ifcLoaderService.ts index c3e004d..2116c1f 100644 --- a/src/lib/ifc/ifcLoaderService.ts +++ b/src/lib/ifc/ifcLoaderService.ts @@ -49,6 +49,7 @@ export interface LoadedIFCResult { weldedGroupMap: Map; materialsByColor: Map; totalMeshes: number; + detectedPhases: string[]; } /** @@ -453,6 +454,7 @@ export async function loadAndAuditIFC( cleanAssemblyMark: string; section: string; description: string; + phase: string; } { const props = elementPropertiesMap.get(expressID) || {}; @@ -559,13 +561,60 @@ export async function loadAndAuditIFC( rawName || ''; + // Extração inteligente de Fase + const phaseCandidates = [ + props.Phase, + props.Fase, + props['Phase Number'], + props['Phase Name'], + props['Tekla Assembly.Phase'], + props['Tekla Common.Phase'], + props['Tekla Assembly.Phase Name'], + props.Etapa, + props.Sequence, + props.Batch + ]; + let chosenPhase = ''; + for (const pCand of phaseCandidates) { + if (pCand && isValidMark(pCand)) { + chosenPhase = String(pCand).trim(); + break; + } + } + if (!chosenPhase && childToAssembly.has(expressID)) { + const parentId = childToAssembly.get(expressID)!; + const parentProps = elementPropertiesMap.get(parentId) || {}; + const parentPhaseCandidates = [ + parentProps.Phase, + parentProps.Fase, + parentProps['Phase Number'], + parentProps['Phase Name'], + parentProps['Tekla Assembly.Phase'], + parentProps['Tekla Common.Phase'], + parentProps['Tekla Assembly.Phase Name'], + parentProps.Etapa, + parentProps.Sequence, + parentProps.Batch + ]; + for (const pCand of parentPhaseCandidates) { + if (pCand && isValidMark(pCand)) { + chosenPhase = String(pCand).trim(); + break; + } + } + } + if (!chosenPhase) { + chosenPhase = extractPhaseFromMark(assemblyMark || finalMark); + } + return { mark: finalMark || 'indefinido', cleanMark: clean || finalMark || 'indefinido', assemblyMark: assemblyMark || finalMark || '', cleanAssemblyMark: cleanAssemblyMark || clean || '', section, - description: rawDesc + description: rawDesc, + phase: chosenPhase }; } @@ -573,6 +622,7 @@ export async function loadAndAuditIFC( const piecesByMark = new Map(); const pieceByExpressID = new Map(); const uniqueMarksSet = new Set(); + const detectedPhasesSet = new Set(); for (let i = 0; i < assemblies.size(); i++) { const assID = assemblies.get(i); @@ -581,6 +631,10 @@ export async function loadAndAuditIFC( const markInfo = extractBestMark(assID); const children = assemblyToChildren.get(assID) || []; + if (markInfo.phase) { + detectedPhasesSet.add(markInfo.phase); + } + const piece: PieceInfo = { expressID: assID, guid: assObj?.GlobalId?.value || '', @@ -593,7 +647,7 @@ export async function loadAndAuditIFC( childrenIDs: children, connectedMeshIDs: children, section: markInfo.section, - phase: extractPhaseFromMark(markInfo.mark), + phase: markInfo.phase || extractPhaseFromMark(markInfo.mark), ofNumber: extractOFFromMark(markInfo.mark) }; @@ -625,6 +679,10 @@ export async function loadAndAuditIFC( const materialName = elementMaterialMap.get(expressID) ?? ''; const connectedGroup = weldedGroupMap.get(expressID) || [expressID]; + if (markInfo.phase) { + detectedPhasesSet.add(markInfo.phase); + } + const elementGroup = new THREE.Group(); elementGroup.name = `ifc_${expressID}`; elementGroup.userData = { @@ -635,6 +693,7 @@ export async function loadAndAuditIFC( assemblyMark: markInfo.assemblyMark, cleanAssemblyMark: markInfo.cleanAssemblyMark, section: markInfo.section, + phase: markInfo.phase, materialName, connectedMeshIDs: connectedGroup }; @@ -688,6 +747,7 @@ export async function loadAndAuditIFC( assemblyMark: markInfo.assemblyMark, cleanAssemblyMark: markInfo.cleanAssemblyMark, section: markInfo.section, + phase: markInfo.phase, materialName, nativeColor: new THREE.Color(color.x, color.y, color.z), connectedMeshIDs: connectedGroup @@ -710,6 +770,7 @@ export async function loadAndAuditIFC( pieceMark: markInfo.mark, cleanMark: markInfo.cleanMark, section: markInfo.section, + phase: markInfo.phase, material: materialName, connectedMeshIDs: connectedGroup, mesh: mesh3 @@ -781,14 +842,31 @@ export async function loadAndAuditIFC( pieceByExpressID, weldedGroupMap, materialsByColor: materialsCache as any, - totalMeshes + totalMeshes, + detectedPhases: Array.from(detectedPhasesSet).sort((a, b) => { + const na = Number(a); + const nb = Number(b); + return !isNaN(na) && !isNaN(nb) ? na - nb : a.localeCompare(b); + }) }; } -function extractPhaseFromMark(mark: string): string { +export function extractPhaseFromMark(mark: string): string { + if (!mark || mark === 'indefinido') return ''; const parts = mark.split('-'); - if (parts.length >= 3) return parts[1]; - return '1'; + if (parts.length >= 3) { + const p1 = parts[1].trim(); + if (p1 && /^\d+$/.test(p1)) return p1; + return p1; + } + if (parts.length === 2) { + const p0 = parts[0].trim(); + if (p0 && /^\d+$/.test(p0)) return p0; + } + const match = mark.match(/(?:fase|phase|etapa)\s*(\d+)/i); + if (match) return match[1]; + + return ''; } function extractOFFromMark(mark: string): string { diff --git a/src/lib/ifc/ifcStorageService.ts b/src/lib/ifc/ifcStorageService.ts new file mode 100644 index 0000000..4682c74 --- /dev/null +++ b/src/lib/ifc/ifcStorageService.ts @@ -0,0 +1,267 @@ +import { supabase } from '@/integrations/supabase/client'; + +const DB_NAME = 'TrackSteel_3D_Cache'; +const STORE_NAME = 'ifc_models'; +const DB_VERSION = 1; + +/** + * Abre o banco local IndexedDB para cache de alta performance de arquivos IFC. + */ +function openIndexedDB(): Promise { + return new Promise((resolve, reject) => { + if (typeof window === 'undefined' || !window.indexedDB) { + reject(new Error('IndexedDB não disponível')); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'ofNumber' }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +/** + * Salva o buffer binário do IFC no cache do navegador. + */ +export async function cacheIFCModel(ofNumber: string, url: string, buffer: ArrayBuffer): Promise { + try { + const db = await openIndexedDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const record = { + ofNumber, + url, + buffer, + cachedAt: Date.now() + }; + const req = store.put(record); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + } catch (err) { + console.warn('[IFC Cache] Falha ao salvar no IndexedDB:', err); + } +} + +/** + * Recupera o modelo IFC em cache local se a URL coincidir com a gravada no banco. + */ +export async function getCachedIFCModel(ofNumber: string, url?: string): Promise { + try { + const db = await openIndexedDB(); + return new Promise((resolve) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.get(ofNumber); + + req.onsuccess = () => { + const res = req.result; + if (res && res.buffer) { + // Se uma URL específica foi solicitada, checa compatibilidade + if (url && res.url && res.url !== url) { + resolve(null); + return; + } + resolve(res.buffer as ArrayBuffer); + } else { + resolve(null); + } + }; + req.onerror = () => resolve(null); + }); + } catch (err) { + console.warn('[IFC Cache] Falha ao ler IndexedDB:', err); + return null; + } +} + +/** + * Remove do cache local + */ +export async function clearCachedIFCModel(ofNumber: string): Promise { + try { + const db = await openIndexedDB(); + return new Promise((resolve) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.delete(ofNumber); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + }); + } catch { + // ignore + } +} + +/** + * Realiza o upload do arquivo IFC para o Supabase Storage e salva o vínculo na OF. + */ +export async function uploadIFCToCloud( + file: File, + ofNumber: string, + auditData?: any, + onProgress?: (step: string, percent: number) => void +): Promise<{ publicUrl: string; filePath: string }> { + const sanitizedOf = ofNumber.replace(/[^a-zA-Z0-9_-]/g, '_'); + const cleanFileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); + const filePath = `ifc/${sanitizedOf}/${cleanFileName}`; + + if (onProgress) onProgress('Enviando arquivo IFC para a nuvem...', 20); + + // 1. Upload no Supabase Storage (bucket modelos-3d) + const { error: uploadError } = await supabase.storage + .from('modelos-3d') + .upload(filePath, file, { + upsert: true, + contentType: 'application/octet-stream' + }); + + if (uploadError) { + console.error('[IFC Storage] Erro no upload:', uploadError); + throw new Error(`Falha ao salvar no storage: ${uploadError.message}`); + } + + if (onProgress) onProgress('Obtendo link de acesso...', 60); + + // 2. Obter URL pública + const { data: { publicUrl } } = supabase.storage + .from('modelos-3d') + .getPublicUrl(filePath); + + if (onProgress) onProgress('Atualizando registro da Ordem de Fabricação...', 80); + + // 3. Atualizar tabela ordens_fabricacao + const { error: dbError } = await supabase + .from('ordens_fabricacao' as any) + .update({ + ifc_url: publicUrl, + ifc_filename: file.name, + ifc_file_size: file.size, + ifc_updated_at: new Date().toISOString() + }) + .eq('num_of', ofNumber); + + if (dbError) { + console.warn('[IFC Storage] Aviso ao atualizar ordens_fabricacao:', dbError); + } + + // 4. Inserir histórico em of_modelos_3d + try { + await supabase + .from('of_modelos_3d' as any) + .insert({ + of_number: ofNumber, + filename: file.name, + storage_path: filePath, + public_url: publicUrl, + file_size: file.size, + audit_data: auditData || null, + updated_at: new Date().toISOString() + }); + } catch (histErr) { + console.debug('[IFC Storage] Registro histórico:', histErr); + } + + // 5. Salva no cache local do navegador + try { + const buffer = await file.arrayBuffer(); + await cacheIFCModel(ofNumber, publicUrl, buffer); + } catch (cErr) { + console.debug('[IFC Storage] Cache local:', cErr); + } + + if (onProgress) onProgress('Modelo IFC salvo com sucesso!', 100); + + return { publicUrl, filePath }; +} + +/** + * Baixa e armazena em cache o modelo IFC salvo para a OF. + */ +export async function fetchSavedIFCModel( + ofNumber: string, + url: string, + onProgress?: (percent: number, step: string) => void +): Promise { + // 1. Tenta recuperar do cache local primeiro para carregamento instantâneo + const cached = await getCachedIFCModel(ofNumber, url); + if (cached) { + if (onProgress) onProgress(30, 'Modelo recuperado do cache ultrarrápido...'); + return cached; + } + + // 2. Se não estiver no cache, baixa via rede + if (onProgress) onProgress(15, 'Baixando modelo 3D salvo da nuvem...'); + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Erro ao baixar modelo (${response.status}: ${response.statusText})`); + } + + const contentLengthHeader = response.headers.get('content-length'); + const totalBytes = contentLengthHeader ? parseInt(contentLengthHeader, 10) : 0; + + let arrayBuffer: ArrayBuffer; + + if (response.body && totalBytes > 0) { + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let receivedBytes = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + chunks.push(value); + receivedBytes += value.length; + if (onProgress && totalBytes > 0) { + const downloadPercent = Math.min(Math.round((receivedBytes / totalBytes) * 40) + 10, 50); + const mbDownloaded = (receivedBytes / (1024 * 1024)).toFixed(1); + const mbTotal = (totalBytes / (1024 * 1024)).toFixed(1); + onProgress(downloadPercent, `Baixando modelo 3D (${mbDownloaded}MB de ${mbTotal}MB)...`); + } + } + } + + const fullBuffer = new Uint8Array(receivedBytes); + let offset = 0; + for (const chunk of chunks) { + fullBuffer.set(chunk, offset); + offset += chunk.length; + } + arrayBuffer = fullBuffer.buffer; + } else { + arrayBuffer = await response.arrayBuffer(); + } + + // 3. Salva no cache local para próximos acessos + await cacheIFCModel(ofNumber, url, arrayBuffer); + + return arrayBuffer; +} + +/** + * Remove o vínculo de modelo IFC de uma OF. + */ +export async function removeSavedIFCModel(ofNumber: string): Promise { + await supabase + .from('ordens_fabricacao' as any) + .update({ + ifc_url: null, + ifc_filename: null, + ifc_file_size: null, + ifc_updated_at: new Date().toISOString() + }) + .eq('num_of', ofNumber); + + await clearCachedIFCModel(ofNumber); +} diff --git a/src/pages/Visualizador3D.tsx b/src/pages/Visualizador3D.tsx index 2670b30..0d240ba 100644 --- a/src/pages/Visualizador3D.tsx +++ b/src/pages/Visualizador3D.tsx @@ -1,20 +1,21 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { loadAndAuditIFC, LoadedIFCResult, IFCQualityAudit } from '@/lib/ifc/ifcLoaderService'; +import { uploadIFCToCloud, fetchSavedIFCModel, removeSavedIFCModel } from '@/lib/ifc/ifcStorageService'; import { ModelViewer3D } from '@/components/viewer3d/ModelViewer3D'; import { IFCQualityModal } from '@/components/viewer3d/IFCQualityModal'; +import { useToast } from '@/hooks/use-toast'; import { Upload, Box, Layers, ShieldCheck, CheckCircle2, - FileCode2, Loader2, TrendingUp, Weight, - Sparkles, - Info + Cloud, + Trash2 } from 'lucide-react'; interface OFOption { @@ -23,6 +24,10 @@ interface OFOption { descritivo?: string; cliente?: string; peso_total?: number; + ifc_url?: string | null; + ifc_filename?: string | null; + ifc_file_size?: number | null; + ifc_updated_at?: string | null; } const PROCESS_COLORS: Record = { @@ -38,6 +43,7 @@ const PROCESS_COLORS: Record = { }; export default function Visualizador3D() { + const { toast } = useToast(); const [ofs, setOfs] = useState([]); const [selectedOF, setSelectedOF] = useState('B135'); const [selectedPhase, setSelectedPhase] = useState('all'); @@ -45,6 +51,7 @@ export default function Visualizador3D() { // Production pointing data from Supabase const [productionMap, setProductionMap] = useState>(new Map()); + const [uniqueItemsList, setUniqueItemsList] = useState([]); const [totalPecasBD, setTotalPecasBD] = useState(0); const [totalApontadasBD, setTotalApontadasBD] = useState(0); const [pesoTotalBD, setPesoTotalBD] = useState(0); @@ -60,21 +67,25 @@ export default function Visualizador3D() { const fileInputRef = useRef(null); - // 1. Fetch OFs + // 1. Fetch OFs (com colunas de IFC para persistência na nuvem) useEffect(() => { async function fetchOFs() { try { const { data, error } = await supabase .from('ordens_fabricacao' as any) - .select('id, num_of, descritivo, peso_total') + .select('id, num_of, descritivo, peso_total, ifc_url, ifc_filename, ifc_file_size, ifc_updated_at') .order('num_of', { ascending: false }); if (!error && data) { - const list: OFOption[] = (data as any[]).map(item => ({ + const list: OFOption[] = (data as any[]).map((item) => ({ id: item.id, of_number: item.num_of || '', descritivo: item.descritivo || '', - peso_total: Number(item.peso_total || 0) + peso_total: Number(item.peso_total || 0), + ifc_url: item.ifc_url || null, + ifc_filename: item.ifc_filename || null, + ifc_file_size: item.ifc_file_size ? Number(item.ifc_file_size) : null, + ifc_updated_at: item.ifc_updated_at || null, })); setOfs(list); if (list.length > 0 && !selectedOF) { @@ -100,7 +111,7 @@ export default function Visualizador3D() { .select('id, of_number, etapa_fase, marca, descricao, quantidade, peso_unitario, peso_total, perfil_principal, material') .eq('of_number', selectedOF); - // Fetch apontamentos com join de peca e processo usando constraints explicitas para evitar PGRST201 + // Fetch apontamentos com join de peca e processo const { data: apontamentosData, error: errorApontamentos } = await supabase .from('apontamentos_producao' as any) .select(` @@ -111,7 +122,7 @@ export default function Visualizador3D() { .eq('of_number', selectedOF); if (errorApontamentos) { - console.error("Erro ao buscar apontamentos no visualizador:", errorApontamentos); + console.error('Erro ao buscar apontamentos no visualizador:', errorApontamentos); } const phases = new Set(); @@ -134,28 +145,26 @@ export default function Visualizador3D() { fase, ofNumber: selectedOF, totalQtd: Number(p.quantidade || 1), + pesoTotal: Number(p.peso_total || 0), + pesoUnitario: Number(p.peso_unitario || 0), pointedQtd: 0, currentProcessName: 'Pendente', processColor: '#64748b', - processOrdem: 0 + processOrdem: 0, }; uniqueItems.push(item); - // Indexa por marca simples (ex: "4") + // Indexações para amarração gráfica flexível prodMap.set(marcaStr, item); prodMap.set(marcaStr.toUpperCase(), item); - // Indexa por fase e marca (ex: "2-4") prodMap.set(`${fase}-${marcaStr}`, item); prodMap.set(`${fase}-${marcaStr}`.toUpperCase(), item); - // Indexa por OF, fase e marca (ex: "B135-2-4") prodMap.set(`${selectedOF}-${fase}-${marcaStr}`, item); prodMap.set(`${selectedOF}-${fase}-${marcaStr}`.toUpperCase(), item); - // Indexa por OF e marca (ex: "B135-4") prodMap.set(`${selectedOF}-${marcaStr}`, item); prodMap.set(`${selectedOF}-${marcaStr}`.toUpperCase(), item); - // Indexa descrição e perfil principal para amarração de elementos sem numeração (ex: barras redondas RD19) if (p.descricao) { const d = String(p.descricao).trim().toUpperCase(); prodMap.set(`DESC:${d}`, item); @@ -168,7 +177,6 @@ export default function Visualizador3D() { } if (apontamentosData) { - // Ordenar apontamentos do menor processo para o maior (assim o processo mais avançado sobressai por último) apontamentosData.sort((a: any, b: any) => (a.processo?.ordem || 0) - (b.processo?.ordem || 0)); apontamentosData.forEach((ap: any) => { @@ -183,7 +191,6 @@ export default function Visualizador3D() { if (existing) { const qty = Number(ap.quantidade_produzida || 0); if (qty > 0) { - // Atualiza a peça para o estágio mais avançado alcançado existing.pointedQtd = Math.max(existing.pointedQtd, qty); existing.currentProcessName = ap.processo?.nome || existing.currentProcessName; existing.processColor = ap.processo?.cor || PROCESS_COLORS[ap.processo?.nome || ''] || '#10b981'; @@ -195,20 +202,24 @@ export default function Visualizador3D() { } let pointedTotal = 0; - let sumProgress = 0; // Para evolução da OF + let sumProgress = 0; - // Itera sobre uniqueItems para não duplicar métricas com os múltiplos índices de busca uniqueItems.forEach((val) => { pointedTotal += val.pointedQtd; - // Considerando maxOrdem = 5 como final para cálculo de %. const pecaProgress = val.pointedQtd > 0 ? Math.min(val.processOrdem / 5, 1) * (val.pointedQtd / val.totalQtd) : 0; sumProgress += pecaProgress * val.totalQtd; }); - + const progressoGeralPercent = totalPecas > 0 ? Math.min(Math.round((sumProgress / totalPecas) * 100), 100) : 0; - setPhasesList(Array.from(phases).sort()); + setPhasesList(Array.from(phases).sort((a, b) => { + const na = Number(a); + const nb = Number(b); + return !isNaN(na) && !isNaN(nb) ? na - nb : a.localeCompare(b); + })); + setProductionMap(prodMap); + setUniqueItemsList(uniqueItems); setTotalPecasBD(totalPecas); setTotalApontadasBD(pointedTotal); setPesoTotalBD(totalPeso); @@ -221,41 +232,243 @@ export default function Visualizador3D() { fetchProductionData(); }, [selectedOF]); - // Handle File Upload - const handleFileChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - + // Função auxiliar de carregamento de modelo salvo + const loadSavedModel = useCallback(async (ofNumber: string, url: string, filename?: string | null) => { setIsLoadingModel(true); - setLoadingStep('Iniciando processamento...'); - setLoadingPercent(5); + setLoadingStep('Buscando modelo 3D vinculado...'); + setLoadingPercent(15); try { - const result = await loadAndAuditIFC(file, (percent, step) => { + const buffer = await fetchSavedIFCModel(ofNumber, url, (percent, step) => { setLoadingPercent(percent); setLoadingStep(step); }); + setLoadingStep('Decodificando entidades e montagens IFC...'); + setLoadingPercent(55); + + const result = await loadAndAuditIFC(buffer, (percent, step) => { + setLoadingPercent(55 + Math.round(percent * 0.45)); + setLoadingStep(step); + }); + setModelData(result); setAuditData(result.audit); - setIsAuditModalOpen(true); // Open quality audit automatically on load + + // Mescla fases detectadas no IFC às fases da OF + if (result.detectedPhases && result.detectedPhases.length > 0) { + setPhasesList((prev) => { + const combined = new Set([...prev, ...result.detectedPhases]); + return Array.from(combined).sort((a, b) => { + const na = Number(a); + const nb = Number(b); + return !isNaN(na) && !isNaN(nb) ? na - nb : a.localeCompare(b); + }); + }); + } + + toast({ + title: 'Modelo 3D Carregado', + description: `Modelo da OF ${ofNumber} carregado automaticamente da nuvem.`, + }); } catch (err: any) { - console.error('Erro ao carregar IFC:', err); - alert(`Falha no carregamento do IFC: ${err.message || 'Erro desconhecido'}`); + console.error('Erro ao carregar modelo salvo:', err); + toast({ + variant: 'destructive', + title: 'Falha no Carregamento Automático', + description: `Não foi possível carregar o modelo salvo: ${err.message || 'Erro de rede'}`, + }); + } finally { + setIsLoadingModel(false); + } + }, [toast]); + + // 3. Efeito para verificar e carregar automaticamente o modelo IFC salvo ao selecionar uma obra + useEffect(() => { + if (!selectedOF) return; + + // Reseta visualizador para a nova OF + setModelData(null); + setAuditData(null); + setSelectedPhase('all'); + + const currentOF = ofs.find((o) => o.of_number === selectedOF); + + if (currentOF && currentOF.ifc_url) { + loadSavedModel(selectedOF, currentOF.ifc_url, currentOF.ifc_filename); + } else if (ofs.length === 0) { + // Caso a lista de ofs ainda esteja sendo buscada, tenta consulta direta rápida + async function checkDirect() { + try { + const { data } = await supabase + .from('ordens_fabricacao' as any) + .select('ifc_url, ifc_filename') + .eq('num_of', selectedOF) + .maybeSingle(); + + if (data && (data as any).ifc_url) { + loadSavedModel(selectedOF, (data as any).ifc_url, (data as any).ifc_filename); + } + } catch (err) { + console.debug('Checagem direta de IFC:', err); + } + } + checkDirect(); + } + }, [selectedOF, ofs, loadSavedModel]); + + // Handle File Upload: Processa, audita e salva automaticamente na nuvem + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsLoadingModel(true); + setLoadingStep('Iniciando processamento e auditoria...'); + setLoadingPercent(10); + + try { + // 1. Processa e audita localmente para renderização imediata + const result = await loadAndAuditIFC(file, (percent, step) => { + setLoadingPercent(Math.min(percent, 70)); + setLoadingStep(step); + }); + + setModelData(result); + setAuditData(result.audit); + setIsAuditModalOpen(true); // Abre modal de qualidade automaticamente + + // Mescla fases detectadas no IFC + if (result.detectedPhases && result.detectedPhases.length > 0) { + setPhasesList((prev) => { + const combined = new Set([...prev, ...result.detectedPhases]); + return Array.from(combined).sort((a, b) => { + const na = Number(a); + const nb = Number(b); + return !isNaN(na) && !isNaN(nb) ? na - nb : a.localeCompare(b); + }); + }); + } + + // 2. Upload e armazenamento permanente no banco de dados e nuvem + setLoadingStep(`Salvando modelo no banco de dados da OF ${selectedOF}...`); + setLoadingPercent(75); + + const { publicUrl } = await uploadIFCToCloud( + file, + selectedOF, + result.audit, + (step, percent) => { + setLoadingStep(step); + setLoadingPercent(75 + Math.round(percent * 0.25)); + } + ); + + // Atualiza o estado local das OFs com o novo arquivo vinculado + setOfs((prev) => + prev.map((o) => + o.of_number === selectedOF + ? { + ...o, + ifc_url: publicUrl, + ifc_filename: file.name, + ifc_file_size: file.size, + ifc_updated_at: new Date().toISOString(), + } + : o + ) + ); + + toast({ + title: 'Modelo IFC Gravado no Banco!', + description: `O arquivo "${file.name}" foi salvo com sucesso e carregará automaticamente ao entrar na OF ${selectedOF}.`, + }); + } catch (err: any) { + console.error('Erro ao carregar e salvar IFC:', err); + toast({ + variant: 'destructive', + title: 'Erro no Carregamento', + description: `Falha: ${err.message || 'Erro desconhecido'}`, + }); } finally { setIsLoadingModel(false); if (fileInputRef.current) fileInputRef.current.value = ''; } }; - const progressPercent = totalPecasBD > 0 ? Math.min(Math.round((totalApontadasBD / totalPecasBD) * 100), 100) : 0; // Será substituido pelo state local caso quisermos, mas o totalApontadasBD já não vai estourar. + // Remover modelo salvo + const handleRemoveSavedModel = async () => { + if (!confirm(`Deseja remover o modelo IFC vinculado à OF ${selectedOF}?`)) return; + try { + await removeSavedIFCModel(selectedOF); + setOfs((prev) => + prev.map((o) => + o.of_number === selectedOF + ? { ...o, ifc_url: null, ifc_filename: null, ifc_file_size: null, ifc_updated_at: null } + : o + ) + ); + setModelData(null); + setAuditData(null); + toast({ + title: 'Modelo Removido', + description: `O modelo vinculado à OF ${selectedOF} foi desvinculado com sucesso.`, + }); + } catch (err: any) { + console.error('Erro ao remover modelo:', err); + toast({ + variant: 'destructive', + title: 'Erro ao desvincular', + description: err.message, + }); + } + }; + + // Métricas dinâmicas refletindo a fase selecionada + const activeMetrics = useMemo(() => { + if (selectedPhase === 'all' || !selectedPhase) { + return { + totalPecas: totalPecasBD, + apontadas: totalApontadasBD, + peso: pesoTotalBD, + progresso: progressoOF, + phaseLabel: null, + }; + } + + const filtered = uniqueItemsList.filter((item) => String(item.fase) === String(selectedPhase)); + let pTotal = 0; + let pApontadas = 0; + let pPeso = 0; + let sumProg = 0; + + filtered.forEach((item) => { + pTotal += item.totalQtd; + pApontadas += item.pointedQtd; + pPeso += (item.pesoTotal || 0); + const pecaProgress = item.pointedQtd > 0 ? Math.min(item.processOrdem / 5, 1) * (item.pointedQtd / item.totalQtd) : 0; + sumProg += pecaProgress * item.totalQtd; + }); + + const progPercent = pTotal > 0 ? Math.min(Math.round((sumProg / pTotal) * 100), 100) : 0; + + return { + totalPecas: pTotal, + apontadas: pApontadas, + peso: pPeso, + progresso: progPercent, + phaseLabel: `Fase ${selectedPhase}`, + }; + }, [selectedPhase, totalPecasBD, totalApontadasBD, pesoTotalBD, progressoOF, uniqueItemsList]); + + const currentOFInfo = ofs.find((o) => o.of_number === selectedOF); + const hasSavedModel = Boolean(currentOFInfo?.ifc_url); return (
{/* Top Header Bar */}
- {/* Left: Title & OF Selector */} + {/* Left: Title, OF Selector & Phase Filter */}
@@ -291,14 +504,35 @@ export default function Visualizador3D() {
- {/* Phase Filter */} + {/* Cloud Stored Model Badge */} + {hasSavedModel && ( +
+ + + {currentOFInfo?.ifc_filename || 'Modelo Salvo'} + + +
+ )} + + {/* Phase Filter Buttons */} {phasesList.length > 0 && (
@@ -367,10 +601,10 @@ export default function Visualizador3D() {
- Total de Peças + Total de Peças {activeMetrics.phaseLabel ? `(${activeMetrics.phaseLabel})` : ''} - {totalPecasBD || 39} peças + {activeMetrics.totalPecas} peças
@@ -379,10 +613,10 @@ export default function Visualizador3D() {
- Apontamentos Realizados + Apontamentos Realizados {activeMetrics.phaseLabel ? `(${activeMetrics.phaseLabel})` : ''} - {totalApontadasBD} apontadas + {activeMetrics.apontadas} apontadas
@@ -391,10 +625,10 @@ export default function Visualizador3D() {
- Evolução da OF + Evolução {activeMetrics.phaseLabel ? `(${activeMetrics.phaseLabel})` : 'da OF'} - {progressoOF}% concluído + {activeMetrics.progresso}% concluído
@@ -403,10 +637,10 @@ export default function Visualizador3D() {
- Peso da Estrutura + Peso {activeMetrics.phaseLabel ? `(${activeMetrics.phaseLabel})` : 'da Estrutura'} - {pesoTotalBD > 0 ? `${(pesoTotalBD / 1000).toFixed(1)} ton` : '15.0 ton'} + {activeMetrics.peso > 0 ? `${(activeMetrics.peso / 1000).toFixed(1)} ton` : '0.0 ton'}
@@ -439,9 +673,9 @@ export default function Visualizador3D() {
-

Nenhum modelo IFC carregado

+

Nenhum modelo IFC carregado para {selectedOF}

- Clique no botão "Carregar Modelo IFC" acima para importar o arquivo de fabricação da OF e visualizar a estrutura com seus apontamentos em tempo real. + Clique no botão "Carregar Modelo IFC" acima para importar o arquivo de fabricação desta obra. Ele será gravado no banco de dados e recarregado automaticamente nas próximas visitas.