feat(smart): sininho de notificacao de apontamentos, grid e camera ortogonal no 3d
This commit is contained in:
@@ -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<SmartModelViewer3DProps> = ({
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const perspCameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const orthoCameraRef = useRef<THREE.OrthographicCamera | null>(null);
|
||||
const activeCameraRef = useRef<THREE.Camera | null>(null);
|
||||
const cameraRef = useRef<THREE.Camera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const modelGroupRef = useRef<THREE.Group | null>(null);
|
||||
const raycasterRef = useRef<THREE.Raycaster>(new THREE.Raycaster());
|
||||
@@ -76,6 +82,14 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// Controles de Visualização: Grid e Câmera Ortogonal/Perspectiva
|
||||
const [showGrid, setShowGrid] = useState<boolean>(true);
|
||||
const [isOrthographic, setIsOrthographic] = useState<boolean>(false);
|
||||
|
||||
// Alerta e Modal de Notificações de Apontamentos
|
||||
const [isNotificationsOpen, setIsNotificationsOpen] = useState<boolean>(false);
|
||||
const { unreadCount, hasNewAlert, clearAlert } = useSmartNotificationsAlert(selectedOF);
|
||||
|
||||
// Filtros principais solicitados: Fase e Processo
|
||||
const [selectedPhase, setSelectedPhase] = useState<string>('all');
|
||||
const [selectedProcess, setSelectedProcess] = useState<string>('all');
|
||||
@@ -116,13 +130,45 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
|
||||
const gridRef = useRef<THREE.GridHelper | null>(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<SmartModelViewer3DProps> = ({
|
||||
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<SmartModelViewer3DProps> = ({
|
||||
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<SmartModelViewer3DProps> = ({
|
||||
};
|
||||
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<SmartModelViewer3DProps> = ({
|
||||
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<SmartModelViewer3DProps> = ({
|
||||
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<SmartModelViewer3DProps> = ({
|
||||
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<SmartModelViewer3DProps> = ({
|
||||
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<SmartModelViewer3DProps> = ({
|
||||
<Activity className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400 absolute left-2.5 top-3.5 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Botão Ligar/Desligar Grid (apenas ícone, sem texto) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleGrid}
|
||||
className={`h-10 w-10 rounded-xl border shadow-md backdrop-blur-md flex items-center justify-center transition-all active:scale-95 cursor-pointer ${
|
||||
showGrid
|
||||
? 'bg-cyan-50 dark:bg-cyan-950/70 text-cyan-600 dark:text-cyan-400 border-cyan-300 dark:border-cyan-700/80 ring-1 ring-cyan-400/30'
|
||||
: 'bg-white/90 dark:bg-slate-900/90 text-slate-400 dark:text-slate-500 border-slate-200 dark:border-slate-700/80 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title={showGrid ? "Desativar Grade de Piso (Grid)" : "Ativar Grade de Piso (Grid)"}
|
||||
>
|
||||
<GridIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Botão Vista Ortogonal / Perspectiva (apenas ícone, sem texto) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleCamera}
|
||||
className={`h-10 w-10 rounded-xl border shadow-md backdrop-blur-md flex items-center justify-center transition-all active:scale-95 cursor-pointer ${
|
||||
isOrthographic
|
||||
? 'bg-fuchsia-50 dark:bg-fuchsia-950/70 text-fuchsia-600 dark:text-fuchsia-400 border-fuchsia-300 dark:border-fuchsia-700/80 ring-1 ring-fuchsia-400/30'
|
||||
: 'bg-white/90 dark:bg-slate-900/90 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-700/80 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title={isOrthographic ? "Alternar para Câmera Perspectiva" : "Alternar para Câmera Ortogonal (Paralela)"}
|
||||
>
|
||||
<Box className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Botão Sininho de Notificações de Apontamento de Produção */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
smartAudio.playClick();
|
||||
clearAlert();
|
||||
setIsNotificationsOpen(true);
|
||||
}}
|
||||
className="relative h-10 w-10 rounded-xl bg-white/90 dark:bg-slate-900/90 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-slate-700/80 shadow-md backdrop-blur-md flex items-center justify-center transition-all active:scale-95 cursor-pointer"
|
||||
title="Notificações de Apontamentos de Produção"
|
||||
>
|
||||
<Bell className={`w-4 h-4 ${hasNewAlert ? 'text-amber-500 animate-bounce' : 'text-slate-600 dark:text-slate-300'}`} />
|
||||
{hasNewAlert && (
|
||||
<span className="absolute top-1.5 right-1.5 flex h-2.5 w-2.5 pointer-events-none">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500 border border-white dark:border-slate-900"></span>
|
||||
</span>
|
||||
)}
|
||||
{unreadCount > 0 && !hasNewAlert && (
|
||||
<span className="absolute -top-1 -right-1 px-1 min-w-[16px] h-4 rounded-full bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-[9px] font-mono font-bold flex items-center justify-center border border-slate-300 dark:border-slate-700 pointer-events-none">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Botão Enquadrar (Fit View) */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -915,6 +1070,14 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
selectedOF={selectedOF}
|
||||
onClearSelection={handleClearSelection}
|
||||
/>
|
||||
|
||||
{/* Modal de Notificações de Apontamentos da Produção */}
|
||||
<SmartNotificationsModal
|
||||
isOpen={isNotificationsOpen}
|
||||
onClose={() => setIsNotificationsOpen(false)}
|
||||
ofFiltrar={selectedOF}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Building2, Volume2, VolumeX, LogOut, ArrowLeft, Sun, Moon } from 'lucide-react';
|
||||
import { Building2, Volume2, VolumeX, LogOut, ArrowLeft, Sun, Moon, Bell } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { smartAudio } from '@/utils/smartAudio';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SmartNotificationsModal, useSmartNotificationsAlert } from './SmartNotificationsModal';
|
||||
|
||||
interface SmartHeaderProps {
|
||||
titulo?: string;
|
||||
@@ -23,6 +24,8 @@ export const SmartHeader: React.FC<SmartHeaderProps> = ({
|
||||
const { user, signOut } = useAuth();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [soundOn, setSoundOn] = useState<boolean>(() => smartAudio.isSoundEnabled());
|
||||
const [isNotificationsOpen, setIsNotificationsOpen] = useState<boolean>(false);
|
||||
const { unreadCount, hasNewAlert, clearAlert } = useSmartNotificationsAlert();
|
||||
|
||||
// Detecta se está efetivamente em modo escuro
|
||||
const isDark =
|
||||
@@ -128,6 +131,35 @@ export const SmartHeader: React.FC<SmartHeaderProps> = ({
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
{/* Notificações de Apontamentos de Produção */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
smartAudio.playClick();
|
||||
clearAlert();
|
||||
setIsNotificationsOpen(true);
|
||||
}}
|
||||
className={`relative h-10 w-8 sm:w-9 rounded-xl flex items-center justify-center border transition-all active:scale-90 ${
|
||||
hasNewAlert
|
||||
? 'bg-amber-100 border-amber-300 text-amber-700 dark:bg-amber-950/70 dark:border-amber-600/50 dark:text-amber-400'
|
||||
: 'bg-slate-100 border-slate-200 text-slate-600 dark:bg-slate-800/80 dark:border-slate-700 dark:text-slate-300 hover:bg-slate-200'
|
||||
}`}
|
||||
title="Notificações de Apontamentos de Produção"
|
||||
>
|
||||
<Bell className={`h-4 w-4 ${hasNewAlert ? 'animate-bounce text-amber-600 dark:text-amber-400' : ''}`} />
|
||||
{hasNewAlert && (
|
||||
<span className="absolute top-1.5 right-1.5 flex h-2 w-2 pointer-events-none">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
|
||||
</span>
|
||||
)}
|
||||
{unreadCount > 0 && !hasNewAlert && (
|
||||
<span className="absolute -top-1 -right-1 px-1 min-w-[15px] h-3.5 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 text-[8px] font-mono font-bold flex items-center justify-center border border-slate-300 dark:border-slate-600 pointer-events-none">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Controle de Som / Áudio */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -165,6 +197,12 @@ export const SmartHeader: React.FC<SmartHeaderProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Notificações de Apontamentos */}
|
||||
<SmartNotificationsModal
|
||||
isOpen={isNotificationsOpen}
|
||||
onClose={() => setIsNotificationsOpen(false)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<SmartNotificationsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
ofFiltrar,
|
||||
}) => {
|
||||
const [apontamentos, setApontamentos] = useState<ApontamentoNotificacao[]>([]);
|
||||
const [carregando, setCarregando] = useState<boolean>(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 <Scissors className="w-3.5 h-3.5" />;
|
||||
if (norm.includes('solda')) return <Flame className="w-3.5 h-3.5" />;
|
||||
if (norm.includes('pintu') || norm.includes('galv')) return <Paintbrush className="w-3.5 h-3.5" />;
|
||||
if (norm.includes('exped')) return <Truck className="w-3.5 h-3.5" />;
|
||||
if (norm.includes('montag')) return <Wrench className="w-3.5 h-3.5" />;
|
||||
return <Box className="w-3.5 h-3.5" />;
|
||||
};
|
||||
|
||||
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<string, string>();
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-3 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg max-h-[85vh] flex flex-col bg-white dark:bg-slate-900 rounded-3xl border border-slate-200 dark:border-slate-800 shadow-2xl overflow-hidden select-none animate-in zoom-in-95 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Cabeçalho do Modal */}
|
||||
<div className="flex items-center justify-between px-5 py-4 bg-slate-50/90 dark:bg-slate-950/80 border-b border-slate-200 dark:border-slate-800">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-2xl bg-amber-500/15 border border-amber-500/30 flex items-center justify-center text-amber-500 dark:text-amber-400">
|
||||
<Bell className="w-5 h-5 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm sm:text-base font-black uppercase tracking-wider text-slate-800 dark:text-slate-100 flex items-center gap-2">
|
||||
Apontamentos Recentes
|
||||
{ofFiltrar && (
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-cyan-100 dark:bg-cyan-900/40 text-cyan-700 dark:text-cyan-300">
|
||||
OF {ofFiltrar}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-[11px] text-slate-500 dark:text-slate-400">
|
||||
Histórico em tempo real das peças produzidas
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
smartAudio.playClick();
|
||||
carregarNotificacoes();
|
||||
}}
|
||||
disabled={carregando}
|
||||
className="p-2 rounded-xl text-slate-500 hover:text-slate-800 dark:hover:text-slate-200 hover:bg-slate-200/60 dark:hover:bg-slate-800 transition-colors"
|
||||
title="Atualizar Notificações"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${carregando ? 'animate-spin text-amber-500' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
smartAudio.playClick();
|
||||
onClose();
|
||||
}}
|
||||
className="p-2 rounded-xl text-slate-500 hover:text-slate-800 dark:hover:text-slate-200 hover:bg-slate-200/60 dark:hover:bg-slate-800 transition-colors"
|
||||
title="Fechar"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Notificações de Apontamentos */}
|
||||
<div className="flex-1 overflow-y-auto p-3 sm:p-4 space-y-2.5 custom-scrollbar">
|
||||
{carregando && apontamentos.length === 0 ? (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-slate-400 space-y-2">
|
||||
<RefreshCw className="w-7 h-7 animate-spin text-amber-500" />
|
||||
<span className="text-xs font-medium">Buscando novos apontamentos...</span>
|
||||
</div>
|
||||
) : apontamentos.length === 0 ? (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-center text-slate-400 space-y-2">
|
||||
<CheckCircle2 className="w-10 h-10 stroke-[1.2] text-slate-300 dark:text-slate-600" />
|
||||
<p className="text-xs font-semibold text-slate-600 dark:text-slate-400">
|
||||
Nenhum apontamento registrado recentemente{ofFiltrar ? ` para a OF ${ofFiltrar}` : ''}.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
apontamentos.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="p-3 rounded-2xl bg-slate-50/80 dark:bg-slate-950/60 hover:bg-slate-100/90 dark:hover:bg-slate-800/60 border border-slate-200 dark:border-slate-800 transition-all duration-150 flex items-start justify-between gap-3 shadow-sm"
|
||||
>
|
||||
{/* Lado Esquerdo: Marca, Detalhes e Usuário */}
|
||||
<div className="flex items-start gap-2.5 min-w-0 flex-1">
|
||||
<div
|
||||
className="p-2 rounded-xl shrink-0 flex items-center justify-center text-white shadow-sm"
|
||||
style={{ backgroundColor: item.processo_cor }}
|
||||
title={item.processo_nome}
|
||||
>
|
||||
{getProcessIcon(item.processo_nome)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono font-black text-xs sm:text-sm text-slate-900 dark:text-slate-100 tracking-tight">
|
||||
{item.marca}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300">
|
||||
OF {item.of_number} • F{item.fase}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-bold px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: `${item.processo_cor}20`,
|
||||
color: item.processo_cor,
|
||||
}}
|
||||
>
|
||||
{item.processo_nome}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.perfil && (
|
||||
<p className="text-[11px] font-mono text-slate-600 dark:text-slate-400 truncate">
|
||||
{item.perfil}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-[10px] text-slate-500 dark:text-slate-400 pt-0.5">
|
||||
<span className="flex items-center gap-1 font-semibold text-slate-700 dark:text-slate-300">
|
||||
<User className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
|
||||
{item.usuario_nome}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatarTempo(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lado Direito: Quantidade Apontada */}
|
||||
<div className="text-right shrink-0 flex flex-col items-end justify-center">
|
||||
<span className="text-xs font-mono font-black px-2 py-1 rounded-xl bg-emerald-100 dark:bg-emerald-950/60 text-emerald-800 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-800 shadow-sm">
|
||||
+{item.quantidade} un
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rodapé */}
|
||||
<div className="p-3 bg-slate-50/90 dark:bg-slate-950/80 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between text-[11px] text-slate-500">
|
||||
<span>{apontamentos.length} apontamentos listados</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
smartAudio.playClick();
|
||||
onClose();
|
||||
}}
|
||||
className="px-3.5 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-white font-bold text-xs active:scale-95 transition-all"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook reativo para alertar e contar novos apontamentos no sininho
|
||||
export const useSmartNotificationsAlert = (ofFiltrar?: string) => {
|
||||
const [unreadCount, setUnreadCount] = useState<number>(0);
|
||||
const [hasNewAlert, setHasNewAlert] = useState<boolean>(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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user