Replace notifications with activity feed
This commit is contained in:
Binary file not shown.
@@ -7,11 +7,10 @@ import {
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getAttendances, getUsers, getUserById, logout, searchGlobal,
|
||||
getNotifications, markNotificationAsRead, markAllNotificationsAsRead,
|
||||
deleteNotification, clearAllNotifications, returnToSuperAdmin
|
||||
getActivityEvents, returnToSuperAdmin
|
||||
} from '../services/dataService';
|
||||
import { User } from '../types';
|
||||
import notificationSound from '../assets/audio/notification.mp3';
|
||||
import type { ActivityEvent } from '../services/activityService';
|
||||
|
||||
const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => (
|
||||
<NavLink
|
||||
@@ -52,53 +51,19 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [showSearchResults, setShowSearchResults] = useState(false);
|
||||
|
||||
// Notifications State
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const unreadCount = notifications.filter(n => !n.is_read).length;
|
||||
const previousUnreadCountRef = React.useRef(0);
|
||||
const isInitialLoadRef = React.useRef(true);
|
||||
|
||||
// Pre-initialize audio to ensure it's loaded and ready
|
||||
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
||||
// Activity Feed State
|
||||
const [activityEvents, setActivityEvents] = useState<ActivityEvent[]>([]);
|
||||
const [showActivity, setShowActivity] = useState(false);
|
||||
|
||||
const playNotificationSound = () => {
|
||||
if (currentUser?.sound_enabled !== false && audioRef.current) {
|
||||
// Reset time to 0 to allow rapid replays
|
||||
audioRef.current.currentTime = 0;
|
||||
const playPromise = audioRef.current.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(e => console.log('Audio play blocked by browser policy:', e));
|
||||
}
|
||||
}
|
||||
const loadActivityEvents = async () => {
|
||||
const data = await getActivityEvents();
|
||||
setActivityEvents(data);
|
||||
};
|
||||
|
||||
const loadNotifications = async () => {
|
||||
const data = await getNotifications();
|
||||
|
||||
const newUnreadCount = data.filter((n: any) => !n.is_read).length;
|
||||
|
||||
// Only play sound if it's NOT the first load AND the count actually increased
|
||||
if (!isInitialLoadRef.current && newUnreadCount > previousUnreadCountRef.current) {
|
||||
playNotificationSound();
|
||||
}
|
||||
|
||||
setNotifications(data);
|
||||
previousUnreadCountRef.current = newUnreadCount;
|
||||
isInitialLoadRef.current = false;
|
||||
};
|
||||
|
||||
const handleBellClick = async () => {
|
||||
const willOpen = !showNotifications;
|
||||
setShowNotifications(willOpen);
|
||||
|
||||
if (willOpen && unreadCount > 0) {
|
||||
// Optimistic update
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
previousUnreadCountRef.current = 0;
|
||||
await markAllNotificationsAsRead();
|
||||
loadNotifications();
|
||||
}
|
||||
const handleActivityClick = async () => {
|
||||
const willOpen = !showActivity;
|
||||
setShowActivity(willOpen);
|
||||
if (willOpen) await loadActivityEvents();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -144,9 +109,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
}
|
||||
};
|
||||
fetchCurrentUser();
|
||||
loadNotifications();
|
||||
const interval = setInterval(loadNotifications, 10000);
|
||||
return () => clearInterval(interval);
|
||||
loadActivityEvents();
|
||||
}, [navigate]);
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -499,94 +462,52 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
{/* Notifications */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleBellClick}
|
||||
onClick={handleActivityClick}
|
||||
className="p-2 text-zinc-500 dark:text-dark-muted hover:bg-zinc-100 dark:hover:bg-dark-border rounded-full relative transition-colors"
|
||||
> <Bell size={20} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1.5 right-2 w-2.5 h-2.5 bg-brand-yellow rounded-full border-2 border-white dark:border-dark-header"></span>
|
||||
)}
|
||||
title="Atividades"
|
||||
>
|
||||
<Bell size={20} />
|
||||
</button>
|
||||
|
||||
{showNotifications && (
|
||||
{showActivity && (
|
||||
<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 bg-zinc-50/50 dark:bg-dark-bg/50">
|
||||
<h3 className="font-bold text-zinc-900 dark:text-dark-text">Notificações</h3>
|
||||
<div className="flex gap-3">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await markAllNotificationsAsRead();
|
||||
loadNotifications();
|
||||
}}
|
||||
className="text-xs text-brand-yellow hover:underline"
|
||||
>
|
||||
Marcar lidas
|
||||
</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>
|
||||
<h3 className="font-bold text-zinc-900 dark:text-dark-text">Atividades</h3>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.length > 0 ? (
|
||||
notifications.map(n => (
|
||||
{activityEvents.length > 0 ? (
|
||||
activityEvents.map(event => (
|
||||
<div
|
||||
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' : ''}`}
|
||||
key={event.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"
|
||||
>
|
||||
<div
|
||||
className="cursor-pointer pr-6"
|
||||
onClick={async () => {
|
||||
if (!n.is_read) await markNotificationAsRead(n.id);
|
||||
if (n.link) navigate(n.link);
|
||||
setShowNotifications(false);
|
||||
loadNotifications();
|
||||
className={event.link ? 'cursor-pointer' : ''}
|
||||
onClick={() => {
|
||||
if (event.link) navigate(event.link);
|
||||
setShowActivity(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${
|
||||
n.type === 'success' ? 'text-green-500' :
|
||||
n.type === 'warning' ? 'text-orange-500' :
|
||||
n.type === 'error' ? 'text-red-500' : 'text-blue-500'
|
||||
event.type === 'success' ? 'text-green-500' :
|
||||
event.type === 'warning' ? 'text-orange-500' :
|
||||
event.type === 'error' ? 'text-red-500' : 'text-blue-500'
|
||||
}`}>
|
||||
{n.type === 'success' ? 'SUCESSO' : n.type === 'warning' ? 'AVISO' : n.type === 'error' ? 'ERRO' : 'INFO'}
|
||||
{event.type === 'success' ? 'SUCESSO' : event.type === 'warning' ? 'AVISO' : event.type === 'error' ? 'ERRO' : 'INFO'}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-400 dark:text-dark-muted">
|
||||
{new Date(n.created_at).toLocaleDateString('pt-BR')}
|
||||
{new Date(event.created_at).toLocaleDateString('pt-BR')}
|
||||
</span>
|
||||
</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>
|
||||
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{event.title}</div>
|
||||
<p className="text-xs text-zinc-500 dark:text-dark-muted line-clamp-2">{event.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 transition-all rounded-md hover:bg-red-50 dark:hover:bg-red-900/30"
|
||||
title="Remover notificação"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
|
||||
Nenhuma notificação por enquanto.
|
||||
Nenhuma atividade por enquanto.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -594,8 +515,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close notifications when clicking outside */}
|
||||
{showNotifications && <div className="fixed inset-0 z-40" onClick={() => setShowNotifications(false)} />}
|
||||
{showActivity && <div className="fixed inset-0 z-40" onClick={() => setShowActivity(false)} />}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -613,12 +533,6 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hidden Audio Player for Notifications */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={notificationSound}
|
||||
preload="auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2, Bell } from 'lucide-react';
|
||||
import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { getUserById, getTenants, getTeams, updateUser, uploadAvatar } from '../services/dataService';
|
||||
import { User, Tenant } from '../types';
|
||||
|
||||
@@ -273,32 +273,6 @@ export const UserProfile: React.FC = () => {
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 text-right">{bio.length}/500 caracteres</p>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
23
src/services/activityService.ts
Normal file
23
src/services/activityService.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { API_URL, apiFetch, getHeaders } from './apiClient';
|
||||
|
||||
export interface ActivityEvent {
|
||||
id: string;
|
||||
type: 'success' | 'info' | 'warning' | 'error';
|
||||
title: string;
|
||||
message: string;
|
||||
link?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const getActivityEvents = async (): Promise<ActivityEvent[]> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/activity`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch activity');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("API Error (getActivityEvents):", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
export * from './apiClient';
|
||||
export * from './activityService';
|
||||
export * from './attendancesService';
|
||||
export * from './authService';
|
||||
export * from './funnelsService';
|
||||
export * from './integrationsService';
|
||||
export * from './notificationsService';
|
||||
export * from './originsService';
|
||||
export * from './teamsService';
|
||||
export * from './tenantsService';
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { API_URL, apiFetch, getHeaders } from './apiClient';
|
||||
|
||||
export const getNotifications = async (): Promise<any[]> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/notifications`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch notifications');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("API Error (getNotifications):", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const markNotificationAsRead = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("API Error (markNotificationAsRead):", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const markAllNotificationsAsRead = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/notifications/read-all`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("API Error (markAllNotificationsAsRead):", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteNotification = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiFetch(`${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 apiFetch(`${API_URL}/notifications/clear-all`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("API Error (clearAllNotifications):", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -49,7 +49,6 @@ export interface User {
|
||||
team_id: string;
|
||||
bio?: string;
|
||||
status: 'active' | 'inactive';
|
||||
sound_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface Attendance {
|
||||
|
||||
Reference in New Issue
Block a user