feat: integrate RichTextEditor and variable copy functionality into notification template management
This commit is contained in:
parent
cde2544cf3
commit
fe707216f6
@ -7,7 +7,7 @@ import {
|
|||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
DeleteConfirmationModal,
|
// DeleteConfirmationModal,
|
||||||
type Column,
|
type Column,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import {
|
import {
|
||||||
@ -58,7 +58,7 @@ const DepartmentsTable = ({
|
|||||||
const [isNewModalOpen, setIsNewModalOpen] = useState(false);
|
const [isNewModalOpen, setIsNewModalOpen] = useState(false);
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
// const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [selectedDepartment, setSelectedDepartment] =
|
const [selectedDepartment, setSelectedDepartment] =
|
||||||
useState<Department | null>(null);
|
useState<Department | null>(null);
|
||||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||||
@ -138,27 +138,27 @@ const DepartmentsTable = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
// const handleDelete = async () => {
|
||||||
if (!selectedDepartment) return;
|
// if (!selectedDepartment) return;
|
||||||
try {
|
// try {
|
||||||
setIsActionLoading(true);
|
// setIsActionLoading(true);
|
||||||
const response = await departmentService.delete(
|
// const response = await departmentService.delete(
|
||||||
selectedDepartment.id,
|
// selectedDepartment.id,
|
||||||
effectiveTenantId,
|
// effectiveTenantId,
|
||||||
);
|
// );
|
||||||
if (response.success) {
|
// if (response.success) {
|
||||||
showToast.success("Department deleted successfully");
|
// showToast.success("Department deleted successfully");
|
||||||
setIsDeleteModalOpen(false);
|
// setIsDeleteModalOpen(false);
|
||||||
fetchDepartments();
|
// fetchDepartments();
|
||||||
}
|
// }
|
||||||
} catch (err: any) {
|
// } catch (err: any) {
|
||||||
showToast.error(
|
// showToast.error(
|
||||||
err?.response?.data?.error?.message || "Failed to delete department",
|
// err?.response?.data?.error?.message || "Failed to delete department",
|
||||||
);
|
// );
|
||||||
} finally {
|
// } finally {
|
||||||
setIsActionLoading(false);
|
// setIsActionLoading(false);
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Client-side pagination logic
|
// Client-side pagination logic
|
||||||
const totalItems = departments.length;
|
const totalItems = departments.length;
|
||||||
@ -237,10 +237,10 @@ const DepartmentsTable = ({
|
|||||||
setSelectedDepartment(dept);
|
setSelectedDepartment(dept);
|
||||||
setIsEditModalOpen(true);
|
setIsEditModalOpen(true);
|
||||||
}}
|
}}
|
||||||
onDelete={() => {
|
// onDelete={() => {
|
||||||
setSelectedDepartment(dept);
|
// setSelectedDepartment(dept);
|
||||||
setIsDeleteModalOpen(true);
|
// setIsDeleteModalOpen(true);
|
||||||
}}
|
// }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -350,7 +350,7 @@ const DepartmentsTable = ({
|
|||||||
department={selectedDepartment}
|
department={selectedDepartment}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteConfirmationModal
|
{/* <DeleteConfirmationModal
|
||||||
isOpen={isDeleteModalOpen}
|
isOpen={isDeleteModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsDeleteModalOpen(false);
|
setIsDeleteModalOpen(false);
|
||||||
@ -361,7 +361,7 @@ const DepartmentsTable = ({
|
|||||||
message="Are you sure you want to delete this department? This action cannot be undone."
|
message="Are you sure you want to delete this department? This action cannot be undone."
|
||||||
itemName={selectedDepartment?.name || ""}
|
itemName={selectedDepartment?.name || ""}
|
||||||
isLoading={isActionLoading}
|
isLoading={isActionLoading}
|
||||||
/>
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
DeleteConfirmationModal,
|
// DeleteConfirmationModal,
|
||||||
type Column,
|
type Column,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import {
|
import {
|
||||||
@ -58,7 +58,7 @@ const DesignationsTable = ({
|
|||||||
const [isNewModalOpen, setIsNewModalOpen] = useState(false);
|
const [isNewModalOpen, setIsNewModalOpen] = useState(false);
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
// const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [selectedDesignation, setSelectedDesignation] =
|
const [selectedDesignation, setSelectedDesignation] =
|
||||||
useState<Designation | null>(null);
|
useState<Designation | null>(null);
|
||||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||||
@ -138,27 +138,27 @@ const DesignationsTable = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
// const handleDelete = async () => {
|
||||||
if (!selectedDesignation) return;
|
// if (!selectedDesignation) return;
|
||||||
try {
|
// try {
|
||||||
setIsActionLoading(true);
|
// setIsActionLoading(true);
|
||||||
const response = await designationService.delete(
|
// const response = await designationService.delete(
|
||||||
selectedDesignation.id,
|
// selectedDesignation.id,
|
||||||
effectiveTenantId,
|
// effectiveTenantId,
|
||||||
);
|
// );
|
||||||
if (response.success) {
|
// if (response.success) {
|
||||||
showToast.success("Designation deleted successfully");
|
// showToast.success("Designation deleted successfully");
|
||||||
setIsDeleteModalOpen(false);
|
// setIsDeleteModalOpen(false);
|
||||||
fetchDesignations();
|
// fetchDesignations();
|
||||||
}
|
// }
|
||||||
} catch (err: any) {
|
// } catch (err: any) {
|
||||||
showToast.error(
|
// showToast.error(
|
||||||
err?.response?.data?.error?.message || "Failed to delete designation",
|
// err?.response?.data?.error?.message || "Failed to delete designation",
|
||||||
);
|
// );
|
||||||
} finally {
|
// } finally {
|
||||||
setIsActionLoading(false);
|
// setIsActionLoading(false);
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Client-side pagination logic
|
// Client-side pagination logic
|
||||||
const totalItems = designations.length;
|
const totalItems = designations.length;
|
||||||
@ -228,10 +228,10 @@ const DesignationsTable = ({
|
|||||||
setSelectedDesignation(desig);
|
setSelectedDesignation(desig);
|
||||||
setIsEditModalOpen(true);
|
setIsEditModalOpen(true);
|
||||||
}}
|
}}
|
||||||
onDelete={() => {
|
// onDelete={() => {
|
||||||
setSelectedDesignation(desig);
|
// setSelectedDesignation(desig);
|
||||||
setIsDeleteModalOpen(true);
|
// setIsDeleteModalOpen(true);
|
||||||
}}
|
// }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -339,7 +339,7 @@ const DesignationsTable = ({
|
|||||||
designation={selectedDesignation}
|
designation={selectedDesignation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteConfirmationModal
|
{/* <DeleteConfirmationModal
|
||||||
isOpen={isDeleteModalOpen}
|
isOpen={isDeleteModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsDeleteModalOpen(false);
|
setIsDeleteModalOpen(false);
|
||||||
@ -350,7 +350,7 @@ const DesignationsTable = ({
|
|||||||
message="Are you sure you want to delete this designation? This action cannot be undone."
|
message="Are you sure you want to delete this designation? This action cannot be undone."
|
||||||
itemName={selectedDesignation?.name || ""}
|
itemName={selectedDesignation?.name || ""}
|
||||||
isLoading={isActionLoading}
|
isLoading={isActionLoading}
|
||||||
/>
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, type KeyboardEvent } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { Layout } from '@/components/layout/Layout';
|
import { Layout } from '@/components/layout/Layout';
|
||||||
import {
|
import {
|
||||||
@ -13,7 +13,7 @@ import {
|
|||||||
Pagination,
|
Pagination,
|
||||||
type Column,
|
type Column,
|
||||||
} from '@/components/shared';
|
} from '@/components/shared';
|
||||||
import { Plus, Code, Search } from 'lucide-react';
|
import { Plus, Code, Search, X, Tag } from 'lucide-react';
|
||||||
import { notificationService } from '@/services/notification-service';
|
import { notificationService } from '@/services/notification-service';
|
||||||
import { moduleService } from '@/services/module-service';
|
import { moduleService } from '@/services/module-service';
|
||||||
import { showToast } from '@/utils/toast';
|
import { showToast } from '@/utils/toast';
|
||||||
@ -39,6 +39,74 @@ const codeSchema = z.object({
|
|||||||
type CategoryFormValues = z.infer<typeof categorySchema>;
|
type CategoryFormValues = z.infer<typeof categorySchema>;
|
||||||
type CodeFormValues = z.infer<typeof codeSchema>;
|
type CodeFormValues = z.infer<typeof codeSchema>;
|
||||||
|
|
||||||
|
// ── Variable Tag Input ──────────────────────────────────────────────────────
|
||||||
|
const VariableTagInput = ({
|
||||||
|
variables,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
variables: string[];
|
||||||
|
onChange: (vars: string[]) => void;
|
||||||
|
}) => {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const addTag = (val: string) => {
|
||||||
|
const tag = val.trim().replace(/\s+/g, '_').toLowerCase();
|
||||||
|
if (tag && !variables.includes(tag)) {
|
||||||
|
onChange([...variables, tag]);
|
||||||
|
}
|
||||||
|
setInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag(input);
|
||||||
|
} else if (e.key === 'Backspace' && !input && variables.length > 0) {
|
||||||
|
onChange(variables.slice(0, -1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tag: string) => onChange(variables.filter(v => v !== tag));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Tag className="w-3.5 h-3.5 text-indigo-500" />
|
||||||
|
Supported Variables
|
||||||
|
<span className="text-gray-400 font-normal">(used in template placeholders)</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap gap-1.5 p-2 border border-gray-200 rounded-lg bg-white min-h-[42px] cursor-text focus-within:ring-2 focus-within:ring-indigo-500/20 focus-within:border-indigo-400 transition-all"
|
||||||
|
onClick={() => inputRef.current?.focus()}
|
||||||
|
>
|
||||||
|
{variables.map(v => (
|
||||||
|
<span key={v} className="flex items-center gap-1 bg-indigo-50 border border-indigo-200 text-indigo-700 text-[11px] font-mono font-bold px-2 py-0.5 rounded-full">
|
||||||
|
{`{{${v}}}`}
|
||||||
|
<button type="button" onClick={() => removeTag(v)} className="hover:text-red-600 transition-colors ml-0.5">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={() => input && addTag(input)}
|
||||||
|
placeholder={variables.length === 0 ? 'Type variable name, press Enter or comma...' : ''}
|
||||||
|
className="flex-1 min-w-[120px] text-xs outline-none bg-transparent text-gray-700 placeholder-gray-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-400 mt-1">
|
||||||
|
These are the <code>{'{{variable}}'}</code> placeholders admins can use in templates for this event code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const NotificationMaster = (): ReactElement => {
|
const NotificationMaster = (): ReactElement => {
|
||||||
const [categories, setCategories] = useState<any[]>([]);
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
@ -54,7 +122,6 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
const [categoryModalOpen, setCategoryModalOpen] = useState<boolean>(false);
|
const [categoryModalOpen, setCategoryModalOpen] = useState<boolean>(false);
|
||||||
const [editingCategory, setEditingCategory] = useState<any>(null);
|
const [editingCategory, setEditingCategory] = useState<any>(null);
|
||||||
|
|
||||||
// New: React Hook Form for Category
|
|
||||||
const {
|
const {
|
||||||
register: registerCategory,
|
register: registerCategory,
|
||||||
handleSubmit: handleCategorySubmit,
|
handleSubmit: handleCategorySubmit,
|
||||||
@ -79,8 +146,8 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
const [codePages, setCodePages] = useState(0);
|
const [codePages, setCodePages] = useState(0);
|
||||||
|
|
||||||
const [editingCode, setEditingCode] = useState<any>(null);
|
const [editingCode, setEditingCode] = useState<any>(null);
|
||||||
|
const [codeVariables, setCodeVariables] = useState<string[]>([]);
|
||||||
|
|
||||||
// New: React Hook Form for Code
|
|
||||||
const {
|
const {
|
||||||
register: registerCode,
|
register: registerCode,
|
||||||
handleSubmit: handleCodeSubmit,
|
handleSubmit: handleCodeSubmit,
|
||||||
@ -117,13 +184,10 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const fetchModules = async () => {
|
const fetchModules = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await moduleService.getAll(1, 100);
|
const res = await moduleService.getAll(1, 100);
|
||||||
if (res.success) {
|
if (res.success) setModules(res.data);
|
||||||
setModules(res.data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch modules', err);
|
console.error('Failed to fetch modules', err);
|
||||||
}
|
}
|
||||||
@ -156,6 +220,9 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
const handleOpenCodes = async (category: any) => {
|
const handleOpenCodes = async (category: any) => {
|
||||||
setSelectedCategory(category);
|
setSelectedCategory(category);
|
||||||
setCodePage(1);
|
setCodePage(1);
|
||||||
|
setEditingCode(null);
|
||||||
|
setCodeVariables([]);
|
||||||
|
resetCode({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' });
|
||||||
await fetchCodes(category, 1);
|
await fetchCodes(category, 1);
|
||||||
setCodeModalOpen(true);
|
setCodeModalOpen(true);
|
||||||
};
|
};
|
||||||
@ -168,11 +235,8 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
|
|
||||||
const onSaveCategory = async (data: CategoryFormValues) => {
|
const onSaveCategory = async (data: CategoryFormValues) => {
|
||||||
try {
|
try {
|
||||||
// Conditionally include module_id only if it's actually selected
|
|
||||||
const payload = { ...data };
|
const payload = { ...data };
|
||||||
if (!payload.module_id) {
|
if (!payload.module_id) delete payload.module_id;
|
||||||
delete payload.module_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editingCategory) {
|
if (editingCategory) {
|
||||||
await notificationService.updateCategory(editingCategory.id, payload);
|
await notificationService.updateCategory(editingCategory.id, payload);
|
||||||
@ -185,26 +249,26 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
resetCategory();
|
resetCategory();
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.error?.message || err.message || 'Action failed';
|
showToast.error(err.response?.data?.error?.message || err.message || 'Action failed');
|
||||||
showToast.error(errorMessage);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSaveCode = async (data: CodeFormValues) => {
|
const onSaveCode = async (data: CodeFormValues) => {
|
||||||
try {
|
try {
|
||||||
|
const payload = { ...data, variables: codeVariables };
|
||||||
if (editingCode) {
|
if (editingCode) {
|
||||||
await notificationService.updateCode(editingCode.id, data);
|
await notificationService.updateCode(editingCode.id, payload);
|
||||||
showToast.success('Code updated');
|
showToast.success('Code updated');
|
||||||
} else {
|
} else {
|
||||||
await notificationService.createCode(selectedCategory.id, data);
|
await notificationService.createCode(selectedCategory.id, payload);
|
||||||
showToast.success('Code created');
|
showToast.success('Code created');
|
||||||
}
|
}
|
||||||
setEditingCode(null);
|
setEditingCode(null);
|
||||||
resetCode();
|
setCodeVariables([]);
|
||||||
|
resetCode({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' });
|
||||||
fetchCodes(selectedCategory, codePage);
|
fetchCodes(selectedCategory, codePage);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.error?.message || err.message || 'Action failed';
|
showToast.error(err.response?.data?.error?.message || err.message || 'Action failed');
|
||||||
showToast.error(errorMessage);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -305,7 +369,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category Modal */}
|
{/* Category Modal */}
|
||||||
<Modal isOpen={categoryModalOpen} onClose={() => setCategoryModalOpen(false)} title={editingCategory ? "Edit Category" : "New Category"} maxWidth="md">
|
<Modal isOpen={categoryModalOpen} onClose={() => setCategoryModalOpen(false)} title={editingCategory ? "Edit Category" : "New Category"} maxWidth="lg">
|
||||||
<div className="space-y-4 p-6">
|
<div className="space-y-4 p-6">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField label="Category Name" required placeholder="e.g. Workflow" error={categoryErrors.name?.message} {...registerCategory('name')} />
|
<FormField label="Category Name" required placeholder="e.g. Workflow" error={categoryErrors.name?.message} {...registerCategory('name')} />
|
||||||
@ -332,25 +396,45 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
|
|
||||||
{/* Codes Modal */}
|
{/* Codes Modal */}
|
||||||
<Modal isOpen={codeModalOpen} onClose={() => setCodeModalOpen(false)} title={`Event Codes: ${selectedCategory?.name}`} maxWidth="2xl">
|
<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="p-6 space-y-6">
|
||||||
|
{/* ── Add / Edit Code Form ─────────────────────────────────── */}
|
||||||
<div className="bg-gray-50 p-5 rounded-xl border border-gray-200 space-y-4 shadow-inner">
|
<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">
|
<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'}
|
<Plus className="w-3 h-3" /> {editingCode ? 'Edit Event Code' : 'Add New Event Trigger'}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField label="Code (e.g. task_assigned)" required error={codeErrors.code?.message} disabled={!!editingCode} {...registerCode('code')} />
|
<FormField label="Code (e.g. task_assigned)" required error={codeErrors.code?.message} disabled={!!editingCode} {...registerCode('code')} />
|
||||||
<FormField label="Display Name" required error={codeErrors.name?.message} {...registerCode('name')} />
|
<FormField label="Display Name" required error={codeErrors.name?.message} {...registerCode('name')} />
|
||||||
</div>
|
</div>
|
||||||
<FormTextArea label="Description" rows={2} error={codeErrors.description?.message} {...registerCode('description')} />
|
<FormTextArea label="Description" rows={2} error={codeErrors.description?.message} {...registerCode('description')} />
|
||||||
|
|
||||||
|
{/* Variables tag input */}
|
||||||
|
<VariableTagInput variables={codeVariables} onChange={setCodeVariables} />
|
||||||
|
|
||||||
<div className="flex justify-between items-center pt-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="text-[10px] text-gray-400 font-medium">Auto-populates default channels (In-App, Email)</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{editingCode && <button onClick={() => { setEditingCode(null); resetCode({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' }); }} className="text-xs text-gray-500 hover:text-gray-800 underline transition-colors">Cancel Edit</button>}
|
{editingCode && (
|
||||||
<PrimaryButton onClick={handleCodeSubmit(onSaveCode)} size="default"> {editingCode ? 'Update Code' : 'Add Code'}</PrimaryButton>
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingCode(null);
|
||||||
|
setCodeVariables([]);
|
||||||
|
resetCode({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' });
|
||||||
|
}}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-800 underline transition-colors"
|
||||||
|
>
|
||||||
|
Cancel Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<PrimaryButton onClick={handleCodeSubmit(onSaveCode)} size="default">
|
||||||
|
{editingCode ? 'Update Code' : 'Add Code'}
|
||||||
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Registered Codes Table ───────────────────────────────── */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-[11px] font-bold text-gray-400 uppercase tracking-widest px-1">Registered Codes</h4>
|
<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">
|
<div className="border border-gray-100 rounded-xl overflow-hidden shadow-sm">
|
||||||
@ -359,21 +443,33 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
<tr>
|
<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">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-left text-[11px] font-bold text-gray-500 uppercase tracking-wider">Name</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-bold text-gray-500 uppercase tracking-wider">Variables</th>
|
||||||
<th className="px-4 py-3 text-right text-[11px] font-bold text-gray-500 uppercase tracking-wider">Actions</th>
|
<th className="px-4 py-3 text-right text-[11px] font-bold text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-50 text-[13px]">
|
<tbody className="divide-y divide-gray-50 text-[13px]">
|
||||||
{isCodesLoading ? (
|
{isCodesLoading ? (
|
||||||
<tr><td colSpan={3} className="px-4 py-10 text-center text-gray-400 animate-pulse">Loading codes...</td></tr>
|
<tr><td colSpan={4} className="px-4 py-10 text-center text-gray-400 animate-pulse">Loading codes...</td></tr>
|
||||||
) : codes.length === 0 ? (
|
) : 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>
|
<tr><td colSpan={4} className="px-4 py-10 text-center text-gray-400 italic">No codes registered for this category.</td></tr>
|
||||||
) : codes.map(c => (
|
) : codes.map(c => (
|
||||||
<tr key={c.id} className="hover:bg-gray-50/50 transition-colors">
|
<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"><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 font-medium text-gray-700">{c.name}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Array.isArray(c.variables) && c.variables.length > 0
|
||||||
|
? c.variables.map((v: string) => (
|
||||||
|
<span key={v} className="text-[10px] font-mono bg-indigo-50 text-indigo-600 border border-indigo-100 px-1.5 py-0.5 rounded-full">{`{{${v}}}`}</span>
|
||||||
|
))
|
||||||
|
: <span className="text-[10px] text-gray-300 italic">none</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
setEditingCode(c);
|
setEditingCode(c);
|
||||||
|
setCodeVariables(Array.isArray(c.variables) ? c.variables : []);
|
||||||
resetCode({ code: c.code, name: c.name, description: c.description || '', default_channels: c.default_channels, default_priority: c.default_priority });
|
resetCode({ 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>
|
}} className="text-blue-600 hover:text-blue-800 font-bold text-[11px] uppercase mr-3">Edit</button>
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
@ -393,7 +489,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
totalItems={codeTotal}
|
totalItems={codeTotal}
|
||||||
limit={5}
|
limit={5}
|
||||||
onPageChange={setCodePage}
|
onPageChange={setCodePage}
|
||||||
onLimitChange={() => {}} // Fixed for codes modal
|
onLimitChange={() => {}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from "react";
|
||||||
import { Layout } from '@/components/layout/Layout';
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import {
|
import {
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
DataTable,
|
DataTable,
|
||||||
@ -8,34 +8,87 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
FormSelect,
|
FormSelect,
|
||||||
FormTextArea,
|
FormTextArea,
|
||||||
|
RichTextEditor,
|
||||||
Pagination,
|
Pagination,
|
||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
type Column,
|
type Column,
|
||||||
} from '@/components/shared';
|
} from "@/components/shared";
|
||||||
import { Plus, Search } from 'lucide-react';
|
import { Plus, Search, Copy, CheckCheck } from "lucide-react";
|
||||||
import { notificationService } from '@/services/notification-service';
|
import { notificationService } from "@/services/notification-service";
|
||||||
import { moduleService } from '@/services/module-service';
|
import { moduleService } from "@/services/module-service";
|
||||||
import { showToast } from '@/utils/toast';
|
import { showToast } from "@/utils/toast";
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
const templateSchema = z.object({
|
const templateSchema = z.object({
|
||||||
category: z.string().min(1, 'Category is required'),
|
category: z.string().min(1, "Category is required"),
|
||||||
code: z.string().min(1, 'Event code is required'),
|
code: z.string().min(1, "Event code is required"),
|
||||||
name: z.string().min(1, 'Friendly name is required'),
|
name: z.string().min(1, "Friendly name is required"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
title_template: z.string().min(1, 'Title template is required'),
|
title_template: z.string().min(1, "Title template is required"),
|
||||||
message_template: z.string().min(1, 'Message template is required'),
|
message_template: z.string().min(1, "Message template is required"),
|
||||||
email_subject_template: z.string().optional(),
|
email_subject_template: z.string().optional(),
|
||||||
email_body_template: z.string().optional(),
|
email_body_template: z.string().optional(),
|
||||||
default_priority: z.enum(['low', 'normal', 'high', 'urgent']),
|
default_priority: z.enum(["low", "normal", "high", "urgent"]),
|
||||||
channels: z.array(z.string()).min(1, 'At least one channel is required'),
|
channels: z.array(z.string()).min(1, "At least one channel is required"),
|
||||||
is_active: z.boolean(),
|
is_active: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type TemplateFormValues = z.infer<typeof templateSchema>;
|
type TemplateFormValues = z.infer<typeof templateSchema>;
|
||||||
|
|
||||||
|
// ── Variable Chips ────────────────────────────────────────────────────────────
|
||||||
|
const VariableChips = ({
|
||||||
|
variables,
|
||||||
|
onCopy,
|
||||||
|
}: {
|
||||||
|
variables: string[];
|
||||||
|
onCopy?: (v: string) => void;
|
||||||
|
}) => {
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCopy = (v: string) => {
|
||||||
|
const placeholder = `{{${v}}}`;
|
||||||
|
navigator.clipboard.writeText(placeholder).then(() => {
|
||||||
|
setCopied(v);
|
||||||
|
onCopy?.(v);
|
||||||
|
setTimeout(() => setCopied(null), 1500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!variables || variables.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-indigo-100 bg-indigo-50/60 p-3 space-y-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-indigo-500">
|
||||||
|
Available Variables — click to copy placeholder
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{variables.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
title={`Click to copy {{${v}}}`}
|
||||||
|
onClick={() => handleCopy(v)}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-mono font-semibold bg-white border border-indigo-200 text-indigo-700 px-2 py-0.5 rounded-full hover:bg-indigo-100 hover:border-indigo-400 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{copied === v ? (
|
||||||
|
<CheckCheck className="w-3 h-3 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3 h-3 opacity-60" />
|
||||||
|
)}
|
||||||
|
{`{{${v}}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-indigo-400">
|
||||||
|
These variables will be replaced with real values when the notification
|
||||||
|
is sent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const NotificationTemplateMaster = (): ReactElement => {
|
const NotificationTemplateMaster = (): ReactElement => {
|
||||||
const [templates, setTemplates] = useState<any[]>([]);
|
const [templates, setTemplates] = useState<any[]>([]);
|
||||||
const [modules, setModules] = useState<any[]>([]);
|
const [modules, setModules] = useState<any[]>([]);
|
||||||
@ -44,45 +97,51 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
const [codes, setCodes] = useState<any[]>([]);
|
const [codes, setCodes] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
|
// Template variable chips (from selected code)
|
||||||
|
const [templateVariables, setTemplateVariables] = useState<string[]>([]);
|
||||||
|
|
||||||
// Pagination & Search
|
// Pagination & Search
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [limit, setLimit] = useState(10);
|
const [limit, setLimit] = useState(10);
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
// Template Modal
|
// Template Modal
|
||||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// HTML body state (managed separately for RichTextEditor)
|
||||||
|
const [emailBodyHtml, setEmailBodyHtml] = useState("");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
watch,
|
watch,
|
||||||
formState: { errors }
|
formState: { errors },
|
||||||
} = useForm<TemplateFormValues>({
|
} = useForm<TemplateFormValues>({
|
||||||
resolver: zodResolver(templateSchema),
|
resolver: zodResolver(templateSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
code: '',
|
code: "",
|
||||||
name: '',
|
name: "",
|
||||||
description: '',
|
description: "",
|
||||||
category: '',
|
category: "",
|
||||||
title_template: '',
|
title_template: "",
|
||||||
message_template: '',
|
message_template: "",
|
||||||
email_subject_template: '',
|
email_subject_template: "",
|
||||||
email_body_template: '',
|
email_body_template: "",
|
||||||
default_priority: 'normal',
|
default_priority: "normal",
|
||||||
channels: ['in_app', 'email'],
|
channels: ["in_app", "email"],
|
||||||
is_active: true
|
is_active: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const channels = watch('channels');
|
const channels = watch("channels");
|
||||||
const categoryValue = watch('category');
|
const categoryValue = watch("category");
|
||||||
const codeValue = watch('code');
|
const codeValue = watch("code");
|
||||||
const priorityValue = watch('default_priority');
|
const priorityValue = watch("default_priority");
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
@ -92,10 +151,10 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
limit,
|
limit,
|
||||||
offset: (currentPage - 1) * limit,
|
offset: (currentPage - 1) * limit,
|
||||||
search,
|
search,
|
||||||
module_id: selectedModule || undefined
|
module_id: selectedModule || undefined,
|
||||||
}),
|
}),
|
||||||
notificationService.getCategories({ limit: 100 }), // Fetch more for dropdown
|
notificationService.getCategories({ limit: 100 }),
|
||||||
moduleService.getAll(1, 100)
|
moduleService.getAll(1, 100),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (tRes.success) {
|
if (tRes.success) {
|
||||||
@ -103,151 +162,257 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
setTotalItems(tRes.pagination?.total || tRes.data.length);
|
setTotalItems(tRes.pagination?.total || tRes.data.length);
|
||||||
setTotalPages(tRes.pagination?.pages || 1);
|
setTotalPages(tRes.pagination?.pages || 1);
|
||||||
}
|
}
|
||||||
if (cRes.success) {
|
if (cRes.success) setCategories(cRes.data);
|
||||||
setCategories(cRes.data);
|
if (mRes.success) setModules(mRes.data);
|
||||||
}
|
|
||||||
if (mRes.success) {
|
|
||||||
setModules(mRes.data);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast.error(err.message || 'Failed to fetch data');
|
showToast.error(err.message || "Failed to fetch data");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [currentPage, limit, search, selectedModule]);
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [currentPage, limit, search, selectedModule]);
|
||||||
|
|
||||||
const fetchCodesForCategory = async (categoryCode: string) => {
|
const fetchCodesForCategory = async (categoryCode: string) => {
|
||||||
if (!categoryCode) return;
|
if (!categoryCode) return;
|
||||||
try {
|
try {
|
||||||
const res = await notificationService.getCodesByCategory(categoryCode, { limit: 100 });
|
const res = await notificationService.getCodesByCategory(categoryCode, {
|
||||||
if (res.success) setCodes(res.data);
|
limit: 100,
|
||||||
|
});
|
||||||
|
if (res.success) setCodes(res.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load codes:', e);
|
console.error("Failed to load codes:", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCategorySelect = async (categoryCode: string) => {
|
const handleCategorySelect = async (categoryCode: string) => {
|
||||||
setValue('category', categoryCode, { shouldValidate: true });
|
setValue("category", categoryCode, { shouldValidate: true });
|
||||||
setValue('code', '', { shouldValidate: true });
|
setValue("code", "", { shouldValidate: true });
|
||||||
|
setTemplateVariables([]);
|
||||||
await fetchCodesForCategory(categoryCode);
|
await fetchCodesForCategory(categoryCode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCodeSelect = (val: string) => {
|
||||||
|
const selectedCode = codes.find((c) => c.code === val);
|
||||||
|
setValue("code", val, { shouldValidate: true });
|
||||||
|
if (selectedCode?.name)
|
||||||
|
setValue("name", selectedCode.name, { shouldValidate: true });
|
||||||
|
// Load variables for this code
|
||||||
|
const vars = Array.isArray(selectedCode?.variables)
|
||||||
|
? selectedCode.variables
|
||||||
|
: [];
|
||||||
|
setTemplateVariables(vars);
|
||||||
|
};
|
||||||
|
|
||||||
const onSave = async (data: TemplateFormValues) => {
|
const onSave = async (data: TemplateFormValues) => {
|
||||||
try {
|
try {
|
||||||
|
const payload = { ...data, email_body_template: emailBodyHtml };
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await notificationService.updateTemplate(editingId, data);
|
await notificationService.updateTemplate(editingId, payload);
|
||||||
} else {
|
} else {
|
||||||
await notificationService.createTemplate(data);
|
await notificationService.createTemplate(payload);
|
||||||
}
|
}
|
||||||
showToast.success(editingId ? 'Template updated' : 'Template created');
|
showToast.success(editingId ? "Template updated" : "Template created");
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
reset();
|
reset();
|
||||||
|
setEmailBodyHtml("");
|
||||||
|
setTemplateVariables([]);
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.error?.message || err.message || 'Action failed';
|
showToast.error(
|
||||||
showToast.error(errorMessage);
|
err.response?.data?.error?.message || err.message || "Action failed",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEditModal = (t: any) => {
|
||||||
|
setEditingId(t.id);
|
||||||
|
const vars = Array.isArray(t.variables) ? t.variables : [];
|
||||||
|
setTemplateVariables(vars);
|
||||||
|
setEmailBodyHtml(t.email_body_template || "");
|
||||||
|
reset({
|
||||||
|
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") as any,
|
||||||
|
channels: t.channels || ["in_app", "email"],
|
||||||
|
is_active: t.is_active ?? true,
|
||||||
|
});
|
||||||
|
// Load codes for the category so user can change code
|
||||||
|
if (t.category) fetchCodesForCategory(t.category);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const columns: Column<any>[] = [
|
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',
|
key: "category",
|
||||||
label: 'Channels',
|
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: "variables",
|
||||||
|
label: "Variables",
|
||||||
|
render: (t) => (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Array.isArray(t.variables) && t.variables.length > 0 ? (
|
||||||
|
t.variables
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((v: string) => (
|
||||||
|
<span
|
||||||
|
key={v}
|
||||||
|
className="text-[9px] font-mono bg-indigo-50 text-indigo-600 px-1.5 py-0.5 rounded-full border border-indigo-100"
|
||||||
|
>{`{{${v}}}`}</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-gray-300 italic">none</span>
|
||||||
|
)}
|
||||||
|
{Array.isArray(t.variables) && t.variables.length > 3 && (
|
||||||
|
<span className="text-[9px] text-gray-400">
|
||||||
|
+{t.variables.length - 3} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => (
|
render: (t) => (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{t.channels?.map((c: string) => (
|
{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>
|
<span
|
||||||
|
key={c}
|
||||||
|
className="text-[9px] bg-green-50 text-green-700 px-1 rounded border border-green-200 uppercase"
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: "actions",
|
||||||
label: 'Actions',
|
label: "Actions",
|
||||||
align: 'right',
|
align: "right",
|
||||||
render: (t) => (
|
render: (t) => (
|
||||||
<button onClick={() => {
|
<button
|
||||||
setEditingId(t.id);
|
onClick={() => openEditModal(t)}
|
||||||
reset({
|
className="text-xs text-blue-600 hover:underline font-semibold"
|
||||||
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') as any,
|
|
||||||
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
|
Edit
|
||||||
</button>
|
</button>
|
||||||
)
|
),
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
currentPage="Global Templates"
|
currentPage="Global Templates"
|
||||||
pageHeader={{
|
pageHeader={{
|
||||||
title: 'Global Notification Templates',
|
title: "Global Notification Templates",
|
||||||
description: 'Define default notification templates for all tenants.',
|
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="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="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="flex items-center gap-4 flex-1">
|
||||||
<div className="relative flex-1 max-w-sm">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search templates..."
|
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"
|
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}
|
value={search}
|
||||||
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
onChange={(e) => {
|
||||||
/>
|
setSearch(e.target.value);
|
||||||
</div>
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Module"
|
label="Module"
|
||||||
value={selectedModule}
|
value={selectedModule}
|
||||||
onChange={(val) => { setSelectedModule(val as string | null); setCurrentPage(1); }}
|
onChange={(val) => {
|
||||||
options={modules.map(m => ({ value: m.id, label: m.name }))}
|
setSelectedModule(val as string | null);
|
||||||
placeholder="All Modules"
|
setCurrentPage(1);
|
||||||
isSearchable
|
}}
|
||||||
/>
|
options={modules.map((m) => ({ value: m.id, label: m.name }))}
|
||||||
</div>
|
placeholder="All Modules"
|
||||||
|
isSearchable
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PrimaryButton onClick={() => {
|
<PrimaryButton
|
||||||
setEditingId(null);
|
onClick={() => {
|
||||||
reset({
|
setEditingId(null);
|
||||||
code: '', name: '', description: '', category: '',
|
setTemplateVariables([]);
|
||||||
title_template: '', message_template: '',
|
setEmailBodyHtml("");
|
||||||
email_subject_template: '', email_body_template: '',
|
reset({
|
||||||
default_priority: 'normal', channels: ['in_app', 'email'],
|
code: "",
|
||||||
is_active: true
|
name: "",
|
||||||
});
|
description: "",
|
||||||
setModalOpen(true);
|
category: "",
|
||||||
}} className="flex gap-2">
|
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
|
<Plus className="w-4 h-4" /> New Template
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<DataTable columns={columns} data={templates} isLoading={isLoading} keyExtractor={(t) => t.id} />
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={templates}
|
||||||
|
isLoading={isLoading}
|
||||||
|
keyExtractor={(t) => t.id}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
@ -256,20 +421,48 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
totalItems={totalItems}
|
totalItems={totalItems}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
onLimitChange={(l) => { setLimit(l); setCurrentPage(1); }}
|
onLimitChange={(l) => {
|
||||||
|
setLimit(l);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title={editingId ? "Edit Notification Template" : "Create New Template"} maxWidth="2xl">
|
<Modal
|
||||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6 max-h-[80vh] overflow-y-auto custom-scrollbar">
|
isOpen={modalOpen}
|
||||||
<div className="space-y-4">
|
onClose={() => setModalOpen(false)}
|
||||||
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Identification</h3>
|
title={editingId ? "Edit Notification Template" : "Create New Template"}
|
||||||
|
maxWidth="2xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
className="px-5 py-2 text-sm text-gray-600 transition-colors hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<PrimaryButton onClick={handleSubmit(onSave)} className="px-10">
|
||||||
|
{editingId ? "Update Template" : "Create Template"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* ── Identification ─────────────────────────────────────── */}
|
||||||
|
<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">
|
||||||
|
Identification
|
||||||
|
</h3>
|
||||||
<FormSelect
|
<FormSelect
|
||||||
label="Category"
|
label="Category"
|
||||||
required
|
required
|
||||||
value={categoryValue}
|
value={categoryValue}
|
||||||
onValueChange={handleCategorySelect}
|
onValueChange={handleCategorySelect}
|
||||||
options={categories.map(c => ({ value: c.code, label: c.name }))}
|
options={categories.map((c) => ({
|
||||||
|
value: c.code,
|
||||||
|
label: c.name,
|
||||||
|
}))}
|
||||||
error={errors.category?.message}
|
error={errors.category?.message}
|
||||||
placeholder="Select Category"
|
placeholder="Select Category"
|
||||||
/>
|
/>
|
||||||
@ -277,81 +470,136 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
label="Event Code"
|
label="Event Code"
|
||||||
required
|
required
|
||||||
value={codeValue}
|
value={codeValue}
|
||||||
onValueChange={(val) => {
|
onValueChange={handleCodeSelect}
|
||||||
const selectedCode = codes.find(c => c.code === val);
|
options={codes.map((c) => ({
|
||||||
setValue('code', val, { shouldValidate: true });
|
value: c.code,
|
||||||
if (selectedCode?.name) setValue('name', selectedCode.name, { shouldValidate: true });
|
label: `${c.name} (${c.code})`,
|
||||||
}}
|
}))}
|
||||||
options={codes.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }))}
|
|
||||||
disabled={!categoryValue}
|
disabled={!categoryValue}
|
||||||
error={errors.code?.message}
|
error={errors.code?.message}
|
||||||
placeholder="Select Event Code"
|
placeholder="Select Event Code"
|
||||||
/>
|
/>
|
||||||
<FormField label="Friendly Name" required error={errors.name?.message} placeholder="e.g. Project Assigned" {...register('name')} />
|
<FormField
|
||||||
<FormTextArea label="Description" error={errors.description?.message} rows={2} {...register('description')} />
|
label="Friendly Name"
|
||||||
</div>
|
required
|
||||||
|
error={errors.name?.message}
|
||||||
|
placeholder="e.g. Project Assigned"
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
<FormTextArea
|
||||||
|
label="Description"
|
||||||
|
error={errors.description?.message}
|
||||||
|
rows={2}
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Settings</h3>
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">
|
||||||
|
Settings
|
||||||
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormSelect
|
<FormSelect
|
||||||
label="Priority"
|
label="Priority"
|
||||||
value={priorityValue}
|
value={priorityValue}
|
||||||
onValueChange={(val) => setValue('default_priority', val as any, { shouldValidate: true })}
|
onValueChange={(val) =>
|
||||||
|
setValue("default_priority", val as any, {
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'low', label: 'Low' },
|
{ value: "low", label: "Low" },
|
||||||
{ value: 'normal', label: 'Normal' },
|
{ value: "normal", label: "Normal" },
|
||||||
{ value: 'high', label: 'High' },
|
{ value: "high", label: "High" },
|
||||||
{ value: 'urgent', label: 'Urgent' }
|
{ value: "urgent", label: "Urgent" },
|
||||||
]}
|
]}
|
||||||
error={errors.default_priority?.message}
|
error={errors.default_priority?.message}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium text-gray-700">Channels</label>
|
<label className="text-xs font-medium text-gray-700">
|
||||||
<div className="flex gap-4 items-center h-10 px-1">
|
Channels
|
||||||
<label className="flex items-center gap-2 text-xs">
|
</label>
|
||||||
<input type="checkbox" checked={channels.includes('in_app')} onChange={(e) => {
|
<div className="flex gap-4 items-center h-10 px-1">
|
||||||
const next = e.target.checked ? [...channels, 'in_app'] : channels.filter(c => c !== 'in_app');
|
<label className="flex items-center gap-2 text-xs">
|
||||||
setValue('channels', next, { shouldValidate: true });
|
<input
|
||||||
}} /> In-App
|
type="checkbox"
|
||||||
</label>
|
checked={channels.includes("in_app")}
|
||||||
<label className="flex items-center gap-2 text-xs">
|
onChange={(e) => {
|
||||||
<input type="checkbox" checked={channels.includes('email')} onChange={(e) => {
|
const next = e.target.checked
|
||||||
const next = e.target.checked ? [...channels, 'email'] : channels.filter(c => c !== 'email');
|
? [...channels, "in_app"]
|
||||||
setValue('channels', next, { shouldValidate: true });
|
: channels.filter((c) => c !== "in_app");
|
||||||
}} /> Email
|
setValue("channels", next, { shouldValidate: true });
|
||||||
</label>
|
}}
|
||||||
</div>
|
/>{" "}
|
||||||
{errors.channels && <p className="text-xs text-red-500">{errors.channels.message}</p>}
|
In-App
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={channels.includes("email")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked
|
||||||
|
? [...channels, "email"]
|
||||||
|
: channels.filter((c) => c !== "email");
|
||||||
|
setValue("channels", next, { shouldValidate: true });
|
||||||
|
}}
|
||||||
|
/>{" "}
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{errors.channels && (
|
||||||
|
<p className="text-xs text-red-500">
|
||||||
|
{errors.channels.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</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" required error={errors.title_template?.message} placeholder="e.g. Task Assigned: {{task_name}}" {...register('title_template')} />
|
{/* Variable Chips */}
|
||||||
<FormTextArea label="Message Template" required error={errors.message_template?.message} placeholder="Use {{var}} for dynamic data" rows={3} {...register('message_template')} />
|
<VariableChips variables={templateVariables} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2 space-y-4 pt-4 border-t">
|
{/* ── In-App Content ─────────────────────────────────────── */}
|
||||||
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Email Content</h3>
|
<div className="space-y-4 pt-4 border-t">
|
||||||
<FormField
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">
|
||||||
label="Email Subject"
|
In-App Content
|
||||||
error={errors.email_subject_template?.message}
|
</h3>
|
||||||
placeholder="Leave blank to use In-App title"
|
<FormField
|
||||||
{...register('email_subject_template')}
|
label="Title Template"
|
||||||
/>
|
required
|
||||||
<FormTextArea
|
error={errors.title_template?.message}
|
||||||
label="Email Body (HTML supported)"
|
placeholder="e.g. Task Assigned: {{task_name}}"
|
||||||
error={errors.email_body_template?.message}
|
{...register("title_template")}
|
||||||
placeholder="Full HTML body template..."
|
/>
|
||||||
rows={6}
|
<FormTextArea
|
||||||
{...register('email_body_template')}
|
label="Message Template"
|
||||||
/>
|
required
|
||||||
|
error={errors.message_template?.message}
|
||||||
|
placeholder="Use {{var}} for dynamic data"
|
||||||
|
rows={3}
|
||||||
|
{...register("message_template")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2 flex justify-end gap-3 pt-6 border-t sticky bottom-0 bg-white">
|
{/* ── Email Content (HTML Editor) ────────────────────────── */}
|
||||||
<button onClick={() => setModalOpen(false)} className="px-5 py-2 text-sm text-gray-600 transition-colors hover:text-gray-900">Cancel</button>
|
<div className="space-y-4 pt-4 border-t">
|
||||||
<PrimaryButton onClick={handleSubmit(onSave)} className="px-10">
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">
|
||||||
{editingId ? 'Update Template' : 'Create Template'}
|
Email Content
|
||||||
</PrimaryButton>
|
</h3>
|
||||||
|
<FormField
|
||||||
|
label="Email Subject"
|
||||||
|
error={errors.email_subject_template?.message}
|
||||||
|
placeholder="Leave blank to use In-App title"
|
||||||
|
{...register("email_subject_template")}
|
||||||
|
/>
|
||||||
|
<RichTextEditor
|
||||||
|
label="Email Body (HTML)"
|
||||||
|
value={emailBodyHtml}
|
||||||
|
onChange={(html) => setEmailBodyHtml(html)}
|
||||||
|
placeholder="Design your email body here... Use {{variable}} placeholders."
|
||||||
|
minHeightClassName="min-h-[200px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from "react";
|
||||||
import { Layout } from '@/components/layout/Layout';
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import {
|
import {
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
DataTable,
|
DataTable,
|
||||||
@ -8,32 +8,82 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
FormTextArea,
|
FormTextArea,
|
||||||
FormSelect,
|
FormSelect,
|
||||||
|
RichTextEditor,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
Pagination,
|
Pagination,
|
||||||
type Column,
|
type Column,
|
||||||
} from '@/components/shared';
|
} from "@/components/shared";
|
||||||
import { Edit, RotateCcw, Building, Filter } from 'lucide-react';
|
import {
|
||||||
import { notificationService } from '@/services/notification-service';
|
Edit,
|
||||||
import { moduleService } from '@/services/module-service';
|
RotateCcw,
|
||||||
import { showToast } from '@/utils/toast';
|
Building,
|
||||||
import { useForm } from 'react-hook-form';
|
Filter,
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
Copy,
|
||||||
import { z } from 'zod';
|
CheckCheck,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { notificationService } from "@/services/notification-service";
|
||||||
|
import { moduleService } from "@/services/module-service";
|
||||||
|
import { showToast } from "@/utils/toast";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
const overrideSchema = z.object({
|
const overrideSchema = z.object({
|
||||||
title_template: z.string().min(1, 'Title template is required'),
|
title_template: z.string().min(1, "Title template is required"),
|
||||||
message_template: z.string().min(1, 'Message template is required'),
|
message_template: z.string().min(1, "Message template is required"),
|
||||||
email_subject_template: z.string().optional(),
|
email_subject_template: z.string().optional(),
|
||||||
email_body_template: z.string().optional(),
|
email_body_template: z.string().optional(),
|
||||||
is_active: z.boolean()
|
is_active: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type OverrideFormValues = z.infer<typeof overrideSchema>;
|
type OverrideFormValues = z.infer<typeof overrideSchema>;
|
||||||
|
|
||||||
|
// ── Variable Chips ────────────────────────────────────────────────────────────
|
||||||
|
const VariableChips = ({ variables }: { variables: string[] }) => {
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCopy = (v: string) => {
|
||||||
|
navigator.clipboard.writeText(`{{${v}}}`).then(() => {
|
||||||
|
setCopied(v);
|
||||||
|
setTimeout(() => setCopied(null), 1500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!variables || variables.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-indigo-100 bg-indigo-50/60 p-3 space-y-1.5">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-indigo-500">
|
||||||
|
Available Variables — click to copy
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{variables.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(v)}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-mono font-semibold bg-white border border-indigo-200 text-indigo-700 px-2 py-0.5 rounded-full hover:bg-indigo-100 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{copied === v ? (
|
||||||
|
<CheckCheck className="w-3 h-3 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3 h-3 opacity-60" />
|
||||||
|
)}
|
||||||
|
{`{{${v}}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-indigo-400">
|
||||||
|
Use these in title, message, subject and email body fields above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const NotificationTemplates = (): ReactElement => {
|
const NotificationTemplates = (): ReactElement => {
|
||||||
const [templates, setTemplates] = useState<any[]>([]);
|
const [templates, setTemplates] = useState<any[]>([]);
|
||||||
const [modules, setModules] = useState<any[]>([]);
|
const [modules, setModules] = useState<any[]>([]);
|
||||||
const [selectedModule, setSelectedModule] = useState<string>('all');
|
const [selectedModule, setSelectedModule] = useState<string>("all");
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
@ -46,30 +96,31 @@ const NotificationTemplates = (): ReactElement => {
|
|||||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
|
||||||
|
|
||||||
|
// HTML body state for rich editor
|
||||||
|
const [emailBodyHtml, setEmailBodyHtml] = useState("");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
formState: { errors }
|
formState: { errors },
|
||||||
} = useForm<OverrideFormValues>({
|
} = useForm<OverrideFormValues>({
|
||||||
resolver: zodResolver(overrideSchema),
|
resolver: zodResolver(overrideSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
title_template: '',
|
title_template: "",
|
||||||
message_template: '',
|
message_template: "",
|
||||||
email_subject_template: '',
|
email_subject_template: "",
|
||||||
email_body_template: '',
|
email_body_template: "",
|
||||||
is_active: true
|
is_active: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchModules = async () => {
|
const fetchModules = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await moduleService.getMyModules();
|
const res = await moduleService.getMyModules();
|
||||||
if (res.success) {
|
if (res.success) setModules(res.data);
|
||||||
setModules(res.data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch modules:', err);
|
console.error("Failed to fetch modules:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -79,7 +130,7 @@ const NotificationTemplates = (): ReactElement => {
|
|||||||
const res = await notificationService.getTemplates({
|
const res = await notificationService.getTemplates({
|
||||||
limit,
|
limit,
|
||||||
offset: (currentPage - 1) * limit,
|
offset: (currentPage - 1) * limit,
|
||||||
module_id: selectedModule === 'all' ? undefined : selectedModule
|
module_id: selectedModule === "all" ? undefined : selectedModule,
|
||||||
});
|
});
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setTemplates(res.data);
|
setTemplates(res.data);
|
||||||
@ -87,113 +138,198 @@ const NotificationTemplates = (): ReactElement => {
|
|||||||
setTotalPages(res.pagination?.pages || 1);
|
setTotalPages(res.pagination?.pages || 1);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast.error('Failed to load templates');
|
showToast.error("Failed to load templates");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchModules(); }, []);
|
useEffect(() => {
|
||||||
useEffect(() => { fetchTemplates(); }, [currentPage, limit, selectedModule]);
|
fetchModules();
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTemplates();
|
||||||
|
}, [currentPage, limit, selectedModule]);
|
||||||
|
|
||||||
|
const openEditModal = (t: any) => {
|
||||||
|
setSelectedTemplate(t);
|
||||||
|
setEmailBodyHtml(t.email_body_template || "");
|
||||||
|
reset({
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
const onOverride = async (data: OverrideFormValues) => {
|
const onOverride = async (data: OverrideFormValues) => {
|
||||||
try {
|
try {
|
||||||
await notificationService.overrideTemplate(selectedTemplate.code, data);
|
await notificationService.overrideTemplate(selectedTemplate.code, {
|
||||||
showToast.success('Template override saved');
|
...data,
|
||||||
|
email_body_template: emailBodyHtml,
|
||||||
|
});
|
||||||
|
showToast.success("Template override saved");
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
reset();
|
reset();
|
||||||
|
setEmailBodyHtml("");
|
||||||
fetchTemplates();
|
fetchTemplates();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.error?.message || err.message || 'Action failed';
|
showToast.error(
|
||||||
showToast.error(errorMessage);
|
err.response?.data?.error?.message || err.message || "Action failed",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = async (code: string) => {
|
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;
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Are you sure you want to reset this template to the global default? Your custom changes will be lost.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
try {
|
try {
|
||||||
await notificationService.resetTemplate(code);
|
await notificationService.resetTemplate(code);
|
||||||
showToast.success('Template reset to default');
|
showToast.success("Template reset to default");
|
||||||
fetchTemplates();
|
fetchTemplates();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast.error(err.message || 'Reset failed');
|
showToast.error(err.message || "Reset failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: Column<any>[] = [
|
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',
|
key: "category",
|
||||||
label: 'Actions',
|
label: "Category",
|
||||||
align: 'right',
|
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: "variables",
|
||||||
|
label: "Variables",
|
||||||
|
render: (t) => (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Array.isArray(t.variables) && t.variables.length > 0 ? (
|
||||||
|
t.variables
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((v: string) => (
|
||||||
|
<span
|
||||||
|
key={v}
|
||||||
|
className="text-[9px] font-mono bg-indigo-50 text-indigo-600 px-1.5 py-0.5 rounded-full border border-indigo-100"
|
||||||
|
>{`{{${v}}}`}</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-gray-300 italic">none</span>
|
||||||
|
)}
|
||||||
|
{Array.isArray(t.variables) && t.variables.length > 3 && (
|
||||||
|
<span className="text-[9px] text-gray-400">
|
||||||
|
+{t.variables.length - 3} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Actions",
|
||||||
|
align: "right",
|
||||||
render: (t) => (
|
render: (t) => (
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button onClick={() => {
|
<button
|
||||||
setSelectedTemplate(t);
|
onClick={() => openEditModal(t)}
|
||||||
reset({
|
className="p-2 text-blue-600 hover:bg-blue-50 rounded-md transition-colors flex items-center gap-1"
|
||||||
title_template: t.title_template,
|
>
|
||||||
message_template: t.message_template,
|
<Edit className="w-4 h-4" />{" "}
|
||||||
email_subject_template: t.email_subject_template || '',
|
<span className="text-xs font-semibold uppercase">Edit</span>
|
||||||
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>
|
</button>
|
||||||
{t.tenant_id && (
|
{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">
|
<button
|
||||||
<RotateCcw className="w-4 h-4" /> <span className="text-xs font-semibold uppercase">Reset</span>
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
currentPage="Notification Templates"
|
currentPage="Notification Templates"
|
||||||
pageHeader={{
|
pageHeader={{
|
||||||
title: 'Custom Notifications',
|
title: "Custom Notifications",
|
||||||
description: 'Customize the content and delivery of platform notifications for your organization.',
|
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="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="p-4 border-b flex flex-wrap justify-between items-center bg-gray-50/30 gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Building className="w-4 h-4 text-blue-500" />
|
<Building className="w-4 h-4 text-blue-500" />
|
||||||
<h2 className="text-sm font-semibold text-gray-700">Notification Templates</h2>
|
<h2 className="text-sm font-semibold text-gray-700">
|
||||||
</div>
|
Notification Templates
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 min-w-[300px]">
|
<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">
|
<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
|
<Filter className="w-3.5 h-3.5" /> Filter by Module
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<FormSelect
|
<FormSelect
|
||||||
label=""
|
label=""
|
||||||
value={selectedModule}
|
value={selectedModule}
|
||||||
onValueChange={(val) => { setSelectedModule(val); setCurrentPage(1); }}
|
onValueChange={(val) => {
|
||||||
options={[
|
setSelectedModule(val);
|
||||||
{ value: 'all', label: 'All Modules' },
|
setCurrentPage(1);
|
||||||
...modules.map(m => ({ value: m.id, label: m.name }))
|
}}
|
||||||
]}
|
options={[
|
||||||
placeholder="Select Module"
|
{ value: "all", label: "All Modules" },
|
||||||
/>
|
...modules.map((m) => ({ value: m.id, label: m.name })),
|
||||||
</div>
|
]}
|
||||||
</div>
|
placeholder="Select Module"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<DataTable columns={columns} data={templates} isLoading={isLoading} keyExtractor={(t) => t.code} />
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={templates}
|
||||||
|
isLoading={isLoading}
|
||||||
|
keyExtractor={(t) => t.code}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
@ -202,34 +338,103 @@ const NotificationTemplates = (): ReactElement => {
|
|||||||
totalItems={totalItems}
|
totalItems={totalItems}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
onLimitChange={(l) => { setLimit(l); setCurrentPage(1); }}
|
onLimitChange={(l) => {
|
||||||
|
setLimit(l);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title={`Customize: ${selectedTemplate?.code}`} maxWidth="2xl">
|
<Modal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
title={`Customize: ${selectedTemplate?.code}`}
|
||||||
|
maxWidth="2xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
className="px-5 py-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<PrimaryButton onClick={handleSubmit(onOverride)} className="px-8">
|
||||||
|
Save Override
|
||||||
|
</PrimaryButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Variable chips */}
|
||||||
|
{selectedTemplate && (
|
||||||
|
<VariableChips
|
||||||
|
variables={
|
||||||
|
Array.isArray(selectedTemplate?.variables)
|
||||||
|
? selectedTemplate.variables
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">In-App Notification</h3>
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">
|
||||||
<FormField label="Title Template" required error={errors.title_template?.message} {...register('title_template')} />
|
In-App Notification
|
||||||
<FormTextArea label="Message Template" required error={errors.message_template?.message} rows={3} {...register('message_template')} />
|
</h3>
|
||||||
|
<FormField
|
||||||
|
label="Title Template"
|
||||||
|
required
|
||||||
|
error={errors.title_template?.message}
|
||||||
|
{...register("title_template")}
|
||||||
|
/>
|
||||||
|
<FormTextArea
|
||||||
|
label="Message Template"
|
||||||
|
required
|
||||||
|
error={errors.message_template?.message}
|
||||||
|
rows={4}
|
||||||
|
{...register("message_template")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Email Notification</h3>
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">
|
||||||
<FormField label="Email Subject" error={errors.email_subject_template?.message} placeholder="Inherits from title if blank" {...register('email_subject_template')} />
|
Email Notification
|
||||||
<FormTextArea label="Email Body (HTML)" error={errors.email_body_template?.message} rows={6} {...register('email_body_template')} />
|
</h3>
|
||||||
|
<FormField
|
||||||
|
label="Email Subject"
|
||||||
|
error={errors.email_subject_template?.message}
|
||||||
|
placeholder="Inherits from title if blank"
|
||||||
|
{...register("email_subject_template")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center pt-6 border-t">
|
{/* Full-width HTML body editor */}
|
||||||
<div className="text-xs text-gray-400 max-w-sm">
|
<div className="space-y-2 pt-2 border-t">
|
||||||
💡 You can use placeholders like <code>{`{{user_name}}`}</code>, <code>{`{{entity_name}}`}</code>, and <code>{`{{action}}`}</code>.
|
<RichTextEditor
|
||||||
</div>
|
label="Email Body (HTML)"
|
||||||
<div className="flex gap-3">
|
value={emailBodyHtml}
|
||||||
<button onClick={() => setModalOpen(false)} className="px-5 py-2 text-sm text-gray-600 hover:text-gray-900 transition-colors">Cancel</button>
|
onChange={(html) => setEmailBodyHtml(html)}
|
||||||
<PrimaryButton onClick={handleSubmit(onOverride)} className="px-8">Save Override</PrimaryButton>
|
placeholder="Design your email body here... Use {{variable}} placeholders."
|
||||||
</div>
|
minHeightClassName="min-h-[220px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* <div className="flex justify-between items-center pt-4 border-t">
|
||||||
|
<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={handleSubmit(onOverride)}
|
||||||
|
className="px-8"
|
||||||
|
>
|
||||||
|
Save Override
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@ -66,7 +66,7 @@ const Roles = (): ReactElement => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [scopeFilter, setScopeFilter] = useState<string | null>(null);
|
// const [scopeFilter, setScopeFilter] = useState<string | null>(null);
|
||||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||||
|
|
||||||
// View, Edit, Delete modals
|
// View, Edit, Delete modals
|
||||||
@ -81,13 +81,13 @@ const Roles = (): ReactElement => {
|
|||||||
const fetchRoles = async (
|
const fetchRoles = async (
|
||||||
page: number,
|
page: number,
|
||||||
itemsPerPage: number,
|
itemsPerPage: number,
|
||||||
scope: string | null = null,
|
// scope: string | null = null,
|
||||||
sortBy: string[] | null = null
|
sortBy: string[] | null = null
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await roleService.getAll(page, itemsPerPage, scope, sortBy);
|
const response = await roleService.getAll(page, itemsPerPage, sortBy);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setRoles(response.data);
|
setRoles(response.data);
|
||||||
setPagination(response.pagination);
|
setPagination(response.pagination);
|
||||||
@ -102,8 +102,8 @@ const Roles = (): ReactElement => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
fetchRoles(currentPage, limit, orderBy);
|
||||||
}, [currentPage, limit, scopeFilter, orderBy]);
|
}, [currentPage, limit, orderBy]);
|
||||||
|
|
||||||
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
|
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@ -113,7 +113,7 @@ const Roles = (): ReactElement => {
|
|||||||
const description = response.message ? undefined : `${data.name} has been added`;
|
const description = response.message ? undefined : `${data.name} has been added`;
|
||||||
showToast.success(message, description);
|
showToast.success(message, description);
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
await fetchRoles(currentPage, limit, orderBy);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
@ -147,7 +147,7 @@ const Roles = (): ReactElement => {
|
|||||||
showToast.success(message, description);
|
showToast.success(message, description);
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
setSelectedRoleId(null);
|
setSelectedRoleId(null);
|
||||||
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
await fetchRoles(currentPage, limit, orderBy);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
@ -172,7 +172,7 @@ const Roles = (): ReactElement => {
|
|||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
setSelectedRoleId(null);
|
setSelectedRoleId(null);
|
||||||
setSelectedRoleName('');
|
setSelectedRoleName('');
|
||||||
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
await fetchRoles(currentPage, limit, orderBy);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
@ -300,7 +300,7 @@ const Roles = (): ReactElement => {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{/* Scope Filter */}
|
{/* Scope Filter */}
|
||||||
<FilterDropdown
|
{/* <FilterDropdown
|
||||||
label="Scope"
|
label="Scope"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'platform', label: 'Platform' },
|
{ value: 'platform', label: 'Platform' },
|
||||||
@ -313,7 +313,7 @@ const Roles = (): ReactElement => {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
placeholder="All"
|
placeholder="All"
|
||||||
/>
|
/> */}
|
||||||
|
|
||||||
{/* Sort Filter */}
|
{/* Sort Filter */}
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
|
|||||||
@ -95,6 +95,21 @@ export const notificationService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Fetch variables for a specific notification code (by code slug, e.g. 'capa_assigned') */
|
||||||
|
getVariablesForCode: async (categoryIdOrCode: string, codeSlug?: string): Promise<string[]> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/notifications/categories/${categoryIdOrCode}/codes`, { params: { limit: 100 } });
|
||||||
|
const codes: any[] = response.data?.data || [];
|
||||||
|
if (codeSlug) {
|
||||||
|
const found = codes.find((c: any) => c.code === codeSlug);
|
||||||
|
return Array.isArray(found?.variables) ? found.variables : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
createCode: async (categoryId: string, data: any): Promise<NotificationResponse<any>> => {
|
createCode: async (categoryId: string, data: any): Promise<NotificationResponse<any>> => {
|
||||||
const response = await apiClient.post(`/notifications/categories/${categoryId}/codes`, data);
|
const response = await apiClient.post(`/notifications/categories/${categoryId}/codes`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@ -13,15 +13,15 @@ export const roleService = {
|
|||||||
getAll: async (
|
getAll: async (
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
scope?: string | null,
|
// scope?: string | null,
|
||||||
orderBy?: string[] | null
|
orderBy?: string[] | null
|
||||||
): Promise<RolesResponse> => {
|
): Promise<RolesResponse> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('page', String(page));
|
params.append('page', String(page));
|
||||||
params.append('limit', String(limit));
|
params.append('limit', String(limit));
|
||||||
if (scope) {
|
// if (scope) {
|
||||||
params.append('scope', scope);
|
// params.append('scope', scope);
|
||||||
}
|
// }
|
||||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||||
params.append('orderBy[]', orderBy[0]);
|
params.append('orderBy[]', orderBy[0]);
|
||||||
params.append('orderBy[]', orderBy[1]);
|
params.append('orderBy[]', orderBy[1]);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user