feat(smart): sininho de notificacao de apontamentos, grid e camera ortogonal no 3d

This commit is contained in:
2026-09-23 15:21:16 +00:00
parent 9981d5c1dd
commit 27da530308
3 changed files with 624 additions and 42 deletions
+204 -41
View File
@@ -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>
);
};
+39 -1
View File
@@ -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 };
};