From c2e6d779d41bac5b1b29c8018e91199f6de343b2 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Fri, 3 Apr 2026 20:13:48 +0530 Subject: [PATCH] feat: implement real-time notification system with Socket.io, Redux state management, and UI components --- package-lock.json | 99 ++++- package.json | 2 + src/App.tsx | 7 +- src/components/layout/Header.tsx | 12 +- src/components/layout/Sidebar.tsx | 8 + src/components/shared/NotificationBell.tsx | 253 +++++++++++ .../shared/NotificationProvider.tsx | 136 ++++++ src/pages/superadmin/CreateTenantWizard.tsx | 2 +- src/pages/tenant/NotificationSettings.tsx | 298 +++++++++++++ src/pages/tenant/Notifications.tsx | 414 ++++++++++++++++++ src/routes/super-admin-routes.tsx | 10 + src/routes/tenant-admin-routes.tsx | 10 + src/services/notification-service.ts | 64 +++ src/store/notificationSlice.ts | 127 ++++++ src/store/store.ts | 2 + src/types/notification.ts | 49 +++ src/utils/toast.ts | 17 +- 17 files changed, 1492 insertions(+), 18 deletions(-) create mode 100644 src/components/shared/NotificationBell.tsx create mode 100644 src/components/shared/NotificationProvider.tsx create mode 100644 src/pages/tenant/NotificationSettings.tsx create mode 100644 src/pages/tenant/Notifications.tsx create mode 100644 src/services/notification-service.ts create mode 100644 src/store/notificationSlice.ts create mode 100644 src/types/notification.ts 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 ( +
+ + + {isOpen && ( +
+ {/* Header */} +
+

Notifications

+
+ {unread_count > 0 && ( + + )} + +
+
+ + {/* 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 */} +
+ +
+ + {/* Unread indicator dot */} + {!notification.is_read && ( +
+ )} +
+ ))} +
+ )} +
+ + {/* Footer */} +
+ + +
+
+ )} +
+ ); +}; 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

+
+ +
+ + {/* Master Toggles */} +
+ + +
+
+
+ +
+
+

In-App Notifications

+

Receive real-time alerts in the platform

+
+
+ +
+
+
+ + + +
+
+
+ +
+
+

Email Notifications

+

Get updates delivered to your inbox

+
+
+ +
+
+
+
+ + {/* Category Matrix */} + + + Category Preferences + Select which business events you want to be notified about via each channel + + + + + + + + + + + + {CATEGORIES.map((cat) => ( + + + + + + ))} + +
CategoryIn-AppEmail
+

{cat.label}

+

{cat.description}

+
+ handleCategoryToggle(cat.id, 'in_app')} + /> + + handleCategoryToggle(cat.id, 'email')} + /> +
+
+
+ + {/* Advanced Settings */} +
+ {/* Quiet Hours */} + + +
+ + Quiet Hours +
+ Suppress non-urgent notifications during specific times +
+ +
+ Enable Quiet Hours + +
+ + {preferences.quiet_hours_enabled && ( +
+
+ + setLocalPreferences({...preferences, quiet_hours_start: e.target.value})} + /> +
+
+ + setLocalPreferences({...preferences, quiet_hours_end: e.target.value})} + /> +
+
+ )} +
+ + 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) => ( + + ))} +
+
+
+
+
+ ); +}; + +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) => ( + + ))} +
+ +
+
+ + setSearchQuery(e.target.value)} + /> +
+ {unread_count > 0 && ( + + )} +
+
+ + {/* 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."} +

+ +
+ ) : ( +
+ {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 */} +
+
+ + {!notification.is_read && ( + + )} +
+ +
+
+
+
+ ))} +
+ )} +
+
+ + ); +}; + +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, }); },