diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4b22e51..16057fa 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -57,10 +57,18 @@ const superAdminPlatformMenu: 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: Shield, label: "Audit Resources", path: "/audit-resource-types" }, - // { icon: Settings, label: 'Settings', path: '/settings' }, ]; // Tenant Admin menu items @@ -181,7 +189,21 @@ const tenantAdminSystemMenu: MenuItem[] = [ { icon: Settings, label: "Settings", - path: "/tenant/settings", + isGroup: true, + children: [ + { + label: "General Settings", + path: "/tenant/settings", + }, + { + label: "Notification Settings", + path: "/tenant/settings/notifications", + }, + { + label: "Notification Templates", + path: "/tenant/settings/notification-templates", + } + ], requiredPermission: { resource: "tenants" }, }, ]; diff --git a/src/components/shared/NotificationBell.tsx b/src/components/shared/NotificationBell.tsx index d52a649..f1edc7a 100644 --- a/src/components/shared/NotificationBell.tsx +++ b/src/components/shared/NotificationBell.tsx @@ -1,39 +1,78 @@ -import { useState, useRef, useEffect } from 'react'; -import { Bell, Clipboard, FileText, GraduationCap, GitGraph, Building2, Info, CheckCircle2, AlertCircle, AlertTriangle, Trash2, X } from 'lucide-react'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks'; -import { markReadAsync, readAllAsync, fetchNotifications } from '@/store/notificationSlice'; -import { formatDistanceToNow } from 'date-fns'; -import { cn } from '@/lib/utils'; -import { useNavigate } from 'react-router-dom'; -import type { Notification, NotificationType, NotificationCategory } from '@/types/notification'; -import { notificationService } from '@/services/notification-service'; -import { showToast } from '@/utils/toast'; +import { useState, useRef, useEffect } from "react"; +import { + Bell, + Clipboard, + FileText, + GraduationCap, + GitGraph, + Building2, + Info, + CheckCircle2, + AlertCircle, + AlertTriangle, + Trash2, + X, +} from "lucide-react"; +import { useAppDispatch, useAppSelector } from "@/hooks/redux-hooks"; +import { + markReadAsync, + readAllAsync, + fetchNotifications, +} from "@/store/notificationSlice"; +import { formatDistanceToNow } from "date-fns"; +import { cn } from "@/lib/utils"; +import { useNavigate } from "react-router-dom"; +import type { + Notification, + NotificationType, + NotificationCategory, +} from "@/types/notification"; +import { notificationService } from "@/services/notification-service"; +import { showToast } from "@/utils/toast"; -const getNotificationIcon = (category?: NotificationCategory, type?: NotificationType) => { +const getNotificationIcon = ( + category?: NotificationCategory, + type?: NotificationType, +) => { switch (category) { - case 'capa': return ; - case 'document': return ; - case 'training': return ; - case 'workflow': return ; - case 'supplier': return ; - case 'system': return ; + case "capa": + return ; + case "document": + return ; + case "training": + return ; + case "workflow": + return ; + case "supplier": + return ; + case "system": + return ; default: switch (type) { - case 'success': return ; - case 'warning': return ; - case 'action_required': return ; - default: return ; + case "success": + return ; + case "warning": + return ; + case "action_required": + return ; + default: + return ; } } }; const getTypeColor = (type: NotificationType) => { switch (type) { - case 'success': return 'text-green-500 bg-green-50'; - case 'warning': return 'text-orange-500 bg-orange-50'; - case 'action_required': return 'text-amber-500 bg-amber-50'; - case 'escalation': return 'text-red-500 bg-red-50'; - default: return 'text-blue-500 bg-blue-50'; + case "success": + return "text-green-500 bg-green-50"; + case "warning": + return "text-orange-500 bg-orange-50"; + case "action_required": + return "text-amber-500 bg-amber-50"; + case "escalation": + return "text-red-500 bg-red-50"; + default: + return "text-blue-500 bg-blue-50"; } }; @@ -41,22 +80,27 @@ export const NotificationBell = () => { const [isOpen, setIsOpen] = useState(false); const dispatch = useAppDispatch(); 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 dropdownRef = useRef(null); // Handle click outside useEffect(() => { 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); } }; if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); + document.addEventListener("mousedown", handleClickOutside); } return () => { - document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener("mousedown", handleClickOutside); }; }, [isOpen]); @@ -77,13 +121,13 @@ export const NotificationBell = () => { } // Special handling for tasks as requested - redirect to My Tasks tab - if (['workflow', 'training'].includes(notification.category || '')) { - navigate('/tenant/workflows/tasks'); + if (["workflow_task"].includes(notification.entity_type || "")) { + navigate("/tenant/workflows/tasks"); setIsOpen(false); return; } - // Special handling for system as requested - redirect to Dashboard + // Special handling for system as requested - redirect to Dashboard // if (['system'].includes(notification.category || '')) { // navigate('/tenant'); // setIsOpen(false); @@ -91,9 +135,9 @@ export const NotificationBell = () => { // } if (notification.action_url) { - const targetUrl = notification.action_url.startsWith('/tenant') - ? notification.action_url - : `/tenant${notification.action_url.startsWith('/') ? '' : '/'}${notification.action_url}`; + const targetUrl = notification.action_url.startsWith("/tenant") + ? notification.action_url + : `/tenant${notification.action_url.startsWith("/") ? "" : "/"}${notification.action_url}`; navigate(targetUrl); } setIsOpen(false); @@ -104,9 +148,9 @@ export const NotificationBell = () => { try { await notificationService.dismiss(id); dispatch(fetchNotifications({ limit: 20 })); - showToast.success('Notification dismissed'); + showToast.success("Notification dismissed"); } catch (error) { - showToast.error('Failed to dismiss notification'); + showToast.error("Failed to dismiss notification"); } }; @@ -114,9 +158,9 @@ export const NotificationBell = () => { try { await notificationService.dismissAll(); dispatch(fetchNotifications({ limit: 20 })); - showToast.success('All notifications dismissed'); + showToast.success("All notifications dismissed"); } catch (error) { - showToast.error('Failed to dismiss all notifications'); + showToast.error("Failed to dismiss all notifications"); } }; @@ -130,7 +174,7 @@ export const NotificationBell = () => { {unread_count > 0 && ( - {unread_count > 99 ? '99+' : unread_count} + {unread_count > 99 ? "99+" : unread_count} )} @@ -139,7 +183,9 @@ export const NotificationBell = () => {
{/* Header */}
-

Notifications

+

+ Notifications +

{unread_count > 0 && (
) : (
@@ -177,26 +227,36 @@ export const NotificationBell = () => { onClick={() => handleNotificationClick(notification)} className={cn( "group relative px-4 py-4 hover:bg-gray-50 transition-colors cursor-pointer flex gap-3", - !notification.is_read && "bg-blue-50/30" + !notification.is_read && "bg-blue-50/30", )} > -
- {getNotificationIcon(notification.category, notification.notification_type)} +
+ {getNotificationIcon( + notification.category, + notification.notification_type, + )}
-

+

{notification.title}

- {formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })} + {formatDistanceToNow( + new Date(notification.created_at), + { addSuffix: true }, + )}

@@ -205,9 +265,11 @@ export const NotificationBell = () => { {notification.entity_name && (

- {notification.entity_type?.replace('_', ' ')} + {notification.entity_type?.replace("_", " ")} + + + {notification.entity_name} - {notification.entity_name}
)}
@@ -237,8 +299,10 @@ export const NotificationBell = () => {
+ { + 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); + }} + /> +
+ ) + } + ]; + + return ( + +
+
+
+
+ + { setSearch(e.target.value); setCurrentPage(1); }} + /> +
+
+ { + setEditingCategory(null); + setCategoryForm({ name: '', code: '', description: '', module_id: '' }); + setCategoryModalOpen(true); + }} className="flex gap-2"> + New Category + +
+ +
+ c.id} /> +
+ + { setLimit(l); setCurrentPage(1); }} + /> +
+ + {/* Category Modal */} + setCategoryModalOpen(false)} title={editingCategory ? "Edit Category" : "New Category"} maxWidth="md"> +
+
+ setCategoryForm({ ...categoryForm, name: e.target.value })} placeholder="e.g. Workflow" /> + setCategoryForm({ ...categoryForm, code: e.target.value })} placeholder="e.g. workflow" /> +
+ setCategoryForm({ ...categoryForm, description: e.target.value })} rows={2} /> + ({ value: m.id, label: m.name })) + ]} + onValueChange={(val) => setCategoryForm({ ...categoryForm, module_id: val })} + placeholder="Select a module" + /> +
+ + {editingCategory ? 'Update' : 'Create'} +
+
+
+ + {/* Codes Modal */} + setCodeModalOpen(false)} title={`Event Codes: ${selectedCategory?.name}`} maxWidth="2xl"> +
+
+

+ {editingCode ? 'Edit Event Code' : 'Add New Event Trigger'} +

+
+ setCodeForm({ ...codeForm, code: e.target.value })} disabled={!!editingCode} /> + setCodeForm({ ...codeForm, name: e.target.value })} /> +
+ setCodeForm({ ...codeForm, description: e.target.value })} rows={2} /> +
+
Auto-populates default channels (In-App, Email)
+
+ {editingCode && } + {editingCode ? 'Update Code' : 'Add Code'} +
+
+
+ +
+

Registered Codes

+
+ + + + + + + + + + {isCodesLoading ? ( + + ) : codes.length === 0 ? ( + + ) : codes.map(c => ( + + + + + + ))} + +
Trigger CodeNameActions
Loading codes...
No codes registered for this category.
{c.code}{c.name} + + +
+
+ + {}} // Fixed for codes modal + /> +
+
+
+ + 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 || ''} + /> +
+ ); +}; + +export default NotificationMaster; diff --git a/src/pages/superadmin/NotificationTemplateMaster.tsx b/src/pages/superadmin/NotificationTemplateMaster.tsx new file mode 100644 index 0000000..2935fa5 --- /dev/null +++ b/src/pages/superadmin/NotificationTemplateMaster.tsx @@ -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([]); + const [modules, setModules] = useState([]); + const [selectedModule, setSelectedModule] = useState('all'); + const [categories, setCategories] = useState([]); + const [codes, setCodes] = useState([]); + const [isLoading, setIsLoading] = useState(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(false); + const [editingId, setEditingId] = useState(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[] = [ + { key: 'category', label: 'Category', render: (t) => {t.category_name} }, + { key: 'code', label: 'Code', render: (t) => {t.code} }, + { key: 'name', label: 'Friendly Name', render: (t) => {t.name} }, + { key: 'title', label: 'Preview', render: (t) => {t.title_template} }, + { key: 'priority', label: 'Priority', render: (t) => {t.default_priority} }, + { + key: 'channels', + label: 'Channels', + render: (t) => ( +
+ {t.channels?.map((c: string) => ( + {c} + ))} +
+ ) + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (t) => ( + + ) + } + ]; + + return ( + +
+
+
+
+ + { setSearch(e.target.value); setCurrentPage(1); }} + /> +
+ +
+
+ Module: +
+
+ { setSelectedModule(val); setCurrentPage(1); }} + options={[ + { value: 'all', label: 'All Modules' }, + ...modules.map(m => ({ value: m.id, label: m.name })) + ]} + placeholder="Filter by Module" + /> +
+
+
+ { + 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"> + New Template + +
+ +
+ t.id} /> +
+ + { setLimit(l); setCurrentPage(1); }} + /> +
+ + setModalOpen(false)} title={editingId ? "Edit Notification Template" : "Create New Template"} maxWidth="2xl"> +
+
+

Identification

+ c.code === form.category)?.name || form.category }] + : categories.map(c => ({ value: c.code, label: c.name })) + } + disabled={!!editingId} + placeholder="Select Category" + /> + { + 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" + /> + setForm({...form, name: e.target.value})} placeholder="e.g. Project Assigned" /> + setForm({...form, description: e.target.value})} rows={2} /> +
+ +
+

Settings

+
+ setForm({...form, default_priority: val})} + options={[ + { value: 'low', label: 'Low' }, + { value: 'normal', label: 'Normal' }, + { value: 'high', label: 'High' }, + { value: 'urgent', label: 'Urgent' } + ]} + /> +
+ +
+ + +
+
+
+ +

In-App Content

+ setForm({...form, title_template: e.target.value})} placeholder="e.g. Task Assigned: {{task_name}}" /> + setForm({...form, message_template: e.target.value})} placeholder="Use {{var}} for dynamic data" rows={3} /> +
+ +
+

Email Content

+ setForm({...form, email_subject_template: e.target.value})} + placeholder="Leave blank to use In-App title" + /> + setForm({...form, email_body_template: e.target.value})} + placeholder="Full HTML body template..." + rows={6} + /> +
+ +
+ + + {editingId ? 'Update Template' : 'Create Template'} + +
+
+
+
+ ); +}; + +export default NotificationTemplateMaster; diff --git a/src/pages/tenant/Dashboard.tsx b/src/pages/tenant/Dashboard.tsx index 63674bc..2a569fd 100644 --- a/src/pages/tenant/Dashboard.tsx +++ b/src/pages/tenant/Dashboard.tsx @@ -113,7 +113,13 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => { task.is_overdue ? "bg-[#ef4444]" : "bg-[#10b981]" )} /> - {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'; + })() + }
@@ -124,12 +130,12 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => { > View - + */}
diff --git a/src/pages/tenant/NotificationSettings.tsx b/src/pages/tenant/NotificationSettings.tsx index b2b0ea5..5dc9b0b 100644 --- a/src/pages/tenant/NotificationSettings.tsx +++ b/src/pages/tenant/NotificationSettings.tsx @@ -1,68 +1,76 @@ -import { useState, useEffect } from 'react'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks'; -import { notificationService } from '@/services/notification-service'; -import type { NotificationPreferences, NotificationCategory } from '@/types/notification'; -import { setPreferences } from '@/store/notificationSlice'; -import { showToast } from '@/utils/toast'; -import { Save, Bell, Mail, Clock, Shield, Info } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; - -const CATEGORIES: { id: NotificationCategory; label: string; description: string }[] = [ - { id: 'capa', label: 'CAPA', description: 'Corrective and Preventive Actions assignments and updates' }, - { id: 'document', label: 'Documents', description: 'Reviews, approvals, and status changes' }, - { id: 'training', label: 'Training', description: 'Assignments and completion updates' }, - { id: 'workflow', label: 'Workflows', description: 'Task assignments and progress' }, - { id: 'supplier', label: 'Suppliers', description: 'Evaluations and status updates' }, - { id: 'system', label: 'System', description: 'Security alerts and system updates' }, -]; +import { useState, useEffect } from "react"; +import { useAppDispatch, useAppSelector } from "@/hooks/redux-hooks"; +import { notificationService } from "@/services/notification-service"; +import type { + NotificationPreferences, + NotificationCategory, +} from "@/types/notification"; +import { setPreferences } from "@/store/notificationSlice"; +import { showToast } from "@/utils/toast"; +import { Save, Bell, Mail, Clock, Shield, Info } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Layout } from "@/components/layout/Layout"; export const NotificationSettings = () => { const dispatch = useAppDispatch(); - const { preferences: storedPreferences } = useAppSelector((state) => state.notifications); - const [preferences, setLocalPreferences] = useState(storedPreferences); + const { preferences: storedPreferences } = useAppSelector( + (state) => state.notifications, + ); + const [preferences, setLocalPreferences] = + useState(storedPreferences); + const [categories, setCategories] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); useEffect(() => { - const fetchPrefs = async () => { + const fetchData = async () => { setIsLoading(true); try { - const response = await notificationService.getPreferences(); - setLocalPreferences(response.data); - dispatch(setPreferences(response.data)); + const [prefRes, catRes] = await Promise.all([ + notificationService.getPreferences(), + notificationService.getCategoriesForTenant(), + ]); + setLocalPreferences(prefRes.data); + dispatch(setPreferences(prefRes.data)); + setCategories(catRes.data); } catch (error) { - showToast.error('Failed to load notification preferences'); + showToast.error("Failed to load notification settings"); } finally { setIsLoading(false); } }; - if (!storedPreferences) { - fetchPrefs(); - } else { - setLocalPreferences(storedPreferences); - } - }, [dispatch, storedPreferences]); + fetchData(); + }, [dispatch]); const handleToggle = (field: keyof NotificationPreferences) => { if (!preferences) return; setLocalPreferences({ ...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; const categoryPrefs = { ...preferences.category_preferences }; categoryPrefs[category] = { ...categoryPrefs[category], - [channel]: !categoryPrefs[category][channel] + [channel]: !categoryPrefs[category][channel], }; setLocalPreferences({ ...preferences, - category_preferences: categoryPrefs + category_preferences: categoryPrefs, }); }; @@ -72,226 +80,306 @@ export const NotificationSettings = () => { try { const response = await notificationService.updatePreferences(preferences); dispatch(setPreferences(response.data)); - showToast.success('Notification preferences updated successfully'); + showToast.success("Notification preferences updated successfully"); } catch (error) { - showToast.error('Failed to update preferences'); + showToast.error("Failed to update preferences"); } finally { setIsSaving(false); } }; if (isLoading || !preferences) { - return
Loading preferences...
; + return ( +
+ Loading preferences... +
+ ); } return ( -
-
-
-

Notification Settings

-

Control how and when you want to be notified

-
- -
- - {/* Master Toggles */} -
- - -
-
-
- -
-
-

In-App Notifications

-

Receive real-time alerts in the platform

-
-
- -
-
-
- - - -
-
-
- -
-
-

Email Notifications

-

Get updates delivered to your inbox

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

{cat.label}

-

{cat.description}

-
- handleCategoryToggle(cat.id, 'in_app')} - /> - - handleCategoryToggle(cat.id, 'email')} - /> -
-
-
- - {/* Advanced Settings */} -
- {/* Quiet Hours */} - - -
- - Quiet Hours -
- Suppress non-urgent notifications during specific times -
- -
- Enable Quiet Hours - -
- - {preferences.quiet_hours_enabled && ( -
-
- - setLocalPreferences({...preferences, quiet_hours_start: e.target.value})} - /> -
-
- - setLocalPreferences({...preferences, quiet_hours_end: e.target.value})} - /> -
-
+ + {isSaving ? ( + "Saving..." + ) : ( + <> + + Save Changes + )} -
- - Urgent priority notifications bypass quiet hours and are always delivered. -
-
-
- - {/* Email Frequency & Timezone */} - - -
- - Email Frequency -
- How often you want to receive email updates -
- -
- {['instant', 'daily', 'weekly'].map((freq) => ( -
+ ); }; diff --git a/src/pages/tenant/NotificationTemplates.tsx b/src/pages/tenant/NotificationTemplates.tsx new file mode 100644 index 0000000..21d878d --- /dev/null +++ b/src/pages/tenant/NotificationTemplates.tsx @@ -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([]); + const [modules, setModules] = useState([]); + const [selectedModule, setSelectedModule] = useState('all'); + const [isLoading, setIsLoading] = useState(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(false); + const [selectedTemplate, setSelectedTemplate] = useState(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[] = [ + { key: 'category', label: 'Category', render: (t) => {t.category_name} }, + { key: 'code', label: 'Event', render: (t) => {t.code} }, + { key: 'source', label: 'Source', render: (t) => ( + + {t.tenant_id ? 'Custom Override' : 'System Default'} + + )}, + { key: 'preview', label: 'Title Preview', render: (t) => {t.title_template} }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (t) => ( +
+ + {t.tenant_id && ( + + )} +
+ ) + } + ]; + + return ( + +
+
+
+ +

Notification Templates

+
+ +
+
+ Filter by Module +
+
+ { setSelectedModule(val); setCurrentPage(1); }} + options={[ + { value: 'all', label: 'All Modules' }, + ...modules.map(m => ({ value: m.id, label: m.name })) + ]} + placeholder="Select Module" + /> +
+
+
+ +
+ t.code} /> +
+ + { setLimit(l); setCurrentPage(1); }} + /> +
+ + setModalOpen(false)} title={`Customize: ${selectedTemplate?.code}`} maxWidth="2xl"> +
+
+
+

In-App Notification

+ setForm({...form, title_template: e.target.value})} /> + setForm({...form, message_template: e.target.value})} rows={3} /> +
+
+

Email Notification

+ setForm({...form, email_subject_template: e.target.value})} placeholder="Inherits from title if blank" /> + setForm({...form, email_body_template: e.target.value})} rows={6} /> +
+
+ +
+
+ 💡 You can use placeholders like {`{{user_name}}`}, {`{{entity_name}}`}, and {`{{action}}`}. +
+
+ + Save Override +
+
+
+
+
+ ); +}; + +export default NotificationTemplates; diff --git a/src/pages/tenant/Notifications.tsx b/src/pages/tenant/Notifications.tsx index 59ee5c2..6981071 100644 --- a/src/pages/tenant/Notifications.tsx +++ b/src/pages/tenant/Notifications.tsx @@ -163,7 +163,7 @@ export const Notifications = () => { } // 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"); return; } diff --git a/src/pages/tenant/Tasks.tsx b/src/pages/tenant/Tasks.tsx index 3fc51e2..09096ab 100644 --- a/src/pages/tenant/Tasks.tsx +++ b/src/pages/tenant/Tasks.tsx @@ -141,9 +141,14 @@ const Tasks = (): ReactElement => { { key: "assigned_role", label: "Assigned To", - render: (task) => ( - {task.assignment.assigned_role} - ), + render: (task) => { + const role = task.assignment.assigned_role; + return ( + + {Array.isArray(role) ? role.join(", ") : (role || "-")} + + ); + }, }, { key: "status", diff --git a/src/routes/super-admin-routes.tsx b/src/routes/super-admin-routes.tsx index 3498533..cb6dd3b 100644 --- a/src/routes/super-admin-routes.tsx +++ b/src/routes/super-admin-routes.tsx @@ -15,6 +15,8 @@ const Suppliers = lazy(() => import("@/pages/superadmin/Suppliers")); const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings")); const Notifications = lazy(() => import("@/pages/tenant/Notifications")); const AuditLogResourceTypes = lazy(() => import("@/pages/superadmin/AuditLogResourceTypes")); +const NotificationMaster = lazy(() => import("@/pages/superadmin/NotificationMaster")); +const NotificationTemplateMaster = lazy(() => import("@/pages/superadmin/NotificationTemplateMaster")); // Loading fallback component const RouteLoader = (): ReactElement => ( @@ -85,4 +87,12 @@ export const superAdminRoutes: RouteConfig[] = [ path: "/audit-resource-types", element: , }, + { + path: "/notification-master", + element: , + }, + { + path: "/notification-templates", + element: , + }, ]; diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx index 7c63722..d17cf6d 100644 --- a/src/routes/tenant-admin-routes.tsx +++ b/src/routes/tenant-admin-routes.tsx @@ -30,6 +30,7 @@ const NotificationSettings = lazy( () => import("@/pages/tenant/NotificationSettings"), ); const Notifications = lazy(() => import("@/pages/tenant/Notifications")); +const NotificationTemplates = lazy(() => import("@/pages/tenant/NotificationTemplates")); const FilesList = lazy(() => import("@/pages/tenant/FilesList")); const FileView = lazy(() => import("@/pages/tenant/FileView")); const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard")); @@ -139,6 +140,10 @@ export const tenantAdminRoutes: RouteConfig[] = [ path: "/tenant/notifications", element: , }, + { + path: "/tenant/settings/notification-templates", + element: , + }, { path: "/tenant/files", element: , diff --git a/src/services/notification-service.ts b/src/services/notification-service.ts index ff27a6b..22a8b86 100644 --- a/src/services/notification-service.ts +++ b/src/services/notification-service.ts @@ -4,6 +4,12 @@ import type { Notification, NotificationPreferences } from '@/types/notification export interface NotificationResponse { success: boolean; data: T; + pagination?: { + total: number; + limit: number; + offset: number; + pages: number; + }; } export interface GetNotificationsParams { @@ -14,51 +20,122 @@ export interface GetNotificationsParams { } export const notificationService = { - // GET /notifications/me + // USER NOTIFICATIONS getNotifications: async (params?: GetNotificationsParams): Promise> => { const response = await apiClient.get('/notifications/me', { params }); return response.data; }, - // GET /notifications/me/unread-count getUnreadCount: async (): Promise> => { const response = await apiClient.get('/notifications/me/unread-count'); return response.data; }, - // PUT /notifications/me/read-all readAll: async (): Promise> => { const response = await apiClient.put('/notifications/me/read-all'); return response.data; }, - // PUT /notifications/me/dismiss-all dismissAll: async (): Promise> => { const response = await apiClient.put('/notifications/me/dismiss-all'); return response.data; }, - // PUT /notifications/:id/read markAsRead: async (id: string): Promise> => { const response = await apiClient.put(`/notifications/${id}/read`); return response.data; }, - // PUT /notifications/:id/dismiss dismiss: async (id: string): Promise> => { const response = await apiClient.put(`/notifications/${id}/dismiss`); return response.data; }, - // GET /notifications/preferences getPreferences: async (): Promise> => { const response = await apiClient.get('/notifications/preferences'); return response.data; }, - // PUT /notifications/preferences updatePreferences: async (preferences: Partial): Promise> => { const response = await apiClient.put('/notifications/preferences', preferences); return response.data; }, + + // ============================================================================ + // MASTER: CATEGORIES & CODES + // ============================================================================ + + getCategories: async (params?: any): Promise> => { + const response = await apiClient.get('/notifications/categories', { params }); + return response.data; + }, + + getCategoriesForTenant: async (): Promise> => { + const response = await apiClient.get('/notifications/categories/tenant'); + return response.data; + }, + + createCategory: async (data: any): Promise> => { + const response = await apiClient.post('/notifications/categories', data); + return response.data; + }, + + updateCategory: async (id: string, data: any): Promise> => { + const response = await apiClient.put(`/notifications/categories/${id}`, data); + return response.data; + }, + + deleteCategory: async (id: string): Promise> => { + const response = await apiClient.delete(`/notifications/categories/${id}`); + return response.data; + }, + + getCodesByCategory: async (categoryId: string, params?: any): Promise> => { + const response = await apiClient.get(`/notifications/categories/${categoryId}/codes`, { params }); + return response.data; + }, + + createCode: async (categoryId: string, data: any): Promise> => { + const response = await apiClient.post(`/notifications/categories/${categoryId}/codes`, data); + return response.data; + }, + + updateCode: async (codeId: string, data: any): Promise> => { + const response = await apiClient.put(`/notifications/codes/${codeId}`, data); + return response.data; + }, + + deleteCode: async (codeId: string): Promise> => { + const response = await apiClient.delete(`/notifications/codes/${codeId}`); + return response.data; + }, + + // ============================================================================ + // TEMPLATES + // ============================================================================ + + getTemplates: async (params?: any): Promise> => { + const response = await apiClient.get('/notifications/templates', { params }); + return response.data; + }, + + getSuperAdminTemplates: async (params?: any): Promise> => { + const response = await apiClient.get('/notifications/templates/super-admin', { params }); + return response.data; + }, + + createTemplate: async (data: any): Promise> => { + const response = await apiClient.post('/notifications/templates', data); + return response.data; + }, + + overrideTemplate: async (code: string, data: any): Promise> => { + const response = await apiClient.put(`/notifications/templates/${code}/override`, data); + return response.data; + }, + + resetTemplate: async (code: string): Promise> => { + const response = await apiClient.delete(`/notifications/templates/${code}/reset`); + return response.data; + }, }; diff --git a/src/types/workflow.ts b/src/types/workflow.ts index cf89573..205d5b9 100644 --- a/src/types/workflow.ts +++ b/src/types/workflow.ts @@ -174,7 +174,7 @@ export interface WorkflowTask { assignment: { assigned_to: string | null; assigned_to_name: string | null; - assigned_role: string; + assigned_role: string | string[]; assigned_at: string; }; status: string;