Compare commits
2 Commits
5e15326c29
...
484f3b2e07
| Author | SHA1 | Date | |
|---|---|---|---|
| 484f3b2e07 | |||
| af077bb1ce |
@ -57,10 +57,18 @@ const superAdminPlatformMenu: MenuItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const superAdminSystemMenu: MenuItem[] = [
|
const superAdminSystemMenu: MenuItem[] = [
|
||||||
{ icon: Bell, label: "Notifications", path: "/notifications" },
|
{
|
||||||
|
icon: Bell,
|
||||||
|
label: "Notifications",
|
||||||
|
isGroup: true,
|
||||||
|
children: [
|
||||||
|
{ label: "Notifications List", path: "/notifications" },
|
||||||
|
{ label: "Master Management", path: "/notification-master" },
|
||||||
|
{ label: "Global Templates", path: "/notification-templates" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ icon: FileText, label: "Audit Logs", path: "/audit-logs" },
|
{ icon: FileText, label: "Audit Logs", path: "/audit-logs" },
|
||||||
{ icon: Shield, label: "Audit Resources", path: "/audit-resource-types" },
|
{ icon: Shield, label: "Audit Resources", path: "/audit-resource-types" },
|
||||||
// { icon: Settings, label: 'Settings', path: '/settings' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Tenant Admin menu items
|
// Tenant Admin menu items
|
||||||
@ -181,7 +189,21 @@ const tenantAdminSystemMenu: MenuItem[] = [
|
|||||||
{
|
{
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
|
isGroup: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "General Settings",
|
||||||
path: "/tenant/settings",
|
path: "/tenant/settings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Notification Settings",
|
||||||
|
path: "/tenant/settings/notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Notification Templates",
|
||||||
|
path: "/tenant/settings/notification-templates",
|
||||||
|
}
|
||||||
|
],
|
||||||
requiredPermission: { resource: "tenants" },
|
requiredPermission: { resource: "tenants" },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,39 +1,78 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Bell, Clipboard, FileText, GraduationCap, GitGraph, Building2, Info, CheckCircle2, AlertCircle, AlertTriangle, Trash2, X } from 'lucide-react';
|
import {
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
Bell,
|
||||||
import { markReadAsync, readAllAsync, fetchNotifications } from '@/store/notificationSlice';
|
Clipboard,
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
FileText,
|
||||||
import { cn } from '@/lib/utils';
|
GraduationCap,
|
||||||
import { useNavigate } from 'react-router-dom';
|
GitGraph,
|
||||||
import type { Notification, NotificationType, NotificationCategory } from '@/types/notification';
|
Building2,
|
||||||
import { notificationService } from '@/services/notification-service';
|
Info,
|
||||||
import { showToast } from '@/utils/toast';
|
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) => {
|
const getNotificationIcon = (
|
||||||
|
category?: NotificationCategory,
|
||||||
|
type?: NotificationType,
|
||||||
|
) => {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case 'capa': return <Clipboard className="w-4 h-4" />;
|
case "capa":
|
||||||
case 'document': return <FileText className="w-4 h-4" />;
|
return <Clipboard className="w-4 h-4" />;
|
||||||
case 'training': return <GraduationCap className="w-4 h-4" />;
|
case "document":
|
||||||
case 'workflow': return <GitGraph className="w-4 h-4" />;
|
return <FileText className="w-4 h-4" />;
|
||||||
case 'supplier': return <Building2 className="w-4 h-4" />;
|
case "training":
|
||||||
case 'system': return <Bell className="w-4 h-4" />;
|
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:
|
default:
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'success': return <CheckCircle2 className="w-4 h-4" />;
|
case "success":
|
||||||
case 'warning': return <AlertTriangle className="w-4 h-4" />;
|
return <CheckCircle2 className="w-4 h-4" />;
|
||||||
case 'action_required': return <AlertCircle className="w-4 h-4" />;
|
case "warning":
|
||||||
default: return <Info className="w-4 h-4" />;
|
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) => {
|
const getTypeColor = (type: NotificationType) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'success': return 'text-green-500 bg-green-50';
|
case "success":
|
||||||
case 'warning': return 'text-orange-500 bg-orange-50';
|
return "text-green-500 bg-green-50";
|
||||||
case 'action_required': return 'text-amber-500 bg-amber-50';
|
case "warning":
|
||||||
case 'escalation': return 'text-red-500 bg-red-50';
|
return "text-orange-500 bg-orange-50";
|
||||||
default: return 'text-blue-500 bg-blue-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";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -41,22 +80,27 @@ export const NotificationBell = () => {
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { notifications, unread_count } = useAppSelector((state) => state.notifications);
|
const { notifications, unread_count } = useAppSelector(
|
||||||
|
(state) => state.notifications,
|
||||||
|
);
|
||||||
const { roles } = useAppSelector((state) => state.auth);
|
const { roles } = useAppSelector((state) => state.auth);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Handle click outside
|
// Handle click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@ -77,8 +121,8 @@ export const NotificationBell = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Special handling for tasks as requested - redirect to My Tasks tab
|
// Special handling for tasks as requested - redirect to My Tasks tab
|
||||||
if (['workflow', 'training'].includes(notification.category || '')) {
|
if (["workflow_task"].includes(notification.entity_type || "")) {
|
||||||
navigate('/tenant/workflows/tasks');
|
navigate("/tenant/workflows/tasks");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -91,9 +135,9 @@ export const NotificationBell = () => {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
if (notification.action_url) {
|
if (notification.action_url) {
|
||||||
const targetUrl = notification.action_url.startsWith('/tenant')
|
const targetUrl = notification.action_url.startsWith("/tenant")
|
||||||
? notification.action_url
|
? notification.action_url
|
||||||
: `/tenant${notification.action_url.startsWith('/') ? '' : '/'}${notification.action_url}`;
|
: `/tenant${notification.action_url.startsWith("/") ? "" : "/"}${notification.action_url}`;
|
||||||
navigate(targetUrl);
|
navigate(targetUrl);
|
||||||
}
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
@ -104,9 +148,9 @@ export const NotificationBell = () => {
|
|||||||
try {
|
try {
|
||||||
await notificationService.dismiss(id);
|
await notificationService.dismiss(id);
|
||||||
dispatch(fetchNotifications({ limit: 20 }));
|
dispatch(fetchNotifications({ limit: 20 }));
|
||||||
showToast.success('Notification dismissed');
|
showToast.success("Notification dismissed");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast.error('Failed to dismiss notification');
|
showToast.error("Failed to dismiss notification");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -114,9 +158,9 @@ export const NotificationBell = () => {
|
|||||||
try {
|
try {
|
||||||
await notificationService.dismissAll();
|
await notificationService.dismissAll();
|
||||||
dispatch(fetchNotifications({ limit: 20 }));
|
dispatch(fetchNotifications({ limit: 20 }));
|
||||||
showToast.success('All notifications dismissed');
|
showToast.success("All notifications dismissed");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast.error('Failed to dismiss all notifications');
|
showToast.error("Failed to dismiss all notifications");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -130,7 +174,7 @@ export const NotificationBell = () => {
|
|||||||
<Bell className="w-5 h-5 text-gray-600" />
|
<Bell className="w-5 h-5 text-gray-600" />
|
||||||
{unread_count > 0 && (
|
{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">
|
<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}
|
{unread_count > 99 ? "99+" : unread_count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -139,7 +183,9 @@ export const NotificationBell = () => {
|
|||||||
<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">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
|
<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>
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
|
Notifications
|
||||||
|
</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{unread_count > 0 && (
|
{unread_count > 0 && (
|
||||||
<button
|
<button
|
||||||
@ -166,8 +212,12 @@ export const NotificationBell = () => {
|
|||||||
<div className="w-12 h-12 bg-gray-50 rounded-full flex items-center justify-center mb-3">
|
<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" />
|
<Bell className="w-6 h-6 text-gray-300" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-gray-900">All caught up!</p>
|
<p className="text-sm font-medium text-gray-900">
|
||||||
<p className="text-xs text-gray-500 mt-1">No notifications to show right now.</p>
|
All caught up!
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
No notifications to show right now.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-50">
|
<div className="divide-y divide-gray-50">
|
||||||
@ -177,26 +227,36 @@ export const NotificationBell = () => {
|
|||||||
onClick={() => handleNotificationClick(notification)}
|
onClick={() => handleNotificationClick(notification)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative px-4 py-4 hover:bg-gray-50 transition-colors cursor-pointer flex gap-3",
|
"group relative px-4 py-4 hover:bg-gray-50 transition-colors cursor-pointer flex gap-3",
|
||||||
!notification.is_read && "bg-blue-50/30"
|
!notification.is_read && "bg-blue-50/30",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div
|
||||||
|
className={cn(
|
||||||
"flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center",
|
"flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center",
|
||||||
getTypeColor(notification.notification_type)
|
getTypeColor(notification.notification_type),
|
||||||
)}>
|
)}
|
||||||
{getNotificationIcon(notification.category, notification.notification_type)}
|
>
|
||||||
|
{getNotificationIcon(
|
||||||
|
notification.category,
|
||||||
|
notification.notification_type,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 pr-4">
|
<div className="flex-1 min-w-0 pr-4">
|
||||||
<div className="flex items-start justify-between mb-0.5">
|
<div className="flex items-start justify-between mb-0.5">
|
||||||
<p className={cn(
|
<p
|
||||||
|
className={cn(
|
||||||
"text-sm font-medium text-gray-900 truncate pr-2",
|
"text-sm font-medium text-gray-900 truncate pr-2",
|
||||||
!notification.is_read && "font-bold"
|
!notification.is_read && "font-bold",
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{notification.title}
|
{notification.title}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-[10px] text-gray-400 whitespace-nowrap mt-0.5">
|
<span className="text-[10px] text-gray-400 whitespace-nowrap mt-0.5">
|
||||||
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
|
{formatDistanceToNow(
|
||||||
|
new Date(notification.created_at),
|
||||||
|
{ addSuffix: true },
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-600 line-clamp-2 leading-relaxed">
|
<p className="text-xs text-gray-600 line-clamp-2 leading-relaxed">
|
||||||
@ -205,9 +265,11 @@ export const NotificationBell = () => {
|
|||||||
{notification.entity_name && (
|
{notification.entity_name && (
|
||||||
<div className="mt-2 text-[10px] font-medium text-gray-400 flex items-center gap-1">
|
<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">
|
<span className="px-1.5 py-0.5 bg-gray-100 rounded text-gray-500 uppercase tracking-wider">
|
||||||
{notification.entity_type?.replace('_', ' ')}
|
{notification.entity_type?.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">
|
||||||
|
{notification.entity_name}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{notification.entity_name}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -237,8 +299,10 @@ export const NotificationBell = () => {
|
|||||||
<div className="px-4 py-2 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
|
<div className="px-4 py-2 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const isSuperAdmin = roles.includes('super_admin');
|
const isSuperAdmin = roles.includes("super_admin");
|
||||||
navigate(isSuperAdmin ? '/notifications' : '/tenant/notifications');
|
navigate(
|
||||||
|
isSuperAdmin ? "/notifications" : "/tenant/notifications",
|
||||||
|
);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className="text-xs font-medium text-blue-600 hover:text-blue-700 transition-colors"
|
className="text-xs font-medium text-blue-600 hover:text-blue-700 transition-colors"
|
||||||
@ -247,8 +311,12 @@ export const NotificationBell = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const isSuperAdmin = roles.includes('super_admin');
|
const isSuperAdmin = roles.includes("super_admin");
|
||||||
navigate(isSuperAdmin ? '/settings/notifications' : '/tenant/settings/notifications');
|
navigate(
|
||||||
|
isSuperAdmin
|
||||||
|
? "/settings/notifications"
|
||||||
|
: "/tenant/settings/notifications",
|
||||||
|
);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className="text-xs font-medium text-gray-500 hover:text-gray-900 transition-colors"
|
className="text-xs font-medium text-gray-500 hover:text-gray-900 transition-colors"
|
||||||
|
|||||||
361
src/pages/superadmin/NotificationMaster.tsx
Normal file
361
src/pages/superadmin/NotificationMaster.tsx
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { Layout } from '@/components/layout/Layout';
|
||||||
|
import {
|
||||||
|
PrimaryButton,
|
||||||
|
DataTable,
|
||||||
|
ActionDropdown,
|
||||||
|
DeleteConfirmationModal,
|
||||||
|
Modal,
|
||||||
|
FormField,
|
||||||
|
FormTextArea,
|
||||||
|
FormSelect,
|
||||||
|
Pagination,
|
||||||
|
type Column,
|
||||||
|
} from '@/components/shared';
|
||||||
|
import { Plus, Code, Search } from 'lucide-react';
|
||||||
|
import { notificationService } from '@/services/notification-service';
|
||||||
|
import { moduleService } from '@/services/module-service';
|
||||||
|
import { showToast } from '@/utils/toast';
|
||||||
|
|
||||||
|
const NotificationMaster = (): ReactElement => {
|
||||||
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
|
// Pagination & Search for Categories
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [limit, setLimit] = useState(10);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// Category Modal
|
||||||
|
const [categoryModalOpen, setCategoryModalOpen] = useState<boolean>(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState<any>(null);
|
||||||
|
const [categoryForm, setCategoryForm] = useState({ name: '', code: '', description: '', module_id: '' });
|
||||||
|
|
||||||
|
// Code Modal
|
||||||
|
const [codeModalOpen, setCodeModalOpen] = useState<boolean>(false);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<any>(null);
|
||||||
|
const [codes, setCodes] = useState<any[]>([]);
|
||||||
|
const [isCodesLoading, setIsCodesLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Pagination for Codes
|
||||||
|
const [codePage, setCodePage] = useState(1);
|
||||||
|
const [codeTotal, setCodeTotal] = useState(0);
|
||||||
|
const [codePages, setCodePages] = useState(0);
|
||||||
|
|
||||||
|
const [editingCode, setEditingCode] = useState<any>(null);
|
||||||
|
const [codeForm, setCodeForm] = useState({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' });
|
||||||
|
|
||||||
|
// Delete Modal
|
||||||
|
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string, name: string, type: 'category' | 'code' } | null>(null);
|
||||||
|
|
||||||
|
const [modules, setModules] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const res = await notificationService.getCategories({
|
||||||
|
limit,
|
||||||
|
offset: (currentPage - 1) * limit,
|
||||||
|
search
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
setCategories(res.data);
|
||||||
|
setTotalItems(res.pagination?.total || res.data.length);
|
||||||
|
setTotalPages(res.pagination?.pages || 1);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err.message || 'Failed to fetch categories');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const fetchModules = async () => {
|
||||||
|
try {
|
||||||
|
const res = await moduleService.getAll(1, 100);
|
||||||
|
if (res.success) {
|
||||||
|
setModules(res.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch modules', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCategories();
|
||||||
|
fetchModules();
|
||||||
|
}, [currentPage, limit, search]);
|
||||||
|
|
||||||
|
const fetchCodes = async (category: any, page: number = 1) => {
|
||||||
|
try {
|
||||||
|
setIsCodesLoading(true);
|
||||||
|
const res = await notificationService.getCodesByCategory(category.id, {
|
||||||
|
limit: 5,
|
||||||
|
offset: (page - 1) * 5
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
setCodes(res.data);
|
||||||
|
setCodeTotal(res.pagination?.total || res.data.length);
|
||||||
|
setCodePages(res.pagination?.pages || 1);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error('Failed to fetch codes');
|
||||||
|
} finally {
|
||||||
|
setIsCodesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCodes = async (category: any) => {
|
||||||
|
setSelectedCategory(category);
|
||||||
|
setCodePage(1);
|
||||||
|
await fetchCodes(category, 1);
|
||||||
|
setCodeModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCategory && codeModalOpen) {
|
||||||
|
fetchCodes(selectedCategory, codePage);
|
||||||
|
}
|
||||||
|
}, [codePage]);
|
||||||
|
|
||||||
|
const handleSaveCategory = async () => {
|
||||||
|
try {
|
||||||
|
if (editingCategory) {
|
||||||
|
await notificationService.updateCategory(editingCategory.id, categoryForm);
|
||||||
|
showToast.success('Category updated');
|
||||||
|
} else {
|
||||||
|
await notificationService.createCategory(categoryForm);
|
||||||
|
showToast.success('Category created');
|
||||||
|
}
|
||||||
|
setCategoryModalOpen(false);
|
||||||
|
fetchCategories();
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err.message || 'Action failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCode = async () => {
|
||||||
|
try {
|
||||||
|
if (editingCode) {
|
||||||
|
await notificationService.updateCode(editingCode.id, codeForm);
|
||||||
|
showToast.success('Code updated');
|
||||||
|
} else {
|
||||||
|
await notificationService.createCode(selectedCategory.id, codeForm);
|
||||||
|
showToast.success('Code created');
|
||||||
|
}
|
||||||
|
setEditingCode(null);
|
||||||
|
setCodeForm({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' });
|
||||||
|
fetchCodes(selectedCategory, codePage);
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err.message || 'Action failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
if (deleteTarget.type === 'category') {
|
||||||
|
await notificationService.deleteCategory(deleteTarget.id);
|
||||||
|
fetchCategories();
|
||||||
|
} else {
|
||||||
|
await notificationService.deleteCode(deleteTarget.id);
|
||||||
|
fetchCodes(selectedCategory, codePage);
|
||||||
|
}
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
showToast.success(`${deleteTarget.type === 'category' ? 'Category' : 'Code'} deleted`);
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err.message || 'Delete failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<any>[] = [
|
||||||
|
{ key: 'name', label: 'Category Name', render: (c) => <span className="font-semibold text-[#0f1724]">{c.name}</span> },
|
||||||
|
{ key: 'code', label: 'Slug / Code', render: (c) => <code className="text-[10px] bg-gray-100 px-1.5 py-0.5 rounded font-mono text-gray-600">{c.code}</code> },
|
||||||
|
{ key: 'description', label: 'Description', render: (c) => <span className="text-xs text-gray-500 line-clamp-1">{c.description || '-'}</span> },
|
||||||
|
{ key: 'module_id', label: 'Assoc. Module', render: (c) => <span className="text-[10px] text-blue-500 font-bold uppercase">{c.module_code || 'System'}</span> },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: 'Actions',
|
||||||
|
align: 'right',
|
||||||
|
render: (c) => (
|
||||||
|
<div className="flex justify-end gap-3 items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenCodes(c)}
|
||||||
|
className="flex items-center gap-1.5 text-[11px] font-bold text-blue-600 hover:text-blue-800 transition-colors uppercase"
|
||||||
|
>
|
||||||
|
<Code className="w-3.5 h-3.5" />
|
||||||
|
Codes ({c.code_count || 0})
|
||||||
|
</button>
|
||||||
|
<ActionDropdown
|
||||||
|
onEdit={() => {
|
||||||
|
setEditingCategory(c);
|
||||||
|
setCategoryForm({ name: c.name, code: c.code, description: c.description || '', module_id: c.module_id || '' });
|
||||||
|
setCategoryModalOpen(true);
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
setDeleteTarget({ id: c.id, name: c.name, type: 'category' });
|
||||||
|
setDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Notification Master"
|
||||||
|
pageHeader={{
|
||||||
|
title: 'Notification Master Management',
|
||||||
|
description: 'Manage notification categories and event codes across the platform.',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden flex flex-col min-h-[500px]">
|
||||||
|
<div className="p-4 border-b flex flex-wrap justify-between items-center bg-gray-50/50 gap-4">
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search categories..."
|
||||||
|
className="w-full pl-9 pr-4 py-1.5 border rounded-md text-sm outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PrimaryButton onClick={() => {
|
||||||
|
setEditingCategory(null);
|
||||||
|
setCategoryForm({ name: '', code: '', description: '', module_id: '' });
|
||||||
|
setCategoryModalOpen(true);
|
||||||
|
}} className="flex gap-2">
|
||||||
|
<Plus className="w-4 h-4" /> New Category
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<DataTable columns={columns} data={categories} isLoading={isLoading} keyExtractor={(c) => c.id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalItems}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onLimitChange={(l) => { setLimit(l); setCurrentPage(1); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Modal */}
|
||||||
|
<Modal isOpen={categoryModalOpen} onClose={() => setCategoryModalOpen(false)} title={editingCategory ? "Edit Category" : "New Category"} maxWidth="md">
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField label="Category Name" value={categoryForm.name} onChange={(e) => setCategoryForm({ ...categoryForm, name: e.target.value })} placeholder="e.g. Workflow" />
|
||||||
|
<FormField label="Slug (Code)" value={categoryForm.code} onChange={(e) => setCategoryForm({ ...categoryForm, code: e.target.value })} placeholder="e.g. workflow" />
|
||||||
|
</div>
|
||||||
|
<FormTextArea label="Description" value={categoryForm.description} onChange={(e) => setCategoryForm({ ...categoryForm, description: e.target.value })} rows={2} />
|
||||||
|
<FormSelect
|
||||||
|
label="Associated Module"
|
||||||
|
value={categoryForm.module_id}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'System (No Module)' },
|
||||||
|
...modules.map(m => ({ value: m.id, label: m.name }))
|
||||||
|
]}
|
||||||
|
onValueChange={(val) => setCategoryForm({ ...categoryForm, module_id: val })}
|
||||||
|
placeholder="Select a module"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||||
|
<button onClick={() => setCategoryModalOpen(false)} className="px-4 py-2 text-sm text-gray-600">Cancel</button>
|
||||||
|
<PrimaryButton onClick={handleSaveCategory}>{editingCategory ? 'Update' : 'Create'}</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Codes Modal */}
|
||||||
|
<Modal isOpen={codeModalOpen} onClose={() => setCodeModalOpen(false)} title={`Event Codes: ${selectedCategory?.name}`} maxWidth="2xl">
|
||||||
|
<div className="p-6 space-y-6 max-h-[85vh] overflow-y-auto custom-scrollbar">
|
||||||
|
<div className="bg-gray-50 p-5 rounded-xl border border-gray-200 space-y-4 shadow-inner">
|
||||||
|
<h3 className="text-[11px] font-bold uppercase text-gray-500 tracking-wider font-mono flex items-center gap-2">
|
||||||
|
<Plus className="w-3 h-3" /> {editingCode ? 'Edit Event Code' : 'Add New Event Trigger'}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField label="Code (e.g. task_assigned)" value={codeForm.code} onChange={(e) => setCodeForm({ ...codeForm, code: e.target.value })} disabled={!!editingCode} />
|
||||||
|
<FormField label="Display Name" value={codeForm.name} onChange={(e) => setCodeForm({ ...codeForm, name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<FormTextArea label="Description" value={codeForm.description} onChange={(e) => setCodeForm({ ...codeForm, description: e.target.value })} rows={2} />
|
||||||
|
<div className="flex justify-between items-center pt-2">
|
||||||
|
<div className="text-[10px] text-gray-400 font-medium">Auto-populates default channels (In-App, Email)</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{editingCode && <button onClick={() => setEditingCode(null)} className="text-xs text-gray-500 hover:text-gray-800 underline transition-colors">Cancel Edit</button>}
|
||||||
|
<PrimaryButton onClick={handleSaveCode} size="default"> {editingCode ? 'Update Code' : 'Add Code'}</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-[11px] font-bold text-gray-400 uppercase tracking-widest px-1">Registered Codes</h4>
|
||||||
|
<div className="border border-gray-100 rounded-xl overflow-hidden shadow-sm">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[#f8fafc] border-b border-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-bold text-gray-500 uppercase tracking-wider">Trigger Code</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-bold text-gray-500 uppercase tracking-wider">Name</th>
|
||||||
|
<th className="px-4 py-3 text-right text-[11px] font-bold text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50 text-[13px]">
|
||||||
|
{isCodesLoading ? (
|
||||||
|
<tr><td colSpan={3} className="px-4 py-10 text-center text-gray-400 animate-pulse">Loading codes...</td></tr>
|
||||||
|
) : codes.length === 0 ? (
|
||||||
|
<tr><td colSpan={3} className="px-4 py-10 text-center text-gray-400 italic">No codes registered for this category.</td></tr>
|
||||||
|
) : codes.map(c => (
|
||||||
|
<tr key={c.id} className="hover:bg-gray-50/50 transition-colors">
|
||||||
|
<td className="px-4 py-3"><code className="text-[11px] font-mono font-bold text-indigo-600 bg-indigo-50 px-1.5 py-0.5 rounded border border-indigo-100">{c.code}</code></td>
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-700">{c.name}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button onClick={() => {
|
||||||
|
setEditingCode(c);
|
||||||
|
setCodeForm({ code: c.code, name: c.name, description: c.description || '', default_channels: c.default_channels, default_priority: c.default_priority });
|
||||||
|
}} className="text-blue-600 hover:text-blue-800 font-bold text-[11px] uppercase mr-3">Edit</button>
|
||||||
|
<button onClick={() => {
|
||||||
|
setDeleteTarget({ id: c.id, name: c.code, type: 'code' });
|
||||||
|
setDeleteModalOpen(true);
|
||||||
|
}} className="text-red-500 hover:text-red-700 font-bold text-[11px] uppercase">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={codePage}
|
||||||
|
totalPages={codePages}
|
||||||
|
totalItems={codeTotal}
|
||||||
|
limit={5}
|
||||||
|
onPageChange={setCodePage}
|
||||||
|
onLimitChange={() => {}} // Fixed for codes modal
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<DeleteConfirmationModal
|
||||||
|
isOpen={deleteModalOpen}
|
||||||
|
onClose={() => setDeleteModalOpen(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title={`Delete ${deleteTarget?.type}`}
|
||||||
|
message={`Are you sure you want to delete ${deleteTarget?.name}? This action cannot be undone.`}
|
||||||
|
itemName={deleteTarget?.name || ''}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationMaster;
|
||||||
326
src/pages/superadmin/NotificationTemplateMaster.tsx
Normal file
326
src/pages/superadmin/NotificationTemplateMaster.tsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { Layout } from '@/components/layout/Layout';
|
||||||
|
import {
|
||||||
|
PrimaryButton,
|
||||||
|
DataTable,
|
||||||
|
Modal,
|
||||||
|
FormField,
|
||||||
|
FormSelect,
|
||||||
|
FormTextArea,
|
||||||
|
Pagination,
|
||||||
|
type Column,
|
||||||
|
} from '@/components/shared';
|
||||||
|
import { Plus, Search, Filter } from 'lucide-react';
|
||||||
|
import { notificationService } from '@/services/notification-service';
|
||||||
|
import { moduleService } from '@/services/module-service';
|
||||||
|
import { showToast } from '@/utils/toast';
|
||||||
|
|
||||||
|
const NotificationTemplateMaster = (): ReactElement => {
|
||||||
|
const [templates, setTemplates] = useState<any[]>([]);
|
||||||
|
const [modules, setModules] = useState<any[]>([]);
|
||||||
|
const [selectedModule, setSelectedModule] = useState<string>('all');
|
||||||
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
|
const [codes, setCodes] = useState<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
|
// Pagination & Search
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [limit, setLimit] = useState(10);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// Template Modal
|
||||||
|
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
category: '',
|
||||||
|
title_template: '',
|
||||||
|
message_template: '',
|
||||||
|
email_subject_template: '',
|
||||||
|
email_body_template: '',
|
||||||
|
default_priority: 'normal',
|
||||||
|
channels: ['in_app', 'email'],
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const [tRes, cRes, mRes] = await Promise.all([
|
||||||
|
notificationService.getSuperAdminTemplates({
|
||||||
|
limit,
|
||||||
|
offset: (currentPage - 1) * limit,
|
||||||
|
search,
|
||||||
|
module_id: selectedModule === 'all' ? undefined : selectedModule
|
||||||
|
}),
|
||||||
|
notificationService.getCategories({ limit: 100 }), // Fetch more for dropdown
|
||||||
|
moduleService.getAll(1, 100)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (tRes.success) {
|
||||||
|
setTemplates(tRes.data);
|
||||||
|
setTotalItems(tRes.pagination?.total || tRes.data.length);
|
||||||
|
setTotalPages(tRes.pagination?.pages || 1);
|
||||||
|
}
|
||||||
|
if (cRes.success) {
|
||||||
|
setCategories(cRes.data);
|
||||||
|
}
|
||||||
|
if (mRes.success) {
|
||||||
|
setModules(mRes.data);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err.message || 'Failed to fetch data');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [currentPage, limit, search, selectedModule]);
|
||||||
|
|
||||||
|
const fetchCodesForCategory = async (categoryCode: string) => {
|
||||||
|
if (!categoryCode) return;
|
||||||
|
try {
|
||||||
|
const res = await notificationService.getCodesByCategory(categoryCode, { limit: 100 });
|
||||||
|
if (res.success) setCodes(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load codes:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategorySelect = async (categoryCode: string) => {
|
||||||
|
setForm(f => ({ ...f, category: categoryCode, code: '' }));
|
||||||
|
await fetchCodesForCategory(categoryCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
// For now we use createTemplate (which can override based on code)
|
||||||
|
await notificationService.createTemplate(form);
|
||||||
|
showToast.success(editingId ? 'Template updated' : 'Template created');
|
||||||
|
setModalOpen(false);
|
||||||
|
fetchData();
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err.message || 'Action failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<any>[] = [
|
||||||
|
{ key: 'category', label: 'Category', render: (t) => <span className="text-[10px] font-bold uppercase text-blue-600 bg-blue-50 px-2 py-1 rounded">{t.category_name}</span> },
|
||||||
|
{ key: 'code', label: 'Code', render: (t) => <code className="text-xs font-mono font-bold text-gray-700">{t.code}</code> },
|
||||||
|
{ key: 'name', label: 'Friendly Name', render: (t) => <span className="text-sm font-medium">{t.name}</span> },
|
||||||
|
{ key: 'title', label: 'Preview', render: (t) => <span className="text-xs truncate max-w-[200px] block text-gray-500">{t.title_template}</span> },
|
||||||
|
{ key: 'priority', label: 'Priority', render: (t) => <span className="capitalize text-[10px] bg-gray-100 px-1.5 py-0.5 rounded">{t.default_priority}</span> },
|
||||||
|
{
|
||||||
|
key: 'channels',
|
||||||
|
label: 'Channels',
|
||||||
|
render: (t) => (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{t.channels?.map((c: string) => (
|
||||||
|
<span key={c} className="text-[9px] bg-green-50 text-green-700 px-1 rounded border border-green-200 uppercase">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: 'Actions',
|
||||||
|
align: 'right',
|
||||||
|
render: (t) => (
|
||||||
|
<button onClick={() => {
|
||||||
|
setEditingId(t.id);
|
||||||
|
setForm({
|
||||||
|
code: t.code || '',
|
||||||
|
name: t.name || '',
|
||||||
|
description: t.description || '',
|
||||||
|
category: t.category || '',
|
||||||
|
title_template: t.title_template || '',
|
||||||
|
message_template: t.message_template || '',
|
||||||
|
email_subject_template: t.email_subject_template || '',
|
||||||
|
email_body_template: t.email_body_template || '',
|
||||||
|
default_priority: t.default_priority || 'normal',
|
||||||
|
channels: t.channels || ['in_app', 'email'],
|
||||||
|
is_active: t.is_active ?? true
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}} className="text-xs text-blue-600 hover:underline font-semibold">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Global Templates"
|
||||||
|
pageHeader={{
|
||||||
|
title: 'Global Notification Templates',
|
||||||
|
description: 'Define default notification templates for all tenants.',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden flex flex-col min-h-[600px]">
|
||||||
|
<div className="p-4 border-b flex flex-wrap justify-between items-center bg-gray-50/50 gap-4">
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search templates..."
|
||||||
|
className="w-full pl-9 pr-4 py-2 border rounded-md text-sm focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 min-w-[200px]">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-gray-400 whitespace-nowrap">
|
||||||
|
<Filter className="w-3.5 h-3.5" /> Module:
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<FormSelect
|
||||||
|
label=""
|
||||||
|
value={selectedModule}
|
||||||
|
onValueChange={(val) => { setSelectedModule(val); setCurrentPage(1); }}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: 'All Modules' },
|
||||||
|
...modules.map(m => ({ value: m.id, label: m.name }))
|
||||||
|
]}
|
||||||
|
placeholder="Filter by Module"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PrimaryButton onClick={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm({
|
||||||
|
code: '', name: '', description: '', category: '',
|
||||||
|
title_template: '', message_template: '',
|
||||||
|
email_subject_template: '', email_body_template: '',
|
||||||
|
default_priority: 'normal', channels: ['in_app', 'email'],
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}} className="flex gap-2">
|
||||||
|
<Plus className="w-4 h-4" /> New Template
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<DataTable columns={columns} data={templates} isLoading={isLoading} keyExtractor={(t) => t.id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalItems}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onLimitChange={(l) => { setLimit(l); setCurrentPage(1); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title={editingId ? "Edit Notification Template" : "Create New Template"} maxWidth="2xl">
|
||||||
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6 max-h-[80vh] overflow-y-auto custom-scrollbar">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Identification</h3>
|
||||||
|
<FormSelect
|
||||||
|
label="Category"
|
||||||
|
value={form.category}
|
||||||
|
onValueChange={handleCategorySelect}
|
||||||
|
options={editingId
|
||||||
|
? [{ value: form.category, label: categories.find(c => c.code === form.category)?.name || form.category }]
|
||||||
|
: categories.map(c => ({ value: c.code, label: c.name }))
|
||||||
|
}
|
||||||
|
disabled={!!editingId}
|
||||||
|
placeholder="Select Category"
|
||||||
|
/>
|
||||||
|
<FormSelect
|
||||||
|
label="Event Code"
|
||||||
|
value={form.code}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const selectedCode = codes.find(c => c.code === val);
|
||||||
|
setForm({ ...form, code: val, name: selectedCode?.name || form.name });
|
||||||
|
}}
|
||||||
|
options={editingId
|
||||||
|
? [{ value: form.code, label: `${form.name} (${form.code})` }]
|
||||||
|
: codes.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }))
|
||||||
|
}
|
||||||
|
disabled={!!editingId || !form.category}
|
||||||
|
placeholder="Select Event Code"
|
||||||
|
/>
|
||||||
|
<FormField label="Friendly Name" value={form.name} onChange={(e) => setForm({...form, name: e.target.value})} placeholder="e.g. Project Assigned" />
|
||||||
|
<FormTextArea label="Description" value={form.description} onChange={(e) => setForm({...form, description: e.target.value})} rows={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Settings</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormSelect
|
||||||
|
label="Priority"
|
||||||
|
value={form.default_priority}
|
||||||
|
onValueChange={(val) => setForm({...form, default_priority: val})}
|
||||||
|
options={[
|
||||||
|
{ value: 'low', label: 'Low' },
|
||||||
|
{ value: 'normal', label: 'Normal' },
|
||||||
|
{ value: 'high', label: 'High' },
|
||||||
|
{ value: 'urgent', label: 'Urgent' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-gray-700">Channels</label>
|
||||||
|
<div className="flex gap-4 items-center h-10 px-1">
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<input type="checkbox" checked={form.channels.includes('in_app')} onChange={(e) => {
|
||||||
|
const next = e.target.checked ? [...form.channels, 'in_app'] : form.channels.filter(c => c !== 'in_app');
|
||||||
|
setForm({...form, channels: next});
|
||||||
|
}} /> In-App
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<input type="checkbox" checked={form.channels.includes('email')} onChange={(e) => {
|
||||||
|
const next = e.target.checked ? [...form.channels, 'email'] : form.channels.filter(c => c !== 'email');
|
||||||
|
setForm({...form, channels: next});
|
||||||
|
}} /> Email
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider pt-2 border-t">In-App Content</h3>
|
||||||
|
<FormField label="Title Template" value={form.title_template} onChange={(e) => setForm({...form, title_template: e.target.value})} placeholder="e.g. Task Assigned: {{task_name}}" />
|
||||||
|
<FormTextArea label="Message Template" value={form.message_template} onChange={(e) => setForm({...form, message_template: e.target.value})} placeholder="Use {{var}} for dynamic data" rows={3} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 space-y-4 pt-4 border-t">
|
||||||
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Email Content</h3>
|
||||||
|
<FormField
|
||||||
|
label="Email Subject"
|
||||||
|
value={form.email_subject_template}
|
||||||
|
onChange={(e) => setForm({...form, email_subject_template: e.target.value})}
|
||||||
|
placeholder="Leave blank to use In-App title"
|
||||||
|
/>
|
||||||
|
<FormTextArea
|
||||||
|
label="Email Body (HTML supported)"
|
||||||
|
value={form.email_body_template}
|
||||||
|
onChange={(e) => setForm({...form, email_body_template: e.target.value})}
|
||||||
|
placeholder="Full HTML body template..."
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 flex justify-end gap-3 pt-6 border-t sticky bottom-0 bg-white">
|
||||||
|
<button onClick={() => setModalOpen(false)} className="px-5 py-2 text-sm text-gray-600 transition-colors hover:text-gray-900">Cancel</button>
|
||||||
|
<PrimaryButton onClick={handleSave} className="px-10">
|
||||||
|
{editingId ? 'Update Template' : 'Create Template'}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationTemplateMaster;
|
||||||
@ -74,7 +74,7 @@ const StatCard = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TaskCard = ({ task }: { task: WorkflowTask }) => {
|
const TaskCard = ({ task }: { task: WorkflowTask }) => {
|
||||||
const { primaryColor } = useAppTheme();
|
// const { primaryColor } = useAppTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const formatDeadline = (dueDate: string) => {
|
const formatDeadline = (dueDate: string) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -113,7 +113,13 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => {
|
|||||||
task.is_overdue ? "bg-[#ef4444]" : "bg-[#10b981]"
|
task.is_overdue ? "bg-[#ef4444]" : "bg-[#10b981]"
|
||||||
)} />
|
)} />
|
||||||
<span className="text-[11px] text-[#6b7280] font-medium leading-none">
|
<span className="text-[11px] text-[#6b7280] font-medium leading-none">
|
||||||
{task.step.name} • {task.assignment?.assigned_role || task.assignment?.assigned_to_name || 'Unassigned'}
|
{task.step.name} • {
|
||||||
|
(() => {
|
||||||
|
const role = task.assignment?.assigned_role;
|
||||||
|
if (role) return Array.isArray(role) ? role.join(", ") : role;
|
||||||
|
return task.assignment?.assigned_to_name || 'Unassigned';
|
||||||
|
})()
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -124,12 +130,12 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => {
|
|||||||
>
|
>
|
||||||
View
|
View
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* <button
|
||||||
className="text-[11px] px-2.5 py-1.5 text-white rounded-md font-bold transition-colors shadow-sm"
|
className="text-[11px] px-2.5 py-1.5 text-white rounded-md font-bold transition-colors shadow-sm"
|
||||||
style={{ backgroundColor: primaryColor, boxShadow: `0 2px 4px ${primaryColor}33` }}
|
style={{ backgroundColor: primaryColor, boxShadow: `0 2px 4px ${primaryColor}33` }}
|
||||||
>
|
>
|
||||||
Complete
|
Complete
|
||||||
</button>
|
</button> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,68 +1,76 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
import { useAppDispatch, useAppSelector } from "@/hooks/redux-hooks";
|
||||||
import { notificationService } from '@/services/notification-service';
|
import { notificationService } from "@/services/notification-service";
|
||||||
import type { NotificationPreferences, NotificationCategory } from '@/types/notification';
|
import type {
|
||||||
import { setPreferences } from '@/store/notificationSlice';
|
NotificationPreferences,
|
||||||
import { showToast } from '@/utils/toast';
|
NotificationCategory,
|
||||||
import { Save, Bell, Mail, Clock, Shield, Info } from 'lucide-react';
|
} from "@/types/notification";
|
||||||
import { Button } from '@/components/ui/button';
|
import { setPreferences } from "@/store/notificationSlice";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
import { showToast } from "@/utils/toast";
|
||||||
|
import { Save, Bell, Mail, Clock, Shield, Info } from "lucide-react";
|
||||||
const CATEGORIES: { id: NotificationCategory; label: string; description: string }[] = [
|
import { Button } from "@/components/ui/button";
|
||||||
{ id: 'capa', label: 'CAPA', description: 'Corrective and Preventive Actions assignments and updates' },
|
import {
|
||||||
{ id: 'document', label: 'Documents', description: 'Reviews, approvals, and status changes' },
|
Card,
|
||||||
{ id: 'training', label: 'Training', description: 'Assignments and completion updates' },
|
CardHeader,
|
||||||
{ id: 'workflow', label: 'Workflows', description: 'Task assignments and progress' },
|
CardTitle,
|
||||||
{ id: 'supplier', label: 'Suppliers', description: 'Evaluations and status updates' },
|
CardDescription,
|
||||||
{ id: 'system', label: 'System', description: 'Security alerts and system updates' },
|
CardContent,
|
||||||
];
|
} from "@/components/ui/card";
|
||||||
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
|
||||||
export const NotificationSettings = () => {
|
export const NotificationSettings = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { preferences: storedPreferences } = useAppSelector((state) => state.notifications);
|
const { preferences: storedPreferences } = useAppSelector(
|
||||||
const [preferences, setLocalPreferences] = useState<NotificationPreferences | null>(storedPreferences);
|
(state) => state.notifications,
|
||||||
|
);
|
||||||
|
const [preferences, setLocalPreferences] =
|
||||||
|
useState<NotificationPreferences | null>(storedPreferences);
|
||||||
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPrefs = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await notificationService.getPreferences();
|
const [prefRes, catRes] = await Promise.all([
|
||||||
setLocalPreferences(response.data);
|
notificationService.getPreferences(),
|
||||||
dispatch(setPreferences(response.data));
|
notificationService.getCategoriesForTenant(),
|
||||||
|
]);
|
||||||
|
setLocalPreferences(prefRes.data);
|
||||||
|
dispatch(setPreferences(prefRes.data));
|
||||||
|
setCategories(catRes.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast.error('Failed to load notification preferences');
|
showToast.error("Failed to load notification settings");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!storedPreferences) {
|
fetchData();
|
||||||
fetchPrefs();
|
}, [dispatch]);
|
||||||
} else {
|
|
||||||
setLocalPreferences(storedPreferences);
|
|
||||||
}
|
|
||||||
}, [dispatch, storedPreferences]);
|
|
||||||
|
|
||||||
const handleToggle = (field: keyof NotificationPreferences) => {
|
const handleToggle = (field: keyof NotificationPreferences) => {
|
||||||
if (!preferences) return;
|
if (!preferences) return;
|
||||||
setLocalPreferences({
|
setLocalPreferences({
|
||||||
...preferences,
|
...preferences,
|
||||||
[field]: !preferences[field]
|
[field]: !preferences[field],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCategoryToggle = (category: NotificationCategory, channel: 'in_app' | 'email') => {
|
const handleCategoryToggle = (
|
||||||
|
category: NotificationCategory,
|
||||||
|
channel: "in_app" | "email",
|
||||||
|
) => {
|
||||||
if (!preferences) return;
|
if (!preferences) return;
|
||||||
const categoryPrefs = { ...preferences.category_preferences };
|
const categoryPrefs = { ...preferences.category_preferences };
|
||||||
categoryPrefs[category] = {
|
categoryPrefs[category] = {
|
||||||
...categoryPrefs[category],
|
...categoryPrefs[category],
|
||||||
[channel]: !categoryPrefs[category][channel]
|
[channel]: !categoryPrefs[category][channel],
|
||||||
};
|
};
|
||||||
setLocalPreferences({
|
setLocalPreferences({
|
||||||
...preferences,
|
...preferences,
|
||||||
category_preferences: categoryPrefs
|
category_preferences: categoryPrefs,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -72,39 +80,47 @@ export const NotificationSettings = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await notificationService.updatePreferences(preferences);
|
const response = await notificationService.updatePreferences(preferences);
|
||||||
dispatch(setPreferences(response.data));
|
dispatch(setPreferences(response.data));
|
||||||
showToast.success('Notification preferences updated successfully');
|
showToast.success("Notification preferences updated successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast.error('Failed to update preferences');
|
showToast.error("Failed to update preferences");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading || !preferences) {
|
if (isLoading || !preferences) {
|
||||||
return <div className="p-8 text-center text-gray-500">Loading preferences...</div>;
|
return (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
Loading preferences...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
|
<Layout
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
currentPage="Notification Settings"
|
||||||
<div>
|
pageHeader={{
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Notification Settings</h1>
|
title: "Notification Settings",
|
||||||
<p className="text-sm text-gray-500 mt-1">Control how and when you want to be notified</p>
|
description: "Control how and when you want to be notified",
|
||||||
</div>
|
action: (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white min-w-[120px]"
|
className="bg-blue-600 hover:bg-blue-700 text-white min-w-[120px]"
|
||||||
>
|
>
|
||||||
{isSaving ? 'Saving...' : (
|
{isSaving ? (
|
||||||
|
"Saving..."
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="w-4 h-4 mr-2" />
|
<Save className="w-4 h-4 mr-2" />
|
||||||
Save Changes
|
Save Changes
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-8 animate-in fade-in duration-500">
|
||||||
{/* Master Toggles */}
|
{/* Master Toggles */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<Card className="border-none shadow-sm bg-blue-50/30">
|
<Card className="border-none shadow-sm bg-blue-50/30">
|
||||||
@ -115,8 +131,12 @@ export const NotificationSettings = () => {
|
|||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900">In-App Notifications</p>
|
<p className="font-semibold text-gray-900">
|
||||||
<p className="text-xs text-gray-500">Receive real-time alerts in the platform</p>
|
In-App Notifications
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Receive real-time alerts in the platform
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
@ -124,7 +144,7 @@ export const NotificationSettings = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="sr-only peer"
|
className="sr-only peer"
|
||||||
checked={preferences.in_app_enabled}
|
checked={preferences.in_app_enabled}
|
||||||
onChange={() => handleToggle('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>
|
<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>
|
</label>
|
||||||
@ -140,8 +160,12 @@ export const NotificationSettings = () => {
|
|||||||
<Mail className="w-5 h-5" />
|
<Mail className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900">Email Notifications</p>
|
<p className="font-semibold text-gray-900">
|
||||||
<p className="text-xs text-gray-500">Get updates delivered to your inbox</p>
|
Email Notifications
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Get updates delivered to your inbox
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
@ -149,7 +173,7 @@ export const NotificationSettings = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="sr-only peer"
|
className="sr-only peer"
|
||||||
checked={preferences.email_enabled}
|
checked={preferences.email_enabled}
|
||||||
onChange={() => handleToggle('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>
|
<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>
|
</label>
|
||||||
@ -162,7 +186,10 @@ export const NotificationSettings = () => {
|
|||||||
<Card className="border-gray-100 shadow-sm overflow-hidden">
|
<Card className="border-gray-100 shadow-sm overflow-hidden">
|
||||||
<CardHeader className="bg-gray-50/50 border-b border-gray-100">
|
<CardHeader className="bg-gray-50/50 border-b border-gray-100">
|
||||||
<CardTitle className="text-lg">Category Preferences</CardTitle>
|
<CardTitle className="text-lg">Category Preferences</CardTitle>
|
||||||
<CardDescription>Select which business events you want to be notified about via each channel</CardDescription>
|
<CardDescription>
|
||||||
|
Select which business events you want to be notified about via
|
||||||
|
each channel
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
@ -174,26 +201,51 @@ export const NotificationSettings = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-50">
|
<tbody className="divide-y divide-gray-50">
|
||||||
{CATEGORIES.map((cat) => (
|
{categories.map((cat: any) => (
|
||||||
<tr key={cat.id} className="hover:bg-gray-50/50 transition-colors">
|
<tr
|
||||||
|
key={cat.id}
|
||||||
|
className="hover:bg-gray-50/50 transition-colors"
|
||||||
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<p className="font-medium text-gray-900 text-sm">{cat.label}</p>
|
<p className="font-medium text-gray-900 text-sm">
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{cat.description}</p>
|
{cat.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{cat.description}
|
||||||
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
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}
|
checked={
|
||||||
onChange={() => handleCategoryToggle(cat.id, 'in_app')}
|
preferences.category_preferences[
|
||||||
|
cat.code as NotificationCategory
|
||||||
|
]?.in_app ?? true
|
||||||
|
}
|
||||||
|
onChange={() =>
|
||||||
|
handleCategoryToggle(
|
||||||
|
cat.code as NotificationCategory,
|
||||||
|
"in_app",
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
className="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
||||||
checked={preferences.category_preferences[cat.id]?.email ?? true}
|
checked={
|
||||||
onChange={() => handleCategoryToggle(cat.id, 'email')}
|
preferences.category_preferences[
|
||||||
|
cat.code as NotificationCategory
|
||||||
|
]?.email ?? true
|
||||||
|
}
|
||||||
|
onChange={() =>
|
||||||
|
handleCategoryToggle(
|
||||||
|
cat.code as NotificationCategory,
|
||||||
|
"email",
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -212,17 +264,21 @@ export const NotificationSettings = () => {
|
|||||||
<Clock className="w-5 h-5 text-purple-600" />
|
<Clock className="w-5 h-5 text-purple-600" />
|
||||||
<CardTitle className="text-lg">Quiet Hours</CardTitle>
|
<CardTitle className="text-lg">Quiet Hours</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Suppress non-urgent notifications during specific times</CardDescription>
|
<CardDescription>
|
||||||
|
Suppress non-urgent notifications during specific times
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between pb-4 border-b border-gray-50">
|
<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>
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
Enable Quiet Hours
|
||||||
|
</span>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="sr-only peer"
|
className="sr-only peer"
|
||||||
checked={preferences.quiet_hours_enabled}
|
checked={preferences.quiet_hours_enabled}
|
||||||
onChange={() => handleToggle('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>
|
<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>
|
</label>
|
||||||
@ -231,28 +287,45 @@ export const NotificationSettings = () => {
|
|||||||
{preferences.quiet_hours_enabled && (
|
{preferences.quiet_hours_enabled && (
|
||||||
<div className="grid grid-cols-2 gap-4 animate-in slide-in-from-top-2 duration-300">
|
<div className="grid grid-cols-2 gap-4 animate-in slide-in-from-top-2 duration-300">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Start Time</label>
|
<label className="text-xs font-semibold text-gray-500 uppercase">
|
||||||
|
Start Time
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="time"
|
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"
|
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 || ''}
|
value={preferences.quiet_hours_start || ""}
|
||||||
onChange={(e) => setLocalPreferences({...preferences, quiet_hours_start: e.target.value})}
|
onChange={(e) =>
|
||||||
|
setLocalPreferences({
|
||||||
|
...preferences,
|
||||||
|
quiet_hours_start: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">End Time</label>
|
<label className="text-xs font-semibold text-gray-500 uppercase">
|
||||||
|
End Time
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="time"
|
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"
|
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 || ''}
|
value={preferences.quiet_hours_end || ""}
|
||||||
onChange={(e) => setLocalPreferences({...preferences, quiet_hours_end: e.target.value})}
|
onChange={(e) =>
|
||||||
|
setLocalPreferences({
|
||||||
|
...preferences,
|
||||||
|
quiet_hours_end: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-start gap-2 pt-2 text-[11px] text-gray-500 italic">
|
<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" />
|
<Info className="w-3.5 h-3.5 mt-0.5" />
|
||||||
<span>Urgent priority notifications bypass quiet hours and are always delivered.</span>
|
<span>
|
||||||
|
Urgent priority notifications bypass quiet hours and are
|
||||||
|
always delivered.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -264,25 +337,39 @@ export const NotificationSettings = () => {
|
|||||||
<Shield className="w-5 h-5 text-amber-600" />
|
<Shield className="w-5 h-5 text-amber-600" />
|
||||||
<CardTitle className="text-lg">Email Frequency</CardTitle>
|
<CardTitle className="text-lg">Email Frequency</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>How often you want to receive email updates</CardDescription>
|
<CardDescription>
|
||||||
|
How often you want to receive email updates
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{['instant', 'daily', 'weekly'].map((freq) => (
|
{["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">
|
<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
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="email_digest"
|
name="email_digest"
|
||||||
className="w-4 h-4 text-amber-600 border-gray-300 focus:ring-amber-500"
|
className="w-4 h-4 text-amber-600 border-gray-300 focus:ring-amber-500"
|
||||||
checked={preferences.email_digest === freq}
|
checked={preferences.email_digest === freq}
|
||||||
onChange={() => setLocalPreferences({...preferences, email_digest: freq as any})}
|
onChange={() =>
|
||||||
|
setLocalPreferences({
|
||||||
|
...preferences,
|
||||||
|
email_digest: freq as any,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-gray-900 capitalize">{freq}</p>
|
<p className="text-sm font-medium text-gray-900 capitalize">
|
||||||
|
{freq}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{freq === 'instant' ? 'Receive as soon as they happen' :
|
{freq === "instant"
|
||||||
freq === 'daily' ? 'One daily digest at 8:00 AM' :
|
? "Receive as soon as they happen"
|
||||||
'One weekly summary every Monday'}
|
: freq === "daily"
|
||||||
|
? "One daily digest at 8:00 AM"
|
||||||
|
: "One weekly summary every Monday"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@ -292,6 +379,7 @@ export const NotificationSettings = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
215
src/pages/tenant/NotificationTemplates.tsx
Normal file
215
src/pages/tenant/NotificationTemplates.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { Layout } from '@/components/layout/Layout';
|
||||||
|
import {
|
||||||
|
PrimaryButton,
|
||||||
|
DataTable,
|
||||||
|
Modal,
|
||||||
|
FormField,
|
||||||
|
FormTextArea,
|
||||||
|
FormSelect,
|
||||||
|
StatusBadge,
|
||||||
|
Pagination,
|
||||||
|
type Column,
|
||||||
|
} from '@/components/shared';
|
||||||
|
import { Edit, RotateCcw, Building, Filter } from 'lucide-react';
|
||||||
|
import { notificationService } from '@/services/notification-service';
|
||||||
|
import { moduleService } from '@/services/module-service';
|
||||||
|
import { showToast } from '@/utils/toast';
|
||||||
|
|
||||||
|
const NotificationTemplates = (): ReactElement => {
|
||||||
|
const [templates, setTemplates] = useState<any[]>([]);
|
||||||
|
const [modules, setModules] = useState<any[]>([]);
|
||||||
|
const [selectedModule, setSelectedModule] = useState<string>('all');
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [limit, setLimit] = useState(10);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
|
||||||
|
// Override Modal
|
||||||
|
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title_template: '',
|
||||||
|
message_template: '',
|
||||||
|
email_subject_template: '',
|
||||||
|
email_body_template: '',
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchModules = async () => {
|
||||||
|
try {
|
||||||
|
const res = await moduleService.getMyModules();
|
||||||
|
if (res.success) {
|
||||||
|
setModules(res.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch modules:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTemplates = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const res = await notificationService.getTemplates({
|
||||||
|
limit,
|
||||||
|
offset: (currentPage - 1) * limit,
|
||||||
|
module_id: selectedModule === 'all' ? undefined : selectedModule
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
setTemplates(res.data);
|
||||||
|
setTotalItems(res.pagination?.total || res.data.length);
|
||||||
|
setTotalPages(res.pagination?.pages || 1);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error('Failed to load templates');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchModules(); }, []);
|
||||||
|
useEffect(() => { fetchTemplates(); }, [currentPage, limit, selectedModule]);
|
||||||
|
|
||||||
|
const handleOverride = async () => {
|
||||||
|
try {
|
||||||
|
await notificationService.overrideTemplate(selectedTemplate.code, form);
|
||||||
|
showToast.success('Template override saved');
|
||||||
|
setModalOpen(false);
|
||||||
|
fetchTemplates();
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err.message || 'Action failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async (code: string) => {
|
||||||
|
if (!confirm('Are you sure you want to reset this template to the global default? Your custom changes will be lost.')) return;
|
||||||
|
try {
|
||||||
|
await notificationService.resetTemplate(code);
|
||||||
|
showToast.success('Template reset to default');
|
||||||
|
fetchTemplates();
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err.message || 'Reset failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<any>[] = [
|
||||||
|
{ key: 'category', label: 'Category', render: (t) => <span className="text-[10px] font-bold uppercase text-gray-500 bg-gray-100 px-2 py-1 rounded">{t.category_name}</span> },
|
||||||
|
{ key: 'code', label: 'Event', render: (t) => <span className="text-sm font-semibold">{t.code}</span> },
|
||||||
|
{ key: 'source', label: 'Source', render: (t) => (
|
||||||
|
<StatusBadge variant={t.tenant_id ? 'success' : 'process'}>
|
||||||
|
{t.tenant_id ? 'Custom Override' : 'System Default'}
|
||||||
|
</StatusBadge>
|
||||||
|
)},
|
||||||
|
{ key: 'preview', label: 'Title Preview', render: (t) => <span className="text-xs truncate max-w-xs block text-gray-500">{t.title_template}</span> },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: 'Actions',
|
||||||
|
align: 'right',
|
||||||
|
render: (t) => (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button onClick={() => {
|
||||||
|
setSelectedTemplate(t);
|
||||||
|
setForm({
|
||||||
|
title_template: t.title_template,
|
||||||
|
message_template: t.message_template,
|
||||||
|
email_subject_template: t.email_subject_template || '',
|
||||||
|
email_body_template: t.email_body_template || '',
|
||||||
|
is_active: t.is_active
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}} className="p-2 text-blue-600 hover:bg-blue-50 rounded-md transition-colors flex items-center gap-1">
|
||||||
|
<Edit className="w-4 h-4" /> <span className="text-xs font-semibold uppercase">Edit</span>
|
||||||
|
</button>
|
||||||
|
{t.tenant_id && (
|
||||||
|
<button onClick={() => handleReset(t.code)} className="p-2 text-orange-600 hover:bg-orange-50 rounded-md transition-colors flex items-center gap-1">
|
||||||
|
<RotateCcw className="w-4 h-4" /> <span className="text-xs font-semibold uppercase">Reset</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Notification Templates"
|
||||||
|
pageHeader={{
|
||||||
|
title: 'Custom Notifications',
|
||||||
|
description: 'Customize the content and delivery of platform notifications for your organization.',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden flex flex-col min-h-[500px]">
|
||||||
|
<div className="p-4 border-b flex flex-wrap justify-between items-center bg-gray-50/30 gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building className="w-4 h-4 text-blue-500" />
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700">Notification Templates</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 min-w-[300px]">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-gray-400 mr-1">
|
||||||
|
<Filter className="w-3.5 h-3.5" /> Filter by Module
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<FormSelect
|
||||||
|
label=""
|
||||||
|
value={selectedModule}
|
||||||
|
onValueChange={(val) => { setSelectedModule(val); setCurrentPage(1); }}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: 'All Modules' },
|
||||||
|
...modules.map(m => ({ value: m.id, label: m.name }))
|
||||||
|
]}
|
||||||
|
placeholder="Select Module"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<DataTable columns={columns} data={templates} isLoading={isLoading} keyExtractor={(t) => t.code} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalItems}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onLimitChange={(l) => { setLimit(l); setCurrentPage(1); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title={`Customize: ${selectedTemplate?.code}`} maxWidth="2xl">
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">In-App Notification</h3>
|
||||||
|
<FormField label="Title Template" value={form.title_template} onChange={(e) => setForm({...form, title_template: e.target.value})} />
|
||||||
|
<FormTextArea label="Message Template" value={form.message_template} onChange={(e) => setForm({...form, message_template: e.target.value})} rows={3} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Email Notification</h3>
|
||||||
|
<FormField label="Email Subject" value={form.email_subject_template} onChange={(e) => setForm({...form, email_subject_template: e.target.value})} placeholder="Inherits from title if blank" />
|
||||||
|
<FormTextArea label="Email Body (HTML)" value={form.email_body_template} onChange={(e) => setForm({...form, email_body_template: e.target.value})} rows={6} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-6 border-t">
|
||||||
|
<div className="text-xs text-gray-400 max-w-sm">
|
||||||
|
💡 You can use placeholders like <code>{`{{user_name}}`}</code>, <code>{`{{entity_name}}`}</code>, and <code>{`{{action}}`}</code>.
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setModalOpen(false)} className="px-5 py-2 text-sm text-gray-600 hover:text-gray-900 transition-colors">Cancel</button>
|
||||||
|
<PrimaryButton onClick={handleOverride} className="px-8">Save Override</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationTemplates;
|
||||||
@ -163,7 +163,7 @@ export const Notifications = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Special handling for tasks as requested - redirect to My Tasks tab
|
// Special handling for tasks as requested - redirect to My Tasks tab
|
||||||
if (["workflow", "training"].includes(notification.category || "")) {
|
if (["workflow_task"].includes(notification.entity_type || "")) {
|
||||||
navigate("/tenant/workflows/tasks");
|
navigate("/tenant/workflows/tasks");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -141,9 +141,14 @@ const Tasks = (): ReactElement => {
|
|||||||
{
|
{
|
||||||
key: "assigned_role",
|
key: "assigned_role",
|
||||||
label: "Assigned To",
|
label: "Assigned To",
|
||||||
render: (task) => (
|
render: (task) => {
|
||||||
<span className="text-gray-600">{task.assignment.assigned_role}</span>
|
const role = task.assignment.assigned_role;
|
||||||
),
|
return (
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{Array.isArray(role) ? role.join(", ") : (role || "-")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
|
|||||||
@ -15,6 +15,8 @@ const Suppliers = lazy(() => import("@/pages/superadmin/Suppliers"));
|
|||||||
const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
|
const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
|
||||||
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
|
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
|
||||||
const AuditLogResourceTypes = lazy(() => import("@/pages/superadmin/AuditLogResourceTypes"));
|
const AuditLogResourceTypes = lazy(() => import("@/pages/superadmin/AuditLogResourceTypes"));
|
||||||
|
const NotificationMaster = lazy(() => import("@/pages/superadmin/NotificationMaster"));
|
||||||
|
const NotificationTemplateMaster = lazy(() => import("@/pages/superadmin/NotificationTemplateMaster"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -85,4 +87,12 @@ export const superAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/audit-resource-types",
|
path: "/audit-resource-types",
|
||||||
element: <LazyRoute component={AuditLogResourceTypes} />,
|
element: <LazyRoute component={AuditLogResourceTypes} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/notification-master",
|
||||||
|
element: <LazyRoute component={NotificationMaster} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/notification-templates",
|
||||||
|
element: <LazyRoute component={NotificationTemplateMaster} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -30,6 +30,7 @@ const NotificationSettings = lazy(
|
|||||||
() => import("@/pages/tenant/NotificationSettings"),
|
() => import("@/pages/tenant/NotificationSettings"),
|
||||||
);
|
);
|
||||||
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
|
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
|
||||||
|
const NotificationTemplates = lazy(() => import("@/pages/tenant/NotificationTemplates"));
|
||||||
const FilesList = lazy(() => import("@/pages/tenant/FilesList"));
|
const FilesList = lazy(() => import("@/pages/tenant/FilesList"));
|
||||||
const FileView = lazy(() => import("@/pages/tenant/FileView"));
|
const FileView = lazy(() => import("@/pages/tenant/FileView"));
|
||||||
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
|
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
|
||||||
@ -139,6 +140,10 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/notifications",
|
path: "/tenant/notifications",
|
||||||
element: <LazyRoute component={Notifications} />,
|
element: <LazyRoute component={Notifications} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/settings/notification-templates",
|
||||||
|
element: <LazyRoute component={NotificationTemplates} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/tenant/files",
|
path: "/tenant/files",
|
||||||
element: <LazyRoute component={FilesList} />,
|
element: <LazyRoute component={FilesList} />,
|
||||||
|
|||||||
@ -4,6 +4,12 @@ import type { Notification, NotificationPreferences } from '@/types/notification
|
|||||||
export interface NotificationResponse<T> {
|
export interface NotificationResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
|
pagination?: {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
pages: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetNotificationsParams {
|
export interface GetNotificationsParams {
|
||||||
@ -14,51 +20,122 @@ export interface GetNotificationsParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const notificationService = {
|
export const notificationService = {
|
||||||
// GET /notifications/me
|
// USER NOTIFICATIONS
|
||||||
getNotifications: async (params?: GetNotificationsParams): Promise<NotificationResponse<Notification[]>> => {
|
getNotifications: async (params?: GetNotificationsParams): Promise<NotificationResponse<Notification[]>> => {
|
||||||
const response = await apiClient.get('/notifications/me', { params });
|
const response = await apiClient.get('/notifications/me', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// GET /notifications/me/unread-count
|
|
||||||
getUnreadCount: async (): Promise<NotificationResponse<{ unread_count: number }>> => {
|
getUnreadCount: async (): Promise<NotificationResponse<{ unread_count: number }>> => {
|
||||||
const response = await apiClient.get('/notifications/me/unread-count');
|
const response = await apiClient.get('/notifications/me/unread-count');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// PUT /notifications/me/read-all
|
|
||||||
readAll: async (): Promise<NotificationResponse<{ marked_count: number }>> => {
|
readAll: async (): Promise<NotificationResponse<{ marked_count: number }>> => {
|
||||||
const response = await apiClient.put('/notifications/me/read-all');
|
const response = await apiClient.put('/notifications/me/read-all');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// PUT /notifications/me/dismiss-all
|
|
||||||
dismissAll: async (): Promise<NotificationResponse<{ dismissed_count: number }>> => {
|
dismissAll: async (): Promise<NotificationResponse<{ dismissed_count: number }>> => {
|
||||||
const response = await apiClient.put('/notifications/me/dismiss-all');
|
const response = await apiClient.put('/notifications/me/dismiss-all');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// PUT /notifications/:id/read
|
|
||||||
markAsRead: async (id: string): Promise<NotificationResponse<Notification>> => {
|
markAsRead: async (id: string): Promise<NotificationResponse<Notification>> => {
|
||||||
const response = await apiClient.put(`/notifications/${id}/read`);
|
const response = await apiClient.put(`/notifications/${id}/read`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// PUT /notifications/:id/dismiss
|
|
||||||
dismiss: async (id: string): Promise<NotificationResponse<void>> => {
|
dismiss: async (id: string): Promise<NotificationResponse<void>> => {
|
||||||
const response = await apiClient.put(`/notifications/${id}/dismiss`);
|
const response = await apiClient.put(`/notifications/${id}/dismiss`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// GET /notifications/preferences
|
|
||||||
getPreferences: async (): Promise<NotificationResponse<NotificationPreferences>> => {
|
getPreferences: async (): Promise<NotificationResponse<NotificationPreferences>> => {
|
||||||
const response = await apiClient.get('/notifications/preferences');
|
const response = await apiClient.get('/notifications/preferences');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// PUT /notifications/preferences
|
|
||||||
updatePreferences: async (preferences: Partial<NotificationPreferences>): Promise<NotificationResponse<NotificationPreferences>> => {
|
updatePreferences: async (preferences: Partial<NotificationPreferences>): Promise<NotificationResponse<NotificationPreferences>> => {
|
||||||
const response = await apiClient.put('/notifications/preferences', preferences);
|
const response = await apiClient.put('/notifications/preferences', preferences);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MASTER: CATEGORIES & CODES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
getCategories: async (params?: any): Promise<NotificationResponse<any>> => {
|
||||||
|
const response = await apiClient.get('/notifications/categories', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCategoriesForTenant: async (): Promise<NotificationResponse<any[]>> => {
|
||||||
|
const response = await apiClient.get('/notifications/categories/tenant');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createCategory: async (data: any): Promise<NotificationResponse<any>> => {
|
||||||
|
const response = await apiClient.post('/notifications/categories', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCategory: async (id: string, data: any): Promise<NotificationResponse<any>> => {
|
||||||
|
const response = await apiClient.put(`/notifications/categories/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteCategory: async (id: string): Promise<NotificationResponse<void>> => {
|
||||||
|
const response = await apiClient.delete(`/notifications/categories/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCodesByCategory: async (categoryId: string, params?: any): Promise<NotificationResponse<any>> => {
|
||||||
|
const response = await apiClient.get(`/notifications/categories/${categoryId}/codes`, { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createCode: async (categoryId: string, data: any): Promise<NotificationResponse<any>> => {
|
||||||
|
const response = await apiClient.post(`/notifications/categories/${categoryId}/codes`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCode: async (codeId: string, data: any): Promise<NotificationResponse<any>> => {
|
||||||
|
const response = await apiClient.put(`/notifications/codes/${codeId}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteCode: async (codeId: string): Promise<NotificationResponse<void>> => {
|
||||||
|
const response = await apiClient.delete(`/notifications/codes/${codeId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TEMPLATES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
getTemplates: async (params?: any): Promise<NotificationResponse<any>> => {
|
||||||
|
const response = await apiClient.get('/notifications/templates', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSuperAdminTemplates: async (params?: any): Promise<NotificationResponse<any>> => {
|
||||||
|
const response = await apiClient.get('/notifications/templates/super-admin', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createTemplate: async (data: any): Promise<NotificationResponse<any>> => {
|
||||||
|
const response = await apiClient.post('/notifications/templates', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
overrideTemplate: async (code: string, data: any): Promise<NotificationResponse<any>> => {
|
||||||
|
const response = await apiClient.put(`/notifications/templates/${code}/override`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetTemplate: async (code: string): Promise<NotificationResponse<void>> => {
|
||||||
|
const response = await apiClient.delete(`/notifications/templates/${code}/reset`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -174,7 +174,7 @@ export interface WorkflowTask {
|
|||||||
assignment: {
|
assignment: {
|
||||||
assigned_to: string | null;
|
assigned_to: string | null;
|
||||||
assigned_to_name: string | null;
|
assigned_to_name: string | null;
|
||||||
assigned_role: string;
|
assigned_role: string | string[];
|
||||||
assigned_at: string;
|
assigned_at: string;
|
||||||
};
|
};
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user