diff --git a/src/components/superadmin/DepartmentsTable.tsx b/src/components/superadmin/DepartmentsTable.tsx index 1d6f4fb..47af4df 100644 --- a/src/components/superadmin/DepartmentsTable.tsx +++ b/src/components/superadmin/DepartmentsTable.tsx @@ -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(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); + // }} /> ), @@ -350,7 +350,7 @@ const DepartmentsTable = ({ department={selectedDepartment} /> - { 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} - /> + /> */} ); }; diff --git a/src/components/superadmin/DesignationsTable.tsx b/src/components/superadmin/DesignationsTable.tsx index f5be85f..5fd3c64 100644 --- a/src/components/superadmin/DesignationsTable.tsx +++ b/src/components/superadmin/DesignationsTable.tsx @@ -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(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); + // }} /> ), @@ -339,7 +339,7 @@ const DesignationsTable = ({ designation={selectedDesignation} /> - { 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} - /> + /> */} ); }; diff --git a/src/pages/superadmin/NotificationMaster.tsx b/src/pages/superadmin/NotificationMaster.tsx index a81e813..9e9eec7 100644 --- a/src/pages/superadmin/NotificationMaster.tsx +++ b/src/pages/superadmin/NotificationMaster.tsx @@ -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; type CodeFormValues = z.infer; +// ── Variable Tag Input ────────────────────────────────────────────────────── +const VariableTagInput = ({ + variables, + onChange, +}: { + variables: string[]; + onChange: (vars: string[]) => void; +}) => { + const [input, setInput] = useState(''); + const inputRef = useRef(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) => { + 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 ( +
+ +
inputRef.current?.focus()} + > + {variables.map(v => ( + + {`{{${v}}}`} + + + ))} + 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" + /> +
+

+ These are the {'{{variable}}'} placeholders admins can use in templates for this event code. +

+
+ ); +}; + const NotificationMaster = (): ReactElement => { const [categories, setCategories] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -54,7 +122,6 @@ const NotificationMaster = (): ReactElement => { const [categoryModalOpen, setCategoryModalOpen] = useState(false); const [editingCategory, setEditingCategory] = useState(null); - // New: React Hook Form for Category const { register: registerCategory, handleSubmit: handleCategorySubmit, @@ -72,15 +139,15 @@ const NotificationMaster = (): ReactElement => { const [selectedCategory, setSelectedCategory] = useState(null); const [codes, setCodes] = useState([]); const [isCodesLoading, setIsCodesLoading] = useState(false); - + // Pagination for Codes const [codePage, setCodePage] = useState(1); const [codeTotal, setCodeTotal] = useState(0); const [codePages, setCodePages] = useState(0); const [editingCode, setEditingCode] = useState(null); + const [codeVariables, setCodeVariables] = useState([]); - // 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) => (
- {
- { setSearch(e.target.value); setCurrentPage(1); }} @@ -294,7 +358,7 @@ const NotificationMaster = (): ReactElement => { c.id} />
- {
{/* Category Modal */} - setCategoryModalOpen(false)} title={editingCategory ? "Edit Category" : "New Category"} maxWidth="md"> + setCategoryModalOpen(false)} title={editingCategory ? "Edit Category" : "New Category"} maxWidth="lg">
- ({ 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} />
@@ -332,25 +396,45 @@ const NotificationMaster = (): ReactElement => { {/* Codes Modal */} setCodeModalOpen(false)} title={`Event Codes: ${selectedCategory?.name}`} maxWidth="2xl"> -
+
+ {/* ── Add / Edit Code Form ─────────────────────────────────── */}

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

+
+ + {/* Variables tag input */} + +
Auto-populates default channels (In-App, Email)
- {editingCode && } - {editingCode ? 'Update Code' : 'Add Code'} + {editingCode && ( + + )} + + {editingCode ? 'Update Code' : 'Add Code'} +
+ {/* ── Registered Codes Table ───────────────────────────────── */}

Registered Codes

@@ -359,21 +443,33 @@ const NotificationMaster = (): ReactElement => { Trigger Code Name + Variables Actions {isCodesLoading ? ( - Loading codes... + Loading codes... ) : codes.length === 0 ? ( - No codes registered for this category. + No codes registered for this category. ) : codes.map(c => ( {c.code} {c.name} + +
+ {Array.isArray(c.variables) && c.variables.length > 0 + ? c.variables.map((v: string) => ( + {`{{${v}}}`} + )) + : none + } +
+
- {}} // Fixed for codes modal + onLimitChange={() => {}} />
diff --git a/src/pages/superadmin/NotificationTemplateMaster.tsx b/src/pages/superadmin/NotificationTemplateMaster.tsx index e125816..07e33b1 100644 --- a/src/pages/superadmin/NotificationTemplateMaster.tsx +++ b/src/pages/superadmin/NotificationTemplateMaster.tsx @@ -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; +// ── Variable Chips ──────────────────────────────────────────────────────────── +const VariableChips = ({ + variables, + onCopy, +}: { + variables: string[]; + onCopy?: (v: string) => void; +}) => { + const [copied, setCopied] = useState(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 ( +
+

+ Available Variables — click to copy placeholder +

+
+ {variables.map((v) => ( + + ))} +
+

+ These variables will be replaced with real values when the notification + is sent. +

+
+ ); +}; + const NotificationTemplateMaster = (): ReactElement => { const [templates, setTemplates] = useState([]); const [modules, setModules] = useState([]); @@ -44,45 +97,51 @@ const NotificationTemplateMaster = (): ReactElement => { const [codes, setCodes] = useState([]); const [isLoading, setIsLoading] = useState(true); + // Template variable chips (from selected code) + const [templateVariables, setTemplateVariables] = useState([]); + // 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(false); const [editingId, setEditingId] = useState(null); + // HTML body state (managed separately for RichTextEditor) + const [emailBodyHtml, setEmailBodyHtml] = useState(""); + const { register, handleSubmit, reset, setValue, watch, - formState: { errors } + formState: { errors }, } = useForm({ 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[] = [ - { key: 'category', label: 'Category', render: (t) => {t.category_name} }, - { key: 'code', label: 'Code', render: (t) => {t.code} }, - { key: 'name', label: 'Friendly Name', render: (t) => {t.name} }, - { key: 'title', label: 'Preview', render: (t) => {t.title_template} }, - { key: 'priority', label: 'Priority', render: (t) => {t.default_priority} }, - { - key: 'channels', - label: 'Channels', + { + key: "category", + label: "Category", + render: (t) => ( + + {t.category_name} + + ), + }, + { + key: "code", + label: "Code", + render: (t) => ( + + {t.code} + + ), + }, + { + key: "name", + label: "Friendly Name", + render: (t) => {t.name}, + }, + { + key: "variables", + label: "Variables", + render: (t) => ( +
+ {Array.isArray(t.variables) && t.variables.length > 0 ? ( + t.variables + .slice(0, 3) + .map((v: string) => ( + {`{{${v}}}`} + )) + ) : ( + none + )} + {Array.isArray(t.variables) && t.variables.length > 3 && ( + + +{t.variables.length - 3} more + + )} +
+ ), + }, + { + key: "priority", + label: "Priority", + render: (t) => ( + + {t.default_priority} + + ), + }, + { + key: "channels", + label: "Channels", render: (t) => (
{t.channels?.map((c: string) => ( - {c} + + {c} + ))}
- ) + ), }, { - key: 'actions', - label: 'Actions', - align: 'right', + key: "actions", + label: "Actions", + align: "right", render: (t) => ( - - ) - } + ), + }, ]; return (
-
- - { setSearch(e.target.value); setCurrentPage(1); }} - /> -
+
+ + { + setSearch(e.target.value); + setCurrentPage(1); + }} + /> +
-
-
- { setSelectedModule(val as string | null); setCurrentPage(1); }} - options={modules.map(m => ({ value: m.id, label: m.name }))} - placeholder="All Modules" - isSearchable - /> -
+
+
+ { + setSelectedModule(val as string | null); + setCurrentPage(1); + }} + options={modules.map((m) => ({ value: m.id, label: m.name }))} + placeholder="All Modules" + isSearchable + />
+
- { - 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"> + { + 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" + > New Template
- t.id} /> + t.id} + />
- { setLimit(l); setCurrentPage(1); }} + onLimitChange={(l) => { + setLimit(l); + setCurrentPage(1); + }} />
- setModalOpen(false)} title={editingId ? "Edit Notification Template" : "Create New Template"} maxWidth="2xl"> -
-
-

Identification

+ setModalOpen(false)} + title={editingId ? "Edit Notification Template" : "Create New Template"} + maxWidth="2xl" + footer={ + <> + + + {editingId ? "Update Template" : "Create Template"} + + + } + > +
+ {/* ── Identification ─────────────────────────────────────── */} +
+
+

+ Identification +

({ 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" /> - - -
+ + +
-
-

Settings

+
+

+ Settings +

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} />
- -
- - -
- {errors.channels &&

{errors.channels.message}

} + +
+ + +
+ {errors.channels && ( +

+ {errors.channels.message} +

+ )}
-
-

In-App Content

- - +
+ + {/* Variable Chips */} + +
-
-

Email Content

- - + {/* ── In-App Content ─────────────────────────────────────── */} +
+

+ In-App Content +

+ +
-
- - - {editingId ? 'Update Template' : 'Create Template'} - + {/* ── Email Content (HTML Editor) ────────────────────────── */} +
+

+ Email Content +

+ + setEmailBodyHtml(html)} + placeholder="Design your email body here... Use {{variable}} placeholders." + minHeightClassName="min-h-[200px]" + />
diff --git a/src/pages/tenant/NotificationTemplates.tsx b/src/pages/tenant/NotificationTemplates.tsx index 5ee98f2..d39349c 100644 --- a/src/pages/tenant/NotificationTemplates.tsx +++ b/src/pages/tenant/NotificationTemplates.tsx @@ -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; +// ── Variable Chips ──────────────────────────────────────────────────────────── +const VariableChips = ({ variables }: { variables: string[] }) => { + const [copied, setCopied] = useState(null); + + const handleCopy = (v: string) => { + navigator.clipboard.writeText(`{{${v}}}`).then(() => { + setCopied(v); + setTimeout(() => setCopied(null), 1500); + }); + }; + + if (!variables || variables.length === 0) return null; + + return ( +
+

+ Available Variables — click to copy +

+
+ {variables.map((v) => ( + + ))} +
+

+ Use these in title, message, subject and email body fields above. +

+
+ ); +}; + const NotificationTemplates = (): ReactElement => { const [templates, setTemplates] = useState([]); const [modules, setModules] = useState([]); - const [selectedModule, setSelectedModule] = useState('all'); + const [selectedModule, setSelectedModule] = useState("all"); const [isLoading, setIsLoading] = useState(true); // Pagination @@ -46,30 +96,31 @@ const NotificationTemplates = (): ReactElement => { const [modalOpen, setModalOpen] = useState(false); const [selectedTemplate, setSelectedTemplate] = useState(null); + // HTML body state for rich editor + const [emailBodyHtml, setEmailBodyHtml] = useState(""); + const { register, handleSubmit, reset, - formState: { errors } + formState: { errors }, } = useForm({ 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[] = [ - { key: 'category', label: 'Category', render: (t) => {t.category_name} }, - { key: 'code', label: 'Event', render: (t) => {t.code} }, - { key: 'source', label: 'Source', render: (t) => ( - - {t.tenant_id ? 'Custom Override' : 'System Default'} - - )}, - { key: 'preview', label: 'Title Preview', render: (t) => {t.title_template} }, { - key: 'actions', - label: 'Actions', - align: 'right', + key: "category", + label: "Category", + render: (t) => ( + + {t.category_name} + + ), + }, + { + key: "code", + label: "Event", + render: (t) => {t.code}, + }, + { + key: "source", + label: "Source", + render: (t) => ( + + {t.tenant_id ? "Custom Override" : "System Default"} + + ), + }, + { + key: "preview", + label: "Title Preview", + render: (t) => ( + + {t.title_template} + + ), + }, + { + key: "variables", + label: "Variables", + render: (t) => ( +
+ {Array.isArray(t.variables) && t.variables.length > 0 ? ( + t.variables + .slice(0, 3) + .map((v: string) => ( + {`{{${v}}}`} + )) + ) : ( + none + )} + {Array.isArray(t.variables) && t.variables.length > 3 && ( + + +{t.variables.length - 3} more + + )} +
+ ), + }, + { + key: "actions", + label: "Actions", + align: "right", render: (t) => (
- {t.tenant_id && ( - )}
- ) - } + ), + }, ]; return (
-
- -

Notification Templates

-
- -
-
- Filter by Module -
-
- { setSelectedModule(val); setCurrentPage(1); }} - options={[ - { value: 'all', label: 'All Modules' }, - ...modules.map(m => ({ value: m.id, label: m.name })) - ]} - placeholder="Select Module" - /> -
-
-
- -
- t.code} /> +
+ +

+ Notification Templates +

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

In-App Notification

- - +

+ In-App Notification +

+ +
-

Email Notification

- - +

+ Email Notification +

+
- -
-
- 💡 You can use placeholders like {`{{user_name}}`}, {`{{entity_name}}`}, and {`{{action}}`}. -
+ + {/* Full-width HTML body editor */} +
+ setEmailBodyHtml(html)} + placeholder="Design your email body here... Use {{variable}} placeholders." + minHeightClassName="min-h-[220px]" + /> +
+ + {/*
- - Save Override + + + Save Override +
-
+
*/}
diff --git a/src/pages/tenant/Roles.tsx b/src/pages/tenant/Roles.tsx index 14dabfe..a98bf17 100644 --- a/src/pages/tenant/Roles.tsx +++ b/src/pages/tenant/Roles.tsx @@ -66,7 +66,7 @@ const Roles = (): ReactElement => { }); // Filter state - const [scopeFilter, setScopeFilter] = useState(null); + // const [scopeFilter, setScopeFilter] = useState(null); const [orderBy, setOrderBy] = useState(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 => { 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 => { 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 */}
{/* Scope Filter */} - { setCurrentPage(1); }} placeholder="All" - /> + /> */} {/* Sort Filter */} => { + 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> => { const response = await apiClient.post(`/notifications/categories/${categoryId}/codes`, data); return response.data; diff --git a/src/services/role-service.ts b/src/services/role-service.ts index 05031cb..5a239bf 100644 --- a/src/services/role-service.ts +++ b/src/services/role-service.ts @@ -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 => { 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]);