feat: integrate RichTextEditor and variable copy functionality into notification template management

This commit is contained in:
Yashwin 2026-04-15 17:48:35 +05:30
parent cde2544cf3
commit fe707216f6
8 changed files with 976 additions and 412 deletions

View File

@ -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>
); );
}; };

View File

@ -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>
); );
}; };

View File

@ -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,
@ -72,15 +139,15 @@ const NotificationMaster = (): ReactElement => {
const [selectedCategory, setSelectedCategory] = useState<any>(null); const [selectedCategory, setSelectedCategory] = useState<any>(null);
const [codes, setCodes] = useState<any[]>([]); const [codes, setCodes] = useState<any[]>([]);
const [isCodesLoading, setIsCodesLoading] = useState<boolean>(false); const [isCodesLoading, setIsCodesLoading] = useState<boolean>(false);
// Pagination for Codes // Pagination for Codes
const [codePage, setCodePage] = useState(1); const [codePage, setCodePage] = useState(1);
const [codeTotal, setCodeTotal] = useState(0); const [codeTotal, setCodeTotal] = useState(0);
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,20 +184,17 @@ 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);
} }
}; };
useEffect(() => { useEffect(() => {
fetchCategories(); fetchCategories();
fetchModules(); fetchModules();
}, [currentPage, limit, search]); }, [currentPage, limit, search]);
@ -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);
} }
}; };
@ -236,11 +300,11 @@ const NotificationMaster = (): ReactElement => {
align: 'right', align: 'right',
render: (c) => ( render: (c) => (
<div className="flex justify-end gap-3 items-center"> <div className="flex justify-end gap-3 items-center">
<button <button
onClick={() => handleOpenCodes(c)} onClick={() => handleOpenCodes(c)}
className="flex items-center gap-1.5 text-[11px] font-bold text-blue-600 hover:text-blue-800 transition-colors uppercase" className="flex items-center gap-1.5 text-[11px] font-bold text-blue-600 hover:text-blue-800 transition-colors uppercase"
> >
<Code className="w-3.5 h-3.5" /> <Code className="w-3.5 h-3.5" />
Codes ({c.code_count || 0}) Codes ({c.code_count || 0})
</button> </button>
<ActionDropdown <ActionDropdown
@ -272,9 +336,9 @@ const NotificationMaster = (): ReactElement => {
<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 categories..." placeholder="Search categories..."
className="w-full pl-9 pr-4 py-1.5 border rounded-md text-sm outline-none focus:ring-2 focus:ring-blue-500/20" className="w-full pl-9 pr-4 py-1.5 border rounded-md text-sm outline-none focus:ring-2 focus:ring-blue-500/20"
value={search} value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }} onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
@ -294,7 +358,7 @@ const NotificationMaster = (): ReactElement => {
<DataTable columns={columns} data={categories} isLoading={isLoading} keyExtractor={(c) => c.id} /> <DataTable columns={columns} data={categories} isLoading={isLoading} keyExtractor={(c) => c.id} />
</div> </div>
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
totalItems={totalItems} totalItems={totalItems}
@ -305,22 +369,22 @@ 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')} />
<FormField label="Slug (Code)" required placeholder="e.g. workflow" error={categoryErrors.code?.message} {...registerCategory('code')} /> <FormField label="Slug (Code)" required placeholder="e.g. workflow" error={categoryErrors.code?.message} {...registerCategory('code')} />
</div> </div>
<FormTextArea label="Description" rows={2} error={categoryErrors.description?.message} {...registerCategory('description')} /> <FormTextArea label="Description" rows={2} error={categoryErrors.description?.message} {...registerCategory('description')} />
<FormSelect <FormSelect
label="Associated Module" label="Associated Module"
value={watchCategory('module_id')} value={watchCategory('module_id')}
options={[ options={[
{ value: '', label: 'System (No Module)' }, { value: '', label: 'System (No Module)' },
...modules.map(m => ({ value: m.id, label: m.name })) ...modules.map(m => ({ value: m.id, label: m.name }))
]} ]}
onValueChange={(val) => setCategoryValue('module_id', val, { shouldValidate: true })} onValueChange={(val) => setCategoryValue('module_id', val, { shouldValidate: true })}
placeholder="Select a module" placeholder="Select a module"
error={categoryErrors.module_id?.message} error={categoryErrors.module_id?.message}
/> />
<div className="flex justify-end gap-3 mt-6 pt-4 border-t"> <div className="flex justify-end gap-3 mt-6 pt-4 border-t">
@ -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={() => {
@ -387,13 +483,13 @@ const NotificationMaster = (): ReactElement => {
</table> </table>
</div> </div>
<Pagination <Pagination
currentPage={codePage} currentPage={codePage}
totalPages={codePages} totalPages={codePages}
totalItems={codeTotal} totalItems={codeTotal}
limit={5} limit={5}
onPageChange={setCodePage} onPageChange={setCodePage}
onLimitChange={() => {}} // Fixed for codes modal onLimitChange={() => {}}
/> />
</div> </div>
</div> </div>

View File

@ -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,184 +151,318 @@ 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) {
setTemplates(tRes.data); setTemplates(tRes.data);
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,
} catch (e) { });
console.error('Failed to load codes:', e); if (res.success) setCodes(res.data);
} catch (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: "category",
{ key: 'name', label: 'Friendly Name', render: (t) => <span className="text-sm font-medium">{t.name}</span> }, label: "Category",
{ key: 'title', label: 'Preview', render: (t) => <span className="text-xs truncate max-w-[200px] block text-gray-500">{t.title_template}</span> }, render: (t) => (
{ 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> }, <span className="text-[10px] font-bold uppercase text-blue-600 bg-blue-50 px-2 py-1 rounded">
{ {t.category_name}
key: 'channels', </span>
label: 'Channels', ),
},
{
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
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
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>

View File

@ -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,149 +138,303 @@ 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 className="flex items-center gap-3 min-w-[300px]"> </div>
<div className="flex items-center gap-2 text-xs font-medium text-gray-400 mr-1">
<Filter className="w-3.5 h-3.5" /> Filter by Module <div className="flex items-center gap-3 min-w-[300px]">
</div> <div className="flex items-center gap-2 text-xs font-medium text-gray-400 mr-1">
<div className="w-full"> <Filter className="w-3.5 h-3.5" /> Filter by Module
<FormSelect </div>
label="" <div className="w-full">
value={selectedModule} <FormSelect
onValueChange={(val) => { setSelectedModule(val); setCurrentPage(1); }} label=""
options={[ value={selectedModule}
{ value: 'all', label: 'All Modules' }, onValueChange={(val) => {
...modules.map(m => ({ value: m.id, label: m.name })) setSelectedModule(val);
]} setCurrentPage(1);
placeholder="Select Module" }}
/> options={[
</div> { value: "all", label: "All Modules" },
</div> ...modules.map((m) => ({ value: m.id, label: m.name })),
</div> ]}
placeholder="Select Module"
<div className="flex-1"> />
<DataTable columns={columns} data={templates} isLoading={isLoading} keyExtractor={(t) => t.code} /> </div>
</div>
</div> </div>
<Pagination <div className="flex-1">
<DataTable
columns={columns}
data={templates}
isLoading={isLoading}
keyExtractor={(t) => t.code}
/>
</div>
<Pagination
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
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)"
value={emailBodyHtml}
onChange={(html) => setEmailBodyHtml(html)}
placeholder="Design your email body here... Use {{variable}} placeholders."
minHeightClassName="min-h-[220px]"
/>
</div>
{/* <div className="flex justify-between items-center pt-4 border-t">
<div className="flex gap-3"> <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> <button
<PrimaryButton onClick={handleSubmit(onOverride)} className="px-8">Save Override</PrimaryButton> 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> */}
</div> </div>
</Modal> </Modal>
</Layout> </Layout>

View File

@ -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

View File

@ -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;

View File

@ -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]);