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",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@ -33,6 +34,7 @@
|
|||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"redux-persist": "^6.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
@ -1443,6 +1445,12 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@ -3121,11 +3129,20 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@ -3191,6 +3208,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io-client": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.18.3",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.19.0",
|
"version": "5.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
||||||
@ -4409,7 +4448,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@ -5099,6 +5137,34 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sonner": {
|
"node_modules/sonner": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||||
@ -5460,6 +5526,35 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@ -35,6 +36,7 @@
|
|||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"redux-persist": "^6.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { AppRoutes } from "@/routes";
|
import { AppRoutes } from "@/routes";
|
||||||
|
import { NotificationProvider } from "@/components/shared/NotificationProvider";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<NotificationProvider>
|
||||||
<Toaster position="top-right" richColors />
|
<Toaster position="top-right" richColors />
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
</NotificationProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ChevronRight, Search, Bell, ChevronDown, Menu, LogOut, User } from 'lucide-react';
|
import { Search, ChevronDown, Menu, LogOut, User, ChevronRight } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
||||||
import { logoutAsync } from '@/store/authSlice';
|
import { logoutAsync } from '@/store/authSlice';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { showToast } from '@/utils/toast';
|
import { showToast } from '@/utils/toast';
|
||||||
|
import { NotificationBell } from '@/components/shared/NotificationBell';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@ -162,14 +163,7 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
|||||||
>
|
>
|
||||||
<Search className="w-4 h-4" />
|
<Search className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<NotificationBell />
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="w-8 h-8 md:w-8 md:h-8 rounded-full border border-[rgba(0,0,0,0.08)] min-h-[44px] min-w-[44px]"
|
|
||||||
aria-label="Notifications"
|
|
||||||
>
|
|
||||||
<Bell className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Desktop User Dropdown */}
|
{/* Desktop User Dropdown */}
|
||||||
<div className="hidden md:block relative" ref={dropdownRef}>
|
<div className="hidden md:block relative" ref={dropdownRef}>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
Briefcase,
|
Briefcase,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Bell,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -56,6 +57,7 @@ const superAdminPlatformMenu: MenuItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const superAdminSystemMenu: MenuItem[] = [
|
const superAdminSystemMenu: MenuItem[] = [
|
||||||
|
{ icon: Bell, label: "Notifications", path: "/notifications" },
|
||||||
{ icon: FileText, label: "Audit Logs", path: "/audit-logs" },
|
{ icon: FileText, label: "Audit Logs", path: "/audit-logs" },
|
||||||
// { icon: Settings, label: 'Settings', path: '/settings' },
|
// { icon: Settings, label: 'Settings', path: '/settings' },
|
||||||
];
|
];
|
||||||
@ -121,6 +123,12 @@ const tenantAdminPlatformMenu: MenuItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const tenantAdminSystemMenu: MenuItem[] = [
|
const tenantAdminSystemMenu: MenuItem[] = [
|
||||||
|
{
|
||||||
|
icon: Bell,
|
||||||
|
label: "Notifications",
|
||||||
|
path: "/tenant/notifications",
|
||||||
|
requiredPermission: { resource: "notifications" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
label: "Audit Logs",
|
label: "Audit Logs",
|
||||||
|
|||||||
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
|
// Helper function to get base URL with protocol
|
||||||
const getBaseUrlWithProtocol = (): string => {
|
const getBaseUrlWithProtocol = (): string => {
|
||||||
return import.meta.env.VITE_API_BASE_URL || "http://localhost:3000";
|
return import.meta.env.VITE_FRONTEND_BASE_URL || "http://localhost:5173";
|
||||||
};
|
};
|
||||||
|
|
||||||
const CreateTenantWizard = (): ReactElement => {
|
const CreateTenantWizard = (): ReactElement => {
|
||||||
|
|||||||
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 Modules = lazy(() => import("@/pages/superadmin/Modules"));
|
||||||
const AuditLogs = lazy(() => import("@/pages/superadmin/AuditLogs"));
|
const AuditLogs = lazy(() => import("@/pages/superadmin/AuditLogs"));
|
||||||
const Suppliers = lazy(() => import("@/pages/superadmin/Suppliers"));
|
const Suppliers = lazy(() => import("@/pages/superadmin/Suppliers"));
|
||||||
|
const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
|
||||||
|
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -70,4 +72,12 @@ export const superAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/suppliers",
|
path: "/suppliers",
|
||||||
element: <LazyRoute component={Suppliers} />,
|
element: <LazyRoute component={Suppliers} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/settings/notifications",
|
||||||
|
element: <LazyRoute component={NotificationSettings} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/notifications",
|
||||||
|
element: <LazyRoute component={Notifications} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -21,6 +21,8 @@ const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument"));
|
|||||||
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
|
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
|
||||||
const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForReview"));
|
const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForReview"));
|
||||||
const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
|
const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
|
||||||
|
const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
|
||||||
|
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -115,4 +117,12 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/tasks",
|
path: "/tenant/tasks",
|
||||||
element: <LazyRoute component={Tasks} />,
|
element: <LazyRoute component={Tasks} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/settings/notifications",
|
||||||
|
element: <LazyRoute component={NotificationSettings} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/notifications",
|
||||||
|
element: <LazyRoute component={Notifications} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
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 storage from 'redux-persist/lib/storage';
|
||||||
import authReducer from './authSlice';
|
import authReducer from './authSlice';
|
||||||
import themeReducer from './themeSlice';
|
import themeReducer from './themeSlice';
|
||||||
|
import notificationReducer from './notificationSlice';
|
||||||
|
|
||||||
// Persist config for auth slice only
|
// Persist config for auth slice only
|
||||||
const authPersistConfig = {
|
const authPersistConfig = {
|
||||||
@ -17,6 +18,7 @@ export const store = configureStore({
|
|||||||
reducer: {
|
reducer: {
|
||||||
auth: persistedAuthReducer,
|
auth: persistedAuthReducer,
|
||||||
theme: themeReducer,
|
theme: themeReducer,
|
||||||
|
notifications: notificationReducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
|
|||||||
49
src/types/notification.ts
Normal file
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';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface ToastAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable toast utility for showing success and error messages
|
* Reusable toast utility for showing success and error messages
|
||||||
*/
|
*/
|
||||||
export const showToast = {
|
export const showToast = {
|
||||||
success: (message: string, description?: string) => {
|
success: (message: string, description?: string, action?: ToastAction) => {
|
||||||
toast.success(message, {
|
toast.success(message, {
|
||||||
description,
|
description,
|
||||||
|
action,
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
error: (message: string, description?: string) => {
|
error: (message: string, description?: string, action?: ToastAction) => {
|
||||||
toast.error(message, {
|
toast.error(message, {
|
||||||
description,
|
description,
|
||||||
|
action,
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
info: (message: string, description?: string) => {
|
info: (message: string, description?: string, action?: ToastAction) => {
|
||||||
toast.info(message, {
|
toast.info(message, {
|
||||||
description,
|
description,
|
||||||
|
action,
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
warning: (message: string, description?: string) => {
|
warning: (message: string, description?: string, action?: ToastAction) => {
|
||||||
toast.warning(message, {
|
toast.warning(message, {
|
||||||
description,
|
description,
|
||||||
|
action,
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user