feat: add user preference for audio notifications and play sound on new alerts
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m53s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m53s
- Added sound_enabled column to users table with a default of true. - Implemented a pleasant pop sound (notification.mp3) that plays when a new unread notification arrives. - Added a toggle in the User Profile page allowing users to enable/disable the sound.
This commit is contained in:
@@ -399,7 +399,7 @@ apiRouter.post('/users', requireRole(['admin', 'manager', 'super_admin']), async
|
|||||||
});
|
});
|
||||||
|
|
||||||
apiRouter.put('/users/:id', async (req, res) => {
|
apiRouter.put('/users/:id', async (req, res) => {
|
||||||
const { name, bio, role, team_id, status, email } = req.body;
|
const { name, bio, role, team_id, status, email, sound_enabled } = req.body;
|
||||||
try {
|
try {
|
||||||
const [existing] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
|
const [existing] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
|
||||||
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||||
@@ -419,6 +419,7 @@ apiRouter.put('/users/:id', async (req, res) => {
|
|||||||
const finalTeamId = isManagerOrAdmin && team_id !== undefined ? team_id : existing[0].team_id;
|
const finalTeamId = isManagerOrAdmin && team_id !== undefined ? team_id : existing[0].team_id;
|
||||||
const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status;
|
const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status;
|
||||||
const finalEmail = email !== undefined ? email : existing[0].email;
|
const finalEmail = email !== undefined ? email : existing[0].email;
|
||||||
|
const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
|
||||||
|
|
||||||
if (finalEmail !== existing[0].email) {
|
if (finalEmail !== existing[0].email) {
|
||||||
const [emailCheck] = await pool.query('SELECT id FROM users WHERE email = ? AND id != ?', [finalEmail, req.params.id]);
|
const [emailCheck] = await pool.query('SELECT id FROM users WHERE email = ? AND id != ?', [finalEmail, req.params.id]);
|
||||||
@@ -426,12 +427,11 @@ apiRouter.put('/users/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE users SET name = ?, bio = ?, email = ?, role = ?, team_id = ?, status = ? WHERE id = ?',
|
'UPDATE users SET name = ?, bio = ?, email = ?, role = ?, team_id = ?, status = ?, sound_enabled = ? WHERE id = ?',
|
||||||
[name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, req.params.id]
|
[name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, finalSoundEnabled, req.params.id]
|
||||||
);
|
);
|
||||||
res.json({ message: 'User updated successfully.' });
|
res.json({ message: 'User updated successfully.' });
|
||||||
} catch (error) {
|
} catch (error) { console.error('Update user error:', error);
|
||||||
console.error('Update user error:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -510,6 +510,30 @@ apiRouter.put('/notifications/read-all', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
apiRouter.delete('/notifications/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
'DELETE FROM notifications WHERE id = ? AND user_id = ?',
|
||||||
|
[req.params.id, req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ message: 'Notification deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
apiRouter.delete('/notifications', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
'DELETE FROM notifications WHERE user_id = ?',
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ message: 'All notifications deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Global Search ---
|
// --- Global Search ---
|
||||||
apiRouter.get('/search', async (req, res) => {
|
apiRouter.get('/search', async (req, res) => {
|
||||||
const { q } = req.query;
|
const { q } = req.query;
|
||||||
@@ -887,6 +911,13 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
|
|||||||
console.log('Schema update note (populate slugs):', err.message);
|
console.log('Schema update note (populate slugs):', err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add sound_enabled column if it doesn't exist
|
||||||
|
try {
|
||||||
|
await connection.query('ALTER TABLE users ADD COLUMN sound_enabled BOOLEAN DEFAULT true');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (sound_enabled):', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
// Update origin enum
|
// Update origin enum
|
||||||
try {
|
try {
|
||||||
await connection.query("ALTER TABLE attendances MODIFY COLUMN origin ENUM('WhatsApp','Instagram','Website','LinkedIn','Indicação') NOT NULL");
|
await connection.query("ALTER TABLE attendances MODIFY COLUMN origin ENUM('WhatsApp','Instagram','Website','LinkedIn','Indicação') NOT NULL");
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import {
|
|||||||
LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut,
|
LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut,
|
||||||
Hexagon, Settings, Building2, Sun, Moon, Loader2
|
Hexagon, Settings, Building2, Sun, Moon, Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getAttendances, getUsers, getUserById, logout, searchGlobal, getNotifications, markNotificationAsRead, markAllNotificationsAsRead } from '../services/dataService';
|
import {
|
||||||
|
getAttendances, getUsers, getUserById, logout, searchGlobal,
|
||||||
|
getNotifications, markNotificationAsRead, markAllNotificationsAsRead,
|
||||||
|
deleteNotification, clearAllNotifications
|
||||||
|
} from '../services/dataService';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
|
|
||||||
const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => (
|
const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => (
|
||||||
@@ -40,10 +44,26 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
const [notifications, setNotifications] = useState<any[]>([]);
|
const [notifications, setNotifications] = useState<any[]>([]);
|
||||||
const [showNotifications, setShowNotifications] = useState(false);
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
const unreadCount = notifications.filter(n => !n.is_read).length;
|
const unreadCount = notifications.filter(n => !n.is_read).length;
|
||||||
|
const previousUnreadCountRef = React.useRef(unreadCount);
|
||||||
|
|
||||||
|
const playNotificationSound = () => {
|
||||||
|
if (currentUser?.sound_enabled !== false) {
|
||||||
|
const audio = new Audio('/audio/notification.mp3');
|
||||||
|
audio.volume = 0.5;
|
||||||
|
audio.play().catch(e => console.log('Audio play failed (browser policy):', e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadNotifications = async () => {
|
const loadNotifications = async () => {
|
||||||
const data = await getNotifications();
|
const data = await getNotifications();
|
||||||
setNotifications(data);
|
setNotifications(data);
|
||||||
|
|
||||||
|
// Check if there are new unread notifications
|
||||||
|
const newUnreadCount = data.filter((n: any) => !n.is_read).length;
|
||||||
|
if (newUnreadCount > previousUnreadCountRef.current) {
|
||||||
|
playNotificationSound();
|
||||||
|
}
|
||||||
|
previousUnreadCountRef.current = newUnreadCount;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -389,32 +409,50 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
|
|
||||||
{showNotifications && (
|
{showNotifications && (
|
||||||
<div className="absolute top-full mt-2 right-0 w-80 bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
<div className="absolute top-full mt-2 right-0 w-80 bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
<div className="p-4 border-b border-zinc-100 dark:border-dark-border flex justify-between items-center">
|
<div className="p-4 border-b border-zinc-100 dark:border-dark-border flex justify-between items-center bg-zinc-50/50 dark:bg-dark-bg/50">
|
||||||
<h3 className="font-bold text-zinc-900 dark:text-dark-text">Notificações</h3>
|
<h3 className="font-bold text-zinc-900 dark:text-dark-text">Notificações</h3>
|
||||||
|
<div className="flex gap-3">
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
await markAllNotificationsAsRead();
|
await markAllNotificationsAsRead();
|
||||||
loadNotifications();
|
loadNotifications();
|
||||||
}}
|
}}
|
||||||
className="text-xs text-brand-yellow hover:underline"
|
className="text-xs text-brand-yellow hover:underline"
|
||||||
>
|
>
|
||||||
Marcar todas como lidas
|
Marcar lidas
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await clearAllNotifications();
|
||||||
|
loadNotifications();
|
||||||
|
}}
|
||||||
|
className="text-xs text-zinc-400 hover:text-red-500 hover:underline transition-colors"
|
||||||
|
>
|
||||||
|
Limpar tudo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div className="max-h-96 overflow-y-auto">
|
||||||
{notifications.length > 0 ? (
|
{notifications.length > 0 ? (
|
||||||
notifications.map(n => (
|
notifications.map(n => (
|
||||||
<button
|
<div
|
||||||
key={n.id}
|
key={n.id}
|
||||||
|
className={`w-full relative group p-4 text-left hover:bg-zinc-50 dark:hover:bg-dark-border transition-colors border-b border-zinc-50 dark:border-dark-border/50 last:border-0 ${!n.is_read ? 'bg-brand-yellow/5 dark:bg-brand-yellow/5' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer pr-6"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!n.is_read) await markNotificationAsRead(n.id);
|
if (!n.is_read) await markNotificationAsRead(n.id);
|
||||||
if (n.link) navigate(n.link);
|
if (n.link) navigate(n.link);
|
||||||
setShowNotifications(false);
|
setShowNotifications(false);
|
||||||
loadNotifications();
|
loadNotifications();
|
||||||
}}
|
}}
|
||||||
className={`w-full p-4 text-left hover:bg-zinc-50 dark:hover:bg-dark-border transition-colors border-b border-zinc-50 dark:border-dark-border/50 last:border-0 ${!n.is_read ? 'bg-brand-yellow/5 dark:bg-brand-yellow/5' : ''}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start mb-1">
|
<div className="flex justify-between items-start mb-1">
|
||||||
<span className={`text-xs font-bold uppercase tracking-wider ${
|
<span className={`text-xs font-bold uppercase tracking-wider ${
|
||||||
@@ -430,7 +468,21 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{n.title}</div>
|
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{n.title}</div>
|
||||||
<p className="text-xs text-zinc-500 dark:text-dark-muted line-clamp-2">{n.message}</p>
|
<p className="text-xs text-zinc-500 dark:text-dark-muted line-clamp-2">{n.message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await deleteNotification(n.id);
|
||||||
|
loadNotifications();
|
||||||
|
}}
|
||||||
|
className="absolute top-4 right-4 p-1 text-zinc-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all rounded-md hover:bg-red-50 dark:hover:bg-red-900/30"
|
||||||
|
title="Remover notificação"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
|
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2 } from 'lucide-react';
|
import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2, Bell } from 'lucide-react';
|
||||||
import { getUserById, getTenants, getTeams, updateUser, uploadAvatar } from '../services/dataService';
|
import { getUserById, getTenants, getTeams, updateUser, uploadAvatar } from '../services/dataService';
|
||||||
import { User, Tenant } from '../types';
|
import { User, Tenant } from '../types';
|
||||||
|
|
||||||
@@ -264,6 +264,32 @@ export const UserProfile: React.FC = () => {
|
|||||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 text-right">{bio.length}/500 caracteres</p>
|
<p className="text-xs text-zinc-400 dark:text-zinc-500 text-right">{bio.length}/500 caracteres</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-zinc-50 dark:bg-zinc-900/50 rounded-xl border border-zinc-200 dark:border-zinc-800">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
||||||
|
<Bell size={16} className="text-brand-yellow" /> Notificações Sonoras
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">
|
||||||
|
Reproduzir um som quando você receber uma nova notificação.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only peer"
|
||||||
|
checked={user.sound_enabled ?? true}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const newStatus = e.target.checked;
|
||||||
|
setUser({...user, sound_enabled: newStatus});
|
||||||
|
await updateUser(user.id, { sound_enabled: newStatus });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-zinc-200 peer-focus:outline-none rounded-full peer dark:bg-zinc-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-zinc-600 peer-checked:bg-brand-yellow"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 flex items-center justify-end border-t border-zinc-100 dark:border-zinc-800 mt-6 transition-colors">
|
<div className="pt-4 flex items-center justify-end border-t border-zinc-100 dark:border-zinc-800 mt-6 transition-colors">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
9
public/audio/notification.mp3
Normal file
9
public/audio/notification.mp3
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
|
||||||
|
<html><head>
|
||||||
|
<title>404 Not Found</title>
|
||||||
|
</head><body>
|
||||||
|
<h1>Not Found</h1>
|
||||||
|
<p>The requested URL was not found on this server.</p>
|
||||||
|
<p>Additionally, a 404 Not Found
|
||||||
|
error was encountered while trying to use an ErrorDocument to handle the request.</p>
|
||||||
|
</body></html>
|
||||||
@@ -56,6 +56,32 @@ export const markAllNotificationsAsRead = async (): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const deleteNotification = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/notifications/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (deleteNotification):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAllNotifications = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/notifications`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (clearAllNotifications):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => {
|
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
|
const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user