feat: integrate RichTextEditor and variable copy functionality into notification template management
This commit is contained in:
parent
cde2544cf3
commit
fe707216f6
@ -7,7 +7,7 @@ import {
|
||||
DataTable,
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
@ -79,8 +146,8 @@ const NotificationMaster = (): ReactElement => {
|
||||
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,13 +184,10 @@ 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);
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@ -305,7 +369,7 @@ 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')} />
|
||||
@ -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={() => {
|
||||
@ -393,7 +489,7 @@ const NotificationMaster = (): ReactElement => {
|
||||
totalItems={codeTotal}
|
||||
limit={5}
|
||||
onPageChange={setCodePage}
|
||||
onLimitChange={() => {}} // Fixed for codes modal
|
||||
onLimitChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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,10 +151,10 @@ 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) {
|
||||
@ -103,105 +162,189 @@ const NotificationTemplateMaster = (): ReactElement => {
|
||||
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 });
|
||||
const res = await notificationService.getCodesByCategory(categoryCode, {
|
||||
limit: 100,
|
||||
});
|
||||
if (res.success) setCodes(res.data);
|
||||
} catch (e) {
|
||||
console.error('Failed to load codes:', 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]">
|
||||
@ -214,7 +357,10 @@ const NotificationTemplateMaster = (): ReactElement => {
|
||||
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); }}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -223,31 +369,50 @@ const NotificationTemplateMaster = (): ReactElement => {
|
||||
<FilterDropdown
|
||||
label="Module"
|
||||
value={selectedModule}
|
||||
onChange={(val) => { setSelectedModule(val as string | null); setCurrentPage(1); }}
|
||||
options={modules.map(m => ({ value: m.id, label: m.name }))}
|
||||
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={() => {
|
||||
<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
|
||||
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">
|
||||
}}
|
||||
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
|
||||
@ -256,20 +421,48 @@ const NotificationTemplateMaster = (): ReactElement => {
|
||||
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">
|
||||
<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>
|
||||
<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,82 +470,137 @@ 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')} />
|
||||
<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>
|
||||
<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>
|
||||
<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
|
||||
<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
|
||||
<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>}
|
||||
{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>
|
||||
|
||||
<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>
|
||||
{/* Variable Chips */}
|
||||
<VariableChips variables={templateVariables} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 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>
|
||||
|
||||
{/* ── 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')}
|
||||
{...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')}
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Layout>
|
||||
|
||||
@ -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,90 +138,167 @@ 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>
|
||||
<h2 className="text-sm font-semibold text-gray-700">
|
||||
Notification Templates
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 min-w-[300px]">
|
||||
@ -181,10 +309,13 @@ const NotificationTemplates = (): ReactElement => {
|
||||
<FormSelect
|
||||
label=""
|
||||
value={selectedModule}
|
||||
onValueChange={(val) => { setSelectedModule(val); setCurrentPage(1); }}
|
||||
onValueChange={(val) => {
|
||||
setSelectedModule(val);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'all', label: 'All Modules' },
|
||||
...modules.map(m => ({ value: m.id, label: m.name }))
|
||||
{ value: "all", label: "All Modules" },
|
||||
...modules.map((m) => ({ value: m.id, label: m.name })),
|
||||
]}
|
||||
placeholder="Select Module"
|
||||
/>
|
||||
@ -193,7 +324,12 @@ const NotificationTemplates = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<DataTable columns={columns} data={templates} isLoading={isLoading} keyExtractor={(t) => t.code} />
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={templates}
|
||||
isLoading={isLoading}
|
||||
keyExtractor={(t) => t.code}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
@ -202,34 +338,103 @@ const NotificationTemplates = (): ReactElement => {
|
||||
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>.
|
||||
{/* 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>
|
||||
</div>
|
||||
<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>
|
||||
</Modal>
|
||||
</Layout>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user