refactor: update NotificationMaster code style, reorganize state management, and enhance pagination functionality

This commit is contained in:
Yashwin 2026-06-10 14:26:40 +05:30
parent bc7972a92a
commit 3cf91ee256
2 changed files with 488 additions and 221 deletions

View File

@ -142,7 +142,7 @@ export const Pagination = ({
createPortal( createPortal(
<div <div
data-limit-dropdown="true" data-limit-dropdown="true"
className="fixed border border-[rgba(0,0,0,0.2)] rounded-md shadow-lg z-[250] max-h-60 overflow-y-auto" className="fixed bg-white border border-[rgba(0,0,0,0.15)] rounded-md shadow-lg z-[250] max-h-60 overflow-y-auto"
style={limitDropdownStyle} style={limitDropdownStyle}
> >
<ul className="py-1.5"> <ul className="py-1.5">

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useRef, type KeyboardEvent } from 'react'; import { useState, useEffect, useRef, type KeyboardEvent } from "react";
import type { ReactElement } from 'react'; import type { ReactElement } from "react";
import { Layout } from '@/components/layout/Layout'; import { useLocation } from "react-router-dom";
import { Layout } from "@/components/layout/Layout";
import { import {
PrimaryButton, PrimaryButton,
DataTable, DataTable,
@ -14,25 +15,31 @@ import {
Pagination, Pagination,
type Column, type Column,
SearchBox, SearchBox,
} from '@/components/shared'; } from "@/components/shared";
import { Plus, Code, X, Tag } from 'lucide-react'; import { Plus, Code, X, Tag } from "lucide-react";
import { notificationService } from '@/services/notification-service'; import { notificationService } from "@/services/notification-service";
import { moduleService } from '@/services/module-service'; import { moduleService } from "@/services/module-service";
import { showToast } from '@/utils/toast'; import { showToast } from "@/utils/toast";
import { useForm } from 'react-hook-form'; import { useForm } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from 'zod'; import { z } from "zod";
const categorySchema = z.object({ const categorySchema = z.object({
name: z.string().min(1, 'Category name is required').max(100), 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'), code: z
.string()
.min(1, "Category slug is required")
.regex(
/^[a-z0-9_]+$/,
"Slug must be lowercase alphanumeric with underscores",
),
description: z.string().optional(), description: z.string().optional(),
module_id: z.string().optional(), module_id: z.string().optional(),
}); });
const codeSchema = z.object({ const codeSchema = z.object({
code: z.string().min(1, 'Code is required').max(50), code: z.string().min(1, "Code is required").max(50),
name: z.string().min(1, 'Display name is required').max(100), name: z.string().min(1, "Display name is required").max(100),
description: z.string().optional(), description: z.string().optional(),
default_channels: z.array(z.string()), default_channels: z.array(z.string()),
default_priority: z.string(), default_priority: z.string(),
@ -49,27 +56,28 @@ const VariableTagInput = ({
variables: string[]; variables: string[];
onChange: (vars: string[]) => void; onChange: (vars: string[]) => void;
}) => { }) => {
const [input, setInput] = useState(''); const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const addTag = (val: string) => { const addTag = (val: string) => {
const tag = val.trim().replace(/\s+/g, '_').toLowerCase(); const tag = val.trim().replace(/\s+/g, "_").toLowerCase();
if (tag && !variables.includes(tag)) { if (tag && !variables.includes(tag)) {
onChange([...variables, tag]); onChange([...variables, tag]);
} }
setInput(''); setInput("");
}; };
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',' || e.key === ' ') { if (e.key === "Enter" || e.key === "," || e.key === " ") {
e.preventDefault(); e.preventDefault();
addTag(input); addTag(input);
} else if (e.key === 'Backspace' && !input && variables.length > 0) { } else if (e.key === "Backspace" && !input && variables.length > 0) {
onChange(variables.slice(0, -1)); onChange(variables.slice(0, -1));
} }
}; };
const removeTag = (tag: string) => onChange(variables.filter(v => v !== tag)); const removeTag = (tag: string) =>
onChange(variables.filter((v) => v !== tag));
return ( return (
<div> <div>
@ -77,17 +85,26 @@ const VariableTagInput = ({
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Tag className="w-3.5 h-3.5 text-indigo-500" /> <Tag className="w-3.5 h-3.5 text-indigo-500" />
Supported Variables Supported Variables
<span className="text-gray-400 font-normal">(used in template placeholders)</span> <span className="text-gray-400 font-normal">
(used in template placeholders)
</span>
</span> </span>
</label> </label>
<div <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" 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()} onClick={() => inputRef.current?.focus()}
> >
{variables.map(v => ( {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"> <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}}}`} {`{{${v}}}`}
<button type="button" onClick={() => removeTag(v)} className="hover:text-red-600 transition-colors ml-0.5"> <button
type="button"
onClick={() => removeTag(v)}
className="hover:text-red-600 transition-colors ml-0.5"
>
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</button> </button>
</span> </span>
@ -95,21 +112,27 @@ const VariableTagInput = ({
<input <input
ref={inputRef} ref={inputRef}
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={() => input && addTag(input)} onBlur={() => input && addTag(input)}
placeholder={variables.length === 0 ? 'Type variable name, press Enter or comma...' : ''} 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" className="flex-1 min-w-[120px] text-xs outline-none bg-transparent text-gray-700 placeholder-gray-300"
/> />
</div> </div>
<p className="text-[10px] text-gray-400 mt-1"> <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. These are the <code>{"{{variable}}"}</code> placeholders admins can use
in templates for this event code.
</p> </p>
</div> </div>
); );
}; };
const NotificationMaster = (): ReactElement => { const NotificationMaster = (): ReactElement => {
const location = useLocation();
const [categories, setCategories] = useState<any[]>([]); const [categories, setCategories] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
@ -118,7 +141,7 @@ const NotificationMaster = (): ReactElement => {
const [limit, setLimit] = useState(10); const [limit, setLimit] = useState(10);
const [totalItems, setTotalItems] = useState(0); const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
const [search, setSearch] = useState(''); const [search, setSearch] = useState("");
const [moduleFilter, setModuleFilter] = useState<string | null>(null); const [moduleFilter, setModuleFilter] = useState<string | null>(null);
// Category Modal // Category Modal
@ -134,11 +157,14 @@ const NotificationMaster = (): ReactElement => {
watch: watchCategory, watch: watchCategory,
} = useForm<CategoryFormValues>({ } = useForm<CategoryFormValues>({
resolver: zodResolver(categorySchema), resolver: zodResolver(categorySchema),
defaultValues: { name: '', code: '', description: '', module_id: '' } defaultValues: { name: "", code: "", description: "", module_id: "" },
}); });
// Code Modal // View Mode
const [codeModalOpen, setCodeModalOpen] = useState<boolean>(false); const [viewMode, setViewMode] = useState<"categories" | "codes">(
"categories",
);
const [codeFormModalOpen, setCodeFormModalOpen] = useState<boolean>(false);
const [selectedCategory, setSelectedCategory] = useState<any>(null); const [selectedCategory, setSelectedCategory] = useState<any>(null);
const [codes, setCodes] = useState<any[]>([]); const [codes, setCodes] = useState<any[]>([]);
const [isCodesLoading, setIsCodesLoading] = useState<boolean>(false); const [isCodesLoading, setIsCodesLoading] = useState<boolean>(false);
@ -147,6 +173,7 @@ const NotificationMaster = (): ReactElement => {
const [codePage, setCodePage] = useState(1); const [codePage, setCodePage] = useState(1);
const [codeTotal, setCodeTotal] = useState(0); const [codeTotal, setCodeTotal] = useState(0);
const [codePages, setCodePages] = useState(0); const [codePages, setCodePages] = useState(0);
const [codeLimit, setCodeLimit] = useState(5);
const [editingCode, setEditingCode] = useState<any>(null); const [editingCode, setEditingCode] = useState<any>(null);
const [codeVariables, setCodeVariables] = useState<string[]>([]); const [codeVariables, setCodeVariables] = useState<string[]>([]);
@ -158,12 +185,22 @@ const NotificationMaster = (): ReactElement => {
formState: { errors: codeErrors }, formState: { errors: codeErrors },
} = useForm<CodeFormValues>({ } = useForm<CodeFormValues>({
resolver: zodResolver(codeSchema), resolver: zodResolver(codeSchema),
defaultValues: { code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' } defaultValues: {
code: "",
name: "",
description: "",
default_channels: ["in_app", "email"],
default_priority: "normal",
},
}); });
// Delete Modal // Delete Modal
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false); const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
const [deleteTarget, setDeleteTarget] = useState<{ id: string, name: string, type: 'category' | 'code' } | null>(null); const [deleteTarget, setDeleteTarget] = useState<{
id: string;
name: string;
type: "category" | "code";
} | null>(null);
const [modules, setModules] = useState<any[]>([]); const [modules, setModules] = useState<any[]>([]);
@ -174,7 +211,7 @@ const NotificationMaster = (): ReactElement => {
limit, limit,
offset: (currentPage - 1) * limit, offset: (currentPage - 1) * limit,
search, search,
module_id: moduleFilter || undefined module_id: moduleFilter || undefined,
}); });
if (res.success) { if (res.success) {
setCategories(res.data); setCategories(res.data);
@ -182,7 +219,7 @@ const NotificationMaster = (): ReactElement => {
setTotalPages(res.pagination?.pages || 1); setTotalPages(res.pagination?.pages || 1);
} }
} catch (err: any) { } catch (err: any) {
showToast.error(err.message || 'Failed to fetch categories'); showToast.error(err.message || "Failed to fetch categories");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -193,7 +230,7 @@ const NotificationMaster = (): ReactElement => {
const res = await moduleService.getDropdown(); const res = await moduleService.getDropdown();
if (res.success) setModules(res.data); if (res.success) setModules(res.data);
} catch (err) { } catch (err) {
console.error('Failed to fetch modules', err); console.error("Failed to fetch modules", err);
} }
}; };
@ -202,12 +239,20 @@ const NotificationMaster = (): ReactElement => {
fetchModules(); fetchModules();
}, [currentPage, limit, search, moduleFilter]); }, [currentPage, limit, search, moduleFilter]);
const fetchCodes = async (category: any, page: number = 1) => { useEffect(() => {
setViewMode("categories");
}, [location]);
const fetchCodes = async (
category: any,
page: number = 1,
currentLimit: number = codeLimit,
) => {
try { try {
setIsCodesLoading(true); setIsCodesLoading(true);
const res = await notificationService.getCodesByCategory(category.id, { const res = await notificationService.getCodesByCategory(category.id, {
limit: 5, limit: currentLimit,
offset: (page - 1) * 5 offset: (page - 1) * currentLimit,
}); });
if (res.success) { if (res.success) {
setCodes(res.data); setCodes(res.data);
@ -215,7 +260,7 @@ const NotificationMaster = (): ReactElement => {
setCodePages(res.pagination?.pages || 1); setCodePages(res.pagination?.pages || 1);
} }
} catch (err: any) { } catch (err: any) {
showToast.error('Failed to fetch codes'); showToast.error("Failed to fetch codes");
} finally { } finally {
setIsCodesLoading(false); setIsCodesLoading(false);
} }
@ -226,16 +271,22 @@ const NotificationMaster = (): ReactElement => {
setCodePage(1); setCodePage(1);
setEditingCode(null); setEditingCode(null);
setCodeVariables([]); setCodeVariables([]);
resetCode({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' }); resetCode({
await fetchCodes(category, 1); code: "",
setCodeModalOpen(true); name: "",
description: "",
default_channels: ["in_app", "email"],
default_priority: "normal",
});
await fetchCodes(category, 1, codeLimit);
setViewMode("codes");
}; };
useEffect(() => { useEffect(() => {
if (selectedCategory && codeModalOpen) { if (selectedCategory && viewMode === "codes") {
fetchCodes(selectedCategory, codePage); fetchCodes(selectedCategory, codePage, codeLimit);
} }
}, [codePage]); }, [codePage, codeLimit, selectedCategory, viewMode]);
const onSaveCategory = async (data: CategoryFormValues) => { const onSaveCategory = async (data: CategoryFormValues) => {
try { try {
@ -244,16 +295,18 @@ const NotificationMaster = (): ReactElement => {
if (editingCategory) { if (editingCategory) {
await notificationService.updateCategory(editingCategory.id, payload); await notificationService.updateCategory(editingCategory.id, payload);
showToast.success('Category updated'); showToast.success("Category updated");
} else { } else {
await notificationService.createCategory(payload); await notificationService.createCategory(payload);
showToast.success('Category created'); showToast.success("Category created");
} }
setCategoryModalOpen(false); setCategoryModalOpen(false);
resetCategory(); resetCategory();
fetchCategories(); fetchCategories();
} catch (err: any) { } catch (err: any) {
showToast.error(err.response?.data?.error?.message || err.message || 'Action failed'); showToast.error(
err.response?.data?.error?.message || err.message || "Action failed",
);
} }
}; };
@ -262,46 +315,89 @@ const NotificationMaster = (): ReactElement => {
const payload = { ...data, variables: codeVariables }; const payload = { ...data, variables: codeVariables };
if (editingCode) { if (editingCode) {
await notificationService.updateCode(editingCode.id, payload); await notificationService.updateCode(editingCode.id, payload);
showToast.success('Code updated'); showToast.success("Code updated");
} else { } else {
await notificationService.createCode(selectedCategory.id, payload); await notificationService.createCode(selectedCategory.id, payload);
showToast.success('Code created'); showToast.success("Code created");
} }
setEditingCode(null); setEditingCode(null);
setCodeVariables([]); setCodeVariables([]);
resetCode({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' }); resetCode({
code: "",
name: "",
description: "",
default_channels: ["in_app", "email"],
default_priority: "normal",
});
setCodeFormModalOpen(false);
fetchCodes(selectedCategory, codePage); fetchCodes(selectedCategory, codePage);
fetchCategories();
} catch (err: any) { } catch (err: any) {
showToast.error(err.response?.data?.error?.message || err.message || 'Action failed'); showToast.error(
err.response?.data?.error?.message || err.message || "Action failed",
);
} }
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
try { try {
if (deleteTarget.type === 'category') { if (deleteTarget.type === "category") {
await notificationService.deleteCategory(deleteTarget.id); await notificationService.deleteCategory(deleteTarget.id);
fetchCategories(); fetchCategories();
} else { } else {
await notificationService.deleteCode(deleteTarget.id); await notificationService.deleteCode(deleteTarget.id);
fetchCodes(selectedCategory, codePage); fetchCodes(selectedCategory, codePage);
fetchCategories();
} }
setDeleteModalOpen(false); setDeleteModalOpen(false);
showToast.success(`${deleteTarget.type === 'category' ? 'Category' : 'Code'} deleted`); showToast.success(
`${deleteTarget.type === "category" ? "Category" : "Code"} deleted`,
);
} catch (err: any) { } catch (err: any) {
showToast.error(err.message || 'Delete failed'); showToast.error(err.message || "Delete failed");
} }
}; };
const columns: Column<any>[] = [ const columns: Column<any>[] = [
{ key: 'name', label: 'Category Name', render: (c) => <span className="font-semibold text-[#0f1724]">{c.name}</span> },
{ key: 'code', label: 'Slug / Code', render: (c) => <code className="text-[10px] bg-gray-100 px-1.5 py-0.5 rounded font-mono text-gray-600">{c.code}</code> },
{ key: 'description', label: 'Description', render: (c) => <span className="text-xs text-gray-500 line-clamp-1">{c.description || '-'}</span> },
{ key: 'module_id', label: 'Assoc. Module', render: (c) => <span className="text-[10px] text-blue-500 font-bold uppercase">{c.module_code || 'System'}</span> },
{ {
key: 'actions', key: "name",
label: 'Actions', label: "Category Name",
align: 'right', render: (c) => (
<span className="font-semibold text-[#0f1724]">{c.name}</span>
),
},
{
key: "code",
label: "Slug / Code",
render: (c) => (
<code className="text-[10px] bg-gray-100 px-1.5 py-0.5 rounded font-mono text-gray-600">
{c.code}
</code>
),
},
{
key: "description",
label: "Description",
render: (c) => (
<span className="text-xs text-gray-500 line-clamp-1">
{c.description || "-"}
</span>
),
},
{
key: "module_id",
label: "Assoc. Module",
render: (c) => (
<span className="text-[10px] text-blue-500 font-bold uppercase">
{c.module_code || "System"}
</span>
),
},
{
key: "actions",
label: "Actions",
align: "right",
render: (c) => ( render: (c) => (
<div className="flex justify-end gap-3 items-center"> <div className="flex justify-end gap-3 items-center">
<button <button
@ -314,27 +410,154 @@ const NotificationMaster = (): ReactElement => {
<ActionDropdown <ActionDropdown
onEdit={() => { onEdit={() => {
setEditingCategory(c); setEditingCategory(c);
resetCategory({ name: c.name, code: c.code, description: c.description || '', module_id: c.module_id || '' }); resetCategory({
name: c.name,
code: c.code,
description: c.description || "",
module_id: c.module_id || "",
});
setCategoryModalOpen(true); setCategoryModalOpen(true);
}} }}
onDelete={() => { onDelete={() => {
setDeleteTarget({ id: c.id, name: c.name, type: 'category' }); setDeleteTarget({ id: c.id, name: c.name, type: "category" });
setDeleteModalOpen(true); setDeleteModalOpen(true);
}} }}
/> />
</div> </div>
) ),
} },
]; ];
const codeColumns: Column<any>[] = [
{
key: "code",
label: "Trigger Code",
render: (c) => (
<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>
),
},
{
key: "name",
label: "Name",
render: (c) => (
<span className="font-semibold text-[#0f1724]">{c.name}</span>
),
},
{
key: "variables",
label: "Variables",
render: (c) => (
<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>
),
},
{
key: "actions",
label: "Actions",
align: "right",
render: (c) => (
<div className="flex justify-end gap-3 whitespace-nowrap">
<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,
});
setCodeFormModalOpen(true);
}}
className="text-blue-600 hover:text-blue-800 font-bold text-[11px] uppercase"
>
Edit
</button>
<button
onClick={() => {
setDeleteTarget({
id: c.id,
name: c.code,
type: "code",
});
setDeleteModalOpen(true);
}}
className="text-red-500 hover:text-red-700 font-bold text-[11px] uppercase"
>
Delete
</button>
</div>
),
},
];
const breadcrumbs =
viewMode === "codes"
? [
{ label: "Notification Master", path: "/notification-master" },
{ label: `Event Codes (${selectedCategory?.name})` },
]
: [{ label: "Notification Master" }];
return ( return (
<Layout <Layout
currentPage="Notification Master" currentPage="Notification Master"
breadcrumbs={breadcrumbs}
pageHeader={{ pageHeader={{
title: 'Notification Master Management', title:
description: 'Manage notification categories and event codes across the platform.', viewMode === "codes"
? `Event Codes: ${selectedCategory?.name}`
: "Notification Master Management",
description:
viewMode === "codes"
? `Manage event trigger codes and variables for the ${selectedCategory?.name} category.`
: "Manage notification categories and event codes across the platform.",
action:
viewMode === "codes" ? (
<div className="flex gap-3">
<button
onClick={() => setViewMode("categories")}
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 rounded-lg text-xs font-semibold text-gray-600 bg-white hover:bg-gray-50 transition-all shadow-sm"
>
Back to Categories
</button>
<PrimaryButton
onClick={() => {
setEditingCode(null);
setCodeVariables([]);
resetCode({
code: "",
name: "",
description: "",
default_channels: ["in_app", "email"],
default_priority: "normal",
});
setCodeFormModalOpen(true);
}}
className="flex gap-2"
>
<Plus className="w-4 h-4" /> New Code
</PrimaryButton>
</div>
) : undefined,
}} }}
> >
{viewMode === "categories" ? (
<div className="overflow-hidden flex flex-col min-h-[500px]"> <div className="overflow-hidden flex flex-col min-h-[500px]">
<div className="pb-2 flex flex-wrap justify-between items-center bg-gray-50/50 gap-4"> <div className="pb-2 flex flex-wrap justify-between items-center bg-gray-50/50 gap-4">
<div className="flex items-center gap-4 flex-1"> <div className="flex items-center gap-4 flex-1">
@ -360,18 +583,31 @@ const NotificationMaster = (): ReactElement => {
isSearchable isSearchable
/> />
</div> </div>
<PrimaryButton onClick={() => { <PrimaryButton
onClick={() => {
setEditingCategory(null); setEditingCategory(null);
resetCategory({ name: '', code: '', description: '', module_id: '' }); resetCategory({
name: "",
code: "",
description: "",
module_id: "",
});
setCategoryModalOpen(true); setCategoryModalOpen(true);
}} className="flex gap-2"> }}
className="flex gap-2"
>
<Plus className="w-4 h-4" /> New Category <Plus className="w-4 h-4" /> New Category
</PrimaryButton> </PrimaryButton>
</div> </div>
<div className="flex-1"> {/* <div className="flex-1"> */}
<DataTable columns={columns} data={categories} isLoading={isLoading} keyExtractor={(c) => c.id} /> <DataTable
</div> columns={columns}
data={categories}
isLoading={isLoading}
keyExtractor={(c) => c.id}
/>
{/* </div> */}
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
@ -379,134 +615,165 @@ const NotificationMaster = (): ReactElement => {
totalItems={totalItems} totalItems={totalItems}
limit={limit} limit={limit}
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
onLimitChange={(l) => { setLimit(l); setCurrentPage(1); }} onLimitChange={(l) => {
/> setLimit(l);
</div> setCurrentPage(1);
{/* Category Modal */}
<Modal isOpen={categoryModalOpen} onClose={() => setCategoryModalOpen(false)} title={editingCategory ? "Edit Category" : "New Category"} maxWidth="lg">
<div className="space-y-4 p-6">
<div className="grid grid-cols-2 gap-4">
<FormField label="Category Name" required placeholder="e.g. Workflow" error={categoryErrors.name?.message} {...registerCategory('name')} />
<FormField label="Slug (Code)" required placeholder="e.g. workflow" error={categoryErrors.code?.message} {...registerCategory('code')} />
</div>
<FormTextArea label="Description" rows={2} error={categoryErrors.description?.message} {...registerCategory('description')} />
<FormSelect
label="Associated Module"
value={watchCategory('module_id')}
options={[
{ value: '', label: 'System (No Module)' },
...modules.map(m => ({ value: m.id, label: m.name }))
]}
onValueChange={(val) => setCategoryValue('module_id', val, { shouldValidate: true })}
placeholder="Select a module"
error={categoryErrors.module_id?.message}
/>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
<button onClick={() => setCategoryModalOpen(false)} className="px-4 py-2 text-sm text-gray-600">Cancel</button>
<PrimaryButton onClick={handleCategorySubmit(onSaveCategory)}>{editingCategory ? 'Update' : 'Create'}</PrimaryButton>
</div>
</div>
</Modal>
{/* Codes Modal */}
<Modal isOpen={codeModalOpen} onClose={() => setCodeModalOpen(false)} title={`Event Codes: ${selectedCategory?.name}`} maxWidth="2xl">
<div className="p-6 space-y-6">
{/* ── Add / Edit Code Form ─────────────────────────────────── */}
<div className="bg-gray-50 p-5 rounded-xl border border-gray-200 shadow-inner">
<h3 className="text-[11px] font-bold uppercase text-gray-500 tracking-wider font-mono flex items-center gap-2 pb-4">
{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);
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">
<table className="w-full text-sm">
<thead className="bg-[#f8fafc] border-b border-gray-100">
<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={4} className="px-4 py-10 text-center text-gray-400 animate-pulse">Loading codes...</td></tr>
) : codes.length === 0 ? (
<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={() => {
setDeleteTarget({ id: c.id, name: c.code, type: 'code' });
setDeleteModalOpen(true);
}} className="text-red-500 hover:text-red-700 font-bold text-[11px] uppercase">Delete</button>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
) : (
<>
<DataTable
columns={codeColumns}
data={codes}
isLoading={isCodesLoading}
keyExtractor={(c) => c.id}
emptyMessage="No codes registered for this category."
/>
<Pagination <Pagination
currentPage={codePage} currentPage={codePage}
totalPages={codePages} totalPages={codePages}
totalItems={codeTotal} totalItems={codeTotal}
limit={5} limit={codeLimit}
onPageChange={setCodePage} onPageChange={setCodePage}
onLimitChange={() => {}} onLimitChange={(l) => {
setCodeLimit(l);
setCodePage(1);
}}
/>
</>
)}
{/* Category Modal */}
<Modal
isOpen={categoryModalOpen}
onClose={() => setCategoryModalOpen(false)}
title={editingCategory ? "Edit Category" : "New Category"}
maxWidth="lg"
footer={
<>
<button
onClick={() => setCategoryModalOpen(false)}
className="px-4 py-2 text-sm text-gray-600"
>
Cancel
</button>
<PrimaryButton onClick={handleCategorySubmit(onSaveCategory)}>
{editingCategory ? "Update" : "Create"}
</PrimaryButton>
</>
}
>
<div className="grid grid-cols-2 gap-4">
<FormField
label="Category Name"
required
placeholder="e.g. Workflow"
error={categoryErrors.name?.message}
{...registerCategory("name")}
/>
<FormField
label="Slug (Code)"
required
placeholder="e.g. workflow"
error={categoryErrors.code?.message}
{...registerCategory("code")}
/> />
</div> </div>
<FormTextArea
label="Description"
rows={2}
error={categoryErrors.description?.message}
{...registerCategory("description")}
/>
<FormSelect
label="Associated Module"
value={watchCategory("module_id")}
options={[
{ value: "", label: "System (No Module)" },
...modules.map((m) => ({ value: m.id, label: m.name })),
]}
onValueChange={(val) =>
setCategoryValue("module_id", val, { shouldValidate: true })
}
placeholder="Select a module"
error={categoryErrors.module_id?.message}
/>
</Modal>
{/* Code Form Modal */}
<Modal
isOpen={codeFormModalOpen}
onClose={() => {
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={
<>
<button
onClick={() => {
setCodeFormModalOpen(false);
setEditingCode(null);
setCodeVariables([]);
resetCode({
code: "",
name: "",
description: "",
default_channels: ["in_app", "email"],
default_priority: "normal",
});
}}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors"
>
Cancel
</button>
<PrimaryButton onClick={handleCodeSubmit(onSaveCode)}>
{editingCode ? "Update Code" : "Add Code"}
</PrimaryButton>
</>
}
>
<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="text-[10px] text-gray-400 font-medium">
Auto-populates default channels (In-App, Email)
</div> </div>
</Modal> </Modal>
@ -516,7 +783,7 @@ const NotificationMaster = (): ReactElement => {
onConfirm={handleDelete} onConfirm={handleDelete}
title={`Delete ${deleteTarget?.type}`} title={`Delete ${deleteTarget?.type}`}
message={`Are you sure you want to delete ${deleteTarget?.name}? This action cannot be undone.`} message={`Are you sure you want to delete ${deleteTarget?.name}? This action cannot be undone.`}
itemName={deleteTarget?.name || ''} itemName={deleteTarget?.name || ""}
/> />
</Layout> </Layout>
); );