import { useState, useEffect, useRef, type KeyboardEvent } from 'react'; import type { ReactElement } from 'react'; import { Layout } from '@/components/layout/Layout'; 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 [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: '' } }); // Code Modal const [codeModalOpen, setCodeModalOpen] = 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 [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]); const fetchCodes = async (category: any, page: number = 1) => { try { setIsCodesLoading(true); const res = await notificationService.getCodesByCategory(category.id, { limit: 5, offset: (page - 1) * 5 }); 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); setCodeModalOpen(true); }; useEffect(() => { if (selectedCategory && codeModalOpen) { fetchCodes(selectedCategory, codePage); } }, [codePage]); 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' }); fetchCodes(selectedCategory, codePage); } 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); } 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); }} onDelete={() => { setDeleteTarget({ id: c.id, name: c.name, type: 'category' }); setDeleteModalOpen(true); }} />
) } ]; return (
{ 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 />
{ setEditingCategory(null); resetCategory({ name: '', code: '', description: '', module_id: '' }); setCategoryModalOpen(true); }} className="flex gap-2"> New Category
c.id} />
{ setLimit(l); setCurrentPage(1); }} />
{/* Category Modal */} 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" error={categoryErrors.module_id?.message} />
{editingCategory ? 'Update' : 'Create'}
{/* 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'}
{/* ── Registered Codes Table ───────────────────────────────── */}

Registered Codes

{isCodesLoading ? ( ) : codes.length === 0 ? ( ) : codes.map(c => ( ))}
Trigger Code Name Variables Actions
Loading codes...
No codes registered for this category.
{c.code} {c.name}
{Array.isArray(c.variables) && c.variables.length > 0 ? c.variables.map((v: string) => ( {`{{${v}}}`} )) : none }
{}} />
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;