refactor: implement react-hook-form with zod validation for notification templates and master forms, update service methods, and reorder table filter components.
This commit is contained in:
parent
484f3b2e07
commit
2381260190
@ -270,22 +270,7 @@ export const SuppliersTable = ({
|
|||||||
{showHeader && (
|
{showHeader && (
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 px-4 py-3 bg-white border-b border-[rgba(0,0,0,0.08)]">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 px-4 py-3 bg-white border-b border-[rgba(0,0,0,0.08)]">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<FilterDropdown
|
|
||||||
label="Status"
|
|
||||||
options={[
|
|
||||||
{ value: "", label: "All Status" },
|
|
||||||
{ value: "approved", label: "Approved" },
|
|
||||||
{ value: "qualified", label: "Qualified" },
|
|
||||||
{ value: "pending", label: "Pending" },
|
|
||||||
{ value: "suspended", label: "Suspended" },
|
|
||||||
{ value: "disqualified", label: "Disqualified" },
|
|
||||||
]}
|
|
||||||
value={statusFilter || ""}
|
|
||||||
onChange={(val) => {
|
|
||||||
setStatusFilter(val as string);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -311,6 +296,22 @@ export const SuppliersTable = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<FilterDropdown
|
||||||
|
label="Status"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "All Status" },
|
||||||
|
{ value: "approved", label: "Approved" },
|
||||||
|
{ value: "qualified", label: "Qualified" },
|
||||||
|
{ value: "pending", label: "Pending" },
|
||||||
|
{ value: "suspended", label: "Suspended" },
|
||||||
|
{ value: "disqualified", label: "Disqualified" },
|
||||||
|
]}
|
||||||
|
value={statusFilter || ""}
|
||||||
|
onChange={(val) => {
|
||||||
|
setStatusFilter(val as string);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
|
|||||||
@ -17,6 +17,27 @@ import { Plus, Code, Search } 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 { 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<typeof categorySchema>;
|
||||||
|
type CodeFormValues = z.infer<typeof codeSchema>;
|
||||||
|
|
||||||
const NotificationMaster = (): ReactElement => {
|
const NotificationMaster = (): ReactElement => {
|
||||||
const [categories, setCategories] = useState<any[]>([]);
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
@ -32,7 +53,19 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
// Category Modal
|
// Category Modal
|
||||||
const [categoryModalOpen, setCategoryModalOpen] = useState<boolean>(false);
|
const [categoryModalOpen, setCategoryModalOpen] = useState<boolean>(false);
|
||||||
const [editingCategory, setEditingCategory] = useState<any>(null);
|
const [editingCategory, setEditingCategory] = useState<any>(null);
|
||||||
const [categoryForm, setCategoryForm] = useState({ name: '', code: '', description: '', module_id: '' });
|
|
||||||
|
// New: React Hook Form for Category
|
||||||
|
const {
|
||||||
|
register: registerCategory,
|
||||||
|
handleSubmit: handleCategorySubmit,
|
||||||
|
reset: resetCategory,
|
||||||
|
setValue: setCategoryValue,
|
||||||
|
formState: { errors: categoryErrors },
|
||||||
|
watch: watchCategory,
|
||||||
|
} = useForm<CategoryFormValues>({
|
||||||
|
resolver: zodResolver(categorySchema),
|
||||||
|
defaultValues: { name: '', code: '', description: '', module_id: '' }
|
||||||
|
});
|
||||||
|
|
||||||
// Code Modal
|
// Code Modal
|
||||||
const [codeModalOpen, setCodeModalOpen] = useState<boolean>(false);
|
const [codeModalOpen, setCodeModalOpen] = useState<boolean>(false);
|
||||||
@ -46,7 +79,17 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
const [codePages, setCodePages] = useState(0);
|
const [codePages, setCodePages] = useState(0);
|
||||||
|
|
||||||
const [editingCode, setEditingCode] = useState<any>(null);
|
const [editingCode, setEditingCode] = useState<any>(null);
|
||||||
const [codeForm, setCodeForm] = useState({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' });
|
|
||||||
|
// New: React Hook Form for Code
|
||||||
|
const {
|
||||||
|
register: registerCode,
|
||||||
|
handleSubmit: handleCodeSubmit,
|
||||||
|
reset: resetCode,
|
||||||
|
formState: { errors: codeErrors },
|
||||||
|
} = useForm<CodeFormValues>({
|
||||||
|
resolver: zodResolver(codeSchema),
|
||||||
|
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);
|
||||||
@ -123,36 +166,45 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
}, [codePage]);
|
}, [codePage]);
|
||||||
|
|
||||||
const handleSaveCategory = async () => {
|
const onSaveCategory = async (data: CategoryFormValues) => {
|
||||||
try {
|
try {
|
||||||
|
// Conditionally include module_id only if it's actually selected
|
||||||
|
const payload = { ...data };
|
||||||
|
if (!payload.module_id) {
|
||||||
|
delete payload.module_id;
|
||||||
|
}
|
||||||
|
|
||||||
if (editingCategory) {
|
if (editingCategory) {
|
||||||
await notificationService.updateCategory(editingCategory.id, categoryForm);
|
await notificationService.updateCategory(editingCategory.id, payload);
|
||||||
showToast.success('Category updated');
|
showToast.success('Category updated');
|
||||||
} else {
|
} else {
|
||||||
await notificationService.createCategory(categoryForm);
|
await notificationService.createCategory(payload);
|
||||||
showToast.success('Category created');
|
showToast.success('Category created');
|
||||||
}
|
}
|
||||||
setCategoryModalOpen(false);
|
setCategoryModalOpen(false);
|
||||||
|
resetCategory();
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast.error(err.message || 'Action failed');
|
const errorMessage = err.response?.data?.error?.message || err.message || 'Action failed';
|
||||||
|
showToast.error(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveCode = async () => {
|
const onSaveCode = async (data: CodeFormValues) => {
|
||||||
try {
|
try {
|
||||||
if (editingCode) {
|
if (editingCode) {
|
||||||
await notificationService.updateCode(editingCode.id, codeForm);
|
await notificationService.updateCode(editingCode.id, data);
|
||||||
showToast.success('Code updated');
|
showToast.success('Code updated');
|
||||||
} else {
|
} else {
|
||||||
await notificationService.createCode(selectedCategory.id, codeForm);
|
await notificationService.createCode(selectedCategory.id, data);
|
||||||
showToast.success('Code created');
|
showToast.success('Code created');
|
||||||
}
|
}
|
||||||
setEditingCode(null);
|
setEditingCode(null);
|
||||||
setCodeForm({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' });
|
resetCode();
|
||||||
fetchCodes(selectedCategory, codePage);
|
fetchCodes(selectedCategory, codePage);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast.error(err.message || 'Action failed');
|
const errorMessage = err.response?.data?.error?.message || err.message || 'Action failed';
|
||||||
|
showToast.error(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -194,7 +246,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditingCategory(c);
|
setEditingCategory(c);
|
||||||
setCategoryForm({ 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={() => {
|
||||||
@ -231,7 +283,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
<PrimaryButton onClick={() => {
|
<PrimaryButton onClick={() => {
|
||||||
setEditingCategory(null);
|
setEditingCategory(null);
|
||||||
setCategoryForm({ 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
|
||||||
@ -256,23 +308,24 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
<Modal isOpen={categoryModalOpen} onClose={() => setCategoryModalOpen(false)} title={editingCategory ? "Edit Category" : "New Category"} maxWidth="md">
|
<Modal isOpen={categoryModalOpen} onClose={() => setCategoryModalOpen(false)} title={editingCategory ? "Edit Category" : "New Category"} maxWidth="md">
|
||||||
<div className="space-y-4 p-6">
|
<div className="space-y-4 p-6">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField label="Category Name" value={categoryForm.name} onChange={(e) => setCategoryForm({ ...categoryForm, name: e.target.value })} placeholder="e.g. Workflow" />
|
<FormField label="Category Name" required placeholder="e.g. Workflow" error={categoryErrors.name?.message} {...registerCategory('name')} />
|
||||||
<FormField label="Slug (Code)" value={categoryForm.code} onChange={(e) => setCategoryForm({ ...categoryForm, code: e.target.value })} placeholder="e.g. workflow" />
|
<FormField label="Slug (Code)" required placeholder="e.g. workflow" error={categoryErrors.code?.message} {...registerCategory('code')} />
|
||||||
</div>
|
</div>
|
||||||
<FormTextArea label="Description" value={categoryForm.description} onChange={(e) => setCategoryForm({ ...categoryForm, description: e.target.value })} rows={2} />
|
<FormTextArea label="Description" rows={2} error={categoryErrors.description?.message} {...registerCategory('description')} />
|
||||||
<FormSelect
|
<FormSelect
|
||||||
label="Associated Module"
|
label="Associated Module"
|
||||||
value={categoryForm.module_id}
|
value={watchCategory('module_id')}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'System (No Module)' },
|
{ value: '', label: 'System (No Module)' },
|
||||||
...modules.map(m => ({ value: m.id, label: m.name }))
|
...modules.map(m => ({ value: m.id, label: m.name }))
|
||||||
]}
|
]}
|
||||||
onValueChange={(val) => setCategoryForm({ ...categoryForm, module_id: val })}
|
onValueChange={(val) => setCategoryValue('module_id', val, { shouldValidate: true })}
|
||||||
placeholder="Select a module"
|
placeholder="Select a module"
|
||||||
|
error={categoryErrors.module_id?.message}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
<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>
|
<button onClick={() => setCategoryModalOpen(false)} className="px-4 py-2 text-sm text-gray-600">Cancel</button>
|
||||||
<PrimaryButton onClick={handleSaveCategory}>{editingCategory ? 'Update' : 'Create'}</PrimaryButton>
|
<PrimaryButton onClick={handleCategorySubmit(onSaveCategory)}>{editingCategory ? 'Update' : 'Create'}</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@ -285,15 +338,15 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
<Plus className="w-3 h-3" /> {editingCode ? 'Edit Event Code' : 'Add New Event Trigger'}
|
<Plus className="w-3 h-3" /> {editingCode ? 'Edit Event Code' : 'Add New Event Trigger'}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField label="Code (e.g. task_assigned)" value={codeForm.code} onChange={(e) => setCodeForm({ ...codeForm, code: e.target.value })} disabled={!!editingCode} />
|
<FormField label="Code (e.g. task_assigned)" required error={codeErrors.code?.message} disabled={!!editingCode} {...registerCode('code')} />
|
||||||
<FormField label="Display Name" value={codeForm.name} onChange={(e) => setCodeForm({ ...codeForm, name: e.target.value })} />
|
<FormField label="Display Name" required error={codeErrors.name?.message} {...registerCode('name')} />
|
||||||
</div>
|
</div>
|
||||||
<FormTextArea label="Description" value={codeForm.description} onChange={(e) => setCodeForm({ ...codeForm, description: e.target.value })} rows={2} />
|
<FormTextArea label="Description" rows={2} error={codeErrors.description?.message} {...registerCode('description')} />
|
||||||
<div className="flex justify-between items-center pt-2">
|
<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="text-[10px] text-gray-400 font-medium">Auto-populates default channels (In-App, Email)</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{editingCode && <button onClick={() => setEditingCode(null)} className="text-xs text-gray-500 hover:text-gray-800 underline transition-colors">Cancel Edit</button>}
|
{editingCode && <button onClick={() => { setEditingCode(null); resetCode({ code: '', name: '', description: '', default_channels: ['in_app', 'email'], default_priority: 'normal' }); }} className="text-xs text-gray-500 hover:text-gray-800 underline transition-colors">Cancel Edit</button>}
|
||||||
<PrimaryButton onClick={handleSaveCode} size="default"> {editingCode ? 'Update Code' : 'Add Code'}</PrimaryButton>
|
<PrimaryButton onClick={handleCodeSubmit(onSaveCode)} size="default"> {editingCode ? 'Update Code' : 'Add Code'}</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -321,7 +374,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
setEditingCode(c);
|
setEditingCode(c);
|
||||||
setCodeForm({ code: c.code, name: c.name, description: c.description || '', default_channels: c.default_channels, default_priority: c.default_priority });
|
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>
|
}} className="text-blue-600 hover:text-blue-800 font-bold text-[11px] uppercase mr-3">Edit</button>
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
setDeleteTarget({ id: c.id, name: c.code, type: 'code' });
|
setDeleteTarget({ id: c.id, name: c.code, type: 'code' });
|
||||||
|
|||||||
@ -9,17 +9,37 @@ import {
|
|||||||
FormSelect,
|
FormSelect,
|
||||||
FormTextArea,
|
FormTextArea,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
FilterDropdown,
|
||||||
type Column,
|
type Column,
|
||||||
} from '@/components/shared';
|
} from '@/components/shared';
|
||||||
import { Plus, Search, Filter } from 'lucide-react';
|
import { Plus, Search } 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 { 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'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
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'),
|
||||||
|
is_active: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TemplateFormValues = z.infer<typeof templateSchema>;
|
||||||
|
|
||||||
const NotificationTemplateMaster = (): ReactElement => {
|
const NotificationTemplateMaster = (): ReactElement => {
|
||||||
const [templates, setTemplates] = useState<any[]>([]);
|
const [templates, setTemplates] = useState<any[]>([]);
|
||||||
const [modules, setModules] = useState<any[]>([]);
|
const [modules, setModules] = useState<any[]>([]);
|
||||||
const [selectedModule, setSelectedModule] = useState<string>('all');
|
const [selectedModule, setSelectedModule] = useState<string | null>(null);
|
||||||
const [categories, setCategories] = useState<any[]>([]);
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
const [codes, setCodes] = useState<any[]>([]);
|
const [codes, setCodes] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
@ -34,20 +54,36 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
// Template Modal
|
// Template Modal
|
||||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState({
|
|
||||||
code: '',
|
const {
|
||||||
name: '',
|
register,
|
||||||
description: '',
|
handleSubmit,
|
||||||
category: '',
|
reset,
|
||||||
title_template: '',
|
setValue,
|
||||||
message_template: '',
|
watch,
|
||||||
email_subject_template: '',
|
formState: { errors }
|
||||||
email_body_template: '',
|
} = useForm<TemplateFormValues>({
|
||||||
default_priority: 'normal',
|
resolver: zodResolver(templateSchema),
|
||||||
channels: ['in_app', 'email'],
|
defaultValues: {
|
||||||
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 fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -56,7 +92,7 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
limit,
|
limit,
|
||||||
offset: (currentPage - 1) * limit,
|
offset: (currentPage - 1) * limit,
|
||||||
search,
|
search,
|
||||||
module_id: selectedModule === 'all' ? undefined : selectedModule
|
module_id: selectedModule || undefined
|
||||||
}),
|
}),
|
||||||
notificationService.getCategories({ limit: 100 }), // Fetch more for dropdown
|
notificationService.getCategories({ limit: 100 }), // Fetch more for dropdown
|
||||||
moduleService.getAll(1, 100)
|
moduleService.getAll(1, 100)
|
||||||
@ -93,19 +129,25 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCategorySelect = async (categoryCode: string) => {
|
const handleCategorySelect = async (categoryCode: string) => {
|
||||||
setForm(f => ({ ...f, category: categoryCode, code: '' }));
|
setValue('category', categoryCode, { shouldValidate: true });
|
||||||
|
setValue('code', '', { shouldValidate: true });
|
||||||
await fetchCodesForCategory(categoryCode);
|
await fetchCodesForCategory(categoryCode);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const onSave = async (data: TemplateFormValues) => {
|
||||||
try {
|
try {
|
||||||
// For now we use createTemplate (which can override based on code)
|
if (editingId) {
|
||||||
await notificationService.createTemplate(form);
|
await notificationService.updateTemplate(editingId, data);
|
||||||
|
} else {
|
||||||
|
await notificationService.createTemplate(data);
|
||||||
|
}
|
||||||
showToast.success(editingId ? 'Template updated' : 'Template created');
|
showToast.success(editingId ? 'Template updated' : 'Template created');
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
|
reset();
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast.error(err.message || 'Action failed');
|
const errorMessage = err.response?.data?.error?.message || err.message || 'Action failed';
|
||||||
|
showToast.error(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -133,7 +175,7 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
render: (t) => (
|
render: (t) => (
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
setEditingId(t.id);
|
setEditingId(t.id);
|
||||||
setForm({
|
reset({
|
||||||
code: t.code || '',
|
code: t.code || '',
|
||||||
name: t.name || '',
|
name: t.name || '',
|
||||||
description: t.description || '',
|
description: t.description || '',
|
||||||
@ -142,7 +184,7 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
message_template: t.message_template || '',
|
message_template: t.message_template || '',
|
||||||
email_subject_template: t.email_subject_template || '',
|
email_subject_template: t.email_subject_template || '',
|
||||||
email_body_template: t.email_body_template || '',
|
email_body_template: t.email_body_template || '',
|
||||||
default_priority: t.default_priority || 'normal',
|
default_priority: (t.default_priority || 'normal') as any,
|
||||||
channels: t.channels || ['in_app', 'email'],
|
channels: t.channels || ['in_app', 'email'],
|
||||||
is_active: t.is_active ?? true
|
is_active: t.is_active ?? true
|
||||||
});
|
});
|
||||||
@ -176,27 +218,22 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 min-w-[200px]">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-gray-400 whitespace-nowrap">
|
<div className="w-full">
|
||||||
<Filter className="w-3.5 h-3.5" /> Module:
|
<FilterDropdown
|
||||||
</div>
|
label="Module"
|
||||||
<div className="w-full">
|
value={selectedModule}
|
||||||
<FormSelect
|
onChange={(val) => { setSelectedModule(val as string | null); setCurrentPage(1); }}
|
||||||
label=""
|
options={modules.map(m => ({ value: m.id, label: m.name }))}
|
||||||
value={selectedModule}
|
placeholder="All Modules"
|
||||||
onValueChange={(val) => { setSelectedModule(val); setCurrentPage(1); }}
|
isSearchable
|
||||||
options={[
|
/>
|
||||||
{ value: 'all', label: 'All Modules' },
|
</div>
|
||||||
...modules.map(m => ({ value: m.id, label: m.name }))
|
</div>
|
||||||
]}
|
|
||||||
placeholder="Filter by Module"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<PrimaryButton onClick={() => {
|
<PrimaryButton onClick={() => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setForm({
|
reset({
|
||||||
code: '', name: '', description: '', category: '',
|
code: '', name: '', description: '', category: '',
|
||||||
title_template: '', message_template: '',
|
title_template: '', message_template: '',
|
||||||
email_subject_template: '', email_body_template: '',
|
email_subject_template: '', email_body_template: '',
|
||||||
@ -229,91 +266,90 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Identification</h3>
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Identification</h3>
|
||||||
<FormSelect
|
<FormSelect
|
||||||
label="Category"
|
label="Category"
|
||||||
value={form.category}
|
required
|
||||||
|
value={categoryValue}
|
||||||
onValueChange={handleCategorySelect}
|
onValueChange={handleCategorySelect}
|
||||||
options={editingId
|
options={categories.map(c => ({ value: c.code, label: c.name }))}
|
||||||
? [{ value: form.category, label: categories.find(c => c.code === form.category)?.name || form.category }]
|
error={errors.category?.message}
|
||||||
: categories.map(c => ({ value: c.code, label: c.name }))
|
|
||||||
}
|
|
||||||
disabled={!!editingId}
|
|
||||||
placeholder="Select Category"
|
placeholder="Select Category"
|
||||||
/>
|
/>
|
||||||
<FormSelect
|
<FormSelect
|
||||||
label="Event Code"
|
label="Event Code"
|
||||||
value={form.code}
|
required
|
||||||
|
value={codeValue}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
const selectedCode = codes.find(c => c.code === val);
|
const selectedCode = codes.find(c => c.code === val);
|
||||||
setForm({ ...form, code: val, name: selectedCode?.name || form.name });
|
setValue('code', val, { shouldValidate: true });
|
||||||
|
if (selectedCode?.name) setValue('name', selectedCode.name, { shouldValidate: true });
|
||||||
}}
|
}}
|
||||||
options={editingId
|
options={codes.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }))}
|
||||||
? [{ value: form.code, label: `${form.name} (${form.code})` }]
|
disabled={!categoryValue}
|
||||||
: codes.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }))
|
error={errors.code?.message}
|
||||||
}
|
|
||||||
disabled={!!editingId || !form.category}
|
|
||||||
placeholder="Select Event Code"
|
placeholder="Select Event Code"
|
||||||
/>
|
/>
|
||||||
<FormField label="Friendly Name" value={form.name} onChange={(e) => setForm({...form, name: e.target.value})} placeholder="e.g. Project Assigned" />
|
<FormField label="Friendly Name" required error={errors.name?.message} placeholder="e.g. Project Assigned" {...register('name')} />
|
||||||
<FormTextArea label="Description" value={form.description} onChange={(e) => setForm({...form, description: e.target.value})} rows={2} />
|
<FormTextArea label="Description" error={errors.description?.message} rows={2} {...register('description')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Settings</h3>
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Settings</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormSelect
|
<FormSelect
|
||||||
label="Priority"
|
label="Priority"
|
||||||
value={form.default_priority}
|
value={priorityValue}
|
||||||
onValueChange={(val) => setForm({...form, default_priority: val})}
|
onValueChange={(val) => setValue('default_priority', val as any, { shouldValidate: true })}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'low', label: 'Low' },
|
{ value: 'low', label: 'Low' },
|
||||||
{ value: 'normal', label: 'Normal' },
|
{ value: 'normal', label: 'Normal' },
|
||||||
{ value: 'high', label: 'High' },
|
{ value: 'high', label: 'High' },
|
||||||
{ value: 'urgent', label: 'Urgent' }
|
{ value: 'urgent', label: 'Urgent' }
|
||||||
]}
|
]}
|
||||||
|
error={errors.default_priority?.message}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium text-gray-700">Channels</label>
|
<label className="text-xs font-medium text-gray-700">Channels</label>
|
||||||
<div className="flex gap-4 items-center h-10 px-1">
|
<div className="flex gap-4 items-center h-10 px-1">
|
||||||
<label className="flex items-center gap-2 text-xs">
|
<label className="flex items-center gap-2 text-xs">
|
||||||
<input type="checkbox" checked={form.channels.includes('in_app')} onChange={(e) => {
|
<input type="checkbox" checked={channels.includes('in_app')} onChange={(e) => {
|
||||||
const next = e.target.checked ? [...form.channels, 'in_app'] : form.channels.filter(c => c !== 'in_app');
|
const next = e.target.checked ? [...channels, 'in_app'] : channels.filter(c => c !== 'in_app');
|
||||||
setForm({...form, channels: next});
|
setValue('channels', next, { shouldValidate: true });
|
||||||
}} /> In-App
|
}} /> In-App
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-xs">
|
<label className="flex items-center gap-2 text-xs">
|
||||||
<input type="checkbox" checked={form.channels.includes('email')} onChange={(e) => {
|
<input type="checkbox" checked={channels.includes('email')} onChange={(e) => {
|
||||||
const next = e.target.checked ? [...form.channels, 'email'] : form.channels.filter(c => c !== 'email');
|
const next = e.target.checked ? [...channels, 'email'] : channels.filter(c => c !== 'email');
|
||||||
setForm({...form, channels: next});
|
setValue('channels', next, { shouldValidate: true });
|
||||||
}} /> Email
|
}} /> Email
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{errors.channels && <p className="text-xs text-red-500">{errors.channels.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider pt-2 border-t">In-App Content</h3>
|
||||||
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider pt-2 border-t">In-App Content</h3>
|
<FormField label="Title Template" required error={errors.title_template?.message} placeholder="e.g. Task Assigned: {{task_name}}" {...register('title_template')} />
|
||||||
<FormField label="Title Template" value={form.title_template} onChange={(e) => setForm({...form, title_template: e.target.value})} placeholder="e.g. Task Assigned: {{task_name}}" />
|
<FormTextArea label="Message Template" required error={errors.message_template?.message} placeholder="Use {{var}} for dynamic data" rows={3} {...register('message_template')} />
|
||||||
<FormTextArea label="Message Template" value={form.message_template} onChange={(e) => setForm({...form, message_template: e.target.value})} placeholder="Use {{var}} for dynamic data" rows={3} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2 space-y-4 pt-4 border-t">
|
<div className="md:col-span-2 space-y-4 pt-4 border-t">
|
||||||
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Email Content</h3>
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Email Content</h3>
|
||||||
<FormField
|
<FormField
|
||||||
label="Email Subject"
|
label="Email Subject"
|
||||||
value={form.email_subject_template}
|
error={errors.email_subject_template?.message}
|
||||||
onChange={(e) => setForm({...form, email_subject_template: e.target.value})}
|
|
||||||
placeholder="Leave blank to use In-App title"
|
placeholder="Leave blank to use In-App title"
|
||||||
|
{...register('email_subject_template')}
|
||||||
/>
|
/>
|
||||||
<FormTextArea
|
<FormTextArea
|
||||||
label="Email Body (HTML supported)"
|
label="Email Body (HTML supported)"
|
||||||
value={form.email_body_template}
|
error={errors.email_body_template?.message}
|
||||||
onChange={(e) => setForm({...form, email_body_template: e.target.value})}
|
|
||||||
placeholder="Full HTML body template..."
|
placeholder="Full HTML body template..."
|
||||||
rows={6}
|
rows={6}
|
||||||
|
{...register('email_body_template')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2 flex justify-end gap-3 pt-6 border-t sticky bottom-0 bg-white">
|
<div className="md:col-span-2 flex justify-end gap-3 pt-6 border-t sticky bottom-0 bg-white">
|
||||||
<button onClick={() => setModalOpen(false)} className="px-5 py-2 text-sm text-gray-600 transition-colors hover:text-gray-900">Cancel</button>
|
<button onClick={() => setModalOpen(false)} className="px-5 py-2 text-sm text-gray-600 transition-colors hover:text-gray-900">Cancel</button>
|
||||||
<PrimaryButton onClick={handleSave} className="px-10">
|
<PrimaryButton onClick={handleSubmit(onSave)} className="px-10">
|
||||||
{editingId ? 'Update Template' : 'Create Template'}
|
{editingId ? 'Update Template' : 'Create Template'}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,6 +16,19 @@ import { Edit, RotateCcw, Building, Filter } 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 { 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'),
|
||||||
|
email_subject_template: z.string().optional(),
|
||||||
|
email_body_template: z.string().optional(),
|
||||||
|
is_active: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
type OverrideFormValues = z.infer<typeof overrideSchema>;
|
||||||
|
|
||||||
const NotificationTemplates = (): ReactElement => {
|
const NotificationTemplates = (): ReactElement => {
|
||||||
const [templates, setTemplates] = useState<any[]>([]);
|
const [templates, setTemplates] = useState<any[]>([]);
|
||||||
@ -32,12 +45,21 @@ const NotificationTemplates = (): ReactElement => {
|
|||||||
// Override Modal
|
// Override Modal
|
||||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
|
||||||
const [form, setForm] = useState({
|
|
||||||
title_template: '',
|
const {
|
||||||
message_template: '',
|
register,
|
||||||
email_subject_template: '',
|
handleSubmit,
|
||||||
email_body_template: '',
|
reset,
|
||||||
is_active: true
|
formState: { errors }
|
||||||
|
} = useForm<OverrideFormValues>({
|
||||||
|
resolver: zodResolver(overrideSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title_template: '',
|
||||||
|
message_template: '',
|
||||||
|
email_subject_template: '',
|
||||||
|
email_body_template: '',
|
||||||
|
is_active: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchModules = async () => {
|
const fetchModules = async () => {
|
||||||
@ -74,14 +96,16 @@ const NotificationTemplates = (): ReactElement => {
|
|||||||
useEffect(() => { fetchModules(); }, []);
|
useEffect(() => { fetchModules(); }, []);
|
||||||
useEffect(() => { fetchTemplates(); }, [currentPage, limit, selectedModule]);
|
useEffect(() => { fetchTemplates(); }, [currentPage, limit, selectedModule]);
|
||||||
|
|
||||||
const handleOverride = async () => {
|
const onOverride = async (data: OverrideFormValues) => {
|
||||||
try {
|
try {
|
||||||
await notificationService.overrideTemplate(selectedTemplate.code, form);
|
await notificationService.overrideTemplate(selectedTemplate.code, data);
|
||||||
showToast.success('Template override saved');
|
showToast.success('Template override saved');
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
|
reset();
|
||||||
fetchTemplates();
|
fetchTemplates();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast.error(err.message || 'Action failed');
|
const errorMessage = err.response?.data?.error?.message || err.message || 'Action failed';
|
||||||
|
showToast.error(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -113,7 +137,7 @@ const NotificationTemplates = (): ReactElement => {
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
setSelectedTemplate(t);
|
setSelectedTemplate(t);
|
||||||
setForm({
|
reset({
|
||||||
title_template: t.title_template,
|
title_template: t.title_template,
|
||||||
message_template: t.message_template,
|
message_template: t.message_template,
|
||||||
email_subject_template: t.email_subject_template || '',
|
email_subject_template: t.email_subject_template || '',
|
||||||
@ -187,13 +211,13 @@ const NotificationTemplates = (): ReactElement => {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">In-App Notification</h3>
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">In-App Notification</h3>
|
||||||
<FormField label="Title Template" value={form.title_template} onChange={(e) => setForm({...form, title_template: e.target.value})} />
|
<FormField label="Title Template" required error={errors.title_template?.message} {...register('title_template')} />
|
||||||
<FormTextArea label="Message Template" value={form.message_template} onChange={(e) => setForm({...form, message_template: e.target.value})} rows={3} />
|
<FormTextArea label="Message Template" required error={errors.message_template?.message} rows={3} {...register('message_template')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Email Notification</h3>
|
<h3 className="text-[11px] font-bold uppercase text-gray-400 tracking-wider">Email Notification</h3>
|
||||||
<FormField label="Email Subject" value={form.email_subject_template} onChange={(e) => setForm({...form, email_subject_template: e.target.value})} placeholder="Inherits from title if blank" />
|
<FormField label="Email Subject" error={errors.email_subject_template?.message} placeholder="Inherits from title if blank" {...register('email_subject_template')} />
|
||||||
<FormTextArea label="Email Body (HTML)" value={form.email_body_template} onChange={(e) => setForm({...form, email_body_template: e.target.value})} rows={6} />
|
<FormTextArea label="Email Body (HTML)" error={errors.email_body_template?.message} rows={6} {...register('email_body_template')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -203,7 +227,7 @@ const NotificationTemplates = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button onClick={() => setModalOpen(false)} className="px-5 py-2 text-sm text-gray-600 hover:text-gray-900 transition-colors">Cancel</button>
|
<button onClick={() => setModalOpen(false)} className="px-5 py-2 text-sm text-gray-600 hover:text-gray-900 transition-colors">Cancel</button>
|
||||||
<PrimaryButton onClick={handleOverride} className="px-8">Save Override</PrimaryButton>
|
<PrimaryButton onClick={handleSubmit(onOverride)} className="px-8">Save Override</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -129,6 +129,11 @@ export const notificationService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateTemplate: async (id: string, data: any): Promise<NotificationResponse<any>> => {
|
||||||
|
const response = await apiClient.put(`/notifications/templates/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
overrideTemplate: async (code: string, data: any): Promise<NotificationResponse<any>> => {
|
overrideTemplate: async (code: string, data: any): Promise<NotificationResponse<any>> => {
|
||||||
const response = await apiClient.put(`/notifications/templates/${code}/override`, data);
|
const response = await apiClient.put(`/notifications/templates/${code}/override`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user