diff --git a/package-lock.json b/package-lock.json
index 27a5cbf..f5a0eff 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -33,6 +34,7 @@
"react-router-dom": "^7.12.0",
"recharts": "^3.6.0",
"redux-persist": "^6.0.0",
+ "socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
@@ -1443,6 +1445,12 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -3121,11 +3129,20 @@
"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": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3191,6 +3208,28 @@
"dev": true,
"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": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
@@ -4409,7 +4448,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -5099,6 +5137,34 @@
"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": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@@ -5460,6 +5526,35 @@
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index 0af1894..6cbcc11 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -35,6 +36,7 @@
"react-router-dom": "^7.12.0",
"recharts": "^3.6.0",
"redux-persist": "^6.0.0",
+ "socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
diff --git a/src/App.tsx b/src/App.tsx
index 8bf63cc..a6c65b1 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,12 +1,15 @@
import { BrowserRouter } from "react-router-dom";
import { Toaster } from "sonner";
import { AppRoutes } from "@/routes";
+import { NotificationProvider } from "@/components/shared/NotificationProvider";
function App() {
return (
-
-
+
+
+
+
);
}
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index afd62c5..f1e0e86 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -1,11 +1,12 @@
import { useState, useRef, useEffect } from 'react';
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 { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
import { logoutAsync } from '@/store/authSlice';
import { cn } from '@/lib/utils';
import { showToast } from '@/utils/toast';
+import { NotificationBell } from '@/components/shared/NotificationBell';
import type { ReactElement } from 'react';
interface HeaderProps {
@@ -162,14 +163,7 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
>
-
-
-
+
{/* Desktop User Dropdown */}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index 8d5a11a..b43d1d9 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -15,6 +15,7 @@ import {
Briefcase,
ChevronDown,
ChevronRight,
+ Bell,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -56,6 +57,7 @@ const superAdminPlatformMenu: MenuItem[] = [
];
const superAdminSystemMenu: MenuItem[] = [
+ { icon: Bell, label: "Notifications", path: "/notifications" },
{ icon: FileText, label: "Audit Logs", path: "/audit-logs" },
// { icon: Settings, label: 'Settings', path: '/settings' },
];
@@ -121,6 +123,12 @@ const tenantAdminPlatformMenu: MenuItem[] = [
];
const tenantAdminSystemMenu: MenuItem[] = [
+ {
+ icon: Bell,
+ label: "Notifications",
+ path: "/tenant/notifications",
+ requiredPermission: { resource: "notifications" },
+ },
{
icon: FileText,
label: "Audit Logs",
diff --git a/src/components/shared/NotificationBell.tsx b/src/components/shared/NotificationBell.tsx
new file mode 100644
index 0000000..0ff1b75
--- /dev/null
+++ b/src/components/shared/NotificationBell.tsx
@@ -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
;
+ case 'document': return
;
+ case 'training': return
;
+ case 'workflow': return
;
+ case 'supplier': return
;
+ case 'system': return
;
+ default:
+ switch (type) {
+ case 'success': return
;
+ case 'warning': return
;
+ case 'action_required': return
;
+ default: return
;
+ }
+ }
+};
+
+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
(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 (
+
+
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"
+ >
+
+ {unread_count > 0 && (
+
+ {unread_count > 99 ? '99+' : unread_count}
+
+ )}
+
+
+ {isOpen && (
+
+ {/* Header */}
+
+
Notifications
+
+ {unread_count > 0 && (
+
+ Mark all as read
+
+ )}
+
+
+
+
+
+
+ {/* List */}
+
+ {notifications.length === 0 ? (
+
+
+
+
+
All caught up!
+
No notifications to show right now.
+
+ ) : (
+
+ {notifications.map((notification) => (
+
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"
+ )}
+ >
+
+ {getNotificationIcon(notification.category, notification.notification_type)}
+
+
+
+
+
+ {notification.title}
+
+
+ {formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
+
+
+
+ {notification.message}
+
+ {notification.entity_name && (
+
+
+ {notification.entity_type?.replace('_', ' ')}
+
+ {notification.entity_name}
+
+ )}
+
+
+ {/* Quick Actions */}
+
+ 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"
+ >
+
+
+
+
+ {/* Unread indicator dot */}
+ {!notification.is_read && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+ {/* Footer */}
+
+ {
+ 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
+
+ {
+ 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
+
+
+
+ )}
+
+ );
+};
diff --git a/src/components/shared/NotificationProvider.tsx b/src/components/shared/NotificationProvider.tsx
new file mode 100644
index 0000000..3439498
--- /dev/null
+++ b/src/components/shared/NotificationProvider.tsx
@@ -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({ 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(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 (
+
+ {children}
+
+ );
+};
diff --git a/src/pages/superadmin/CreateTenantWizard.tsx b/src/pages/superadmin/CreateTenantWizard.tsx
index 113ec43..81e86f5 100644
--- a/src/pages/superadmin/CreateTenantWizard.tsx
+++ b/src/pages/superadmin/CreateTenantWizard.tsx
@@ -123,7 +123,7 @@ const subscriptionTierOptions = [
// Helper function to get base URL with protocol
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 => {
diff --git a/src/pages/tenant/NotificationSettings.tsx b/src/pages/tenant/NotificationSettings.tsx
new file mode 100644
index 0000000..b2b0ea5
--- /dev/null
+++ b/src/pages/tenant/NotificationSettings.tsx
@@ -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(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 Loading preferences...
;
+ }
+
+ return (
+
+
+
+
Notification Settings
+
Control how and when you want to be notified
+
+
+ {isSaving ? 'Saving...' : (
+ <>
+
+ Save Changes
+ >
+ )}
+
+
+
+ {/* Master Toggles */}
+
+
+
+
+
+
+
+
+
+
In-App Notifications
+
Receive real-time alerts in the platform
+
+
+
+ handleToggle('in_app_enabled')}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Email Notifications
+
Get updates delivered to your inbox
+
+
+
+ handleToggle('email_enabled')}
+ />
+
+
+
+
+
+
+
+ {/* Category Matrix */}
+
+
+ Category Preferences
+ Select which business events you want to be notified about via each channel
+
+
+
+
+
+
+ {/* Advanced Settings */}
+
+ {/* Quiet Hours */}
+
+
+
+
+ Quiet Hours
+
+ Suppress non-urgent notifications during specific times
+
+
+
+
Enable Quiet Hours
+
+ handleToggle('quiet_hours_enabled')}
+ />
+
+
+
+
+ {preferences.quiet_hours_enabled && (
+
+ )}
+
+
+ Urgent priority notifications bypass quiet hours and are always delivered.
+
+
+
+
+ {/* Email Frequency & Timezone */}
+
+
+
+
+ Email Frequency
+
+ How often you want to receive email updates
+
+
+
+ {['instant', 'daily', 'weekly'].map((freq) => (
+
+ setLocalPreferences({...preferences, email_digest: freq as any})}
+ />
+
+
{freq}
+
+ {freq === 'instant' ? 'Receive as soon as they happen' :
+ freq === 'daily' ? 'One daily digest at 8:00 AM' :
+ 'One weekly summary every Monday'}
+
+
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export default NotificationSettings;
diff --git a/src/pages/tenant/Notifications.tsx b/src/pages/tenant/Notifications.tsx
new file mode 100644
index 0000000..1444782
--- /dev/null
+++ b/src/pages/tenant/Notifications.tsx
@@ -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 ;
+ case "document":
+ return ;
+ case "training":
+ return ;
+ case "workflow":
+ return ;
+ case "supplier":
+ return ;
+ case "system":
+ return ;
+ default:
+ switch (type) {
+ case "success":
+ return ;
+ case "warning":
+ return ;
+ case "action_required":
+ return ;
+ default:
+ return ;
+ }
+ }
+};
+
+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("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 (
+
+
+
+
+ Notifications
+
+
+ Stay on top of mentions, tasks, and system updates in one place.
+
+
+
+ {/* Filters and Tabs */}
+
+
+ {(
+ ["all", "unread", "tasks", "mentions", "system"] as FilterType[]
+ ).map((filter) => (
+ 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",
+ )}
+ >
+ {filter}
+ {counts[filter] > 0 && (
+
+ {counts[filter]}
+
+ )}
+
+ ))}
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ />
+
+ {unread_count > 0 && (
+
+
+ Mark all as read
+
+ )}
+
+
+
+ {/* Content Area */}
+
+ {isLoading ? (
+
+ Loading notifications...
+
+ ) : filteredNotifications.length === 0 ? (
+
+
+
+
+
+ No notifications found
+
+
+ {activeFilter === "unread"
+ ? "You have caught up with all your notifications!"
+ : "We couldn't find any notifications matching your current filters."}
+
+
{
+ setActiveFilter("all");
+ setSearchQuery("");
+ }}
+ >
+ Clear all filters
+
+
+ ) : (
+
+ {filteredNotifications.map((notification) => (
+
+ {/* Unread indicator bar */}
+ {!notification.is_read && (
+
+ )}
+
+
+ {/* Icon */}
+
+ {getNotificationIcon(
+ notification.category,
+ notification.notification_type,
+ )}
+
+
+
+
+
+
+ {notification.title}
+
+ {notification.priority === "urgent" && (
+
+ Urgent
+
+ )}
+
+
+ {formatDate(notification.created_at)}
+
+
+
+
+ {notification.message}
+
+
+ {notification.entity_name && (
+
+
+ {notification.entity_type?.replace(/_/g, " ")}
+
+
+
+ {notification.entity_name}
+
+
+ )}
+
+ {/* Actions */}
+
+
+ handleAction(notification)}
+ >
+
+ {notification.category === "training"
+ ? "View Assignment"
+ : notification.category === "capa"
+ ? "View CAPA"
+ : notification.category === "workflow"
+ ? "View Task"
+ : "View Details"}
+
+ {!notification.is_read && (
+ handleMarkRead(notification.id)}
+ className="text-xs font-medium text-gray-500 hover:text-blue-600 transition-colors flex items-center"
+ >
+
+ Mark as read
+
+ )}
+
+
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"
+ >
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+export default Notifications;
diff --git a/src/routes/super-admin-routes.tsx b/src/routes/super-admin-routes.tsx
index 3ebff87..3fb7931 100644
--- a/src/routes/super-admin-routes.tsx
+++ b/src/routes/super-admin-routes.tsx
@@ -12,6 +12,8 @@ const TenantDetails = lazy(() => import("@/pages/superadmin/TenantDetails"));
const Modules = lazy(() => import("@/pages/superadmin/Modules"));
const AuditLogs = lazy(() => import("@/pages/superadmin/AuditLogs"));
const Suppliers = lazy(() => import("@/pages/superadmin/Suppliers"));
+const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
+const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
// Loading fallback component
const RouteLoader = (): ReactElement => (
@@ -70,4 +72,12 @@ export const superAdminRoutes: RouteConfig[] = [
path: "/suppliers",
element: ,
},
+ {
+ path: "/settings/notifications",
+ element: ,
+ },
+ {
+ path: "/notifications",
+ element: ,
+ },
];
diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx
index 5055118..1bec2fe 100644
--- a/src/routes/tenant-admin-routes.tsx
+++ b/src/routes/tenant-admin-routes.tsx
@@ -21,6 +21,8 @@ const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument"));
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForReview"));
const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
+const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
+const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
// Loading fallback component
const RouteLoader = (): ReactElement => (
@@ -115,4 +117,12 @@ export const tenantAdminRoutes: RouteConfig[] = [
path: "/tenant/tasks",
element: ,
},
+ {
+ path: "/tenant/settings/notifications",
+ element: ,
+ },
+ {
+ path: "/tenant/notifications",
+ element: ,
+ },
];
diff --git a/src/services/notification-service.ts b/src/services/notification-service.ts
new file mode 100644
index 0000000..ff27a6b
--- /dev/null
+++ b/src/services/notification-service.ts
@@ -0,0 +1,64 @@
+import apiClient from './api-client';
+import type { Notification, NotificationPreferences } from '@/types/notification';
+
+export interface NotificationResponse {
+ 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> => {
+ const response = await apiClient.get('/notifications/me', { params });
+ return response.data;
+ },
+
+ // GET /notifications/me/unread-count
+ getUnreadCount: async (): Promise> => {
+ const response = await apiClient.get('/notifications/me/unread-count');
+ return response.data;
+ },
+
+ // PUT /notifications/me/read-all
+ readAll: async (): Promise> => {
+ const response = await apiClient.put('/notifications/me/read-all');
+ return response.data;
+ },
+
+ // PUT /notifications/me/dismiss-all
+ dismissAll: async (): Promise> => {
+ const response = await apiClient.put('/notifications/me/dismiss-all');
+ return response.data;
+ },
+
+ // PUT /notifications/:id/read
+ markAsRead: async (id: string): Promise> => {
+ const response = await apiClient.put(`/notifications/${id}/read`);
+ return response.data;
+ },
+
+ // PUT /notifications/:id/dismiss
+ dismiss: async (id: string): Promise> => {
+ const response = await apiClient.put(`/notifications/${id}/dismiss`);
+ return response.data;
+ },
+
+ // GET /notifications/preferences
+ getPreferences: async (): Promise> => {
+ const response = await apiClient.get('/notifications/preferences');
+ return response.data;
+ },
+
+ // PUT /notifications/preferences
+ updatePreferences: async (preferences: Partial): Promise> => {
+ const response = await apiClient.put('/notifications/preferences', preferences);
+ return response.data;
+ },
+};
diff --git a/src/store/notificationSlice.ts b/src/store/notificationSlice.ts
new file mode 100644
index 0000000..bb94ef5
--- /dev/null
+++ b/src/store/notificationSlice.ts
@@ -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) => {
+ // 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) => {
+ state.unread_count = action.payload;
+ },
+ markReadLocal: (state, action: PayloadAction) => {
+ 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) => {
+ 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) => {
+ 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;
diff --git a/src/store/store.ts b/src/store/store.ts
index c76566a..605631d 100644
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@ -3,6 +3,7 @@ import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import authReducer from './authSlice';
import themeReducer from './themeSlice';
+import notificationReducer from './notificationSlice';
// Persist config for auth slice only
const authPersistConfig = {
@@ -17,6 +18,7 @@ export const store = configureStore({
reducer: {
auth: persistedAuthReducer,
theme: themeReducer,
+ notifications: notificationReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
diff --git a/src/types/notification.ts b/src/types/notification.ts
new file mode 100644
index 0000000..0cd2791
--- /dev/null
+++ b/src/types/notification.ts
@@ -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;
+ 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;
+}
diff --git a/src/utils/toast.ts b/src/utils/toast.ts
index ac2eb08..b88fd2e 100644
--- a/src/utils/toast.ts
+++ b/src/utils/toast.ts
@@ -1,30 +1,39 @@
import { toast } from 'sonner';
+interface ToastAction {
+ label: string;
+ onClick: () => void;
+}
+
/**
* Reusable toast utility for showing success and error messages
*/
export const showToast = {
- success: (message: string, description?: string) => {
+ success: (message: string, description?: string, action?: ToastAction) => {
toast.success(message, {
description,
+ action,
duration: 3000,
});
},
- error: (message: string, description?: string) => {
+ error: (message: string, description?: string, action?: ToastAction) => {
toast.error(message, {
description,
+ action,
duration: 4000,
});
},
- info: (message: string, description?: string) => {
+ info: (message: string, description?: string, action?: ToastAction) => {
toast.info(message, {
description,
+ action,
duration: 3000,
});
},
- warning: (message: string, description?: string) => {
+ warning: (message: string, description?: string, action?: ToastAction) => {
toast.warning(message, {
description,
+ action,
duration: 3000,
});
},