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,
Pagination,
FilterDropdown,
DeleteConfirmationModal,
// DeleteConfirmationModal,
type Column,
} from "@/components/shared";
import {
@ -58,7 +58,7 @@ const DepartmentsTable = ({
const [isNewModalOpen, setIsNewModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedDepartment, setSelectedDepartment] =
useState<Department | null>(null);
const [isActionLoading, setIsActionLoading] = useState(false);
@ -138,27 +138,27 @@ const DepartmentsTable = ({
}
};
const handleDelete = async () => {
if (!selectedDepartment) return;
try {
setIsActionLoading(true);
const response = await departmentService.delete(
selectedDepartment.id,
effectiveTenantId,
);
if (response.success) {
showToast.success("Department deleted successfully");
setIsDeleteModalOpen(false);
fetchDepartments();
}
} catch (err: any) {
showToast.error(
err?.response?.data?.error?.message || "Failed to delete department",
);
} finally {
setIsActionLoading(false);
}
};
// const handleDelete = async () => {
// if (!selectedDepartment) return;
// try {
// setIsActionLoading(true);
// const response = await departmentService.delete(
// selectedDepartment.id,
// effectiveTenantId,
// );
// if (response.success) {
// showToast.success("Department deleted successfully");
// setIsDeleteModalOpen(false);
// fetchDepartments();
// }
// } catch (err: any) {
// showToast.error(
// err?.response?.data?.error?.message || "Failed to delete department",
// );
// } finally {
// setIsActionLoading(false);
// }
// };
// Client-side pagination logic
const totalItems = departments.length;
@ -237,10 +237,10 @@ const DepartmentsTable = ({
setSelectedDepartment(dept);
setIsEditModalOpen(true);
}}
onDelete={() => {
setSelectedDepartment(dept);
setIsDeleteModalOpen(true);
}}
// onDelete={() => {
// setSelectedDepartment(dept);
// setIsDeleteModalOpen(true);
// }}
/>
</div>
),
@ -350,7 +350,7 @@ const DepartmentsTable = ({
department={selectedDepartment}
/>
<DeleteConfirmationModal
{/* <DeleteConfirmationModal
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false);
@ -361,7 +361,7 @@ const DepartmentsTable = ({
message="Are you sure you want to delete this department? This action cannot be undone."
itemName={selectedDepartment?.name || ""}
isLoading={isActionLoading}
/>
/> */}
</div>
);
};

View File

@ -7,7 +7,7 @@ import {
DataTable,
Pagination,
FilterDropdown,
DeleteConfirmationModal,
// DeleteConfirmationModal,
type Column,
} from "@/components/shared";
import {
@ -58,7 +58,7 @@ const DesignationsTable = ({
const [isNewModalOpen, setIsNewModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedDesignation, setSelectedDesignation] =
useState<Designation | null>(null);
const [isActionLoading, setIsActionLoading] = useState(false);
@ -138,27 +138,27 @@ const DesignationsTable = ({
}
};
const handleDelete = async () => {
if (!selectedDesignation) return;
try {
setIsActionLoading(true);
const response = await designationService.delete(
selectedDesignation.id,
effectiveTenantId,
);
if (response.success) {
showToast.success("Designation deleted successfully");
setIsDeleteModalOpen(false);
fetchDesignations();
}
} catch (err: any) {
showToast.error(
err?.response?.data?.error?.message || "Failed to delete designation",
);
} finally {
setIsActionLoading(false);
}
};
// const handleDelete = async () => {
// if (!selectedDesignation) return;
// try {
// setIsActionLoading(true);
// const response = await designationService.delete(
// selectedDesignation.id,
// effectiveTenantId,
// );
// if (response.success) {
// showToast.success("Designation deleted successfully");
// setIsDeleteModalOpen(false);
// fetchDesignations();
// }
// } catch (err: any) {
// showToast.error(
// err?.response?.data?.error?.message || "Failed to delete designation",
// );
// } finally {
// setIsActionLoading(false);
// }
// };
// Client-side pagination logic
const totalItems = designations.length;
@ -228,10 +228,10 @@ const DesignationsTable = ({
setSelectedDesignation(desig);
setIsEditModalOpen(true);
}}
onDelete={() => {
setSelectedDesignation(desig);
setIsDeleteModalOpen(true);
}}
// onDelete={() => {
// setSelectedDesignation(desig);
// setIsDeleteModalOpen(true);
// }}
/>
</div>
),
@ -339,7 +339,7 @@ const DesignationsTable = ({
designation={selectedDesignation}
/>
<DeleteConfirmationModal
{/* <DeleteConfirmationModal
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false);
@ -350,7 +350,7 @@ const DesignationsTable = ({
message="Are you sure you want to delete this designation? This action cannot be undone."
itemName={selectedDesignation?.name || ""}
isLoading={isActionLoading}
/>
/> */}
</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 { Layout } from '@/components/layout/Layout';
import {
@ -13,7 +13,7 @@ import {
Pagination,
type Column,
} 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 { moduleService } from '@/services/module-service';
import { showToast } from '@/utils/toast';
@ -39,6 +39,74 @@ const codeSchema = z.object({
type CategoryFormValues = z.infer<typeof categorySchema>;
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 [categories, setCategories] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
@ -54,7 +122,6 @@ const NotificationMaster = (): ReactElement => {
const [categoryModalOpen, setCategoryModalOpen] = useState<boolean>(false);
const [editingCategory, setEditingCategory] = useState<any>(null);
// New: React Hook Form for Category
const {
register: registerCategory,
handleSubmit: handleCategorySubmit,
@ -72,15 +139,15 @@ const NotificationMaster = (): ReactElement => {
const [selectedCategory, setSelectedCategory] = useState<any>(null);
const [codes, setCodes] = useState<any[]>([]);
const [isCodesLoading, setIsCodesLoading] = useState<boolean>(false);
// Pagination for Codes
const [codePage, setCodePage] = useState(1);
const [codeTotal, setCodeTotal] = useState(0);
const [codePages, setCodePages] = useState(0);
const [editingCode, setEditingCode] = useState<any>(null);
const [codeVariables, setCodeVariables] = useState<string[]>([]);
// New: React Hook Form for Code
const {
register: registerCode,
handleSubmit: handleCodeSubmit,
@ -117,20 +184,17 @@ const NotificationMaster = (): ReactElement => {
}
};
const fetchModules = async () => {
try {
const res = await moduleService.getAll(1, 100);
if (res.success) {
setModules(res.data);
}
if (res.success) setModules(res.data);
} catch (err) {
console.error('Failed to fetch modules', err);
}
};
useEffect(() => {
fetchCategories();
useEffect(() => {
fetchCategories();
fetchModules();
}, [currentPage, limit, search]);
@ -156,6 +220,9 @@ const NotificationMaster = (): ReactElement => {
const handleOpenCodes = async (category: any) => {
setSelectedCategory(category);
setCodePage(1);
setEditingCode(null);
setCodeVariables([]);
resetCode({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' });
await fetchCodes(category, 1);
setCodeModalOpen(true);
};
@ -168,11 +235,8 @@ const NotificationMaster = (): ReactElement => {
const onSaveCategory = async (data: CategoryFormValues) => {
try {
// Conditionally include module_id only if it's actually selected
const payload = { ...data };
if (!payload.module_id) {
delete payload.module_id;
}
if (!payload.module_id) delete payload.module_id;
if (editingCategory) {
await notificationService.updateCategory(editingCategory.id, payload);
@ -185,26 +249,26 @@ const NotificationMaster = (): ReactElement => {
resetCategory();
fetchCategories();
} catch (err: any) {
const errorMessage = err.response?.data?.error?.message || err.message || 'Action failed';
showToast.error(errorMessage);
showToast.error(err.response?.data?.error?.message || err.message || 'Action failed');
}
};
const onSaveCode = async (data: CodeFormValues) => {
try {
const payload = { ...data, variables: codeVariables };
if (editingCode) {
await notificationService.updateCode(editingCode.id, data);
await notificationService.updateCode(editingCode.id, payload);
showToast.success('Code updated');
} else {
await notificationService.createCode(selectedCategory.id, data);
await notificationService.createCode(selectedCategory.id, payload);
showToast.success('Code created');
}
setEditingCode(null);
resetCode();
setCodeVariables([]);
resetCode({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' });
fetchCodes(selectedCategory, codePage);
} catch (err: any) {
const errorMessage = err.response?.data?.error?.message || err.message || 'Action failed';
showToast.error(errorMessage);
showToast.error(err.response?.data?.error?.message || err.message || 'Action failed');
}
};
@ -236,11 +300,11 @@ const NotificationMaster = (): ReactElement => {
align: 'right',
render: (c) => (
<div className="flex justify-end gap-3 items-center">
<button
onClick={() => handleOpenCodes(c)}
<button
onClick={() => handleOpenCodes(c)}
className="flex items-center gap-1.5 text-[11px] font-bold text-blue-600 hover:text-blue-800 transition-colors uppercase"
>
<Code className="w-3.5 h-3.5" />
<Code className="w-3.5 h-3.5" />
Codes ({c.code_count || 0})
</button>
<ActionDropdown
@ -272,9 +336,9 @@ const NotificationMaster = (): ReactElement => {
<div className="flex items-center gap-4 flex-1">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search categories..."
<input
type="text"
placeholder="Search categories..."
className="w-full pl-9 pr-4 py-1.5 border rounded-md text-sm outline-none focus:ring-2 focus:ring-blue-500/20"
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
@ -294,7 +358,7 @@ const NotificationMaster = (): ReactElement => {
<DataTable columns={columns} data={categories} isLoading={isLoading} keyExtractor={(c) => c.id} />
</div>
<Pagination
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
@ -305,22 +369,22 @@ const NotificationMaster = (): ReactElement => {
</div>
{/* 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="grid grid-cols-2 gap-4">
<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')} />
</div>
<FormTextArea label="Description" rows={2} error={categoryErrors.description?.message} {...registerCategory('description')} />
<FormSelect
label="Associated Module"
value={watchCategory('module_id')}
<FormSelect
label="Associated Module"
value={watchCategory('module_id')}
options={[
{ value: '', label: 'System (No Module)' },
...modules.map(m => ({ value: m.id, label: m.name }))
]}
onValueChange={(val) => setCategoryValue('module_id', val, { shouldValidate: true })}
placeholder="Select a module"
onValueChange={(val) => setCategoryValue('module_id', val, { shouldValidate: true })}
placeholder="Select a module"
error={categoryErrors.module_id?.message}
/>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
@ -332,25 +396,45 @@ const NotificationMaster = (): ReactElement => {
{/* Codes Modal */}
<Modal isOpen={codeModalOpen} onClose={() => setCodeModalOpen(false)} title={`Event Codes: ${selectedCategory?.name}`} maxWidth="2xl">
<div className="p-6 space-y-6 max-h-[85vh] overflow-y-auto custom-scrollbar">
<div className="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">
<h3 className="text-[11px] font-bold uppercase text-gray-500 tracking-wider font-mono flex items-center gap-2">
<Plus className="w-3 h-3" /> {editingCode ? 'Edit Event Code' : 'Add New Event Trigger'}
</h3>
<div className="grid grid-cols-2 gap-4">
<FormField label="Code (e.g. task_assigned)" required error={codeErrors.code?.message} disabled={!!editingCode} {...registerCode('code')} />
<FormField label="Display Name" required error={codeErrors.name?.message} {...registerCode('name')} />
</div>
<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="text-[10px] text-gray-400 font-medium">Auto-populates default channels (In-App, Email)</div>
<div className="flex gap-3">
{editingCode && <button onClick={() => { setEditingCode(null); 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>
{editingCode && (
<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>
{/* ── Registered Codes Table ───────────────────────────────── */}
<div className="space-y-4">
<h4 className="text-[11px] font-bold text-gray-400 uppercase tracking-widest px-1">Registered Codes</h4>
<div className="border border-gray-100 rounded-xl overflow-hidden shadow-sm">
@ -359,21 +443,33 @@ const NotificationMaster = (): ReactElement => {
<tr>
<th className="px-4 py-3 text-left text-[11px] font-bold text-gray-500 uppercase tracking-wider">Trigger Code</th>
<th className="px-4 py-3 text-left text-[11px] font-bold text-gray-500 uppercase tracking-wider">Name</th>
<th className="px-4 py-3 text-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>
</tr>
</thead>
<tbody className="divide-y divide-gray-50 text-[13px]">
{isCodesLoading ? (
<tr><td colSpan={3} className="px-4 py-10 text-center text-gray-400 animate-pulse">Loading codes...</td></tr>
<tr><td colSpan={4} className="px-4 py-10 text-center text-gray-400 animate-pulse">Loading codes...</td></tr>
) : codes.length === 0 ? (
<tr><td colSpan={3} className="px-4 py-10 text-center text-gray-400 italic">No codes registered for this category.</td></tr>
<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 => (
<tr key={c.id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-4 py-3"><code className="text-[11px] font-mono font-bold text-indigo-600 bg-indigo-50 px-1.5 py-0.5 rounded border border-indigo-100">{c.code}</code></td>
<td className="px-4 py-3 font-medium text-gray-700">{c.name}</td>
<td className="px-4 py-3">
<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">
<button onClick={() => {
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 });
}} className="text-blue-600 hover:text-blue-800 font-bold text-[11px] uppercase mr-3">Edit</button>
<button onClick={() => {
@ -387,13 +483,13 @@ const NotificationMaster = (): ReactElement => {
</table>
</div>
<Pagination
<Pagination
currentPage={codePage}
totalPages={codePages}
totalItems={codeTotal}
limit={5}
onPageChange={setCodePage}
onLimitChange={() => {}} // Fixed for codes modal
onLimitChange={() => {}}
/>
</div>
</div>

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import type { ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout';
import { useState, useEffect } from "react";
import type { ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import {
PrimaryButton,
DataTable,
@ -8,34 +8,87 @@ import {
FormField,
FormSelect,
FormTextArea,
RichTextEditor,
Pagination,
FilterDropdown,
type Column,
} from '@/components/shared';
import { Plus, Search } 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';
} from "@/components/shared";
import { Plus, Search, Copy, 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 templateSchema = z.object({
category: z.string().min(1, 'Category is required'),
code: z.string().min(1, 'Event code is required'),
name: z.string().min(1, 'Friendly name is required'),
category: z.string().min(1, "Category is required"),
code: z.string().min(1, "Event code is required"),
name: z.string().min(1, "Friendly name is required"),
description: z.string().optional(),
title_template: z.string().min(1, 'Title template is required'),
message_template: z.string().min(1, 'Message template is required'),
title_template: z.string().min(1, "Title template is required"),
message_template: z.string().min(1, "Message template is required"),
email_subject_template: z.string().optional(),
email_body_template: z.string().optional(),
default_priority: z.enum(['low', 'normal', 'high', 'urgent']),
channels: z.array(z.string()).min(1, 'At least one channel is required'),
default_priority: z.enum(["low", "normal", "high", "urgent"]),
channels: z.array(z.string()).min(1, "At least one channel is required"),
is_active: z.boolean(),
});
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 [templates, setTemplates] = useState<any[]>([]);
const [modules, setModules] = useState<any[]>([]);
@ -44,45 +97,51 @@ const NotificationTemplateMaster = (): ReactElement => {
const [codes, setCodes] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
// Template variable chips (from selected code)
const [templateVariables, setTemplateVariables] = useState<string[]>([]);
// Pagination & Search
const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(10);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [search, setSearch] = useState('');
const [search, setSearch] = useState("");
// Template Modal
const [modalOpen, setModalOpen] = useState<boolean>(false);
const [editingId, setEditingId] = useState<string | null>(null);
// HTML body state (managed separately for RichTextEditor)
const [emailBodyHtml, setEmailBodyHtml] = useState("");
const {
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors }
formState: { errors },
} = useForm<TemplateFormValues>({
resolver: zodResolver(templateSchema),
defaultValues: {
code: '',
name: '',
description: '',
category: '',
title_template: '',
message_template: '',
email_subject_template: '',
email_body_template: '',
default_priority: 'normal',
channels: ['in_app', 'email'],
is_active: true
}
code: "",
name: "",
description: "",
category: "",
title_template: "",
message_template: "",
email_subject_template: "",
email_body_template: "",
default_priority: "normal",
channels: ["in_app", "email"],
is_active: true,
},
});
const channels = watch('channels');
const categoryValue = watch('category');
const codeValue = watch('code');
const priorityValue = watch('default_priority');
const channels = watch("channels");
const categoryValue = watch("category");
const codeValue = watch("code");
const priorityValue = watch("default_priority");
const fetchData = async () => {
try {
@ -92,184 +151,318 @@ const NotificationTemplateMaster = (): ReactElement => {
limit,
offset: (currentPage - 1) * limit,
search,
module_id: selectedModule || undefined
module_id: selectedModule || undefined,
}),
notificationService.getCategories({ limit: 100 }), // Fetch more for dropdown
moduleService.getAll(1, 100)
notificationService.getCategories({ limit: 100 }),
moduleService.getAll(1, 100),
]);
if (tRes.success) {
setTemplates(tRes.data);
setTotalItems(tRes.pagination?.total || tRes.data.length);
setTotalPages(tRes.pagination?.pages || 1);
}
if (cRes.success) {
setCategories(cRes.data);
}
if (mRes.success) {
setModules(mRes.data);
}
if (cRes.success) setCategories(cRes.data);
if (mRes.success) setModules(mRes.data);
} catch (err: any) {
showToast.error(err.message || 'Failed to fetch data');
showToast.error(err.message || "Failed to fetch data");
} finally {
setIsLoading(false);
}
};
useEffect(() => { fetchData(); }, [currentPage, limit, search, selectedModule]);
useEffect(() => {
fetchData();
}, [currentPage, limit, search, selectedModule]);
const fetchCodesForCategory = async (categoryCode: string) => {
if (!categoryCode) return;
try {
const res = await notificationService.getCodesByCategory(categoryCode, { limit: 100 });
if (res.success) setCodes(res.data);
} catch (e) {
console.error('Failed to load codes:', e);
const res = await notificationService.getCodesByCategory(categoryCode, {
limit: 100,
});
if (res.success) setCodes(res.data);
} catch (e) {
console.error("Failed to load codes:", e);
}
};
const handleCategorySelect = async (categoryCode: string) => {
setValue('category', categoryCode, { shouldValidate: true });
setValue('code', '', { shouldValidate: true });
setValue("category", categoryCode, { shouldValidate: true });
setValue("code", "", { shouldValidate: true });
setTemplateVariables([]);
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) => {
try {
const payload = { ...data, email_body_template: emailBodyHtml };
if (editingId) {
await notificationService.updateTemplate(editingId, data);
await notificationService.updateTemplate(editingId, payload);
} 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);
reset();
setEmailBodyHtml("");
setTemplateVariables([]);
fetchData();
} catch (err: any) {
const errorMessage = err.response?.data?.error?.message || err.message || 'Action failed';
showToast.error(errorMessage);
showToast.error(
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>[] = [
{ key: 'category', label: 'Category', render: (t) => <span className="text-[10px] font-bold uppercase text-blue-600 bg-blue-50 px-2 py-1 rounded">{t.category_name}</span> },
{ key: 'code', label: 'Code', render: (t) => <code className="text-xs font-mono font-bold text-gray-700">{t.code}</code> },
{ key: 'name', label: 'Friendly Name', render: (t) => <span className="text-sm font-medium">{t.name}</span> },
{ key: 'title', label: 'Preview', render: (t) => <span className="text-xs truncate max-w-[200px] block text-gray-500">{t.title_template}</span> },
{ key: 'priority', label: 'Priority', render: (t) => <span className="capitalize text-[10px] bg-gray-100 px-1.5 py-0.5 rounded">{t.default_priority}</span> },
{
key: 'channels',
label: 'Channels',
{
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: "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) => (
<div className="flex gap-1">
{t.channels?.map((c: string) => (
<span key={c} className="text-[9px] bg-green-50 text-green-700 px-1 rounded border border-green-200 uppercase">{c}</span>
<span
key={c}
className="text-[9px] bg-green-50 text-green-700 px-1 rounded border border-green-200 uppercase"
>
{c}
</span>
))}
</div>
)
),
},
{
key: 'actions',
label: 'Actions',
align: 'right',
key: "actions",
label: "Actions",
align: "right",
render: (t) => (
<button onClick={() => {
setEditingId(t.id);
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
});
setModalOpen(true);
}} className="text-xs text-blue-600 hover:underline font-semibold">
<button
onClick={() => openEditModal(t)}
className="text-xs text-blue-600 hover:underline font-semibold"
>
Edit
</button>
)
}
),
},
];
return (
<Layout
currentPage="Global Templates"
pageHeader={{
title: 'Global Notification Templates',
description: 'Define default notification templates for all tenants.',
title: "Global Notification Templates",
description: "Define default notification templates for all tenants.",
}}
>
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden flex flex-col min-h-[600px]">
<div className="p-4 border-b flex flex-wrap justify-between items-center bg-gray-50/50 gap-4">
<div className="flex items-center gap-4 flex-1">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search templates..."
className="w-full pl-9 pr-4 py-2 border rounded-md text-sm focus:ring-2 focus:ring-blue-500 outline-none transition-all"
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
/>
</div>
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search templates..."
className="w-full pl-9 pr-4 py-2 border rounded-md text-sm focus:ring-2 focus:ring-blue-500 outline-none transition-all"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setCurrentPage(1);
}}
/>
</div>
<div className="flex items-center gap-2">
<div className="w-full">
<FilterDropdown
label="Module"
value={selectedModule}
onChange={(val) => { setSelectedModule(val as string | null); setCurrentPage(1); }}
options={modules.map(m => ({ value: m.id, label: m.name }))}
placeholder="All Modules"
isSearchable
/>
</div>
<div className="flex items-center gap-2">
<div className="w-full">
<FilterDropdown
label="Module"
value={selectedModule}
onChange={(val) => {
setSelectedModule(val as string | null);
setCurrentPage(1);
}}
options={modules.map((m) => ({ value: m.id, label: m.name }))}
placeholder="All Modules"
isSearchable
/>
</div>
</div>
</div>
<PrimaryButton onClick={() => {
setEditingId(null);
reset({
code: '', name: '', description: '', category: '',
title_template: '', message_template: '',
email_subject_template: '', email_body_template: '',
default_priority: 'normal', channels: ['in_app', 'email'],
is_active: true
});
setModalOpen(true);
}} className="flex gap-2">
<PrimaryButton
onClick={() => {
setEditingId(null);
setTemplateVariables([]);
setEmailBodyHtml("");
reset({
code: "",
name: "",
description: "",
category: "",
title_template: "",
message_template: "",
email_subject_template: "",
email_body_template: "",
default_priority: "normal",
channels: ["in_app", "email"],
is_active: true,
});
setModalOpen(true);
}}
className="flex gap-2"
>
<Plus className="w-4 h-4" /> New Template
</PrimaryButton>
</div>
<div className="flex-1">
<DataTable columns={columns} data={templates} isLoading={isLoading} keyExtractor={(t) => t.id} />
<DataTable
columns={columns}
data={templates}
isLoading={isLoading}
keyExtractor={(t) => t.id}
/>
</div>
<Pagination
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={(l) => { setLimit(l); setCurrentPage(1); }}
onLimitChange={(l) => {
setLimit(l);
setCurrentPage(1);
}}
/>
</div>
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title={editingId ? "Edit Notification Template" : "Create New Template"} maxWidth="2xl">
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6 max-h-[80vh] overflow-y-auto custom-scrollbar">
<div className="space-y-4">
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Identification</h3>
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
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
label="Category"
required
value={categoryValue}
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}
placeholder="Select Category"
/>
@ -277,81 +470,136 @@ const NotificationTemplateMaster = (): ReactElement => {
label="Event Code"
required
value={codeValue}
onValueChange={(val) => {
const selectedCode = codes.find(c => c.code === val);
setValue('code', val, { shouldValidate: true });
if (selectedCode?.name) setValue('name', selectedCode.name, { shouldValidate: true });
}}
options={codes.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }))}
onValueChange={handleCodeSelect}
options={codes.map((c) => ({
value: c.code,
label: `${c.name} (${c.code})`,
}))}
disabled={!categoryValue}
error={errors.code?.message}
placeholder="Select Event Code"
/>
<FormField label="Friendly Name" required error={errors.name?.message} placeholder="e.g. Project Assigned" {...register('name')} />
<FormTextArea label="Description" error={errors.description?.message} rows={2} {...register('description')} />
</div>
<FormField
label="Friendly Name"
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">
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Settings</h3>
<div className="space-y-4">
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">
Settings
</h3>
<div className="grid grid-cols-2 gap-4">
<FormSelect
label="Priority"
value={priorityValue}
onValueChange={(val) => setValue('default_priority', val as any, { shouldValidate: true })}
onValueChange={(val) =>
setValue("default_priority", val as any, {
shouldValidate: true,
})
}
options={[
{ value: 'low', label: 'Low' },
{ value: 'normal', label: 'Normal' },
{ value: 'high', label: 'High' },
{ value: 'urgent', label: 'Urgent' }
{ value: "low", label: "Low" },
{ value: "normal", label: "Normal" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
]}
error={errors.default_priority?.message}
/>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-700">Channels</label>
<div className="flex gap-4 items-center h-10 px-1">
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={channels.includes('in_app')} onChange={(e) => {
const next = e.target.checked ? [...channels, 'in_app'] : channels.filter(c => c !== 'in_app');
setValue('channels', next, { shouldValidate: true });
}} /> 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>}
<label className="text-xs font-medium text-gray-700">
Channels
</label>
<div className="flex gap-4 items-center h-10 px-1">
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={channels.includes("in_app")}
onChange={(e) => {
const next = e.target.checked
? [...channels, "in_app"]
: channels.filter((c) => c !== "in_app");
setValue("channels", next, { shouldValidate: true });
}}
/>{" "}
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>
<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')} />
<FormTextArea label="Message Template" required error={errors.message_template?.message} placeholder="Use {{var}} for dynamic data" rows={3} {...register('message_template')} />
</div>
{/* Variable Chips */}
<VariableChips variables={templateVariables} />
</div>
</div>
<div className="md:col-span-2 space-y-4 pt-4 border-t">
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Email Content</h3>
<FormField
label="Email Subject"
error={errors.email_subject_template?.message}
placeholder="Leave blank to use In-App title"
{...register('email_subject_template')}
/>
<FormTextArea
label="Email Body (HTML supported)"
error={errors.email_body_template?.message}
placeholder="Full HTML body template..."
rows={6}
{...register('email_body_template')}
/>
{/* ── In-App Content ─────────────────────────────────────── */}
<div className="space-y-4 pt-4 border-t">
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">
In-App Content
</h3>
<FormField
label="Title Template"
required
error={errors.title_template?.message}
placeholder="e.g. Task Assigned: {{task_name}}"
{...register("title_template")}
/>
<FormTextArea
label="Message Template"
required
error={errors.message_template?.message}
placeholder="Use {{var}} for dynamic data"
rows={3}
{...register("message_template")}
/>
</div>
<div className="md:col-span-2 flex justify-end gap-3 pt-6 border-t sticky bottom-0 bg-white">
<button onClick={() => setModalOpen(false)} className="px-5 py-2 text-sm text-gray-600 transition-colors hover:text-gray-900">Cancel</button>
<PrimaryButton onClick={handleSubmit(onSave)} className="px-10">
{editingId ? 'Update Template' : 'Create Template'}
</PrimaryButton>
{/* ── Email Content (HTML Editor) ────────────────────────── */}
<div className="space-y-4 pt-4 border-t">
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">
Email Content
</h3>
<FormField
label="Email Subject"
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>
</Modal>

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import type { ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout';
import { useState, useEffect } from "react";
import type { ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import {
PrimaryButton,
DataTable,
@ -8,32 +8,82 @@ import {
FormField,
FormTextArea,
FormSelect,
RichTextEditor,
StatusBadge,
Pagination,
type Column,
} from '@/components/shared';
import { Edit, RotateCcw, Building, Filter } from 'lucide-react';
import { notificationService } from '@/services/notification-service';
import { moduleService } from '@/services/module-service';
import { showToast } from '@/utils/toast';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
} from "@/components/shared";
import {
Edit,
RotateCcw,
Building,
Filter,
Copy,
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({
title_template: z.string().min(1, 'Title template is required'),
message_template: z.string().min(1, 'Message template is required'),
title_template: z.string().min(1, "Title template is required"),
message_template: z.string().min(1, "Message template is required"),
email_subject_template: z.string().optional(),
email_body_template: z.string().optional(),
is_active: z.boolean()
is_active: z.boolean(),
});
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 [templates, setTemplates] = 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);
// Pagination
@ -46,30 +96,31 @@ const NotificationTemplates = (): ReactElement => {
const [modalOpen, setModalOpen] = useState<boolean>(false);
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
// HTML body state for rich editor
const [emailBodyHtml, setEmailBodyHtml] = useState("");
const {
register,
handleSubmit,
reset,
formState: { errors }
formState: { errors },
} = useForm<OverrideFormValues>({
resolver: zodResolver(overrideSchema),
defaultValues: {
title_template: '',
message_template: '',
email_subject_template: '',
email_body_template: '',
is_active: true
}
title_template: "",
message_template: "",
email_subject_template: "",
email_body_template: "",
is_active: true,
},
});
const fetchModules = async () => {
try {
const res = await moduleService.getMyModules();
if (res.success) {
setModules(res.data);
}
if (res.success) setModules(res.data);
} 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({
limit,
offset: (currentPage - 1) * limit,
module_id: selectedModule === 'all' ? undefined : selectedModule
module_id: selectedModule === "all" ? undefined : selectedModule,
});
if (res.success) {
setTemplates(res.data);
@ -87,149 +138,303 @@ const NotificationTemplates = (): ReactElement => {
setTotalPages(res.pagination?.pages || 1);
}
} catch (err: any) {
showToast.error('Failed to load templates');
showToast.error("Failed to load templates");
} finally {
setIsLoading(false);
}
};
useEffect(() => { fetchModules(); }, []);
useEffect(() => { fetchTemplates(); }, [currentPage, limit, selectedModule]);
useEffect(() => {
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) => {
try {
await notificationService.overrideTemplate(selectedTemplate.code, data);
showToast.success('Template override saved');
await notificationService.overrideTemplate(selectedTemplate.code, {
...data,
email_body_template: emailBodyHtml,
});
showToast.success("Template override saved");
setModalOpen(false);
reset();
setEmailBodyHtml("");
fetchTemplates();
} catch (err: any) {
const errorMessage = err.response?.data?.error?.message || err.message || 'Action failed';
showToast.error(errorMessage);
showToast.error(
err.response?.data?.error?.message || err.message || "Action failed",
);
}
};
const handleReset = async (code: string) => {
if (!confirm('Are you sure you want to reset this template to the global default? Your custom changes will be lost.')) return;
if (
!confirm(
"Are you sure you want to reset this template to the global default? Your custom changes will be lost.",
)
)
return;
try {
await notificationService.resetTemplate(code);
showToast.success('Template reset to default');
showToast.success("Template reset to default");
fetchTemplates();
} catch (err: any) {
showToast.error(err.message || 'Reset failed');
showToast.error(err.message || "Reset failed");
}
};
const columns: Column<any>[] = [
{ key: 'category', label: 'Category', render: (t) => <span className="text-[10px] font-bold uppercase text-gray-500 bg-gray-100 px-2 py-1 rounded">{t.category_name}</span> },
{ key: 'code', label: 'Event', render: (t) => <span className="text-sm font-semibold">{t.code}</span> },
{ key: 'source', label: 'Source', render: (t) => (
<StatusBadge variant={t.tenant_id ? 'success' : 'process'}>
{t.tenant_id ? 'Custom Override' : 'System Default'}
</StatusBadge>
)},
{ key: 'preview', label: 'Title Preview', render: (t) => <span className="text-xs truncate max-w-xs block text-gray-500">{t.title_template}</span> },
{
key: 'actions',
label: 'Actions',
align: 'right',
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: "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) => (
<div className="flex justify-end gap-2">
<button onClick={() => {
setSelectedTemplate(t);
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);
}} 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
onClick={() => openEditModal(t)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-md transition-colors flex items-center gap-1"
>
<Edit className="w-4 h-4" />{" "}
<span className="text-xs font-semibold uppercase">Edit</span>
</button>
{t.tenant_id && (
<button onClick={() => handleReset(t.code)} className="p-2 text-orange-600 hover:bg-orange-50 rounded-md transition-colors flex items-center gap-1">
<RotateCcw className="w-4 h-4" /> <span className="text-xs font-semibold uppercase">Reset</span>
<button
onClick={() => handleReset(t.code)}
className="p-2 text-orange-600 hover:bg-orange-50 rounded-md transition-colors flex items-center gap-1"
>
<RotateCcw className="w-4 h-4" />{" "}
<span className="text-xs font-semibold uppercase">Reset</span>
</button>
)}
</div>
)
}
),
},
];
return (
<Layout
currentPage="Notification Templates"
pageHeader={{
title: 'Custom Notifications',
description: 'Customize the content and delivery of platform notifications for your organization.',
title: "Custom Notifications",
description:
"Customize the content and delivery of platform notifications for your organization.",
}}
>
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden flex flex-col min-h-[500px]">
<div className="p-4 border-b flex flex-wrap justify-between items-center bg-gray-50/30 gap-4">
<div className="flex items-center gap-2">
<Building className="w-4 h-4 text-blue-500" />
<h2 className="text-sm font-semibold text-gray-700">Notification Templates</h2>
</div>
<div className="flex items-center gap-3 min-w-[300px]">
<div className="flex items-center gap-2 text-xs font-medium text-gray-400 mr-1">
<Filter className="w-3.5 h-3.5" /> Filter by Module
</div>
<div className="w-full">
<FormSelect
label=""
value={selectedModule}
onValueChange={(val) => { setSelectedModule(val); setCurrentPage(1); }}
options={[
{ value: 'all', label: 'All Modules' },
...modules.map(m => ({ value: m.id, label: m.name }))
]}
placeholder="Select Module"
/>
</div>
</div>
</div>
<div className="flex-1">
<DataTable columns={columns} data={templates} isLoading={isLoading} keyExtractor={(t) => t.code} />
<div className="flex items-center gap-2">
<Building className="w-4 h-4 text-blue-500" />
<h2 className="text-sm font-semibold text-gray-700">
Notification Templates
</h2>
</div>
<div className="flex items-center gap-3 min-w-[300px]">
<div className="flex items-center gap-2 text-xs font-medium text-gray-400 mr-1">
<Filter className="w-3.5 h-3.5" /> Filter by Module
</div>
<div className="w-full">
<FormSelect
label=""
value={selectedModule}
onValueChange={(val) => {
setSelectedModule(val);
setCurrentPage(1);
}}
options={[
{ value: "all", label: "All Modules" },
...modules.map((m) => ({ value: m.id, label: m.name })),
]}
placeholder="Select Module"
/>
</div>
</div>
</div>
<Pagination
<div className="flex-1">
<DataTable
columns={columns}
data={templates}
isLoading={isLoading}
keyExtractor={(t) => t.code}
/>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={(l) => { setLimit(l); setCurrentPage(1); }}
onLimitChange={(l) => {
setLimit(l);
setCurrentPage(1);
}}
/>
</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">
{/* 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="space-y-4">
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">In-App Notification</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={3} {...register('message_template')} />
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">
In-App Notification
</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 className="space-y-4">
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Email Notification</h3>
<FormField label="Email Subject" error={errors.email_subject_template?.message} placeholder="Inherits from title if blank" {...register('email_subject_template')} />
<FormTextArea label="Email Body (HTML)" error={errors.email_body_template?.message} rows={6} {...register('email_body_template')} />
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">
Email Notification
</h3>
<FormField
label="Email Subject"
error={errors.email_subject_template?.message}
placeholder="Inherits from title if blank"
{...register("email_subject_template")}
/>
</div>
</div>
<div className="flex justify-between items-center pt-6 border-t">
<div className="text-xs text-gray-400 max-w-sm">
💡 You can use placeholders like <code>{`{{user_name}}`}</code>, <code>{`{{entity_name}}`}</code>, and <code>{`{{action}}`}</code>.
</div>
{/* Full-width HTML body editor */}
<div className="space-y-2 pt-2 border-t">
<RichTextEditor
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">
<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>
<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>
</Layout>

View File

@ -66,7 +66,7 @@ const Roles = (): ReactElement => {
});
// Filter state
const [scopeFilter, setScopeFilter] = useState<string | null>(null);
// const [scopeFilter, setScopeFilter] = useState<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null);
// View, Edit, Delete modals
@ -81,13 +81,13 @@ const Roles = (): ReactElement => {
const fetchRoles = async (
page: number,
itemsPerPage: number,
scope: string | null = null,
// scope: string | null = null,
sortBy: string[] | null = null
): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = await roleService.getAll(page, itemsPerPage, scope, sortBy);
const response = await roleService.getAll(page, itemsPerPage, sortBy);
if (response.success) {
setRoles(response.data);
setPagination(response.pagination);
@ -102,8 +102,8 @@ const Roles = (): ReactElement => {
};
useEffect(() => {
fetchRoles(currentPage, limit, scopeFilter, orderBy);
}, [currentPage, limit, scopeFilter, orderBy]);
fetchRoles(currentPage, limit, orderBy);
}, [currentPage, limit, orderBy]);
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
try {
@ -113,7 +113,7 @@ const Roles = (): ReactElement => {
const description = response.message ? undefined : `${data.name} has been added`;
showToast.success(message, description);
setIsModalOpen(false);
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
await fetchRoles(currentPage, limit, orderBy);
} catch (err: any) {
throw err;
} finally {
@ -147,7 +147,7 @@ const Roles = (): ReactElement => {
showToast.success(message, description);
setEditModalOpen(false);
setSelectedRoleId(null);
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
await fetchRoles(currentPage, limit, orderBy);
} catch (err: any) {
throw err;
} finally {
@ -172,7 +172,7 @@ const Roles = (): ReactElement => {
setDeleteModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
await fetchRoles(currentPage, limit, orderBy);
} catch (err: any) {
throw err;
} finally {
@ -300,7 +300,7 @@ const Roles = (): ReactElement => {
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Scope Filter */}
<FilterDropdown
{/* <FilterDropdown
label="Scope"
options={[
{ value: 'platform', label: 'Platform' },
@ -313,7 +313,7 @@ const Roles = (): ReactElement => {
setCurrentPage(1);
}}
placeholder="All"
/>
/> */}
{/* Sort Filter */}
<FilterDropdown

View File

@ -95,6 +95,21 @@ export const notificationService = {
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>> => {
const response = await apiClient.post(`/notifications/categories/${categoryId}/codes`, data);
return response.data;

View File

@ -13,15 +13,15 @@ export const roleService = {
getAll: async (
page: number = 1,
limit: number = 20,
scope?: string | null,
// scope?: string | null,
orderBy?: string[] | null
): Promise<RolesResponse> => {
const params = new URLSearchParams();
params.append('page', String(page));
params.append('limit', String(limit));
if (scope) {
params.append('scope', scope);
}
// if (scope) {
// params.append('scope', scope);
// }
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
params.append('orderBy[]', orderBy[0]);
params.append('orderBy[]', orderBy[1]);