diff --git a/src/components/viewer3d/SmartModelViewer3D.tsx b/src/components/viewer3d/SmartModelViewer3D.tsx index 8bf45a9..56eb922 100644 --- a/src/components/viewer3d/SmartModelViewer3D.tsx +++ b/src/components/viewer3d/SmartModelViewer3D.tsx @@ -4,6 +4,7 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { LoadedIFCResult, PieceInfo } from '@/lib/ifc/ifcLoaderService'; import { ViewCube } from './ViewCube'; import { SelectionListModal, SelectedPieceItem } from './SelectionListModal'; +import { SmartNotificationsModal, useSmartNotificationsAlert } from '@/pages/smart/components/SmartNotificationsModal'; import { useTheme } from '@/hooks/useTheme'; import { smartAudio } from '@/utils/smartAudio'; import { @@ -19,7 +20,9 @@ import { Check, Sun, Moon, - FileSpreadsheet + FileSpreadsheet, + Grid as GridIcon, + Bell } from 'lucide-react'; export interface ProductionPieceStatus { @@ -68,7 +71,10 @@ export const SmartModelViewer3D: React.FC = ({ const canvasContainerRef = useRef(null); const rendererRef = useRef(null); const sceneRef = useRef(null); - const cameraRef = useRef(null); + const perspCameraRef = useRef(null); + const orthoCameraRef = useRef(null); + const activeCameraRef = useRef(null); + const cameraRef = useRef(null); const controlsRef = useRef(null); const modelGroupRef = useRef(null); const raycasterRef = useRef(new THREE.Raycaster()); @@ -76,6 +82,14 @@ export const SmartModelViewer3D: React.FC = ({ const { theme, setTheme } = useTheme(); const isDark = theme === 'dark'; + // Controles de Visualização: Grid e Câmera Ortogonal/Perspectiva + const [showGrid, setShowGrid] = useState(true); + const [isOrthographic, setIsOrthographic] = useState(false); + + // Alerta e Modal de Notificações de Apontamentos + const [isNotificationsOpen, setIsNotificationsOpen] = useState(false); + const { unreadCount, hasNewAlert, clearAlert } = useSmartNotificationsAlert(selectedOF); + // Filtros principais solicitados: Fase e Processo const [selectedPhase, setSelectedPhase] = useState('all'); const [selectedProcess, setSelectedProcess] = useState('all'); @@ -116,13 +130,45 @@ export const SmartModelViewer3D: React.FC = ({ const gridRef = useRef(null); - // Função para atualizar cores do piso de acordo com o tema claro / escuro - const updateGridTheme = useCallback((gridHelper: THREE.GridHelper) => { + // 1. Alternar Visibilidade da Grade de Piso (Grid) + const handleToggleGrid = useCallback(() => { + smartAudio.playClick(); + setShowGrid((prev) => { + const next = !prev; + if (gridRef.current) { + gridRef.current.visible = next; + } + return next; + }); + }, []); + + // 2. Alternar Câmera Ortogonal / Perspectiva + const handleToggleCamera = useCallback(() => { + if (!perspCameraRef.current || !orthoCameraRef.current || !controlsRef.current) return; + smartAudio.playClick(); + + const nextOrtho = !isOrthographic; + setIsOrthographic(nextOrtho); + + const prevCamera = activeCameraRef.current || perspCameraRef.current; + const nextCamera = nextOrtho ? orthoCameraRef.current : perspCameraRef.current; + + nextCamera.position.copy(prevCamera.position); + nextCamera.quaternion.copy(prevCamera.quaternion); + + activeCameraRef.current = nextCamera; + controlsRef.current.object = nextCamera; + controlsRef.current.update(); + }, [isOrthographic]); + + // 3. Atualizar cores do piso quando o tema claro/escuro mudar (SEM recriar a cena Three.js!) + useEffect(() => { + if (!gridRef.current) return; const isDarkMode = document.documentElement.classList.contains('dark') || theme === 'dark'; const centerColor = isDarkMode ? new THREE.Color(0x475569) : new THREE.Color(0x94a3b8); const gridColor = isDarkMode ? new THREE.Color(0x1e293b) : new THREE.Color(0xe2e8f0); - const colors = gridHelper.geometry.attributes.color as THREE.BufferAttribute; + const colors = gridRef.current.geometry.attributes.color as THREE.BufferAttribute; if (colors) { const colorArray = colors.array as Float32Array; for (let i = 0; i < colorArray.length; i += 6) { @@ -138,38 +184,48 @@ export const SmartModelViewer3D: React.FC = ({ colors.needsUpdate = true; } - if (gridHelper.material && (gridHelper.material as THREE.Material)) { - (gridHelper.material as THREE.Material).transparent = true; - (gridHelper.material as THREE.Material).opacity = isDarkMode ? 0.35 : 0.45; + if (gridRef.current.material && (gridRef.current.material as THREE.Material)) { + (gridRef.current.material as THREE.Material).transparent = true; + (gridRef.current.material as THREE.Material).opacity = isDarkMode ? 0.35 : 0.45; } }, [theme]); - // Atualiza cores do grid quando o tema mudar - useEffect(() => { - if (gridRef.current) { - updateGridTheme(gridRef.current); - } - }, [theme, updateGridTheme]); - // Rastreamento para diferenciar gesto touch/drag de toque simples (Tap) const pointerDownRef = useRef<{ x: number; y: number; time: number }>({ x: 0, y: 0, time: 0 }); - // 1. Inicialização do Three.js e OrbitControls Touch-First + // 4. Inicialização ÚNICA do Three.js e OrbitControls Touch-First (independente de tema!) useEffect(() => { if (!containerRef.current || !canvasContainerRef.current) return; - const width = containerRef.current.clientWidth; - const height = containerRef.current.clientHeight; + const width = containerRef.current.clientWidth || 800; + const height = containerRef.current.clientHeight || 600; - // Cena + // Cena única persistente const scene = new THREE.Scene(); - scene.background = null; // Fundo transparente controlado por Tailwind (modo claro / escuro) + scene.background = null; // Fundo controlado por CSS do container (slate-50 / slate-950) sceneRef.current = scene; - // Câmera Perspectiva otimizada para campo de visão amplo em telas touch - const camera = new THREE.PerspectiveCamera(48, width / height, 0.1, 10000); - camera.position.set(25, 20, 30); - cameraRef.current = camera; + // Câmera Perspectiva + const perspCamera = new THREE.PerspectiveCamera(48, width / height, 0.1, 10000); + perspCamera.position.set(25, 20, 30); + perspCameraRef.current = perspCamera; + + // Câmera Ortográfica + const frustumSize = 40; + const aspect = width / height; + const orthoCamera = new THREE.OrthographicCamera( + (frustumSize * aspect) / -2, + (frustumSize * aspect) / 2, + frustumSize / 2, + frustumSize / -2, + 0.1, + 10000 + ); + orthoCamera.position.copy(perspCamera.position); + orthoCameraRef.current = orthoCamera; + + // Câmera Ativa + activeCameraRef.current = perspCamera; // Renderer WebGL com antialias e color space SRGB const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' }); @@ -182,7 +238,7 @@ export const SmartModelViewer3D: React.FC = ({ canvasContainerRef.current.appendChild(renderer.domElement); // OrbitControls otimizado para toque (Touch-First) - const controls = new OrbitControls(camera, renderer.domElement); + const controls = new OrbitControls(perspCamera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.rotateSpeed = 0.8; @@ -194,7 +250,7 @@ export const SmartModelViewer3D: React.FC = ({ }; controlsRef.current = controls; - // Iluminação balanceada para aço industrial + // Iluminação industrial const ambientLight = new THREE.AmbientLight(0xffffff, 0.95); scene.add(ambientLight); @@ -206,10 +262,25 @@ export const SmartModelViewer3D: React.FC = ({ dirLight2.position.set(-40, -20, -40); scene.add(dirLight2); - // Grid suave de chão sincronizado com o tema claro/escuro + // Grid de chão const grid = new THREE.GridHelper(60, 60, 0x06b6d4, 0x94a3b8); grid.position.y = -0.05; - updateGridTheme(grid); + const isDarkInit = document.documentElement.classList.contains('dark'); + const centerColor = isDarkInit ? new THREE.Color(0x475569) : new THREE.Color(0x94a3b8); + const gridColor = isDarkInit ? new THREE.Color(0x1e293b) : new THREE.Color(0xe2e8f0); + const colors = grid.geometry.attributes.color as THREE.BufferAttribute; + if (colors) { + const arr = colors.array as Float32Array; + for (let i = 0; i < arr.length; i += 6) { + const isCenter = i === Math.floor(arr.length / 2); + const c = isCenter ? centerColor : gridColor; + arr[i] = c.r; arr[i + 1] = c.g; arr[i + 2] = c.b; + arr[i + 3] = c.r; arr[i + 4] = c.g; arr[i + 5] = c.b; + } + colors.needsUpdate = true; + } + (grid.material as THREE.Material).transparent = true; + (grid.material as THREE.Material).opacity = isDarkInit ? 0.35 : 0.45; scene.add(grid); gridRef.current = grid; @@ -223,17 +294,32 @@ export const SmartModelViewer3D: React.FC = ({ const animate = () => { animId = requestAnimationFrame(animate); controls.update(); - renderer.render(scene, camera); + const curCam = activeCameraRef.current || perspCamera; + cameraRef.current = curCam; + renderer.render(scene, curCam); }; animate(); // Redimensionamento responsivo const handleResize = () => { - if (!containerRef.current || !rendererRef.current || !cameraRef.current) return; + if (!containerRef.current || !rendererRef.current) return; const w = containerRef.current.clientWidth; const h = containerRef.current.clientHeight; - cameraRef.current.aspect = w / h; - cameraRef.current.updateProjectionMatrix(); + const currentAspect = w / h; + + if (perspCameraRef.current) { + perspCameraRef.current.aspect = currentAspect; + perspCameraRef.current.updateProjectionMatrix(); + } + + if (orthoCameraRef.current) { + orthoCameraRef.current.left = (frustumSize * currentAspect) / -2; + orthoCameraRef.current.right = (frustumSize * currentAspect) / 2; + orthoCameraRef.current.top = frustumSize / 2; + orthoCameraRef.current.bottom = frustumSize / -2; + orthoCameraRef.current.updateProjectionMatrix(); + } + rendererRef.current.setSize(w, h); }; window.addEventListener('resize', handleResize); @@ -243,11 +329,11 @@ export const SmartModelViewer3D: React.FC = ({ window.removeEventListener('resize', handleResize); renderer.dispose(); }; - }, [updateGridTheme]); + }, []); // Executa apenas UMA VEZ na montagem! NUNCA reinicia ao alternar tema! - // 2. Centralizar Modelo na Visão (Fit View) + // 5. Centralizar Modelo na Visão (Fit View) const fitModelToView = useCallback(() => { - if (!modelGroupRef.current || !cameraRef.current || !controlsRef.current) return; + if (!modelGroupRef.current || !controlsRef.current) return; const box = new THREE.Box3().setFromObject(modelGroupRef.current); if (box.isEmpty()) return; @@ -258,13 +344,29 @@ export const SmartModelViewer3D: React.FC = ({ box.getCenter(center); const maxDim = Math.max(size.x, size.y, size.z); - const fov = cameraRef.current.fov * (Math.PI / 180); - let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 1.5; - cameraZ = Math.max(cameraZ, 5); - cameraRef.current.position.set(center.x + cameraZ * 0.7, center.y + cameraZ * 0.5, center.z + cameraZ * 0.9); - cameraRef.current.lookAt(center); - cameraRef.current.updateProjectionMatrix(); + if (perspCameraRef.current) { + const fov = perspCameraRef.current.fov * (Math.PI / 180); + let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 1.5; + cameraZ = Math.max(cameraZ, 5); + perspCameraRef.current.position.set(center.x + cameraZ * 0.7, center.y + cameraZ * 0.5, center.z + cameraZ * 0.9); + perspCameraRef.current.lookAt(center); + perspCameraRef.current.updateProjectionMatrix(); + } + + if (orthoCameraRef.current && containerRef.current) { + const w = containerRef.current.clientWidth || 800; + const h = containerRef.current.clientHeight || 600; + const currentAspect = w / h; + const frustum = maxDim * 1.5; + orthoCameraRef.current.left = (frustum * currentAspect) / -2; + orthoCameraRef.current.right = (frustum * currentAspect) / 2; + orthoCameraRef.current.top = frustum / 2; + orthoCameraRef.current.bottom = frustum / -2; + orthoCameraRef.current.position.set(center.x + maxDim, center.y + maxDim * 0.8, center.z + maxDim); + orthoCameraRef.current.lookAt(center); + orthoCameraRef.current.updateProjectionMatrix(); + } controlsRef.current.target.copy(center); controlsRef.current.update(); @@ -773,6 +875,59 @@ export const SmartModelViewer3D: React.FC = ({ + {/* Botão Ligar/Desligar Grid (apenas ícone, sem texto) */} + + + {/* Botão Vista Ortogonal / Perspectiva (apenas ícone, sem texto) */} + + + {/* Botão Sininho de Notificações de Apontamento de Produção */} + + {/* Botão Enquadrar (Fit View) */} + {/* Notificações de Apontamentos de Produção */} + + {/* Controle de Som / Áudio */} + + {/* Modal de Notificações de Apontamentos */} + setIsNotificationsOpen(false)} + /> ); }; diff --git a/src/pages/smart/components/SmartNotificationsModal.tsx b/src/pages/smart/components/SmartNotificationsModal.tsx new file mode 100644 index 0000000..ea920f9 --- /dev/null +++ b/src/pages/smart/components/SmartNotificationsModal.tsx @@ -0,0 +1,381 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { smartAudio } from '@/utils/smartAudio'; +import { + Bell, + X, + CheckCircle2, + User, + Clock, + Layers, + RefreshCw, + Flame, + Wrench, + Paintbrush, + Truck, + Scissors, + Box +} from 'lucide-react'; + +export interface ApontamentoNotificacao { + id: string; + of_number: string; + quantidade: number; + data_apontamento: string; + created_at: string; + marca: string; + fase: string; + perfil?: string; + processo_nome: string; + processo_cor: string; + usuario_nome: string; +} + +interface SmartNotificationsModalProps { + isOpen: boolean; + onClose: () => void; + ofFiltrar?: string; +} + +export const SmartNotificationsModal: React.FC = ({ + isOpen, + onClose, + ofFiltrar, +}) => { + const [apontamentos, setApontamentos] = useState([]); + const [carregando, setCarregando] = useState(false); + + const formatarTempo = (dataIso?: string) => { + if (!dataIso) return 'Recente'; + try { + const data = new Date(dataIso); + const agora = new Date(); + const diffMs = agora.getTime() - data.getTime(); + const diffMin = Math.floor(diffMs / (1000 * 60)); + const diffHoras = Math.floor(diffMin / 60); + + if (diffMin < 1) return 'Agora mesmo'; + if (diffMin < 60) return `há ${diffMin} min`; + if (diffHoras < 24) return `há ${diffHoras}h`; + return data.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); + } catch { + return 'Recente'; + } + }; + + const getProcessIcon = (processoNome: string) => { + const norm = (processoNome || '').toLowerCase(); + if (norm.includes('corte')) return ; + if (norm.includes('solda')) return ; + if (norm.includes('pintu') || norm.includes('galv')) return ; + if (norm.includes('exped')) return ; + if (norm.includes('montag')) return ; + return ; + }; + + const carregarNotificacoes = useCallback(async () => { + setCarregando(true); + try { + // 1. Buscar os últimos 30 apontamentos + let query = supabase + .from('apontamentos_producao' as any) + .select(` + id, of_number, quantidade_produzida, data_apontamento, created_at, created_by, + peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase, perfil_principal, descricao), + processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem) + `) + .order('created_at', { ascending: false }) + .limit(30); + + if (ofFiltrar) { + query = query.eq('of_number', ofFiltrar); + } + + const { data: apontamentosData, error } = await query; + if (error) throw error; + + // 2. Buscar nomes dos usuários dos apontamentos + const userIds = Array.from(new Set((apontamentosData || []).map((ap: any) => ap.created_by).filter(Boolean))); + const profilesMap = new Map(); + + if (userIds.length > 0) { + try { + const { data: profs } = await supabase + .from('profiles' as any) + .select('id, full_name, email') + .in('id', userIds); + + if (profs) { + profs.forEach((pr: any) => { + const nome = pr.full_name || pr.email?.split('@')[0] || 'Operador'; + profilesMap.set(pr.id, nome); + }); + } + } catch (e) { + console.debug('Erro ao buscar perfis:', e); + } + } + + // 3. Montar lista tratada + const lista: ApontamentoNotificacao[] = (apontamentosData || []).map((ap: any) => { + const peca = ap.peca || {}; + const processo = ap.processo || {}; + const userName = (ap.created_by && profilesMap.get(ap.created_by)) || 'Chão de Fábrica'; + + return { + id: String(ap.id), + of_number: ap.of_number || 'OF', + quantidade: Number(ap.quantidade_produzida || 1), + data_apontamento: ap.data_apontamento, + created_at: ap.created_at || ap.data_apontamento, + marca: peca.marca || 'Sem Marca', + fase: peca.etapa_fase || '1', + perfil: peca.perfil_principal || peca.descricao || '', + processo_nome: processo.nome || 'Produção', + processo_cor: processo.cor || '#10b981', + usuario_nome: userName, + }; + }); + + setApontamentos(lista); + } catch (err) { + console.error('Erro ao carregar notificações de produção:', err); + } finally { + setCarregando(false); + } + }, [ofFiltrar]); + + useEffect(() => { + if (isOpen) { + carregarNotificacoes(); + } + }, [isOpen, carregarNotificacoes]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > + {/* Cabeçalho do Modal */} +
+
+
+ +
+
+

+ Apontamentos Recentes + {ofFiltrar && ( + + OF {ofFiltrar} + + )} +

+

+ Histórico em tempo real das peças produzidas +

+
+
+ +
+ + +
+
+ + {/* Lista de Notificações de Apontamentos */} +
+ {carregando && apontamentos.length === 0 ? ( +
+ + Buscando novos apontamentos... +
+ ) : apontamentos.length === 0 ? ( +
+ +

+ Nenhum apontamento registrado recentemente{ofFiltrar ? ` para a OF ${ofFiltrar}` : ''}. +

+
+ ) : ( + apontamentos.map((item) => ( +
+ {/* Lado Esquerdo: Marca, Detalhes e Usuário */} +
+
+ {getProcessIcon(item.processo_nome)} +
+ +
+
+ + {item.marca} + + + OF {item.of_number} • F{item.fase} + + + {item.processo_nome} + +
+ + {item.perfil && ( +

+ {item.perfil} +

+ )} + +
+ + + {item.usuario_nome} + + + + {formatarTempo(item.created_at)} + +
+
+
+ + {/* Lado Direito: Quantidade Apontada */} +
+ + +{item.quantidade} un + +
+
+ )) + )} +
+ + {/* Rodapé */} +
+ {apontamentos.length} apontamentos listados + +
+
+
+ ); +}; + +// Hook reativo para alertar e contar novos apontamentos no sininho +export const useSmartNotificationsAlert = (ofFiltrar?: string) => { + const [unreadCount, setUnreadCount] = useState(0); + const [hasNewAlert, setHasNewAlert] = useState(false); + + useEffect(() => { + // 1. Busca contagem de apontamentos de hoje / recentes + const fetchRecentCount = async () => { + try { + const hoje = new Date(); + hoje.setHours(0, 0, 0, 0); + + let query = supabase + .from('apontamentos_producao' as any) + .select('id', { count: 'exact', head: true }) + .gte('created_at', hoje.toISOString()); + + if (ofFiltrar) { + query = query.eq('of_number', ofFiltrar); + } + + const { count, error } = await query; + if (!error && count !== null) { + setUnreadCount(count); + if (count > 0) setHasNewAlert(true); + } + } catch (err) { + console.debug('Erro ao contar apontamentos:', err); + } + }; + + fetchRecentCount(); + + // 2. Realtime listener para alertar quando novos apontamentos forem inseridos + const channelName = `realtime-smart-notifs-${ofFiltrar || 'all'}-${Math.random().toString(36).substring(2, 7)}`; + const channel = supabase + .channel(channelName) + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'apontamentos_producao', + }, + (payload) => { + const newRow = payload.new as any; + if (!ofFiltrar || newRow?.of_number === ofFiltrar) { + setUnreadCount((c) => c + 1); + setHasNewAlert(true); + try { + smartAudio.playSuccess(); + } catch { + // ignore + } + } + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [ofFiltrar]); + + const clearAlert = useCallback(() => { + setHasNewAlert(false); + }, []); + + return { unreadCount, hasNewAlert, clearAlert }; +}; +