feat: implement real-time notification system with Socket.io, Redux state management, and UI components
This commit is contained in:
parent
b6102d0b31
commit
c2e6d779d4
99
package-lock.json
generated
99
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 (
|
||||
<BrowserRouter>
|
||||
<Toaster position="top-right" richColors />
|
||||
<AppRoutes />
|
||||
<NotificationProvider>
|
||||
<Toaster position="top-right" richColors />
|
||||
<AppRoutes />
|
||||
</NotificationProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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):
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
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>
|
||||
<NotificationBell />
|
||||
|
||||
{/* Desktop User Dropdown */}
|
||||
<div className="hidden md:block relative" ref={dropdownRef}>
|
||||
|
||||
@ -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",
|
||||
|
||||
253
src/components/shared/NotificationBell.tsx
Normal file
253
src/components/shared/NotificationBell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
136
src/components/shared/NotificationProvider.tsx
Normal file
136
src/components/shared/NotificationProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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 => {
|
||||
|
||||
298
src/pages/tenant/NotificationSettings.tsx
Normal file
298
src/pages/tenant/NotificationSettings.tsx
Normal 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;
|
||||
414
src/pages/tenant/Notifications.tsx
Normal file
414
src/pages/tenant/Notifications.tsx
Normal 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;
|
||||
@ -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: <LazyRoute component={Suppliers} />,
|
||||
},
|
||||
{
|
||||
path: "/settings/notifications",
|
||||
element: <LazyRoute component={NotificationSettings} />,
|
||||
},
|
||||
{
|
||||
path: "/notifications",
|
||||
element: <LazyRoute component={Notifications} />,
|
||||
},
|
||||
];
|
||||
|
||||
@ -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: <LazyRoute component={Tasks} />,
|
||||
},
|
||||
{
|
||||
path: "/tenant/settings/notifications",
|
||||
element: <LazyRoute component={NotificationSettings} />,
|
||||
},
|
||||
{
|
||||
path: "/tenant/notifications",
|
||||
element: <LazyRoute component={Notifications} />,
|
||||
},
|
||||
];
|
||||
|
||||
64
src/services/notification-service.ts
Normal file
64
src/services/notification-service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
127
src/store/notificationSlice.ts
Normal file
127
src/store/notificationSlice.ts
Normal 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;
|
||||
@ -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({
|
||||
|
||||
49
src/types/notification.ts
Normal file
49
src/types/notification.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user