import { useState, useEffect, useRef, type KeyboardEvent } from "react"; import type { ReactElement } from "react"; import { useLocation } from "react-router-dom"; import { Layout } from "@/components/layout/Layout"; import { usePermissions } from "@/hooks/usePermissions"; import { PrimaryButton, DataTable, ActionDropdown, DeleteConfirmationModal, Modal, FormField, FormTextArea, FormSelect, FilterDropdown, Pagination, type Column, SearchBox, } from "@/components/shared"; import { Plus, Code, X, Tag } 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 categorySchema = z.object({ name: z.string().min(1, "Category name is required").max(100), code: z .string() .min(1, "Category slug is required") .regex( /^[a-z0-9_]+$/, "Slug must be lowercase alphanumeric with underscores", ), description: z.string().optional(), module_id: z.string().optional(), }); const codeSchema = z.object({ code: z.string().min(1, "Code is required").max(50), name: z.string().min(1, "Display name is required").max(100), description: z.string().optional(), default_channels: z.array(z.string()), default_priority: z.string(), }); 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 { canCreate, canUpdate, canDelete } = usePermissions(); const location = useLocation(); const [categories, setCategories] = useState([]); const [isLoading, setIsLoading] = useState(true); // Pagination & Search for Categories 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 [moduleFilter, setModuleFilter] = useState(null); // Category Modal const [categoryModalOpen, setCategoryModalOpen] = useState(false); const [editingCategory, setEditingCategory] = useState(null); const { register: registerCategory, handleSubmit: handleCategorySubmit, reset: resetCategory, setValue: setCategoryValue, formState: { errors: categoryErrors }, watch: watchCategory, } = useForm({ resolver: zodResolver(categorySchema), defaultValues: { name: "", code: "", description: "", module_id: "" }, }); // View Mode const [viewMode, setViewMode] = useState<"categories" | "codes">( "categories", ); const [codeFormModalOpen, setCodeFormModalOpen] = useState(false); 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 [codeLimit, setCodeLimit] = useState(5); const [editingCode, setEditingCode] = useState(null); const [codeVariables, setCodeVariables] = useState([]); const { register: registerCode, handleSubmit: handleCodeSubmit, reset: resetCode, formState: { errors: codeErrors }, } = useForm({ resolver: zodResolver(codeSchema), defaultValues: { code: "", name: "", description: "", default_channels: ["in_app", "email"], default_priority: "normal", }, }); // Delete Modal const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string; type: "category" | "code"; } | null>(null); const [modules, setModules] = useState([]); const fetchCategories = async () => { try { setIsLoading(true); const res = await notificationService.getCategories({ limit, offset: (currentPage - 1) * limit, search, module_id: moduleFilter || undefined, }); if (res.success) { setCategories(res.data); setTotalItems(res.pagination?.total || res.data.length); setTotalPages(res.pagination?.pages || 1); } } catch (err: any) { showToast.error(err.message || "Failed to fetch categories"); } finally { setIsLoading(false); } }; const fetchModules = async () => { try { const res = await moduleService.getDropdown(); if (res.success) setModules(res.data); } catch (err) { console.error("Failed to fetch modules", err); } }; useEffect(() => { fetchCategories(); fetchModules(); }, [currentPage, limit, search, moduleFilter]); useEffect(() => { setViewMode("categories"); }, [location]); const fetchCodes = async ( category: any, page: number = 1, currentLimit: number = codeLimit, ) => { try { setIsCodesLoading(true); const res = await notificationService.getCodesByCategory(category.id, { limit: currentLimit, offset: (page - 1) * currentLimit, }); if (res.success) { setCodes(res.data); setCodeTotal(res.pagination?.total || res.data.length); setCodePages(res.pagination?.pages || 1); } } catch (err: any) { showToast.error("Failed to fetch codes"); } finally { setIsCodesLoading(false); } }; 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, codeLimit); setViewMode("codes"); }; useEffect(() => { if (selectedCategory && viewMode === "codes") { fetchCodes(selectedCategory, codePage, codeLimit); } }, [codePage, codeLimit, selectedCategory, viewMode]); const onSaveCategory = async (data: CategoryFormValues) => { try { const payload = { ...data }; if (!payload.module_id) delete payload.module_id; if (editingCategory) { await notificationService.updateCategory(editingCategory.id, payload); showToast.success("Category updated"); } else { await notificationService.createCategory(payload); showToast.success("Category created"); } setCategoryModalOpen(false); resetCategory(); fetchCategories(); } catch (err: any) { 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, payload); showToast.success("Code updated"); } else { await notificationService.createCode(selectedCategory.id, payload); showToast.success("Code created"); } setEditingCode(null); setCodeVariables([]); resetCode({ code: "", name: "", description: "", default_channels: ["in_app", "email"], default_priority: "normal", }); setCodeFormModalOpen(false); fetchCodes(selectedCategory, codePage); fetchCategories(); } catch (err: any) { showToast.error( err.response?.data?.error?.message || err.message || "Action failed", ); } }; const handleDelete = async () => { if (!deleteTarget) return; try { if (deleteTarget.type === "category") { await notificationService.deleteCategory(deleteTarget.id); fetchCategories(); } else { await notificationService.deleteCode(deleteTarget.id); fetchCodes(selectedCategory, codePage); fetchCategories(); } setDeleteModalOpen(false); showToast.success( `${deleteTarget.type === "category" ? "Category" : "Code"} deleted`, ); } catch (err: any) { showToast.error(err.message || "Delete failed"); } }; const columns: Column[] = [ { key: "name", label: "Category Name", render: (c) => ( {c.name} ), }, { key: "code", label: "Slug / Code", render: (c) => ( {c.code} ), }, { key: "description", label: "Description", render: (c) => ( {c.description || "-"} ), }, { key: "module_id", label: "Assoc. Module", render: (c) => ( {c.module_code || "System"} ), }, { key: "actions", label: "Actions", align: "right", render: (c) => (
{ setEditingCategory(c); resetCategory({ name: c.name, code: c.code, description: c.description || "", module_id: c.module_id || "", }); setCategoryModalOpen(true); } : undefined } onDelete={ canDelete("notifications") ? () => { setDeleteTarget({ id: c.id, name: c.name, type: "category" }); setDeleteModalOpen(true); } : undefined } />
), }, ]; const codeColumns: Column[] = [ { key: "code", label: "Trigger Code", render: (c) => ( {c.code} ), }, { key: "name", label: "Name", render: (c) => ( {c.name} ), }, { key: "variables", label: "Variables", render: (c) => (
{Array.isArray(c.variables) && c.variables.length > 0 ? ( c.variables.map((v: string) => ( {`{{${v}}}`} )) ) : ( none )}
), }, { key: "actions", label: "Actions", align: "right", render: (c) => (
{canUpdate("notifications") && ( )} {canDelete("notifications") && ( )}
), }, ]; const breadcrumbs = viewMode === "codes" ? [ { label: "Notification Master", path: "/notification-master" }, { label: `Event Codes (${selectedCategory?.name})` }, ] : [{ label: "Notification Master" }]; return ( {canCreate("notifications") && ( { setEditingCode(null); setCodeVariables([]); resetCode({ code: "", name: "", description: "", default_channels: ["in_app", "email"], default_priority: "normal", }); setCodeFormModalOpen(true); }} className="flex gap-2" > New Code )} ) : undefined, }} > {viewMode === "categories" ? (
{ setSearch(val); setCurrentPage(1); }} placeholder="Search categories..." containerClassName="relative flex-1 max-w-sm" /> { setModuleFilter(val as string | null); setCurrentPage(1); }} options={modules.map((m) => ({ value: m.id, label: m.name }))} placeholder="All Modules" isSearchable />
{canCreate("notifications") && ( { setEditingCategory(null); resetCategory({ name: "", code: "", description: "", module_id: "", }); setCategoryModalOpen(true); }} className="flex gap-2" > New Category )}
{/*
*/} c.id} /> {/*
*/} { setLimit(l); setCurrentPage(1); }} />
) : ( <> c.id} emptyMessage="No codes registered for this category." /> { setCodeLimit(l); setCodePage(1); }} /> )} {/* Category Modal */} setCategoryModalOpen(false)} title={editingCategory ? "Edit Category" : "New Category"} maxWidth="lg" footer={ <> {editingCategory ? "Update" : "Create"} } >
({ value: m.id, label: m.name })), ]} onValueChange={(val) => setCategoryValue("module_id", val, { shouldValidate: true }) } placeholder="Select a module" error={categoryErrors.module_id?.message} />
{/* Code Form Modal */} { setCodeFormModalOpen(false); setEditingCode(null); setCodeVariables([]); resetCode({ code: "", name: "", description: "", default_channels: ["in_app", "email"], default_priority: "normal", }); }} title={editingCode ? "Edit Event Code" : "Add New Event Trigger"} maxWidth="lg" footer={ <> {editingCode ? "Update Code" : "Add Code"} } >
{/* Variables tag input */}
Auto-populates default channels (In-App, Email)
setDeleteModalOpen(false)} onConfirm={handleDelete} title={`Delete ${deleteTarget?.type}`} message={`Are you sure you want to delete ${deleteTarget?.name}? This action cannot be undone.`} itemName={deleteTarget?.name || ""} />
); }; export default NotificationMaster;