feat: implement real-time notification system with Socket.io, Redux state management, and UI components

This commit is contained in:
Yashwin 2026-04-03 20:13:48 +05:30
parent b6102d0b31
commit c2e6d779d4
17 changed files with 1492 additions and 18 deletions

99
package-lock.json generated
View File

@ -25,6 +25,7 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@ -33,6 +34,7 @@
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
@ -1443,6 +1445,12 @@
"win32" "win32"
] ]
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -3121,11 +3129,20 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -3191,6 +3208,28 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.19.0", "version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
@ -4409,7 +4448,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@ -5099,6 +5137,34 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/sonner": { "node_modules/sonner": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@ -5460,6 +5526,35 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -27,6 +27,7 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@ -35,6 +36,7 @@
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",

View File

@ -1,12 +1,15 @@
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { AppRoutes } from "@/routes"; import { AppRoutes } from "@/routes";
import { NotificationProvider } from "@/components/shared/NotificationProvider";
function App() { function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Toaster position="top-right" richColors /> <NotificationProvider>
<AppRoutes /> <Toaster position="top-right" richColors />
<AppRoutes />
</NotificationProvider>
</BrowserRouter> </BrowserRouter>
); );
} }

View File

@ -1,11 +1,12 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ChevronRight, Search, Bell, ChevronDown, Menu, LogOut, User } from 'lucide-react'; import { Search, ChevronDown, Menu, LogOut, User, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks'; import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
import { logoutAsync } from '@/store/authSlice'; import { logoutAsync } from '@/store/authSlice';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { showToast } from '@/utils/toast'; import { showToast } from '@/utils/toast';
import { NotificationBell } from '@/components/shared/NotificationBell';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
interface HeaderProps { interface HeaderProps {
@ -162,14 +163,7 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
> >
<Search className="w-4 h-4" /> <Search className="w-4 h-4" />
</Button> </Button>
<Button <NotificationBell />
variant="ghost"
size="icon"
className="w-8 h-8 md:w-8 md:h-8 rounded-full border border-[rgba(0,0,0,0.08)] min-h-[44px] min-w-[44px]"
aria-label="Notifications"
>
<Bell className="w-4 h-4" />
</Button>
{/* Desktop User Dropdown */} {/* Desktop User Dropdown */}
<div className="hidden md:block relative" ref={dropdownRef}> <div className="hidden md:block relative" ref={dropdownRef}>

View File

@ -15,6 +15,7 @@ import {
Briefcase, Briefcase,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Bell,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -56,6 +57,7 @@ const superAdminPlatformMenu: MenuItem[] = [
]; ];
const superAdminSystemMenu: MenuItem[] = [ const superAdminSystemMenu: MenuItem[] = [
{ icon: Bell, label: "Notifications", path: "/notifications" },
{ icon: FileText, label: "Audit Logs", path: "/audit-logs" }, { icon: FileText, label: "Audit Logs", path: "/audit-logs" },
// { icon: Settings, label: 'Settings', path: '/settings' }, // { icon: Settings, label: 'Settings', path: '/settings' },
]; ];
@ -121,6 +123,12 @@ const tenantAdminPlatformMenu: MenuItem[] = [
]; ];
const tenantAdminSystemMenu: MenuItem[] = [ const tenantAdminSystemMenu: MenuItem[] = [
{
icon: Bell,
label: "Notifications",
path: "/tenant/notifications",
requiredPermission: { resource: "notifications" },
},
{ {
icon: FileText, icon: FileText,
label: "Audit Logs", label: "Audit Logs",

View File

@ -0,0 +1,253 @@
import { useState, useRef, useEffect } from 'react';
import { Bell, Clipboard, FileText, GraduationCap, GitGraph, Building2, Info, CheckCircle2, AlertCircle, AlertTriangle, Trash2, X } from 'lucide-react';
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
import { markReadAsync, readAllAsync, fetchNotifications } from '@/store/notificationSlice';
import { formatDistanceToNow } from 'date-fns';
import { cn } from '@/lib/utils';
import { useNavigate } from 'react-router-dom';
import type { Notification, NotificationType, NotificationCategory } from '@/types/notification';
import { notificationService } from '@/services/notification-service';
import { showToast } from '@/utils/toast';
const getNotificationIcon = (category?: NotificationCategory, type?: NotificationType) => {
switch (category) {
case 'capa': return <Clipboard className="w-4 h-4" />;
case 'document': return <FileText className="w-4 h-4" />;
case 'training': return <GraduationCap className="w-4 h-4" />;
case 'workflow': return <GitGraph className="w-4 h-4" />;
case 'supplier': return <Building2 className="w-4 h-4" />;
case 'system': return <Bell className="w-4 h-4" />;
default:
switch (type) {
case 'success': return <CheckCircle2 className="w-4 h-4" />;
case 'warning': return <AlertTriangle className="w-4 h-4" />;
case 'action_required': return <AlertCircle className="w-4 h-4" />;
default: return <Info className="w-4 h-4" />;
}
}
};
const getTypeColor = (type: NotificationType) => {
switch (type) {
case 'success': return 'text-green-500 bg-green-50';
case 'warning': return 'text-orange-500 bg-orange-50';
case 'action_required': return 'text-amber-500 bg-amber-50';
case 'escalation': return 'text-red-500 bg-red-50';
default: return 'text-blue-500 bg-blue-50';
}
};
export const NotificationBell = () => {
const [isOpen, setIsOpen] = useState(false);
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { notifications, unread_count } = useAppSelector((state) => state.notifications);
const { roles } = useAppSelector((state) => state.auth);
const dropdownRef = useRef<HTMLDivElement>(null);
// Handle click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleMarkAllRead = () => {
dispatch(readAllAsync());
};
const handleNotificationClick = (notification: Notification) => {
if (!notification.is_read) {
dispatch(markReadAsync(notification.id));
}
// Special handling for tasks as requested - redirect to My Tasks tab
if (['workflow', 'training'].includes(notification.category || '')) {
navigate('/tenant/tasks');
setIsOpen(false);
return;
}
// Special handling for system as requested - redirect to Dashboard
if (['system'].includes(notification.category || '')) {
navigate('/tenant');
setIsOpen(false);
return;
}
if (notification.action_url) {
navigate(notification.action_url);
}
setIsOpen(false);
};
const handleDismiss = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
try {
await notificationService.dismiss(id);
dispatch(fetchNotifications({ limit: 20 }));
showToast.success('Notification dismissed');
} catch (error) {
showToast.error('Failed to dismiss notification');
}
};
const handleDismissAll = async () => {
try {
await notificationService.dismissAll();
dispatch(fetchNotifications({ limit: 20 }));
showToast.success('All notifications dismissed');
} catch (error) {
showToast.error('Failed to dismiss all notifications');
}
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="relative w-10 h-10 flex items-center justify-center rounded-full border border-[rgba(0,0,0,0.08)] hover:bg-gray-50 transition-colors"
aria-label="Notifications"
>
<Bell className="w-5 h-5 text-gray-600" />
{unread_count > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[20px] h-5 px-1.5 flex items-center justify-center bg-red-500 text-white text-[10px] font-bold rounded-full border-2 border-white">
{unread_count > 99 ? '99+' : unread_count}
</span>
)}
</button>
{isOpen && (
<div className="absolute right-0 mt-3 w-[400px] max-w-[90vw] bg-white border border-gray-200 rounded-xl shadow-2xl z-[100] flex flex-col overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
<h3 className="text-sm font-semibold text-gray-900">Notifications</h3>
<div className="flex items-center gap-2">
{unread_count > 0 && (
<button
onClick={handleMarkAllRead}
className="text-xs font-medium text-blue-600 hover:text-blue-700 transition-colors"
>
Mark all as read
</button>
)}
<button
onClick={handleDismissAll}
className="text-gray-400 hover:text-gray-600 transition-colors"
title="Dismiss all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto max-h-[480px]">
{notifications.length === 0 ? (
<div className="py-12 px-4 flex flex-col items-center justify-center text-center">
<div className="w-12 h-12 bg-gray-50 rounded-full flex items-center justify-center mb-3">
<Bell className="w-6 h-6 text-gray-300" />
</div>
<p className="text-sm font-medium text-gray-900">All caught up!</p>
<p className="text-xs text-gray-500 mt-1">No notifications to show right now.</p>
</div>
) : (
<div className="divide-y divide-gray-50">
{notifications.map((notification) => (
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={cn(
"group relative px-4 py-4 hover:bg-gray-50 transition-colors cursor-pointer flex gap-3",
!notification.is_read && "bg-blue-50/30"
)}
>
<div className={cn(
"flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center",
getTypeColor(notification.notification_type)
)}>
{getNotificationIcon(notification.category, notification.notification_type)}
</div>
<div className="flex-1 min-w-0 pr-4">
<div className="flex items-start justify-between mb-0.5">
<p className={cn(
"text-sm font-medium text-gray-900 truncate pr-2",
!notification.is_read && "font-bold"
)}>
{notification.title}
</p>
<span className="text-[10px] text-gray-400 whitespace-nowrap mt-0.5">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</span>
</div>
<p className="text-xs text-gray-600 line-clamp-2 leading-relaxed">
{notification.message}
</p>
{notification.entity_name && (
<div className="mt-2 text-[10px] font-medium text-gray-400 flex items-center gap-1">
<span className="px-1.5 py-0.5 bg-gray-100 rounded text-gray-500 uppercase tracking-wider">
{notification.entity_type?.replace('_', ' ')}
</span>
<span className="truncate">{notification.entity_name}</span>
</div>
)}
</div>
{/* Quick Actions */}
<div className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => handleDismiss(e, notification.id)}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
title="Dismiss"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
{/* Unread indicator dot */}
{!notification.is_read && (
<div className="absolute left-1 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-blue-500 rounded-full" />
)}
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-2 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
<button
onClick={() => {
const isSuperAdmin = roles.includes('super_admin');
navigate(isSuperAdmin ? '/notifications' : '/tenant/notifications');
setIsOpen(false);
}}
className="text-xs font-medium text-blue-600 hover:text-blue-700 transition-colors"
>
View All Notifications
</button>
<button
onClick={() => {
const isSuperAdmin = roles.includes('super_admin');
navigate(isSuperAdmin ? '/settings/notifications' : '/tenant/settings/notifications');
setIsOpen(false);
}}
className="text-xs font-medium text-gray-500 hover:text-gray-900 transition-colors"
>
Preferences
</button>
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,136 @@
import { useEffect, useCallback, createContext, useContext, useRef, type ReactNode } from 'react';
import { io, Socket } from 'socket.io-client';
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
import { addNotification, fetchNotifications, fetchUnreadCount, setUnreadCount, markReadLocal } from '@/store/notificationSlice';
import { showToast } from '@/utils/toast';
import { useNavigate } from 'react-router-dom';
interface SocketContextValue {
socket: Socket | null;
}
const SocketContext = createContext<SocketContextValue>({ socket: null });
export const useNotificationSocket = () => useContext(SocketContext);
interface NotificationProviderProps {
children: ReactNode;
}
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
// Extract domain from VITE_API_BASE_URL if it exists, otherwise use it as is for socket.io
const SOCKET_URL = API_BASE_URL.replace('/api/v1', '');
export const NotificationProvider = ({ children }: NotificationProviderProps) => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { accessToken, isAuthenticated } = useAppSelector((state) => state.auth);
const socketRef = useRef<Socket | null>(null);
const handleNotification = useCallback((data: any) => {
console.log('WebSocket: Received notification event', data);
if (data.type === 'new' && data.notification) {
const notification = data.notification;
dispatch(addNotification(notification));
// Use showToast based on notification type
const toastType = ['warning', 'action_required', 'escalation'].includes(notification.notification_type) ? 'warning' :
notification.notification_type === 'success' ? 'success' : 'info';
const action = notification.action_url ? {
label: 'View',
onClick: () => {
if (['workflow', 'training'].includes(notification.category || '')) {
navigate('/tenant/tasks');
} else {
navigate(notification.action_url);
}
}
} : undefined;
showToast[toastType](notification.title, notification.message, action);
}
}, [dispatch, navigate]);
const handleUnreadCount = useCallback((data: any) => {
console.log('WebSocket: Received unread_count event', data);
if (typeof data.count === 'number') {
dispatch(setUnreadCount(data.count));
}
}, [dispatch]);
const handleNotificationRead = useCallback((data: any) => {
console.log('WebSocket: Received notification_read event', data);
if (data.id) {
dispatch(markReadLocal(data.id));
}
}, [dispatch]);
useEffect(() => {
if (!isAuthenticated || !accessToken) {
if (socketRef.current) {
console.log('WebSocket: Disconnecting due to logout');
socketRef.current.disconnect();
socketRef.current = null;
}
return;
}
if (socketRef.current?.connected) return;
console.log('WebSocket: Connecting to', SOCKET_URL);
// Connect to WebSocket
const socket = io(SOCKET_URL, {
auth: {
token: accessToken
},
transports: ["websocket", "polling"],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000
});
socket.on('connected', (data) => {
console.log('WebSocket: Authenticated for user', data.userId);
});
socket.on('notification', handleNotification);
socket.on('unread_count', handleUnreadCount);
socket.on('notification_read', handleNotificationRead);
socket.on('connect', () => {
console.log('WebSocket: Connected');
// Re-fetch on reconnect to avoid missing updates
dispatch(fetchNotifications({ limit: 20 }));
dispatch(fetchUnreadCount());
});
socket.on('disconnect', (reason) => {
console.log('WebSocket: Disconnected', reason);
});
socket.on('connect_error', (err) => {
console.error('WebSocket: Connection error', err.message);
});
socketRef.current = socket;
// Initial fetch
dispatch(fetchNotifications({ limit: 20 }));
dispatch(fetchUnreadCount());
return () => {
if (socketRef.current) {
console.log('WebSocket: Cleaning up connection');
socketRef.current.disconnect();
socketRef.current = null;
}
};
}, [isAuthenticated, accessToken, dispatch, handleNotification, handleUnreadCount, handleNotificationRead]);
return (
<SocketContext.Provider value={{ socket: socketRef.current }}>
{children}
</SocketContext.Provider>
);
};

View File

@ -123,7 +123,7 @@ const subscriptionTierOptions = [
// Helper function to get base URL with protocol // Helper function to get base URL with protocol
const getBaseUrlWithProtocol = (): string => { const getBaseUrlWithProtocol = (): string => {
return import.meta.env.VITE_API_BASE_URL || "http://localhost:3000"; return import.meta.env.VITE_FRONTEND_BASE_URL || "http://localhost:5173";
}; };
const CreateTenantWizard = (): ReactElement => { const CreateTenantWizard = (): ReactElement => {

View File

@ -0,0 +1,298 @@
import { useState, useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
import { notificationService } from '@/services/notification-service';
import type { NotificationPreferences, NotificationCategory } from '@/types/notification';
import { setPreferences } from '@/store/notificationSlice';
import { showToast } from '@/utils/toast';
import { Save, Bell, Mail, Clock, Shield, Info } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
const CATEGORIES: { id: NotificationCategory; label: string; description: string }[] = [
{ id: 'capa', label: 'CAPA', description: 'Corrective and Preventive Actions assignments and updates' },
{ id: 'document', label: 'Documents', description: 'Reviews, approvals, and status changes' },
{ id: 'training', label: 'Training', description: 'Assignments and completion updates' },
{ id: 'workflow', label: 'Workflows', description: 'Task assignments and progress' },
{ id: 'supplier', label: 'Suppliers', description: 'Evaluations and status updates' },
{ id: 'system', label: 'System', description: 'Security alerts and system updates' },
];
export const NotificationSettings = () => {
const dispatch = useAppDispatch();
const { preferences: storedPreferences } = useAppSelector((state) => state.notifications);
const [preferences, setLocalPreferences] = useState<NotificationPreferences | null>(storedPreferences);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
const fetchPrefs = async () => {
setIsLoading(true);
try {
const response = await notificationService.getPreferences();
setLocalPreferences(response.data);
dispatch(setPreferences(response.data));
} catch (error) {
showToast.error('Failed to load notification preferences');
} finally {
setIsLoading(false);
}
};
if (!storedPreferences) {
fetchPrefs();
} else {
setLocalPreferences(storedPreferences);
}
}, [dispatch, storedPreferences]);
const handleToggle = (field: keyof NotificationPreferences) => {
if (!preferences) return;
setLocalPreferences({
...preferences,
[field]: !preferences[field]
});
};
const handleCategoryToggle = (category: NotificationCategory, channel: 'in_app' | 'email') => {
if (!preferences) return;
const categoryPrefs = { ...preferences.category_preferences };
categoryPrefs[category] = {
...categoryPrefs[category],
[channel]: !categoryPrefs[category][channel]
};
setLocalPreferences({
...preferences,
category_preferences: categoryPrefs
});
};
const handleSave = async () => {
if (!preferences) return;
setIsSaving(true);
try {
const response = await notificationService.updatePreferences(preferences);
dispatch(setPreferences(response.data));
showToast.success('Notification preferences updated successfully');
} catch (error) {
showToast.error('Failed to update preferences');
} finally {
setIsSaving(false);
}
};
if (isLoading || !preferences) {
return <div className="p-8 text-center text-gray-500">Loading preferences...</div>;
}
return (
<div className="max-w-4xl mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Notification Settings</h1>
<p className="text-sm text-gray-500 mt-1">Control how and when you want to be notified</p>
</div>
<Button
onClick={handleSave}
disabled={isSaving}
className="bg-blue-600 hover:bg-blue-700 text-white min-w-[120px]"
>
{isSaving ? 'Saving...' : (
<>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
{/* Master Toggles */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="border-none shadow-sm bg-blue-50/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600">
<Bell className="w-5 h-5" />
</div>
<div>
<p className="font-semibold text-gray-900">In-App Notifications</p>
<p className="text-xs text-gray-500">Receive real-time alerts in the platform</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={preferences.in_app_enabled}
onChange={() => handleToggle('in_app_enabled')}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-green-50/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
<Mail className="w-5 h-5" />
</div>
<div>
<p className="font-semibold text-gray-900">Email Notifications</p>
<p className="text-xs text-gray-500">Get updates delivered to your inbox</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={preferences.email_enabled}
onChange={() => handleToggle('email_enabled')}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-green-600"></div>
</label>
</div>
</CardContent>
</Card>
</div>
{/* Category Matrix */}
<Card className="border-gray-100 shadow-sm overflow-hidden">
<CardHeader className="bg-gray-50/50 border-b border-gray-100">
<CardTitle className="text-lg">Category Preferences</CardTitle>
<CardDescription>Select which business events you want to be notified about via each channel</CardDescription>
</CardHeader>
<CardContent className="p-0">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-100 text-xs font-semibold text-gray-400 uppercase tracking-wider bg-gray-50/30">
<th className="px-6 py-4">Category</th>
<th className="px-6 py-4 text-center">In-App</th>
<th className="px-6 py-4 text-center">Email</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{CATEGORIES.map((cat) => (
<tr key={cat.id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-6 py-4">
<p className="font-medium text-gray-900 text-sm">{cat.label}</p>
<p className="text-xs text-gray-500 mt-0.5">{cat.description}</p>
</td>
<td className="px-6 py-4 text-center">
<input
type="checkbox"
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
checked={preferences.category_preferences[cat.id]?.in_app ?? true}
onChange={() => handleCategoryToggle(cat.id, 'in_app')}
/>
</td>
<td className="px-6 py-4 text-center">
<input
type="checkbox"
className="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
checked={preferences.category_preferences[cat.id]?.email ?? true}
onChange={() => handleCategoryToggle(cat.id, 'email')}
/>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
{/* Advanced Settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Quiet Hours */}
<Card className="border-gray-100 shadow-sm">
<CardHeader>
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-purple-600" />
<CardTitle className="text-lg">Quiet Hours</CardTitle>
</div>
<CardDescription>Suppress non-urgent notifications during specific times</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between pb-4 border-b border-gray-50">
<span className="text-sm font-medium text-gray-900">Enable Quiet Hours</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={preferences.quiet_hours_enabled}
onChange={() => handleToggle('quiet_hours_enabled')}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
{preferences.quiet_hours_enabled && (
<div className="grid grid-cols-2 gap-4 animate-in slide-in-from-top-2 duration-300">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase">Start Time</label>
<input
type="time"
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-md text-sm outline-none focus:border-purple-400 transition-colors"
value={preferences.quiet_hours_start || ''}
onChange={(e) => setLocalPreferences({...preferences, quiet_hours_start: e.target.value})}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase">End Time</label>
<input
type="time"
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-md text-sm outline-none focus:border-purple-400 transition-colors"
value={preferences.quiet_hours_end || ''}
onChange={(e) => setLocalPreferences({...preferences, quiet_hours_end: e.target.value})}
/>
</div>
</div>
)}
<div className="flex items-start gap-2 pt-2 text-[11px] text-gray-500 italic">
<Info className="w-3.5 h-3.5 mt-0.5" />
<span>Urgent priority notifications bypass quiet hours and are always delivered.</span>
</div>
</CardContent>
</Card>
{/* Email Frequency & Timezone */}
<Card className="border-gray-100 shadow-sm">
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-amber-600" />
<CardTitle className="text-lg">Email Frequency</CardTitle>
</div>
<CardDescription>How often you want to receive email updates</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
{['instant', 'daily', 'weekly'].map((freq) => (
<label key={freq} className="flex items-center gap-3 p-3 border border-gray-100 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
<input
type="radio"
name="email_digest"
className="w-4 h-4 text-amber-600 border-gray-300 focus:ring-amber-500"
checked={preferences.email_digest === freq}
onChange={() => setLocalPreferences({...preferences, email_digest: freq as any})}
/>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900 capitalize">{freq}</p>
<p className="text-xs text-gray-500">
{freq === 'instant' ? 'Receive as soon as they happen' :
freq === 'daily' ? 'One daily digest at 8:00 AM' :
'One weekly summary every Monday'}
</p>
</div>
</label>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
};
export default NotificationSettings;

View File

@ -0,0 +1,414 @@
import { useState, useEffect, useMemo } from "react";
import { useAppDispatch, useAppSelector } from "@/hooks/redux-hooks";
import {
fetchNotifications,
markReadAsync,
readAllAsync,
} from "@/store/notificationSlice";
import {
Bell,
Search,
Check,
Trash2,
CheckCircle2,
AlertCircle,
AlertTriangle,
Info,
Clipboard,
FileText,
GraduationCap,
GitGraph,
Building2,
ExternalLink,
Clock,
} from "lucide-react";
import { formatDistanceToNow, isToday, isYesterday } from "date-fns";
import { cn } from "@/lib/utils";
import { useNavigate } from "react-router-dom";
import type {
Notification,
NotificationType,
NotificationCategory,
} from "@/types/notification";
import { Layout } from "@/components/layout/Layout";
import { Button } from "@/components/ui/button";
import { notificationService } from "@/services/notification-service";
import { showToast } from "@/utils/toast";
const getNotificationIcon = (
category?: NotificationCategory,
type?: NotificationType,
) => {
switch (category) {
case "capa":
return <Clipboard className="w-5 h-5" />;
case "document":
return <FileText className="w-5 h-5" />;
case "training":
return <GraduationCap className="w-5 h-5" />;
case "workflow":
return <GitGraph className="w-5 h-5" />;
case "supplier":
return <Building2 className="w-5 h-5" />;
case "system":
return <Bell className="w-5 h-5" />;
default:
switch (type) {
case "success":
return <CheckCircle2 className="w-5 h-5" />;
case "warning":
return <AlertTriangle className="w-5 h-5" />;
case "action_required":
return <AlertCircle className="w-5 h-5" />;
default:
return <Info className="w-5 h-5" />;
}
}
};
const getTypeColors = (type: NotificationType) => {
switch (type) {
case "success":
return "bg-green-100 text-green-600 border-green-200";
case "warning":
return "bg-orange-100 text-orange-600 border-orange-200";
case "action_required":
return "bg-amber-100 text-amber-600 border-amber-200";
case "escalation":
return "bg-red-100 text-red-600 border-red-200";
default:
return "bg-blue-100 text-blue-600 border-blue-200";
}
};
type FilterType = "all" | "unread" | "tasks" | "mentions" | "system";
export const Notifications = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { notifications, unread_count, isLoading } = useAppSelector(
(state) => state.notifications,
);
const [activeFilter, setActiveFilter] = useState<FilterType>("all");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
dispatch(fetchNotifications({ limit: 100 }));
}, [dispatch]);
const filteredNotifications = useMemo(() => {
return notifications.filter((n) => {
// Filter by tab
if (activeFilter === "unread" && n.is_read) return false;
if (
activeFilter === "tasks" &&
!["workflow", "training", "capa"].includes(n.category || "")
)
return false;
if (
activeFilter === "mentions" &&
n.notification_type !== "action_required"
)
return false; // Approximation
if (activeFilter === "system" && n.category !== "system") return false;
// Filter by search
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
n.title.toLowerCase().includes(query) ||
n.message.toLowerCase().includes(query)
);
}
return true;
});
}, [notifications, activeFilter, searchQuery]);
const counts = useMemo(
() => ({
all: notifications.length,
unread: unread_count,
tasks: notifications.filter((n) =>
["workflow", "training", "capa"].includes(n.category || ""),
).length,
mentions: notifications.filter(
(n) => n.notification_type === "action_required",
).length,
system: notifications.filter((n) => n.category === "system").length,
}),
[notifications, unread_count],
);
const handleMarkRead = (id: string) => {
dispatch(markReadAsync(id));
};
const handleReadAll = () => {
dispatch(readAllAsync());
};
const handleDismiss = async (id: string) => {
try {
await notificationService.dismiss(id);
dispatch(fetchNotifications({ limit: 100 }));
showToast.success("Notification dismissed");
} catch (error) {
showToast.error("Failed to dismiss notification");
}
};
const handleAction = (notification: Notification) => {
if (!notification.is_read) {
handleMarkRead(notification.id);
}
// Special handling for tasks as requested - redirect to My Tasks tab
if (["workflow", "training"].includes(notification.category || "")) {
navigate("/tenant/tasks");
return;
}
// Special handling for system as requested - redirect to Dashboard
if (["system"].includes(notification.category || "")) {
navigate("/tenant");
return;
}
if (notification.action_url) {
navigate(notification.action_url);
}
};
const formatDate = (date: string) => {
const d = new Date(date);
if (isToday(d)) return formatDistanceToNow(d, { addSuffix: true });
if (isYesterday(d)) return "Yesterday";
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
return (
<Layout
currentPage="Notifications"
breadcrumbs={[
{ label: "Home", path: "/tenant" },
{ label: "Notifications" },
]}
>
<div className="max-w-6xl mx-auto space-y-6">
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 tracking-tight">
Notifications
</h1>
<p className="text-gray-500 mt-1.5 flex items-center gap-2">
Stay on top of mentions, tasks, and system updates in one place.
</p>
</div>
{/* Filters and Tabs */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-gray-200">
<div className="flex items-center gap-1 overflow-x-auto no-scrollbar">
{(
["all", "unread", "tasks", "mentions", "system"] as FilterType[]
).map((filter) => (
<button
key={filter}
onClick={() => setActiveFilter(filter)}
className={cn(
"relative px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap flex items-center gap-2",
activeFilter === filter
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700",
)}
>
<span className="capitalize">{filter}</span>
{counts[filter] > 0 && (
<span
className={cn(
"flex items-center justify-center w-5 h-5 text-[10px] rounded-full",
activeFilter === filter
? "bg-blue-100 text-blue-600"
: "bg-gray-100 text-gray-500",
)}
>
{counts[filter]}
</span>
)}
</button>
))}
</div>
<div className="flex items-center gap-3 pb-2 md:pb-0">
<div className="relative group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="text"
placeholder="Search notifications..."
className="pl-9 pr-4 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50/50 outline-none focus:bg-white focus:ring-2 focus:ring-blue-100 focus:border-blue-400 transition-all w-full md:w-64"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{unread_count > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleReadAll}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
>
<Check className="w-4 h-4 mr-2" />
Mark all as read
</Button>
)}
</div>
</div>
{/* Content Area */}
<div className="space-y-4">
{isLoading ? (
<div className="py-20 text-center text-gray-400">
Loading notifications...
</div>
) : filteredNotifications.length === 0 ? (
<div className="py-24 flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center mb-4">
<Bell className="w-8 h-8 text-blue-200" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
No notifications found
</h3>
<p className="text-sm text-gray-500 mt-1 max-w-xs mx-auto">
{activeFilter === "unread"
? "You have caught up with all your notifications!"
: "We couldn't find any notifications matching your current filters."}
</p>
<Button
variant="outline"
className="mt-6"
onClick={() => {
setActiveFilter("all");
setSearchQuery("");
}}
>
Clear all filters
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-3">
{filteredNotifications.map((notification) => (
<div
key={notification.id}
className={cn(
"group relative overflow-hidden bg-white border rounded-xl transition-all duration-200 hover:shadow-md",
!notification.is_read
? "border-blue-100 shadow-[0_2px_8px_rgba(59,130,246,0.05)]"
: "border-gray-100 shadow-sm",
)}
>
{/* Unread indicator bar */}
{!notification.is_read && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-blue-500" />
)}
<div className="p-4 md:p-5 flex gap-4">
{/* Icon */}
<div
className={cn(
"flex-shrink-0 w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center border",
getTypeColors(notification.notification_type),
)}
>
{getNotificationIcon(
notification.category,
notification.notification_type,
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-1 mb-1">
<div className="flex items-center gap-2">
<h4
className={cn(
"text-sm md:text-base font-semibold text-gray-900 truncate",
!notification.is_read && "text-blue-900",
)}
>
{notification.title}
</h4>
{notification.priority === "urgent" && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-600 text-[10px] font-bold rounded uppercase tracking-wider">
Urgent
</span>
)}
</div>
<span className="text-xs text-gray-400 font-medium">
{formatDate(notification.created_at)}
</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed max-w-3xl">
{notification.message}
</p>
{notification.entity_name && (
<div className="mt-3 flex items-center gap-2 text-[11px] font-medium text-gray-400">
<span className="px-1.5 py-0.5 bg-gray-50 border border-gray-100 rounded text-gray-500">
{notification.entity_type?.replace(/_/g, " ")}
</span>
<span className="px-1.5 py-0.5 bg-gray-50 border border-gray-100 rounded text-gray-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{notification.entity_name}
</span>
</div>
)}
{/* Actions */}
<div className="mt-4 pt-4 border-t border-gray-50 flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
size="sm"
className="bg-[#112868] hover:bg-[#0a1b4d] text-white"
onClick={() => handleAction(notification)}
>
<ExternalLink className="w-3.5 h-3.5 mr-2" />
{notification.category === "training"
? "View Assignment"
: notification.category === "capa"
? "View CAPA"
: notification.category === "workflow"
? "View Task"
: "View Details"}
</Button>
{!notification.is_read && (
<button
onClick={() => handleMarkRead(notification.id)}
className="text-xs font-medium text-gray-500 hover:text-blue-600 transition-colors flex items-center"
>
<Check className="w-3.5 h-3.5 mr-1" />
Mark as read
</button>
)}
</div>
<button
onClick={() => handleDismiss(notification.id)}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</Layout>
);
};
export default Notifications;

View File

@ -12,6 +12,8 @@ const TenantDetails = lazy(() => import("@/pages/superadmin/TenantDetails"));
const Modules = lazy(() => import("@/pages/superadmin/Modules")); const Modules = lazy(() => import("@/pages/superadmin/Modules"));
const AuditLogs = lazy(() => import("@/pages/superadmin/AuditLogs")); const AuditLogs = lazy(() => import("@/pages/superadmin/AuditLogs"));
const Suppliers = lazy(() => import("@/pages/superadmin/Suppliers")); const Suppliers = lazy(() => import("@/pages/superadmin/Suppliers"));
const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
// Loading fallback component // Loading fallback component
const RouteLoader = (): ReactElement => ( const RouteLoader = (): ReactElement => (
@ -70,4 +72,12 @@ export const superAdminRoutes: RouteConfig[] = [
path: "/suppliers", path: "/suppliers",
element: <LazyRoute component={Suppliers} />, element: <LazyRoute component={Suppliers} />,
}, },
{
path: "/settings/notifications",
element: <LazyRoute component={NotificationSettings} />,
},
{
path: "/notifications",
element: <LazyRoute component={Notifications} />,
},
]; ];

View File

@ -21,6 +21,8 @@ const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument"));
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories")); const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForReview")); const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForReview"));
const Tasks = lazy(() => import("@/pages/tenant/Tasks")); const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
// Loading fallback component // Loading fallback component
const RouteLoader = (): ReactElement => ( const RouteLoader = (): ReactElement => (
@ -115,4 +117,12 @@ export const tenantAdminRoutes: RouteConfig[] = [
path: "/tenant/tasks", path: "/tenant/tasks",
element: <LazyRoute component={Tasks} />, element: <LazyRoute component={Tasks} />,
}, },
{
path: "/tenant/settings/notifications",
element: <LazyRoute component={NotificationSettings} />,
},
{
path: "/tenant/notifications",
element: <LazyRoute component={Notifications} />,
},
]; ];

View File

@ -0,0 +1,64 @@
import apiClient from './api-client';
import type { Notification, NotificationPreferences } from '@/types/notification';
export interface NotificationResponse<T> {
success: boolean;
data: T;
}
export interface GetNotificationsParams {
is_read?: boolean;
category?: string;
limit?: number;
offset?: number;
}
export const notificationService = {
// GET /notifications/me
getNotifications: async (params?: GetNotificationsParams): Promise<NotificationResponse<Notification[]>> => {
const response = await apiClient.get('/notifications/me', { params });
return response.data;
},
// GET /notifications/me/unread-count
getUnreadCount: async (): Promise<NotificationResponse<{ unread_count: number }>> => {
const response = await apiClient.get('/notifications/me/unread-count');
return response.data;
},
// PUT /notifications/me/read-all
readAll: async (): Promise<NotificationResponse<{ marked_count: number }>> => {
const response = await apiClient.put('/notifications/me/read-all');
return response.data;
},
// PUT /notifications/me/dismiss-all
dismissAll: async (): Promise<NotificationResponse<{ dismissed_count: number }>> => {
const response = await apiClient.put('/notifications/me/dismiss-all');
return response.data;
},
// PUT /notifications/:id/read
markAsRead: async (id: string): Promise<NotificationResponse<Notification>> => {
const response = await apiClient.put(`/notifications/${id}/read`);
return response.data;
},
// PUT /notifications/:id/dismiss
dismiss: async (id: string): Promise<NotificationResponse<void>> => {
const response = await apiClient.put(`/notifications/${id}/dismiss`);
return response.data;
},
// GET /notifications/preferences
getPreferences: async (): Promise<NotificationResponse<NotificationPreferences>> => {
const response = await apiClient.get('/notifications/preferences');
return response.data;
},
// PUT /notifications/preferences
updatePreferences: async (preferences: Partial<NotificationPreferences>): Promise<NotificationResponse<NotificationPreferences>> => {
const response = await apiClient.put('/notifications/preferences', preferences);
return response.data;
},
};

View File

@ -0,0 +1,127 @@
import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit';
import type { Notification, NotificationPreferences, NotificationState } from '@/types/notification';
import { notificationService } from '@/services/notification-service';
const initialState: NotificationState = {
notifications: [],
unread_count: 0,
preferences: null,
isLoading: false,
error: null,
};
// Async thunks
export const fetchNotifications = createAsyncThunk(
'notifications/fetchNotifications',
async (params: any = {}, { rejectWithValue }) => {
try {
const response = await notificationService.getNotifications(params);
return response.data;
} catch (error: any) {
return rejectWithValue(error.response?.data?.message || 'Failed to fetch notifications');
}
}
);
export const fetchUnreadCount = createAsyncThunk(
'notifications/fetchUnreadCount',
async (_, { rejectWithValue }) => {
try {
const response = await notificationService.getUnreadCount();
return response.data;
} catch (error: any) {
return rejectWithValue(error.response?.data?.message || 'Failed to fetch unread count');
}
}
);
export const markReadAsync = createAsyncThunk(
'notifications/markRead',
async (id: string, { rejectWithValue }) => {
try {
const response = await notificationService.markAsRead(id);
return response.data;
} catch (error: any) {
return rejectWithValue(error.response?.data?.message || 'Failed to mark notification as read');
}
}
);
export const readAllAsync = createAsyncThunk(
'notifications/readAll',
async (_, { rejectWithValue }) => {
try {
const response = await notificationService.readAll();
return response.data;
} catch (error: any) {
return rejectWithValue(error.response?.data?.message || 'Failed to mark all as read');
}
}
);
const notificationSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
addNotification: (state, action: PayloadAction<Notification>) => {
// Add to notifications if not already present (avoid duplicates from WebSocket/initial load overlap)
if (!state.notifications.find((n) => n.id === action.payload.id)) {
state.notifications = [action.payload, ...state.notifications];
}
},
setUnreadCount: (state, action: PayloadAction<number>) => {
state.unread_count = action.payload;
},
markReadLocal: (state, action: PayloadAction<string>) => {
const notification = state.notifications.find((n) => n.id === action.payload);
if (notification && !notification.is_read) {
notification.is_read = true;
state.unread_count = Math.max(0, state.unread_count - 1);
}
},
dismissLocal: (state, action: PayloadAction<string>) => {
const index = state.notifications.findIndex((n) => n.id === action.payload);
if (index !== -1) {
if (!state.notifications[index].is_read) {
state.unread_count = Math.max(0, state.unread_count - 1);
}
state.notifications.splice(index, 1);
}
},
setPreferences: (state, action: PayloadAction<NotificationPreferences>) => {
state.preferences = action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchNotifications.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchNotifications.fulfilled, (state, action) => {
state.isLoading = false;
state.notifications = action.payload;
})
.addCase(fetchNotifications.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
})
.addCase(fetchUnreadCount.fulfilled, (state, action) => {
state.unread_count = action.payload.unread_count;
})
.addCase(markReadAsync.fulfilled, (state, action) => {
const index = state.notifications.findIndex((n) => n.id === action.payload.id);
if (index !== -1 && !state.notifications[index].is_read) {
state.notifications[index].is_read = true;
state.unread_count = Math.max(0, state.unread_count - 1);
}
})
.addCase(readAllAsync.fulfilled, (state) => {
state.notifications.forEach((n) => (n.is_read = true));
state.unread_count = 0;
});
},
});
export const { addNotification, setUnreadCount, markReadLocal, dismissLocal, setPreferences } = notificationSlice.actions;
export default notificationSlice.reducer;

View File

@ -3,6 +3,7 @@ import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage'; import storage from 'redux-persist/lib/storage';
import authReducer from './authSlice'; import authReducer from './authSlice';
import themeReducer from './themeSlice'; import themeReducer from './themeSlice';
import notificationReducer from './notificationSlice';
// Persist config for auth slice only // Persist config for auth slice only
const authPersistConfig = { const authPersistConfig = {
@ -17,6 +18,7 @@ export const store = configureStore({
reducer: { reducer: {
auth: persistedAuthReducer, auth: persistedAuthReducer,
theme: themeReducer, theme: themeReducer,
notifications: notificationReducer,
}, },
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ getDefaultMiddleware({

49
src/types/notification.ts Normal file
View File

@ -0,0 +1,49 @@
export type NotificationType = 'action_required' | 'info' | 'success' | 'warning' | 'escalation';
export type NotificationCategory = 'capa' | 'document' | 'training' | 'workflow' | 'supplier' | 'system';
export type NotificationPriority = 'normal' | 'high' | 'urgent';
export interface Notification {
id: string;
tenant_id: string;
user_id: string;
title: string;
message: string;
notification_type: NotificationType;
category?: NotificationCategory;
priority: NotificationPriority;
entity_type?: string;
entity_id?: string;
entity_name?: string;
action_url?: string;
is_read: boolean;
read_at?: string;
is_dismissed: boolean;
dismissed_at?: string;
channels_sent: string[];
expires_at?: string;
created_at: string;
}
export interface CategoryPreference {
in_app: boolean;
email: boolean;
}
export interface NotificationPreferences {
in_app_enabled: boolean;
email_enabled: boolean;
email_digest: 'instant' | 'daily' | 'weekly';
category_preferences: Record<NotificationCategory, CategoryPreference>;
quiet_hours_enabled: boolean;
quiet_hours_start: string | null;
quiet_hours_end: string | null;
timezone: string;
}
export interface NotificationState {
notifications: Notification[];
unread_count: number;
preferences: NotificationPreferences | null;
isLoading: boolean;
error: string | null;
}

View File

@ -1,30 +1,39 @@
import { toast } from 'sonner'; import { toast } from 'sonner';
interface ToastAction {
label: string;
onClick: () => void;
}
/** /**
* Reusable toast utility for showing success and error messages * Reusable toast utility for showing success and error messages
*/ */
export const showToast = { export const showToast = {
success: (message: string, description?: string) => { success: (message: string, description?: string, action?: ToastAction) => {
toast.success(message, { toast.success(message, {
description, description,
action,
duration: 3000, duration: 3000,
}); });
}, },
error: (message: string, description?: string) => { error: (message: string, description?: string, action?: ToastAction) => {
toast.error(message, { toast.error(message, {
description, description,
action,
duration: 4000, duration: 4000,
}); });
}, },
info: (message: string, description?: string) => { info: (message: string, description?: string, action?: ToastAction) => {
toast.info(message, { toast.info(message, {
description, description,
action,
duration: 3000, duration: 3000,
}); });
}, },
warning: (message: string, description?: string) => { warning: (message: string, description?: string, action?: ToastAction) => {
toast.warning(message, { toast.warning(message, {
description, description,
action,
duration: 3000, duration: 3000,
}); });
}, },